From 9df9816dabb63bd7039b358f40501ce3490a5753 Mon Sep 17 00:00:00 2001 From: glennc Date: Fri, 15 May 2026 14:36:18 -0700 Subject: [PATCH 001/338] feat(azure-foundry): add Microsoft Entra ID auth Use azure-identity DefaultAzureCredential for keyless Foundry auth. Preserve refreshable callable credentials through OpenAI and Anthropic client paths. Add setup, doctor, auth status, docs, and tests for Entra auth. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- acp_adapter/auth.py | 15 +- agent/agent_init.py | 31 +- agent/agent_runtime_helpers.py | 10 +- agent/anthropic_adapter.py | 131 +++- agent/auxiliary_client.py | 185 ++++- agent/azure_identity_adapter.py | 555 +++++++++++++++ agent/chat_completion_helpers.py | 9 +- agent/context_compressor.py | 2 +- agent/conversation_compression.py | 10 +- agent/conversation_loop.py | 23 +- batch_runner.py | 23 +- cli-config.yaml.example | 9 + cli.py | 18 +- hermes_cli/auth.py | 83 ++- hermes_cli/auth_commands.py | 48 ++ hermes_cli/azure_detect.py | 146 +++- hermes_cli/doctor.py | 82 +++ hermes_cli/main.py | 196 +++++- hermes_cli/runtime_provider.py | 101 ++- hermes_cli/web_server.py | 6 + .../model-providers/azure-foundry/__init__.py | 4 +- .../model-providers/azure-foundry/plugin.yaml | 2 +- pyproject.toml | 1 + run_agent.py | 6 +- .../acp_adapter/test_detect_provider_entra.py | 87 +++ tests/agent/test_anthropic_adapter.py | 15 + .../test_auxiliary_client_azure_foundry.py | 350 +++++++++ tests/agent/test_azure_identity_adapter.py | 662 ++++++++++++++++++ tests/agent/test_bedrock_1m_context.py | 3 +- tests/hermes_cli/test_azure_detect.py | 8 +- tests/hermes_cli/test_azure_foundry_entra.py | 404 +++++++++++ tests/run_agent/test_callable_api_key.py | 375 ++++++++++ tools/lazy_deps.py | 5 + tui_gateway/server.py | 11 +- uv.lock | 61 +- website/docs/guides/azure-foundry.md | 202 +++++- .../docs/reference/environment-variables.md | 13 +- .../user-guide/features/fallback-providers.md | 2 +- 38 files changed, 3772 insertions(+), 122 deletions(-) create mode 100644 agent/azure_identity_adapter.py create mode 100644 tests/acp_adapter/test_detect_provider_entra.py create mode 100644 tests/agent/test_auxiliary_client_azure_foundry.py create mode 100644 tests/agent/test_azure_identity_adapter.py create mode 100644 tests/hermes_cli/test_azure_foundry_entra.py create mode 100644 tests/run_agent/test_callable_api_key.py diff --git a/acp_adapter/auth.py b/acp_adapter/auth.py index 7b2556fd06..b04a7b7b40 100644 --- a/acp_adapter/auth.py +++ b/acp_adapter/auth.py @@ -9,13 +9,24 @@ TERMINAL_SETUP_AUTH_METHOD_ID = "hermes-setup" def detect_provider() -> Optional[str]: - """Resolve the active Hermes runtime provider, or None if unavailable.""" + """Resolve the active Hermes runtime provider, or None if unavailable. + + Treats a ``Callable`` ``api_key`` (Azure Foundry Entra ID bearer + token provider — see :mod:`agent.azure_identity_adapter`) as a valid + credential. Without this, ACP sessions for Entra-configured Foundry + deployments silently default to ``"openrouter"`` and the ACP auth + handshake rejects the legitimate provider. + """ try: from hermes_cli.runtime_provider import resolve_runtime_provider runtime = resolve_runtime_provider() api_key = runtime.get("api_key") provider = runtime.get("provider") - if isinstance(api_key, str) and api_key.strip() and isinstance(provider, str) and provider.strip(): + if not isinstance(provider, str) or not provider.strip(): + return None + is_string_key = isinstance(api_key, str) and api_key.strip() + is_callable_provider = callable(api_key) and not isinstance(api_key, str) + if is_string_key or is_callable_provider: return provider.strip().lower() except Exception: return None diff --git a/agent/agent_init.py b/agent/agent_init.py index df8fe229e7..71b04e3e54 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -560,7 +560,16 @@ def init_agent( agent._client_kwargs = {} if not agent.quiet_mode: print(f"🤖 AI Agent initialized with model: {agent.model} (Anthropic native)") - if effective_key and len(effective_key) > 12: + # ``effective_key`` may be a callable Entra ID bearer + # provider for Azure Foundry anthropic_messages mode. + # The Anthropic adapter installs an httpx event hook + # that mints a fresh JWT per request — we never + # invoke or inspect the callable in the banner. + from agent.azure_identity_adapter import is_token_provider + + if is_token_provider(effective_key): + print("🔑 Using credentials: Microsoft Entra ID") + elif isinstance(effective_key, str) and len(effective_key) > 12: print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}") elif agent.api_mode == "bedrock_converse": # AWS Bedrock — uses boto3 directly, no OpenAI client needed. @@ -764,12 +773,19 @@ def init_agent( print(f"🤖 AI Agent initialized with model: {agent.model}") if base_url: print(f"🔗 Using custom base URL: {base_url}") - # Always show API key info (masked) for debugging auth issues + # ``api_key`` may be a callable Entra ID bearer + # provider (Azure Foundry). The OpenAI SDK mints a + # fresh JWT per request internally — the banner + # never invokes or inspects the callable. + from agent.azure_identity_adapter import is_token_provider + key_used = client_kwargs.get("api_key", "none") - if key_used and key_used != "dummy-key" and len(key_used) > 12: + if is_token_provider(key_used): + print("🔑 Using credentials: Microsoft Entra ID") + elif isinstance(key_used, str) and key_used and key_used != "dummy-key" and len(key_used) > 12: print(f"🔑 Using API key: {key_used[:8]}...{key_used[-4:]}") else: - print(f"âš ī¸ Warning: API key appears invalid or missing (got: '{key_used[:20] if key_used else 'none'}...')") + print("âš ī¸ Warning: API key appears invalid or missing") except Exception as e: raise RuntimeError(f"Failed to initialize OpenAI client: {e}") @@ -1395,7 +1411,12 @@ def init_agent( _ra().logger.debug("Invalid ollama_num_ctx config value: %r", _ollama_num_ctx_override) if agent._ollama_num_ctx is None and agent.base_url and is_local_endpoint(agent.base_url): try: - _detected = query_ollama_num_ctx(agent.model, agent.base_url, api_key=agent.api_key or "") + # ``agent.api_key`` may be a callable (Entra token provider). + # Ollama detection makes a manual HTTP request and expects a + # string — Azure Foundry isn't a local endpoint so this branch + # never fires for Entra, but guard defensively. + _key_for_ollama = agent.api_key if isinstance(agent.api_key, str) else "" + _detected = query_ollama_num_ctx(agent.model, agent.base_url, api_key=_key_for_ollama or "") if _detected and _detected > 0: agent._ollama_num_ctx = _detected except Exception as exc: diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 61551a65dc..8e5b81ce27 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1390,10 +1390,16 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo _sm_custom_providers = get_compatible_custom_providers(_sm_cfg) except Exception: _sm_custom_providers = None + # ``agent.api_key`` may be a callable (Azure Foundry Entra ID + # token provider). ``get_model_context_length`` expects a + # string for its live-probe paths; for Foundry the context + # length normally resolves via config or static catalogs and + # never hits a probe, but coerce to empty string defensively. + _ctx_api_key = agent.api_key if isinstance(agent.api_key, str) else "" new_context_length = get_model_context_length( agent.model, base_url=agent.base_url, - api_key=agent.api_key, + api_key=_ctx_api_key, provider=agent.provider, config_context_length=getattr(agent, "_config_context_length", None), custom_providers=_sm_custom_providers, @@ -1402,7 +1408,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo model=agent.model, context_length=new_context_length, base_url=agent.base_url, - api_key=getattr(agent, "api_key", ""), + api_key=agent.api_key, # context_compressor forwards to call_llm; callable preserved provider=agent.provider, api_mode=agent.api_mode, ) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index de9b7dd586..c94d664a43 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -17,6 +17,7 @@ import os import platform import subprocess from pathlib import Path +from urllib.parse import urlparse from hermes_constants import get_hermes_home from typing import Any, Dict, List, Optional, Tuple @@ -364,7 +365,7 @@ def _normalize_base_url_text(base_url) -> str: def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool: """Return True for non-Anthropic endpoints using the Anthropic Messages API. - Third-party proxies (Azure AI Foundry, AWS Bedrock, self-hosted) authenticate + Third-party proxies (Microsoft Foundry, AWS Bedrock, self-hosted) authenticate with their own API keys via x-api-key, not Anthropic OAuth tokens. OAuth detection should be skipped for these endpoints. """ @@ -508,6 +509,29 @@ def _is_minimax_anthropic_endpoint(base_url: str | None) -> bool: ) +def _is_azure_anthropic_endpoint(base_url: str | None) -> bool: + """Return True for Azure-hosted Anthropic Messages endpoints. + + Covers both the modern Foundry host family (``*.services.ai.azure.*``) + and the legacy Azure OpenAI host family (``*.openai.azure.*``) when + serving Anthropic's ``/anthropic`` route. Used to opt-in those hosts + to the ``api-version`` query-param plumbing required by Azure. + + Intentionally avoids a finite allow-list of TLD suffixes so it works + across sovereign / private Azure clouds. + """ + normalized = _normalize_base_url_text(base_url) + if not normalized: + return False + parsed = urlparse(normalized) + host = (parsed.hostname or "").lower().rstrip(".") + path = (parsed.path or "").lower() + host_padded = f".{host}." + is_foundry_host = ".services.ai.azure." in host_padded + is_legacy_azoai_host = ".openai.azure." in host_padded + return (is_foundry_host or is_legacy_azoai_host) and "/anthropic" in path + + def _common_betas_for_base_url( base_url: str | None, *, @@ -523,7 +547,7 @@ def _common_betas_for_base_url( The ``context-1m-2025-08-07`` beta is not sent to native Anthropic by default because some subscriptions reject it. Add it only for endpoint - families that still require it for 1M context, currently Azure AI Foundry. + families that still require it for 1M context, currently Microsoft Foundry. Bedrock uses its own client helper below and opts in explicitly. ``drop_context_1m_beta=True`` strips the 1M-context beta from any path that @@ -540,8 +564,81 @@ def _common_betas_for_base_url( return betas +def _build_anthropic_client_with_bearer_hook( + token_provider, + base_url: str = None, + timeout: float = None, + *, + drop_context_1m_beta: bool = False, +): + """Anthropic-on-Foundry Entra ID variant of :func:`build_anthropic_client`. + + Anthropic SDK 0.86.0 stores ``api_key`` / ``auth_token`` as static + strings; there is no callable-token contract. To get per-request + bearer refresh (Microsoft's documented Foundry pattern), we hand + the SDK a custom ``httpx.Client`` whose request event hook mints a + fresh JWT from the Entra credential chain and rewrites + ``Authorization: Bearer `` on every outbound request. The SDK + ignores its own auth logic when ``http_client`` is provided (the + hook strips any pre-set Authorization). + + The placeholder ``auth_token`` is required because the SDK raises + ``AnthropicError`` at construction if neither ``api_key`` nor + ``auth_token`` is set — but the hook overrides it per-request so + the placeholder value never reaches Azure. + """ + _anthropic_sdk = _get_anthropic_sdk() + if _anthropic_sdk is None: + raise ImportError( + "The 'anthropic' package is required for Azure Foundry Anthropic-style " + "endpoints with Entra ID auth. Install with: pip install 'anthropic>=0.39.0'" + ) + + normalize_proxy_env_vars() + + from httpx import Timeout + from agent.azure_identity_adapter import build_bearer_http_client + + _read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0 + timeout_obj = Timeout(timeout=float(_read_timeout), connect=10.0) + + # Strip any trailing /v1 — the Anthropic SDK appends /v1/messages. + normalized_base_url = _normalize_base_url_text(base_url) + if normalized_base_url: + import re as _re + normalized_base_url = _re.sub(r"/v1/?$", "", normalized_base_url.rstrip("/")) + + http_client = build_bearer_http_client(token_provider, timeout=timeout_obj) + + kwargs = { + "timeout": timeout_obj, + "http_client": http_client, + # The SDK requires *something* for api_key/auth_token. Our + # event hook overrides Authorization per request so this value + # is never sent. The sentinel string makes accidental leaks + # diagnosable in logs. + "auth_token": "entra-id-bearer-via-http-hook", + } + + if normalized_base_url: + if _is_azure_anthropic_endpoint(normalized_base_url) and "api-version" not in normalized_base_url: + kwargs["base_url"] = normalized_base_url + kwargs["default_query"] = {"api-version": "2025-04-15"} + else: + kwargs["base_url"] = normalized_base_url + + common_betas = _common_betas_for_base_url( + normalized_base_url, + drop_context_1m_beta=drop_context_1m_beta, + ) + if common_betas: + kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} + + return _anthropic_sdk.Anthropic(**kwargs) + + def build_anthropic_client( - api_key: str, + api_key, base_url: str = None, timeout: float = None, *, @@ -549,6 +646,17 @@ def build_anthropic_client( ): """Create an Anthropic client, auto-detecting setup-tokens vs API keys. + ``api_key`` accepts either: + + * a static ``str`` — the historical contract for all key-based and + OAuth flows. + * a ``Callable[[], str]`` — an Entra ID bearer token provider from + :mod:`agent.azure_identity_adapter`. The Anthropic SDK itself + requires a static string, so when given a callable we construct + a custom ``httpx.Client`` with a request event hook that mints a + fresh JWT per outbound request and rewrites the ``Authorization`` + header. The SDK never sees the callable directly. + If *timeout* is provided it overrides the default 900s read timeout. The connect timeout stays at 10s. Callers pass this from the per-provider / per-model ``request_timeout_seconds`` config so Anthropic-native and @@ -570,6 +678,14 @@ def build_anthropic_client( "Install it with: pip install 'anthropic>=0.39.0'" ) + # Callable api_key → Entra ID bearer provider path. Delegated to a + # helper so the existing static-key code below stays unchanged. + if callable(api_key) and not isinstance(api_key, str): + return _build_anthropic_client_with_bearer_hook( + api_key, base_url, timeout, + drop_context_1m_beta=drop_context_1m_beta, + ) + normalize_proxy_env_vars() from httpx import Timeout @@ -584,8 +700,7 @@ def build_anthropic_client( # Pass it via default_query so the SDK appends it to every request URL # without corrupting the base_url (appending it directly produces # malformed paths like /anthropic?api-version=.../v1/messages). - _is_azure_endpoint = "azure.com" in normalized_base_url.lower() - if _is_azure_endpoint and "api-version" not in normalized_base_url: + if _is_azure_anthropic_endpoint(normalized_base_url) and "api-version" not in normalized_base_url: kwargs["base_url"] = normalized_base_url.rstrip("/") kwargs["default_query"] = {"api-version": "2025-04-15"} else: @@ -615,7 +730,7 @@ def build_anthropic_client( if common_betas: kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} elif _is_third_party_anthropic_endpoint(base_url): - # Third-party proxies (Azure AI Foundry, AWS Bedrock, etc.) use their + # Third-party proxies (Microsoft Foundry, AWS Bedrock, etc.) use their # own API keys with x-api-key auth. Skip OAuth detection — their keys # don't follow Anthropic's sk-ant-* prefix convention and would be # misclassified as OAuth tokens. @@ -1757,7 +1872,7 @@ def convert_messages_to_anthropic( # causing HTTP 400 "Invalid signature in thinking block". # # Signatures are Anthropic-proprietary. Third-party endpoints - # (MiniMax, Azure AI Foundry, self-hosted proxies) cannot validate + # (MiniMax, Microsoft Foundry, self-hosted proxies) cannot validate # them and will reject them outright. When targeting a third-party # endpoint, strip ALL thinking/redacted_thinking blocks from every # assistant message — the third-party will generate its own @@ -2103,5 +2218,3 @@ def build_anthropic_kwargs( kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)} return kwargs - - diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 5d44fe1086..807ed07687 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1902,6 +1902,120 @@ def _build_codex_client(model: str) -> Tuple[Optional[Any], Optional[str]]: return CodexAuxiliaryClient(real_client, model), model +def _try_azure_foundry( + *, + model: Optional[str] = None, + explicit_api_key: Optional[str] = None, + explicit_base_url: Optional[str] = None, + api_mode: Optional[str] = None, +) -> Tuple[Optional[Any], Optional[str]]: + """Resolve an Azure Foundry auxiliary client via the runtime resolver. + + Mirrors the ``_try_anthropic`` / ``_try_nous`` shape but delegates to + :func:`hermes_cli.runtime_provider._resolve_azure_foundry_runtime` — + the same resolver the main agent uses — so: + + * ``auth_mode: api_key`` (default) gets the static + ``AZURE_FOUNDRY_API_KEY`` string. + * ``auth_mode: entra_id`` gets a callable bearer-token provider + (``Callable[[], str]`` from + :mod:`agent.azure_identity_adapter`). + * Per-model ``api_mode`` auto-routing for GPT-5.x / o-series / + codex models works. + * ``model.entra.{tenant_id,client_id,authority,scope}`` config + fields propagate. + * Non-default ``model.base_url`` overrides are honored. + + The OpenAI SDK accepts both shapes for ``api_key`` so the caller + can forward the result without coercion. + + Returns ``(client, model)`` or ``(None, None)`` on failure. + """ + try: + from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime + from hermes_cli.auth import AuthError + from hermes_cli.config import load_config + except ImportError: + return None, None + + try: + cfg = load_config() + model_cfg = cfg.get("model") if isinstance(cfg, dict) else {} + if not isinstance(model_cfg, dict): + model_cfg = {} + except Exception: + model_cfg = {} + + try: + runtime = _resolve_azure_foundry_runtime( + requested_provider="azure-foundry", + model_cfg=model_cfg, + explicit_api_key=explicit_api_key, + explicit_base_url=explicit_base_url, + target_model=model, + ) + except AuthError as exc: + logger.debug("Auxiliary azure-foundry: %s", exc) + return None, None + except Exception as exc: + logger.debug("Auxiliary azure-foundry runtime error: %s", exc) + return None, None + + api_key = runtime.get("api_key") + base_url = str(runtime.get("base_url", "") or "") + runtime_api_mode = api_mode or runtime.get("api_mode") or "chat_completions" + + # Empty-string check on api_key here would be wrong for callable + # token providers (callables are truthy and non-empty by definition). + # Bail only when api_key is None / empty string. + _has_key = bool(api_key) if not callable(api_key) else True + if not _has_key or not base_url: + return None, None + + final_model = _normalize_resolved_model( + model or str(model_cfg.get("default") or ""), + "azure-foundry", + ) + if not final_model: + # No fallback aux model for Azure — the user must have a + # deployment name. Surface that as "no client" so the auto + # chain falls through to the next provider rather than 404ing. + logger.debug( + "Auxiliary azure-foundry: no model resolved (model=%r, default=%r)", + model, model_cfg.get("default"), + ) + return None, None + + # Azure pre-v1 endpoints sometimes carry api-version query params + # in the base URL; the OpenAI SDK drops them when joining paths, + # so lift them out and pass via default_query. + extra: Dict[str, Any] = {} + _clean_base, _dq = _extract_url_query_params(base_url) + if _dq: + extra["default_query"] = _dq + + client = OpenAI(api_key=api_key, base_url=_clean_base, **extra) + + if runtime_api_mode == "codex_responses": + # GPT-5.x / o-series / codex models on Azure Foundry are + # Responses-API-only — wrap so chat.completions.create() is + # translated to /responses behind the scenes. + return CodexAuxiliaryClient(client, final_model), final_model + + if runtime_api_mode == "anthropic_messages": + # Forward ``api_key`` verbatim — for static keys it's a string, + # for Entra ID it's a callable. ``_maybe_wrap_anthropic`` → + # ``build_anthropic_client`` detects the callable and installs + # the bearer-injecting httpx hook. + return _maybe_wrap_anthropic( + client, final_model, api_key, + base_url, runtime_api_mode, + ), final_model + + # chat_completions — return the plain OpenAI client. + return client, final_model + + def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optional[str]]: try: from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token @@ -1957,20 +2071,31 @@ _AUTO_PROVIDER_LABELS = { "_resolve_api_key_provider": "api-key", } -_MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode") +_MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode", "auth_mode") -def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, str]: - """Return a sanitized copy of a live main-runtime override.""" +def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Return a sanitized copy of a live main-runtime override. + + Most fields are stripped strings. ``api_key`` may legitimately be a + zero-arg callable (Azure Foundry Entra ID token provider) — preserve + those as-is so auxiliary clients inherit the same authentication + surface as the main agent. The OpenAI SDK accepts ``Callable[[], str]`` + for ``api_key`` and calls it before every request. + """ if not isinstance(main_runtime, dict): return {} - normalized: Dict[str, str] = {} + normalized: Dict[str, Any] = {} for field in _MAIN_RUNTIME_FIELDS: value = main_runtime.get(field) + # Preserve a callable api_key (Entra ID bearer provider) unchanged. + if field == "api_key" and callable(value) and not isinstance(value, str): + normalized[field] = value + continue if isinstance(value, str) and value.strip(): normalized[field] = value.strip() provider = normalized.get("provider") - if provider: + if isinstance(provider, str): normalized["provider"] = provider.lower() return normalized @@ -2762,10 +2887,10 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option auxiliary_is_nous = False # Reset — _try_nous() will set True if it wins runtime = _normalize_main_runtime(main_runtime) runtime_provider = runtime.get("provider", "") - runtime_model = runtime.get("model", "") - runtime_base_url = runtime.get("base_url", "") + runtime_model = str(runtime.get("model") or "") + runtime_base_url = str(runtime.get("base_url") or "") runtime_api_key = runtime.get("api_key", "") - runtime_api_mode = runtime.get("api_mode", "") + runtime_api_mode = str(runtime.get("api_mode") or "") # ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named # provider (not 'custom'). This catches the common "env poisoning" @@ -2793,8 +2918,8 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option # on aggregators (OpenRouter, Nous) who previously got routed to a # cheap provider-side default. Explicit per-task overrides set via # config.yaml (auxiliary..provider) still win over this. - main_provider = runtime_provider or _read_main_provider() - main_model = runtime_model or _read_main_model() + main_provider = str(runtime_provider or _read_main_provider() or "") + main_model = str(runtime_model or _read_main_model() or "") if (main_provider and main_model and main_provider not in {"auto", ""}): resolved_provider = main_provider @@ -3188,7 +3313,11 @@ def resolve_provider_client( if client is not None: final_model = _normalize_resolved_model(model or default, provider) _cbase = str(getattr(client, "base_url", "") or "") - _ckey = str(getattr(client, "api_key", "") or "") + # ``client.api_key`` may be a callable (Azure Foundry Entra + # bearer provider). Pass empty string for the wrapper-detection + # path — wrapping decisions are based on base_url + api_mode. + _raw_ckey = getattr(client, "api_key", "") + _ckey = "" if (callable(_raw_ckey) and not isinstance(_raw_ckey, str)) else str(_raw_ckey or "") client = _wrap_if_needed(client, final_model, _cbase, _ckey) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) @@ -3300,6 +3429,40 @@ def resolve_provider_client( except ImportError: pass + # ── Azure Foundry (delegates to runtime resolver for auth_mode-aware routing) ─ + # + # The generic PROVIDER_REGISTRY path below uses + # ``resolve_api_key_provider_credentials`` which only knows about the + # static ``AZURE_FOUNDRY_API_KEY`` env var. That misses two important + # cases for the ``azure-foundry`` provider: + # + # 1. ``model.auth_mode: entra_id`` — no static key exists; we need + # a callable bearer-token provider from ``azure_identity_adapter``. + # 2. Non-default ``model.base_url`` (Foundry projects path) — the + # env-var-only resolver doesn't apply config-yaml-driven URL + # overrides. + # + # Delegate to the same runtime resolver the main agent uses so + # auxiliary tasks (title generation, compression, vision, embedding, + # session search) inherit the user's full Azure config. + if provider == "azure-foundry": + client, default_model = _try_azure_foundry( + model=model, + explicit_api_key=explicit_api_key, + explicit_base_url=explicit_base_url, + api_mode=api_mode, + ) + if client is None: + logger.warning( + "resolve_provider_client: azure-foundry requested but " + "runtime resolution failed (run: hermes doctor for " + "diagnostics)" + ) + return None, None + final_model = _normalize_resolved_model(model or default_model, provider) + return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + else (client, final_model)) + # ── API-key providers from PROVIDER_REGISTRY ───────────────────── try: from hermes_cli.auth import ( diff --git a/agent/azure_identity_adapter.py b/agent/azure_identity_adapter.py new file mode 100644 index 0000000000..9506715019 --- /dev/null +++ b/agent/azure_identity_adapter.py @@ -0,0 +1,555 @@ +"""Microsoft Entra ID adapter for Microsoft Foundry. + +Provides keyless authentication for Microsoft Foundry deployments using the +`azure-identity` SDK's `DefaultAzureCredential` chain (env service principal +→ workload identity → managed identity → VS Code → Azure CLI → azd → +PowerShell → broker). + +Architecture mirrors `agent/bedrock_adapter.py`: + +* Lazy import. `azure-identity` is only loaded when ``model.auth_mode = + entra_id`` is selected. Users who stick with `AZURE_FOUNDRY_API_KEY` + never pay the import cost. +* SDK-callable contract. The public entry point ``build_token_provider`` + returns a zero-arg callable produced by ``get_bearer_token_provider`` — + this is exactly the value Microsoft's documented sample plugs into + ``OpenAI(api_key=token_provider, base_url=...)``. The OpenAI SDK calls + it before every request, so token refresh is transparent. +* Three explicit consumer-side helpers (display / cache / http-bearer) + rather than one generic "materialize" function — splitting them by + purpose prevents accidental token-minting in logging paths or token + leakage into cache keys / dashboard JSON. +* No persisted JWT. ``azure-identity`` caches in-process and (where + available) in the OS keychain or ``~/.IdentityService``. Hermes does + not duplicate that storage in ``auth.json``. + +Reference: https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/configure-entra-id + +Requires: ``azure-identity`` (optional dependency — only needed when +``model.auth_mode = entra_id``). +""" + +from __future__ import annotations + +import functools +import logging +import os +import threading +from dataclasses import dataclass +from typing import Any, Callable, Dict, Optional + +logger = logging.getLogger(__name__) + +# Microsoft-documented scope for Foundry inference auth. Both the new +# Foundry portal and the legacy Azure OpenAI managed-identity docs use +# this scope for ALL Foundry endpoint shapes (*.openai.azure.com, +# *.services.ai.azure.com, *.ai.azure.com). The older control-plane +# scope ``https://cognitiveservices.azure.com/.default`` is for ARM +# resource management and is rejected for inference by newer +# resources — users with that requirement override via +# ``model.entra.scope`` in config.yaml. +SCOPE_AI_AZURE_DEFAULT = "https://ai.azure.com/.default" + +# --------------------------------------------------------------------------- +# Lazy SDK import — only loaded when the Entra path is actually used. +# --------------------------------------------------------------------------- + +_AZURE_IDENTITY_FEATURE = "provider.azure_identity" + + +def has_azure_identity_installed() -> bool: + """Return True if `azure-identity` can be imported right now. + + Cheap check — does not walk the credential chain. + """ + try: + import azure.identity # noqa: F401 + return True + except Exception: + return False + + +def _require_azure_identity(): + """Import ``azure.identity``, lazy-installing it if allowed. + + Raises ``ImportError`` with a clear actionable message when the + package is missing and lazy installs are disabled. + """ + try: + import azure.identity as _ai + return _ai + except ImportError: + try: + from tools.lazy_deps import ensure, FeatureUnavailable + except ImportError as exc: + raise ImportError( + "The 'azure-identity' package is required for Azure AI " + "Foundry Entra ID authentication. Install it with: " + "pip install azure-identity" + ) from exc + + try: + ensure(_AZURE_IDENTITY_FEATURE, prompt=False) + except FeatureUnavailable as exc: + raise ImportError( + "The 'azure-identity' package is required for Azure AI " + "Foundry Entra ID authentication. " + str(exc) + ) from exc + + # Retry import after lazy install. + import azure.identity as _ai # noqa: WPS440 + return _ai + + +def reset_credential_cache() -> None: + """Clear the cached ``DefaultAzureCredential``. Used by tests and + profile switches. + + Defensive against tests that ``monkeypatch.setattr`` over + ``build_credential`` with a plain (non-lru-cached) function — those + won't expose ``cache_clear()`` until pytest reverts the patch. + """ + cache_clear = getattr(build_credential, "cache_clear", None) + if callable(cache_clear): + cache_clear() + + +# --------------------------------------------------------------------------- +# Token-provider construction +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class EntraIdentityConfig: + """Serializable Entra ID config. + + Captures the Hermes-managed Entra knobs we need outside Azure SDK + environment configuration. Everything else + (tenant ID, service principal secret, federated token file, sovereign + cloud authority, etc.) flows through azure-identity's standard + ``AZURE_*`` env vars — see the Bedrock pattern in + ``hermes_cli/runtime_provider.py:1310-1377`` for the analogous + "let the SDK read env" approach. + + ``scope`` is Microsoft's documented Foundry inference audience. Almost + everyone uses the default; sovereign-cloud / non-standard tenants can + override via ``model.entra.scope``. Identity selection (user-assigned + managed identity, workload identity, service principal, tenant, authority) + stays in the standard Azure SDK env vars such as ``AZURE_CLIENT_ID``. + + ``exclude_interactive_browser`` is kept as an internal constructor knob + so probes stay non-interactive by default. It is not written by the setup + wizard. + + The dataclass is frozen so it's hashable for ``functools.lru_cache`` + keying, and serializable across multiprocessing boundaries (workers + rebuild the credential inside their own process). + """ + + scope: str = SCOPE_AI_AZURE_DEFAULT + exclude_interactive_browser: bool = True + + def __post_init__(self) -> None: + scope = str(self.scope or "").strip() or SCOPE_AI_AZURE_DEFAULT + object.__setattr__(self, "scope", scope) + + def to_dict(self) -> Dict[str, Any]: + return { + "scope": self.scope, + "exclude_interactive_browser": self.exclude_interactive_browser, + } + + @classmethod + def from_dict(cls, data: Optional[Dict[str, Any]], + *, default_scope: Optional[str] = None) -> "EntraIdentityConfig": + data = data or {} + scope = str(data.get("scope") or "").strip() or default_scope or SCOPE_AI_AZURE_DEFAULT + exclude_browser = bool(data.get("exclude_interactive_browser", True)) + return cls( + scope=scope, + exclude_interactive_browser=exclude_browser, + ) + + +def _build_default_credential(config: EntraIdentityConfig) -> Any: + """Construct a ``DefaultAzureCredential`` for ``config``. + + Only Hermes-selected knobs are passed as kwargs. Everything else + (tenant, service principal secret, federated token file, sovereign + cloud authority, etc.) is read by ``azure-identity`` from the + standard ``AZURE_*`` environment variables — see Microsoft's + documented credential resolution chain. Users configure those in + ``~/.hermes/.env`` or the deployment environment. + """ + ai = _require_azure_identity() + kwargs: Dict[str, Any] = {} + # SDK default is True (browser excluded); only pass when the user + # explicitly opts in to interactive browser auth. + if not config.exclude_interactive_browser: + kwargs["exclude_interactive_browser_credential"] = False + return ai.DefaultAzureCredential(**kwargs) + + +@functools.lru_cache(maxsize=1) +def build_credential(config: EntraIdentityConfig) -> Any: + """Return the cached ``DefaultAzureCredential`` for ``config``. + + Hermes processes use exactly one Entra config at a time (the + ``model.entra.*`` block in config.yaml drives every aux task, + subagent, and credential probe in the session). ``maxsize=1`` is + intentional: it reflects the actual usage pattern and keeps the + cache trivially small. + + ``EntraIdentityConfig`` is a frozen dataclass, so it's hashable and + safe as an LRU-cache key. ``functools.lru_cache`` is thread-safe in + CPython. + + If two distinct configs are ever passed (tests do this; production + rarely), the LRU eviction handles it correctly — each call still + returns a credential matching its config; only one is cached at a + time. Use :func:`reset_credential_cache` to clear (e.g. in tests). + """ + return _build_default_credential(config) + + +def build_token_provider(scope: Optional[str] = None, + *, + config: Optional[EntraIdentityConfig] = None, + base_url: Optional[str] = None, + exclude_interactive_browser: bool = True, + ) -> Callable[[], str]: + """Return a zero-arg callable that mints a fresh Entra bearer JWT. + + The returned callable is exactly what Microsoft's documented Foundry + sample expects:: + + from openai import OpenAI + client = OpenAI( + base_url="https://my-resource.openai.azure.com/openai/v1/", + api_key=build_token_provider(), + ) + + Scope resolution order: + 1. ``config.scope`` when a config object is supplied + 2. explicit ``scope`` kwarg + 3. ``SCOPE_AI_AZURE_DEFAULT`` (Microsoft's documented Foundry scope) + + ``base_url`` is unused today and kept for back-compat. Tenant / + service-principal / sovereign-cloud configuration flows through + ``azure-identity``'s standard ``AZURE_*`` environment variables — + see :func:`_build_default_credential` for the rationale. + + NOT serializable across process boundaries. For multiprocessing + workers, serialize the ``EntraIdentityConfig`` and rebuild the + provider inside the worker. + """ + ai = _require_azure_identity() + if config is None: + config = EntraIdentityConfig( + scope=scope or SCOPE_AI_AZURE_DEFAULT, + exclude_interactive_browser=exclude_interactive_browser, + ) + credential = build_credential(config) + return ai.get_bearer_token_provider(credential, config.scope) + + +# --------------------------------------------------------------------------- +# Credential probing +# --------------------------------------------------------------------------- + + +def has_azure_identity_credentials(scope: Optional[str] = None, + *, + config: Optional[EntraIdentityConfig] = None, + timeout_seconds: float = 10.0, + allow_install: bool = True, + **overrides: Any) -> bool: + """Best-effort probe: can `DefaultAzureCredential` mint a token now? + + Runs ``credential.get_token(scope)`` under a thread-based timeout so + a slow token service can't hang the caller. Returns False on any + error — never raises. Use for ``hermes doctor`` / + ``hermes auth status`` / wizard preflight. + + ``allow_install``: when True (default) and ``azure-identity`` is not + importable, the adapter triggers the standard lazy-install path + (subject to ``security.allow_lazy_installs``) before probing. Set + False to make this strictly an "is installed?" check — used on hot + paths like CLI startup where we never want pip to run. + + NOT used by ``is_provider_configured()`` — that path is structural + only (no token mint), so CLI startup doesn't pay this latency. + """ + if not has_azure_identity_installed(): + if not allow_install: + return False + try: + _require_azure_identity() + except ImportError as exc: + logger.debug("azure-identity lazy install unavailable: %s", exc) + return False + if config is None: + effective_scope = (scope or "").strip() or SCOPE_AI_AZURE_DEFAULT + config = EntraIdentityConfig(scope=effective_scope, **overrides) + + result = {"ok": False} + + def _probe() -> None: + try: + credential = build_credential(config) + tok = credential.get_token(config.scope) + result["ok"] = bool(getattr(tok, "token", None)) + except Exception as exc: + logger.debug("Entra credential probe failed: %s", exc) + result["ok"] = False + + thread = threading.Thread(target=_probe, daemon=True) + thread.start() + thread.join(timeout=max(0.01, timeout_seconds)) + if thread.is_alive(): + logger.debug("Entra token service probe timed out after %ss", timeout_seconds) + return False + return bool(result.get("ok")) + + +def describe_active_credential(config: Optional[EntraIdentityConfig] = None, + *, + scope: Optional[str] = None, + timeout_seconds: float = 10.0, + allow_install: bool = True, + **overrides: Any) -> Dict[str, Any]: + """Return diagnostic info about the active credential chain. + + Best-effort: runs ``get_token()`` and inspects what came back. + Designed for ``hermes doctor`` and the wizard preflight — never + raises, returns ``{"ok": False, "error": ...}`` on failure. + + ``allow_install``: when True (default) and ``azure-identity`` is not + importable, the adapter triggers the standard lazy-install path + (subject to ``security.allow_lazy_installs``) before probing. The + install failure is surfaced as the diagnostic error when it fails. + Set False for hot CLI paths that should never trigger pip. + + ``azure-identity`` doesn't expose the winning inner credential as + a public field, so we report a coarse picture (env vars present, + token expiry, claims-derived tenant) rather than the credential + class name. Users wanting the precise class can run with + ``AZURE_LOG_LEVEL=DEBUG``. + """ + info: Dict[str, Any] = {"ok": False} + if not has_azure_identity_installed(): + if not allow_install: + info["error"] = "azure-identity not installed" + info["hint"] = ( + "pip install azure-identity (or rely on lazy install at " + "first use)" + ) + return info + try: + _require_azure_identity() + except ImportError as exc: + info["error"] = str(exc) or "azure-identity not installed" + info["hint"] = ( + "pip install azure-identity manually, or enable lazy " + "installs (security.allow_lazy_installs: true in " + "config.yaml)." + ) + return info + + if config is None: + effective_scope = (scope or "").strip() or SCOPE_AI_AZURE_DEFAULT + config = EntraIdentityConfig(scope=effective_scope, **overrides) + + info["scope"] = config.scope + # Tenant / authority / service-principal config flow through the + # standard ``AZURE_*`` env vars; surface them below. + if os.environ.get("AZURE_TENANT_ID", "").strip(): + info["tenant_id_env"] = os.environ["AZURE_TENANT_ID"].strip() + + # Surface which env-var sources are present without minting yet. + env_sources = [] + if os.environ.get("AZURE_FEDERATED_TOKEN_FILE", "").strip(): + env_sources.append("WorkloadIdentityCredential (AZURE_FEDERATED_TOKEN_FILE)") + if (os.environ.get("AZURE_CLIENT_ID", "").strip() + and os.environ.get("AZURE_CLIENT_SECRET", "").strip() + and os.environ.get("AZURE_TENANT_ID", "").strip()): + env_sources.append("EnvironmentCredential (client secret)") + if os.environ.get("IDENTITY_ENDPOINT", "").strip() or os.environ.get("MSI_ENDPOINT", "").strip(): + env_sources.append("ManagedIdentityCredential (IDENTITY_ENDPOINT)") + info["env_sources"] = env_sources + + # Now try minting. + result: Dict[str, Any] = {} + + def _probe() -> None: + try: + credential = build_credential(config) + tok = credential.get_token(config.scope) + result["token"] = tok + except Exception as exc: + result["error"] = str(exc) + + thread = threading.Thread(target=_probe, daemon=True) + thread.start() + thread.join(timeout=max(0.01, timeout_seconds)) + if thread.is_alive(): + info["error"] = f"Token probe timed out after {timeout_seconds:.0f}s" + info["hint"] = ( + "DefaultAzureCredential can be slow when the token service is unreachable " + "or when az login state is stale. Try `az login` or set " + "AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET." + ) + return info + + if "error" in result: + info["error"] = result["error"] + return info + + token = result.get("token") + if token is None: + info["error"] = "credential chain exhausted" + return info + + info["ok"] = True + info["expires_on"] = getattr(token, "expires_on", None) + return info + + +# --------------------------------------------------------------------------- +# Consumer-side helpers — split by purpose to prevent accidental token +# minting in logging / cache-key / dashboard paths. +# --------------------------------------------------------------------------- + + +def is_token_provider(value: Any) -> bool: + """Return True when ``value`` is a callable Entra token provider. + + Used at the seams where a consumer must decide between + string-API-key semantics and bearer-callable semantics. + """ + return callable(value) and not isinstance(value, str) + + +def materialize_bearer_for_http(value: Any) -> str: + """Return a fresh Bearer JWT for a manual HTTP request. + + Only call this at sites that must construct an ``Authorization`` + header outside the OpenAI SDK (e.g. ``hermes_cli/azure_detect.py``). + Calls the callable exactly once and returns the resulting token. + + **Anthropic SDK integration:** the Anthropic Python SDK does not + accept a ``Callable[[], str]`` for ``auth_token``. Instead, + :func:`build_bearer_http_client` returns an ``httpx.Client`` whose + request event hook calls this function and rewrites the + ``Authorization`` header per request — and that client is passed to + the Anthropic SDK via ``http_client=...``. See + :func:`agent.anthropic_adapter.build_anthropic_client` for the + consumer. + + Raises ``ValueError`` if ``value`` is not a callable token provider + or non-empty string. + """ + if is_token_provider(value): + token = value() + if not isinstance(token, str) or not token: + raise ValueError("token provider returned empty value") + return token + if isinstance(value, str) and value: + return value + raise ValueError("no usable api_key / token provider") + + +def build_bearer_http_client(token_provider: Callable[[], str], **httpx_kwargs: Any) -> Any: + """Return an ``httpx.Client`` that mints a fresh Entra bearer JWT + per outbound request. + + The Anthropic SDK (≤ 0.86.0 at the time of writing) stores + ``api_key`` / ``auth_token`` as static strings and computes the + ``Authorization`` header at construction time. To get per-request + token refresh (the Microsoft-recommended Foundry pattern for + callable bearer providers), we install an httpx ``request`` event + hook on a custom client and pass that client to the SDK via + ``http_client=...``. The hook: + + 1. Calls :func:`materialize_bearer_for_http` to mint a fresh JWT + (azure-identity caches internally — this is cheap when the + cached token is still valid). + 2. Strips any pre-set ``Authorization`` / ``api-key`` / + ``x-api-key`` headers the SDK may have added (avoids + conflicting auth values). + 3. Sets ``Authorization: Bearer ``. + + ``token_provider`` must be a zero-arg callable returning a string — + typically the result of :func:`build_token_provider`. + + ``httpx_kwargs`` are forwarded verbatim to ``httpx.Client(...)`` so + callers can attach a ``timeout``, ``transport``, ``proxy``, etc. + + Raises ``ImportError`` if ``httpx`` is not installed (it is a + transitive dependency of both ``openai`` and ``anthropic`` SDKs, so + in practice always available when this helper is reached). + """ + if not is_token_provider(token_provider): + raise ValueError( + "build_bearer_http_client requires a zero-arg callable " + "token provider" + ) + + try: + import httpx + except ImportError as exc: # pragma: no cover — httpx ships with openai/anthropic + raise ImportError( + "httpx is required for Entra ID bearer auth on Microsoft Foundry " + "Anthropic-style endpoints. It is normally a transitive " + "dependency of the openai/anthropic SDKs." + ) from exc + + def _inject_bearer(request: "httpx.Request") -> None: + try: + token = materialize_bearer_for_http(token_provider) + except ValueError as exc: + # Token provider failed (chain exhausted, token service unreachable, + # az login expired, etc.). Strip any auth headers the SDK + # may have set — including our own placeholder sentinel + # ``entra-id-bearer-via-http-hook`` from + # ``_build_anthropic_client_with_bearer_hook`` — so the + # outbound request hits Azure with NO Authorization rather + # than with the placeholder. Azure returns a clean 401 + # "missing auth" that is easier to diagnose than a 401 + # against the sentinel string, and the sentinel never + # appears in upstream access logs. + # + # Log at WARNING (not DEBUG) so the misconfiguration is + # visible at default log levels. + logger.warning( + "Bearer hook: Entra ID token provider returned empty (%s) " + "— stripping Authorization headers. Azure will respond 401. " + "Run `hermes doctor` or `az login` to recover.", + exc, + ) + for header_name in ("Authorization", "authorization", "Api-Key", "api-key", "X-Api-Key", "x-api-key"): + request.headers.pop(header_name, None) + return + for header_name in ("Authorization", "authorization", "Api-Key", "api-key", "X-Api-Key", "x-api-key"): + request.headers.pop(header_name, None) + request.headers["Authorization"] = f"Bearer {token}" + + return httpx.Client( + event_hooks={"request": [_inject_bearer]}, + **httpx_kwargs, + ) + + +__all__ = [ + "EntraIdentityConfig", + "SCOPE_AI_AZURE_DEFAULT", + "build_bearer_http_client", + "build_credential", + "build_token_provider", + "describe_active_credential", + "has_azure_identity_credentials", + "has_azure_identity_installed", + "is_token_provider", + "materialize_bearer_for_http", + "reset_credential_cache", +] diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index ee5b957bf2..350a54e406 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -866,9 +866,14 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool # the fallback activation drops to 128K even when config says 204800. if hasattr(agent, 'context_compressor') and agent.context_compressor: from agent.model_metadata import get_model_context_length + # ``agent.api_key`` may be callable (Entra ID); the + # context-length resolver expects a string for live + # probes. Foundry typically resolves via config/static + # catalogs anyway, so coerce defensively. + _fb_ctx_api_key = agent.api_key if isinstance(agent.api_key, str) else "" fb_context_length = get_model_context_length( agent.model, base_url=agent.base_url, - api_key=agent.api_key, provider=agent.provider, + api_key=_fb_ctx_api_key, provider=agent.provider, config_context_length=getattr(agent, "_config_context_length", None), custom_providers=getattr(agent, "_custom_providers", None), ) @@ -876,7 +881,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool model=agent.model, context_length=fb_context_length, base_url=agent.base_url, - api_key=getattr(agent, "api_key", ""), + api_key=getattr(agent, "api_key", ""), # callable preserved → call_llm provider=agent.provider, ) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 8eadcf26ef..41983fabba 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -486,7 +486,7 @@ class ContextCompressor(ContextEngine): model: str, context_length: int, base_url: str = "", - api_key: str = "", + api_key: Any = "", provider: str = "", api_mode: str = "", ) -> None: diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index bc70623997..e9aa6c8f68 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -103,7 +103,15 @@ def check_compression_model_feasibility(agent: Any) -> None: return aux_base_url = str(getattr(client, "base_url", "")) - aux_api_key = str(getattr(client, "api_key", "")) + # ``client.api_key`` may be a callable (Azure Foundry Entra ID + # bearer provider). The context-length resolver chain expects a + # string, but it only needs a key for live catalogue probes + # (provider model lists). For Entra clients the model-metadata + # chain still resolves via models.dev + hardcoded family + # fallbacks, which don't require auth — pass empty string rather + # than minting a bearer JWT just to look up a context length. + _raw_aux_key = getattr(client, "api_key", "") + aux_api_key = "" if (callable(_raw_aux_key) and not isinstance(_raw_aux_key, str)) else str(_raw_aux_key or "") aux_context = get_model_context_length( aux_model, diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index d3d47a5a10..98f65e1f7f 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -1807,7 +1807,11 @@ def run_conversation( # that survives message/tool sanitization (#6843). _credential_sanitized = False _raw_key = getattr(agent, "api_key", None) or "" - if _raw_key: + # Entra ID bearer providers are callables — their + # minted JWTs are always ASCII, so no sanitization + # is needed (and ``_strip_non_ascii`` would crash + # on a callable input). + if _raw_key and isinstance(_raw_key, str): _clean_key = _strip_non_ascii(_raw_key) if _clean_key != _raw_key: agent.api_key = _clean_key @@ -2080,15 +2084,26 @@ def run_conversation( ): anthropic_auth_retry_attempted = True from agent.anthropic_adapter import _is_oauth_token + from agent.azure_identity_adapter import is_token_provider if agent._try_refresh_anthropic_client_credentials(): print(f"{agent.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...") continue # Credential refresh didn't help — show diagnostic info key = agent._anthropic_api_key - auth_method = "Bearer (OAuth/setup-token)" if _is_oauth_token(key) else "x-api-key (API key)" print(f"{agent.log_prefix}🔐 Anthropic 401 — authentication failed.") - print(f"{agent.log_prefix} Auth method: {auth_method}") - print(f"{agent.log_prefix} Token prefix: {key[:12]}..." if key and len(key) > 12 else f"{agent.log_prefix} Token: (empty or short)") + if is_token_provider(key): + # Azure Foundry Entra ID — the bearer token is + # minted per-request by an httpx event hook on a + # custom http_client passed to the SDK. The 401 + # means Azure rejected the JWT (RBAC role missing, + # az login expired, IMDS unreachable, etc.). + print(f"{agent.log_prefix} Auth method: Microsoft Entra ID (httpx event hook)") + print(f"{agent.log_prefix} Run `hermes doctor` for credential-chain diagnostics, or") + print(f"{agent.log_prefix} `az login` if your developer session expired.") + else: + auth_method = "Bearer (OAuth/setup-token)" if _is_oauth_token(key) else "x-api-key (API key)" + print(f"{agent.log_prefix} Auth method: {auth_method}") + print(f"{agent.log_prefix} Token prefix: {key[:12]}..." if isinstance(key, str) and len(key) > 12 else f"{agent.log_prefix} Token: (empty or short)") print(f"{agent.log_prefix} Troubleshooting:") from hermes_constants import display_hermes_home as _dhh_fn _dhh = _dhh_fn() diff --git a/batch_runner.py b/batch_runner.py index a67037171b..2893619895 100644 --- a/batch_runner.py +++ b/batch_runner.py @@ -862,13 +862,32 @@ class BatchRunner: "last_updated": None } - # Prepare configuration for workers + # Prepare configuration for workers. + # + # ``self.api_key`` may be a zero-arg callable (Azure Foundry Entra ID + # bearer provider returned by ``agent.azure_identity_adapter``). Such + # closures are not safely picklable across the multiprocessing.Pool + # boundary. Drop the callable here and let each worker rebuild its + # own provider via ``resolve_runtime_provider()``, which reads + # ``model.auth_mode`` from ``config.yaml`` and constructs a fresh + # token provider in the worker process (azure-identity caches + # in-process so each worker gets its own short-lived cache). + if callable(self.api_key) and not isinstance(self.api_key, str): + worker_api_key = None + print( + "â„šī¸ Detected Entra ID bearer provider — workers will rebuild " + "credentials from config.yaml in each process.", + flush=True, + ) + else: + worker_api_key = self.api_key + config = { "distribution": self.distribution, "model": self.model, "max_iterations": self.max_iterations, "base_url": self.base_url, - "api_key": self.api_key, + "api_key": worker_api_key, "verbose": self.verbose, "ephemeral_system_prompt": self.ephemeral_system_prompt, "log_prefix_chars": self.log_prefix_chars, diff --git a/cli-config.yaml.example b/cli-config.yaml.example index f5fb715638..68c716daab 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -30,6 +30,7 @@ model: # "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY — https://ollama.com/settings) # "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY) # "ai-gateway" - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY) + # "azure-foundry" - Microsoft Foundry / Azure OpenAI (API key or Entra ID) # "lmstudio" - LM Studio local server (optional: LM_API_KEY, defaults to http://127.0.0.1:1234/v1) # # Local servers (LM Studio, Ollama, vLLM, llama.cpp): @@ -45,6 +46,14 @@ model: # api_key: "your-key-here" # Uncomment to set here instead of .env base_url: "https://openrouter.ai/api/v1" + # Azure Foundry keyless auth example: + # provider: "azure-foundry" + # base_url: "https://.openai.azure.com/openai/v1" + # auth_mode: "entra_id" # DefaultAzureCredential: az login, managed identity, workload identity, etc. + # default: "gpt-4o" # Deployment/model name + # entra: + # scope: "https://ai.azure.com/.default" # Optional; this is the default. + # ── Token limits — two settings, easy to confuse ────────────────────────── # # context_length: TOTAL context window (input + output tokens combined). diff --git a/cli.py b/cli.py index 6b62493d60..e9169de674 100644 --- a/cli.py +++ b/cli.py @@ -4251,7 +4251,13 @@ class HermesCLI: resolved_acp_command = runtime.get("command") resolved_acp_args = list(runtime.get("args") or []) resolved_credential_pool = runtime.get("credential_pool") - if not isinstance(api_key, str) or not api_key: + # A callable api_key is a bearer-token provider (Azure Foundry + # Entra ID — ``azure_identity_adapter.build_token_provider``). + # The OpenAI SDK accepts ``Callable[[], str]`` for ``api_key`` and + # invokes it before every request. Skip the string-only validation + # and placeholder substitution for callables. + _is_callable_provider = callable(api_key) and not isinstance(api_key, str) + if not _is_callable_provider and (not isinstance(api_key, str) or not api_key): # Custom / local endpoints (llama.cpp, ollama, vLLM, etc.) often # don't require authentication. When a base_url IS configured but # no API key was found, use a placeholder so the OpenAI SDK @@ -5723,7 +5729,15 @@ class HermesCLI: config_path = project_config_path config_status = "(loaded)" if config_path.exists() else "(not found)" - api_key_display = '********' + self.api_key[-4:] if self.api_key and len(self.api_key) > 4 else 'Not set!' + # ``self.api_key`` may be a callable (Azure Foundry Entra ID bearer + # provider). Never invoke it; just identify the auth surface. + from agent.azure_identity_adapter import is_token_provider + if is_token_provider(self.api_key): + api_key_display = "Microsoft Entra ID" + elif isinstance(self.api_key, str) and len(self.api_key) > 12: + api_key_display = f"{self.api_key[:8]}...{self.api_key[-4:]}" + else: + api_key_display = "Not set!" print() title = "(^_^) Configuration" diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index cb97a4c230..df4de463a5 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -5334,7 +5334,9 @@ def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]: def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: """Generic auth status dispatcher.""" - target = provider_id or get_active_provider() + 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": @@ -5351,6 +5353,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: return get_minimax_oauth_auth_status() if target == "copilot-acp": return get_external_process_provider_status(target) + if target == "azure-foundry": + return _get_azure_foundry_auth_status() # API-key providers pconfig = PROVIDER_REGISTRY.get(target) if pconfig and pconfig.auth_type == "api_key": @@ -5365,6 +5369,83 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: 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. + + Never invokes the Entra credential chain — keeps CLI startup latency + flat regardless of token-service / az login state. + """ + info: Dict[str, Any] = {"provider": "azure-foundry"} + try: + from hermes_cli.config import load_config, get_env_value + cfg = load_config() + except Exception: + cfg = {} + + model_cfg = cfg.get("model") if isinstance(cfg, dict) else None + auth_mode = "api_key" + base_url = "" + if isinstance(model_cfg, dict): + auth_mode = str(model_cfg.get("auth_mode") or "api_key").strip().lower() or "api_key" + base_url = str(model_cfg.get("base_url") or "").strip() + info["auth_mode"] = auth_mode + info["base_url"] = base_url + + if auth_mode == "entra_id": + try: + from agent.azure_identity_adapter import ( + EntraIdentityConfig, + SCOPE_AI_AZURE_DEFAULT, + has_azure_identity_installed, + ) + installed = has_azure_identity_installed() + entra_cfg = {} + if isinstance(model_cfg, dict) and isinstance(model_cfg.get("entra"), dict): + entra_cfg = model_cfg["entra"] + identity_config = EntraIdentityConfig.from_dict( + entra_cfg, + default_scope=SCOPE_AI_AZURE_DEFAULT, + ) + info["azure_identity_installed"] = installed + info["scope"] = identity_config.scope + info["credential_probe"] = "not_run" + info["credential_verified"] = False + info["logged_in"] = bool(installed) + if not installed: + info["hint"] = ( + "azure-identity not installed. Install with: " + "pip install azure-identity (or rely on Hermes' " + "lazy-install at first use)." + ) + else: + info["hint"] = ( + "azure-identity is installed; live credential validation " + "is skipped here. Run `hermes doctor` to verify token acquisition." + ) + return info + except Exception as exc: + info["logged_in"] = False + info["error"] = f"azure-identity check failed: {exc}" + return info + + # api_key mode (default) + try: + api_key = get_env_value("AZURE_FOUNDRY_API_KEY") or os.getenv("AZURE_FOUNDRY_API_KEY", "") + except Exception: + api_key = os.getenv("AZURE_FOUNDRY_API_KEY", "") + info["logged_in"] = has_usable_secret(api_key) + return info + + def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: """Resolve API key and base URL for an API-key provider. diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 10b040d8a1..998f72b3e6 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -566,6 +566,54 @@ def _interactive_auth() -> None: 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(f"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() # Main menu diff --git a/hermes_cli/azure_detect.py b/hermes_cli/azure_detect.py index 8dd0d632a9..1420d9334d 100644 --- a/hermes_cli/azure_detect.py +++ b/hermes_cli/azure_detect.py @@ -1,6 +1,6 @@ """Azure Foundry endpoint auto-detection. -Inspect an Azure AI Foundry / Azure OpenAI endpoint to determine: +Inspect a Microsoft Foundry / Azure OpenAI endpoint to determine: - API transport (OpenAI-style ``chat_completions`` vs Anthropic-style ``anthropic_messages``) - Available models (best effort — Azure does not expose a deployment @@ -19,6 +19,16 @@ rather than the user's *deployed* deployment names. In practice it is still a useful hint — the user picks a familiar model name and we look up its context length from the catalog. +Authentication modes: + - ``api_key`` (default): the wizard passes an ``api_key`` string; the + probe sends both ``api-key:`` and ``Authorization: Bearer`` headers + so we hit any Azure deployment regardless of which header it expects. + - ``entra_id``: the wizard passes a ``token_provider`` callable from + :mod:`agent.azure_identity_adapter`. The probe mints exactly one + bearer JWT, sends **only** ``Authorization: Bearer `` (never + ``api-key:``), and never persists the token. This matches Microsoft's + documented contract for keyless inference. + The detector never crashes on errors (every HTTP call is wrapped in a broad try/except). Callers get a :class:`DetectionResult` with whatever information could be gathered, and fall back to manual entry for the @@ -31,7 +41,7 @@ import json import logging import re from dataclasses import dataclass, field -from typing import Optional +from typing import Any, Callable, Optional from urllib import request as urllib_request from urllib.error import HTTPError, URLError from urllib.parse import urlparse @@ -79,15 +89,73 @@ class DetectionResult: is_anthropic: bool = False -def _http_get_json(url: str, api_key: str, timeout: float = 6.0) -> tuple[int, Optional[dict]]: - """GET a URL with ``api-key`` + ``Authorization`` headers. Return +def _resolve_credential(api_key: Any, + token_provider: Optional[Callable[[], str]] = None, + ) -> tuple[Optional[str], str]: + """Coerce wizard inputs into a (token, mode) pair. + + Returns ``(token_or_None, mode)`` where ``mode`` is: + - ``"entra_id"`` when a callable token provider was supplied — the + returned token is a freshly minted bearer JWT, sent ONLY in + ``Authorization: Bearer``. + - ``"api_key"`` when a string key was supplied — the returned token + is the raw API key, sent in BOTH ``api-key:`` and + ``Authorization: Bearer`` headers (preserves the original + broad-compat probe behaviour). + - ``("", "api_key")`` when neither yields a value. + + Bearer minting failures degrade to ``("", "entra_id")`` so the caller + can still report "detection incomplete" rather than crashing. + """ + # Token-provider path (callable wins when both supplied). + if token_provider is not None and callable(token_provider): + try: + token = token_provider() + return (str(token) if token else None), "entra_id" + except Exception as exc: + logger.debug("azure_detect: token_provider failed: %s", exc) + return None, "entra_id" + if callable(api_key) and not isinstance(api_key, str): + try: + token = api_key() + return (str(token) if token else None), "entra_id" + except Exception as exc: + logger.debug("azure_detect: api_key callable failed: %s", exc) + return None, "entra_id" + # API-key path. + if isinstance(api_key, str) and api_key: + return api_key, "api_key" + return None, "api_key" + + +def _apply_auth_headers(req: urllib_request.Request, + token: Optional[str], + mode: str) -> None: + """Attach the right auth headers to ``req`` based on credential mode.""" + if not token: + return + if mode == "entra_id": + # Bearer-only: do NOT also set api-key, which would log a JWT in + # a header slot intended for static keys. + req.add_header("Authorization", f"Bearer {token}") + else: + # Legacy broad-compat behaviour: send both headers so we land on + # any Azure resource regardless of which it accepts. + req.add_header("api-key", token) + req.add_header("Authorization", f"Bearer {token}") + + +def _http_get_json(url: str, + api_key: Any, + timeout: float = 6.0, + *, + token_provider: Optional[Callable[[], str]] = None, + ) -> tuple[int, Optional[dict]]: + """GET a URL with the appropriate auth headers. Return ``(status_code, parsed_json_or_None)``. Never raises.""" + token, mode = _resolve_credential(api_key, token_provider) req = urllib_request.Request(url, method="GET") - # Azure OpenAI uses ``api-key``. Some Azure deployments (and - # Anthropic-style routes) use ``Authorization: Bearer``. Send both - # so we probe once per URL rather than twice. - req.add_header("api-key", api_key) - req.add_header("Authorization", f"Bearer {api_key}") + _apply_auth_headers(req, token, mode) req.add_header("User-Agent", "hermes-agent/azure-detect") try: with urllib_request.urlopen(req, timeout=timeout) as resp: @@ -140,7 +208,11 @@ def _extract_model_ids(payload: dict) -> list[str]: return ids -def _probe_openai_models(base_url: str, api_key: str) -> tuple[bool, list[str]]: +def _probe_openai_models(base_url: str, + api_key: Any, + *, + token_provider: Optional[Callable[[], str]] = None, + ) -> tuple[bool, list[str]]: """Probe ``/models`` for an OpenAI-shaped response. Returns ``(ok, models)``. ``ok`` is True iff the endpoint accepted @@ -156,7 +228,7 @@ def _probe_openai_models(base_url: str, api_key: str) -> tuple[bool, list[str]]: candidates.append(f"{base_url}/models?api-version={v}") for url in candidates: - status, body = _http_get_json(url, api_key) + status, body = _http_get_json(url, api_key, token_provider=token_provider) if status == 200 and body is not None: ids = _extract_model_ids(body) if ids: @@ -172,7 +244,11 @@ def _probe_openai_models(base_url: str, api_key: str) -> tuple[bool, list[str]]: return False, [] -def _probe_anthropic_messages(base_url: str, api_key: str) -> bool: +def _probe_anthropic_messages(base_url: str, + api_key: Any, + *, + token_provider: Optional[Callable[[], str]] = None, + ) -> bool: """Send a zero-token request to ``/v1/messages`` and check whether the endpoint at least *recognises* the Anthropic Messages shape (any 4xx that mentions ``messages`` or ``model``, or a 400 @@ -187,8 +263,8 @@ def _probe_anthropic_messages(base_url: str, api_key: str) -> bool: "messages": [{"role": "user", "content": "ping"}], }).encode("utf-8") req = urllib_request.Request(url, method="POST", data=payload) - req.add_header("api-key", api_key) - req.add_header("Authorization", f"Bearer {api_key}") + token, mode = _resolve_credential(api_key, token_provider) + _apply_auth_headers(req, token, mode) req.add_header("anthropic-version", "2023-06-01") req.add_header("content-type", "application/json") req.add_header("User-Agent", "hermes-agent/azure-detect") @@ -218,13 +294,23 @@ def _probe_anthropic_messages(base_url: str, api_key: str) -> bool: return False -def detect(base_url: str, api_key: str) -> DetectionResult: +def detect(base_url: str, + api_key: Any = "", + *, + token_provider: Optional[Callable[[], str]] = None, + ) -> DetectionResult: """Inspect an Azure endpoint and describe its transport + models. Call this from the wizard before asking the user to pick an API mode manually. The caller should treat the returned :class:`DetectionResult` as *advisory* — if ``api_mode`` is None, fall back to asking the user. + + ``api_key`` may be a string (legacy API-key auth — sends both + ``api-key:`` and ``Authorization: Bearer``) or a callable returning + a bearer JWT (Entra ID auth — sends ONLY ``Authorization: Bearer``). + ``token_provider`` is an alternative explicit name for the callable + form; if both are supplied the callable wins. """ result = DetectionResult() @@ -244,7 +330,7 @@ def detect(base_url: str, api_key: str) -> DetectionResult: # 2. Try the OpenAI-style /models probe. If this works, the # endpoint definitely speaks OpenAI wire. - ok, models = _probe_openai_models(base_url, api_key) + ok, models = _probe_openai_models(base_url, api_key, token_provider=token_provider) if ok: result.models_probe_ok = True result.models = models @@ -259,7 +345,7 @@ def detect(base_url: str, api_key: str) -> DetectionResult: # 3. Fallback: probe the Anthropic Messages shape. Slower and more # intrusive than /models, so only run it when the OpenAI probe # failed. - if _probe_anthropic_messages(base_url, api_key): + if _probe_anthropic_messages(base_url, api_key, token_provider=token_provider): result.is_anthropic = True result.api_mode = "anthropic_messages" result.reason = "Endpoint accepts Anthropic Messages shape" @@ -273,11 +359,26 @@ def detect(base_url: str, api_key: str) -> DetectionResult: return result -def lookup_context_length(model: str, base_url: str, api_key: str) -> Optional[int]: +def lookup_context_length(model: str, + base_url: str, + api_key: Any = "", + *, + token_provider: Optional[Callable[[], str]] = None, + ) -> Optional[int]: """Thin wrapper around :func:`agent.model_metadata.get_model_context_length` that returns ``None`` when only the fallback default (128k) would fire, so the wizard can distinguish "we actually know this" from - "we guessed.""" + "we guessed. + + For Entra-ID mode pass a callable as ``api_key`` (or via + ``token_provider=``); the wrapped resolver expects a string, so we + mint one bearer JWT here for the single lookup. The resolver itself + only reads catalog metadata over HTTP — no SDK client is built — so + the minted token is consumed for at most one /models probe. + """ + model_id = str(model or "").strip() + if not model_id: + return None try: from agent.model_metadata import ( DEFAULT_FALLBACK_CONTEXT, @@ -286,8 +387,13 @@ def lookup_context_length(model: str, base_url: str, api_key: str) -> Optional[i except Exception: return None + # Resolve the credential once. For Entra mode this calls the token + # provider; for legacy api_key this is a no-op string pass-through. + token, mode = _resolve_credential(api_key, token_provider) + effective_key = token or "" + try: - n = get_model_context_length(model, base_url=base_url, api_key=api_key) + n = get_model_context_length(model_id, base_url=base_url, api_key=effective_key) except Exception as exc: logger.debug("azure_detect: context length lookup failed: %s", exc) return None diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 4440b38682..dab22e2640 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -1613,6 +1613,87 @@ def run_doctor(args): f"bedrock:ListFoundationModels"], ) + def _probe_azure_entra() -> _ConnectivityResult: + """Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``. + + Skipped unless the active config has ``model.provider: + azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe + the token-service / CLI chain for users on plain API-key Azure. + + Bounded by a 10s timeout (via + :func:`agent.azure_identity_adapter.describe_active_credential`) + so a slow token service can't pad the doctor run. + """ + label = "Azure Foundry (Entra ID)".ljust(28) + try: + from hermes_cli.config import load_config + cfg = load_config() + model_cfg = cfg.get("model") if isinstance(cfg, dict) else {} + if not isinstance(model_cfg, dict): + return _ConnectivityResult("Azure Foundry (Entra ID)", [], []) + cfg_provider = str(model_cfg.get("provider") or "").strip().lower() + auth_mode = str(model_cfg.get("auth_mode") or "").strip().lower() + if cfg_provider != "azure-foundry" or auth_mode != "entra_id": + return _ConnectivityResult("Azure Foundry (Entra ID)", [], []) + except Exception: + return _ConnectivityResult("Azure Foundry (Entra ID)", [], []) + + try: + from agent.azure_identity_adapter import ( + EntraIdentityConfig, + SCOPE_AI_AZURE_DEFAULT, + describe_active_credential, + has_azure_identity_installed, + ) + except Exception as exc: + return _ConnectivityResult( + "Azure Foundry (Entra ID)", + [(color("⚠", Colors.YELLOW), label, + color(f"(adapter import failed: {exc})", Colors.DIM))], + [f"Azure Foundry adapter import failed: {exc}"], + ) + + if not has_azure_identity_installed(): + return _ConnectivityResult( + "Azure Foundry (Entra ID)", + [(color("⚠", Colors.YELLOW), label, + color("(azure-identity not installed)", Colors.DIM))], + [f"Install azure-identity: {sys.executable} -m pip install azure-identity"], + ) + + base_url = str(model_cfg.get("base_url") or "").strip() + entra_cfg = model_cfg.get("entra") or {} + if not isinstance(entra_cfg, dict): + entra_cfg = {} + scope = ( + str(entra_cfg.get("scope") or "").strip() + or SCOPE_AI_AZURE_DEFAULT + ) + config = EntraIdentityConfig( + scope=scope, + ) + info = describe_active_credential(config=config, timeout_seconds=10.0) + if info.get("ok"): + env_sources = info.get("env_sources") or [] + tag = ", ".join(env_sources) if env_sources else "default credential chain" + return _ConnectivityResult( + "Azure Foundry (Entra ID)", + [(color("✓", Colors.GREEN), label, + color(f"({tag}, scope={scope})", Colors.DIM))], + [], + ) + err = info.get("error") or "credential chain exhausted" + hint = info.get("hint") or ( + "Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/" + "AZURE_CLIENT_SECRET, or attach a managed identity to this VM." + ) + return _ConnectivityResult( + "Azure Foundry (Entra ID)", + [(color("⚠", Colors.YELLOW), label, + color(f"({err})", Colors.DIM))], + [f"Azure Foundry Entra: {err}. {hint}"], + ) + # Build the probe submission list in display order _probes.append(("OpenRouter API", _probe_openrouter)) _probes.append(("Anthropic API", _probe_anthropic)) @@ -1630,6 +1711,7 @@ def run_doctor(args): _probe_apikey_provider(p, e, u, b, s))) _probes.append(("AWS Bedrock", _probe_bedrock)) + _probes.append(("Azure Foundry (Entra ID)", _probe_azure_entra)) # Print a single status line so users see something happening, then # fan out. ``\r`` clears it once the first real result line lands. diff --git a/hermes_cli/main.py b/hermes_cli/main.py index fe28754367..48bf6675b3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3535,11 +3535,27 @@ def _save_custom_provider( def _model_flow_azure_foundry(config, current_model=""): - """Azure Foundry provider: configure endpoint, API mode, API key, and model. + """Azure Foundry provider: configure endpoint, auth mode, API mode, and model. Azure Foundry supports both OpenAI-style (``/v1/chat/completions``) and - Anthropic-style (``/v1/messages``) endpoints. The wizard auto-detects - the transport and available models when possible: + Anthropic-style (``/v1/messages``) endpoints, and two authentication + modes: + + * **API key** (default) — uses ``AZURE_FOUNDRY_API_KEY`` from .env. + * **Microsoft Entra ID** — keyless, RBAC-based auth via the + ``azure-identity`` SDK (Managed Identity / Workload Identity / az + login / VS Code / azd / service principal env vars). Works on both + OpenAI-style and Anthropic-style endpoints — Microsoft RBAC is + per-resource and the same ``Azure AI User`` role grants + both. For OpenAI-style the OpenAI SDK's native callable + ``api_key=`` contract is used; for Anthropic-style an + ``httpx.Client`` with a request event hook (built by + :func:`agent.azure_identity_adapter.build_bearer_http_client`) + mints a fresh JWT per request because the Anthropic SDK does not + accept a callable ``auth_token`` natively. + + The wizard auto-detects the transport and available models when + possible: * URLs ending in ``/anthropic`` → Anthropic Messages API. * Successful ``GET /models`` probe → OpenAI-style + populates @@ -3566,9 +3582,14 @@ def _model_flow_azure_foundry(config, current_model=""): if isinstance(model_cfg, dict) and model_cfg.get("provider") == "azure-foundry": current_base_url = str(model_cfg.get("base_url", "") or "") current_api_mode = str(model_cfg.get("api_mode", "") or "") + current_auth_mode = str(model_cfg.get("auth_mode") or "api_key").strip().lower() or "api_key" + _cur_entra = model_cfg.get("entra") or {} + current_entra = _cur_entra if isinstance(_cur_entra, dict) else {} else: current_base_url = "" current_api_mode = "" + current_auth_mode = "api_key" + current_entra = {} current_api_key = get_env_value("AZURE_FOUNDRY_API_KEY") or "" @@ -3583,22 +3604,29 @@ def _model_flow_azure_foundry(config, current_model=""): print() if current_base_url: - print(f" Current endpoint: {current_base_url}") + print(f" Current endpoint: {current_base_url}") if current_api_mode: _lbl = ( "OpenAI-style" if current_api_mode == "chat_completions" else "Anthropic-style" ) - print(f" Current API mode: {_lbl}") - if current_api_key: - print(f" Current API key: {current_api_key[:8]}...") + print(f" Current API mode: {_lbl}") + if current_auth_mode == "entra_id": + print(f" Current auth mode: Microsoft Entra ID (keyless)") + elif current_api_key: + print(f" Current auth mode: API key ({current_api_key[:8]}...)") print() # ── Step 1: endpoint URL ───────────────────────────────────────── try: + _placeholder = ( + current_base_url + or "e.g. https://.openai.azure.com/openai/v1 " + "or https://.services.ai.azure.com/anthropic" + ) base_url = input( - f"API endpoint URL [{current_base_url or 'e.g. https://your-resource.openai.azure.com/openai/v1'}]: " + f"API endpoint URL [{_placeholder}]: " ).strip() except (KeyboardInterrupt, EOFError): print("\nCancelled.") @@ -3612,25 +3640,125 @@ def _model_flow_azure_foundry(config, current_model=""): print(f"Invalid URL: {effective_url} (must start with http:// or https://)") return - # ── Step 2: API key ────────────────────────────────────────────── + # ── Step 2: authentication mode ────────────────────────────────── print() + print("Authentication:") + print(" 1. API key (AZURE_FOUNDRY_API_KEY in .env)") + print(" 2. Microsoft Entra ID (managed identity / workload identity / az login)") + print(" Recommended by Microsoft. Works for both OpenAI-style and Anthropic-style endpoints.") + print(" Requires the 'Azure AI User' role on the Foundry resource.") try: - api_key = getpass.getpass( - f"API key [{current_api_key[:8] + '...' if current_api_key else 'required'}]: " - ).strip() + _auth_default = "2" if current_auth_mode == "entra_id" else "1" + auth_choice = ( + input(f"Authentication mode [1/2] ({_auth_default}): ").strip() + or _auth_default + ) except (KeyboardInterrupt, EOFError): print("\nCancelled.") return + use_entra = auth_choice == "2" + auth_mode_label = "entra_id" if use_entra else "api_key" - effective_key = api_key or current_api_key - if not effective_key: - print("No API key provided. Cancelled.") - return + # ── Step 3: credentials (key OR Entra preflight) ───────────────── + effective_key: str = "" + entra_overrides: dict = {} + token_provider = None # callable when entra + entra_scope = "" - # ── Step 3: auto-detect transport + models ─────────────────────── + if use_entra: + try: + from agent.azure_identity_adapter import ( + EntraIdentityConfig, + SCOPE_AI_AZURE_DEFAULT, + build_token_provider, + describe_active_credential, + has_azure_identity_installed, + ) + except ImportError as exc: + print() + print(f"⚠ Could not import azure-identity adapter: {exc}") + print(" Falling back to API key auth.") + use_entra = False + auth_mode_label = "api_key" + + if use_entra: + print() + if not has_azure_identity_installed(): + print("◐ The 'azure-identity' package is not installed yet.") + print( + " Hermes will install it now (the preflight below " + "triggers the lazy-install). To skip lazy installs, " + "run: pip install azure-identity" + ) + + # Preserve only the optional scope override. Identity selection + # (tenant, user-assigned MI, workload identity, service principal) + # stays in Azure SDK env vars such as AZURE_CLIENT_ID. + _persisted_scope_override = str(current_entra.get("scope") or "").strip() + entra_scope = _persisted_scope_override or SCOPE_AI_AZURE_DEFAULT + + entra_overrides = {} + if _persisted_scope_override: + entra_overrides["scope"] = _persisted_scope_override + + print() + print("◐ Probing Microsoft Entra ID credential chain (up to 10s)...") + _config = EntraIdentityConfig( + scope=entra_scope, + ) + info = describe_active_credential(config=_config, timeout_seconds=10.0) + if info.get("ok"): + env_sources = info.get("env_sources") or [] + tag = ", ".join(env_sources) if env_sources else "default chain" + print(f"✓ Entra ID token acquired ({tag}, scope={entra_scope})") + else: + err = info.get("error") or "credential chain exhausted" + hint = info.get("hint") or ( + "Run `az login`, attach a managed identity to this VM, or " + "set AZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET." + ) + print(f"⚠ {err}") + print(f" Hint: {hint}") + try: + ans = input("Save Entra config anyway and validate later? [Y/n]: ").strip().lower() + except (KeyboardInterrupt, EOFError): + print("\nCancelled.") + return + if ans and ans not in ("y", "yes"): + print("Cancelled.") + return + + # Build the token provider for the detection probe (best-effort — + # if the credential chain failed above, this will silently return + # None inside azure_detect and the probe falls back to manual). + try: + token_provider = build_token_provider(config=_config) + except Exception as exc: + print(f"⚠ Could not build token provider for probing: {exc}") + token_provider = None + else: + print() + try: + api_key = getpass.getpass( + f"API key [{current_api_key[:8] + '...' if current_api_key else 'required'}]: " + ).strip() + except (KeyboardInterrupt, EOFError): + print("\nCancelled.") + return + + effective_key = api_key or current_api_key + if not effective_key: + print("No API key provided. Cancelled.") + return + + # ── Step 4: auto-detect transport + models ─────────────────────── print() print("◐ Probing endpoint to auto-detect transport and models...") - detection = azure_detect.detect(effective_url, effective_key) + detection = azure_detect.detect( + effective_url, + api_key=effective_key, + token_provider=token_provider, + ) discovered_models: list[str] = list(detection.models) api_mode: str = detection.api_mode or "" @@ -3665,7 +3793,7 @@ def _model_flow_azure_foundry(config, current_model=""): return api_mode = "anthropic_messages" if mode_choice == "2" else "chat_completions" - # ── Step 4: model name ─────────────────────────────────────────── + # ── Step 5: model name ─────────────────────────────────────────── print() effective_model = "" if discovered_models: @@ -3704,15 +3832,17 @@ def _model_flow_azure_foundry(config, current_model=""): print("No model name provided. Cancelled.") return - # ── Step 5: context-length lookup ──────────────────────────────── + # ── Step 6: context-length lookup ──────────────────────────────── ctx_len = azure_detect.lookup_context_length( effective_model, effective_url, - effective_key, + api_key=effective_key, + token_provider=token_provider, ) - # ── Step 6: persist ────────────────────────────────────────────── - save_env_value("AZURE_FOUNDRY_API_KEY", effective_key) + # ── Step 7: persist ────────────────────────────────────────────── + if not use_entra: + save_env_value("AZURE_FOUNDRY_API_KEY", effective_key) cfg = load_config() model = cfg.get("model") @@ -3724,6 +3854,22 @@ def _model_flow_azure_foundry(config, current_model=""): model["base_url"] = effective_url model["api_mode"] = api_mode model["default"] = effective_model + model["auth_mode"] = auth_mode_label + if use_entra: + # Persist only the non-default Entra scope so config.yaml stays tidy. + # Azure identity selection stays in standard AZURE_* env vars. + clean_entra: dict = {} + for key in ("scope",): + val = entra_overrides.get(key) + if val: + clean_entra[key] = val + if clean_entra: + model["entra"] = clean_entra + elif "entra" in model: + del model["entra"] + else: + if "entra" in model: + del model["entra"] if ctx_len: model["context_length"] = ctx_len @@ -3739,10 +3885,14 @@ def _model_flow_azure_foundry(config, current_model=""): save_env_value("OPENAI_API_KEY", "") mode_label = "OpenAI-style" if api_mode == "chat_completions" else "Anthropic-style" + auth_label = ( + "Microsoft Entra ID (keyless)" if use_entra else "API key" + ) print() print("✓ Azure Foundry configured:") print(f" Endpoint: {effective_url}") print(f" API mode: {mode_label}") + print(f" Auth: {auth_label}") print(f" Model: {effective_model}") if ctx_len: print(f" Context length: {ctx_len:,} tokens") diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index de32131d86..11fd9f564c 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -744,6 +744,15 @@ def _resolve_azure_foundry_runtime( strips a trailing ``/v1`` for Anthropic-style endpoints because the Anthropic SDK appends ``/v1/messages`` internally. + When ``model.auth_mode == "entra_id"`` (and the model is OpenAI-style), + the returned ``api_key`` is a zero-arg callable produced by + :func:`agent.azure_identity_adapter.build_token_provider` rather than + a string. Downstream code that constructs an OpenAI SDK client passes + this through unchanged (the SDK accepts ``Callable[[], str]`` for + ``api_key`` and calls it before every request). Code paths that need + a string (logging, manual HTTP probes, header injection) must use the + helpers in ``agent.azure_identity_adapter``. + Raises :class:`AuthError` when required values are missing. """ explicit_api_key = str(explicit_api_key or "").strip() @@ -752,9 +761,15 @@ def _resolve_azure_foundry_runtime( cfg_provider = str(model_cfg.get("provider") or "").strip().lower() cfg_base_url = "" cfg_api_mode = "chat_completions" + cfg_auth_mode = "api_key" + cfg_entra: Dict[str, Any] = {} if cfg_provider == "azure-foundry": cfg_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/") cfg_api_mode = _parse_api_mode(model_cfg.get("api_mode")) or "chat_completions" + cfg_auth_mode = str(model_cfg.get("auth_mode") or "api_key").strip().lower() or "api_key" + _entra = model_cfg.get("entra") + if isinstance(_entra, dict): + cfg_entra = _entra # Model-family inference: Azure Foundry deploys GPT-5.x / codex / o1-o4 # reasoning models as Responses-API-only. Calling /chat/completions @@ -780,6 +795,79 @@ def _resolve_azure_foundry_runtime( "the AZURE_FOUNDRY_BASE_URL environment variable." ) + # Anthropic SDK appends /v1/messages itself, so strip any trailing /v1 + # we inherited from the configured base_url to avoid double-/v1 paths. + if cfg_api_mode == "anthropic_messages": + base_url = re.sub(r"/v1/?$", "", base_url) + + # ── Entra ID (Microsoft Foundry recommended path) ────────────────── + # + # OpenAI-style endpoints use the OpenAI SDK's native callable + # ``api_key=`` contract — the SDK mints a fresh JWT per request + # automatically. + # + # Anthropic-style endpoints (Claude on Foundry) take the callable + # too: :func:`agent.anthropic_adapter.build_anthropic_client` + # detects the callable and constructs an ``httpx.Client`` with a + # request event hook that injects a fresh ``Authorization: Bearer`` + # header per request (the Anthropic SDK does not accept callables + # natively). From the runtime resolver's perspective both modes + # are identical — return the callable api_key and let the + # downstream SDK wrapper handle the contract difference. + if cfg_auth_mode == "entra_id": + if explicit_api_key: + # User passed --api-key on the CLI while config says entra_id — + # honour the explicit string (escape hatch for one-off testing). + api_key: Any = explicit_api_key + source = "explicit" + auth_mode = "api_key" + else: + try: + from agent.azure_identity_adapter import ( + EntraIdentityConfig, + SCOPE_AI_AZURE_DEFAULT, + build_token_provider, + ) + except Exception as exc: + raise AuthError( + "Azure Foundry Entra ID auth requires the 'azure-identity' " + "package. Install it with: pip install azure-identity " + f"(import failed: {exc})" + ) from exc + + scope = ( + str(cfg_entra.get("scope") or "").strip() + or SCOPE_AI_AZURE_DEFAULT + ) + try: + entra_config = EntraIdentityConfig( + scope=scope, + ) + token_provider = build_token_provider(config=entra_config) + except ImportError as exc: + raise AuthError(str(exc)) from exc + api_key = token_provider + source = "entra_id" + auth_mode = "entra_id" + + clean_entra = {} + if auth_mode == "entra_id": + configured_scope = str(cfg_entra.get("scope") or "").strip() + if configured_scope: + clean_entra["scope"] = configured_scope + + return { + "provider": "azure-foundry", + "api_mode": cfg_api_mode, + "base_url": base_url, + "api_key": api_key, + "auth_mode": auth_mode, + "entra": clean_entra, + "source": source, + "requested_provider": requested_provider, + } + + # ── Static API key (legacy / default) ────────────────────────────── api_key = explicit_api_key if not api_key: try: @@ -792,20 +880,19 @@ def _resolve_azure_foundry_runtime( if not api_key: raise AuthError( "Azure Foundry requires an API key. Set AZURE_FOUNDRY_API_KEY in " - "~/.hermes/.env or run 'hermes model' to configure." + "~/.hermes/.env or run 'hermes model' to configure. To use " + "keyless Microsoft Entra ID auth instead, set " + "model.auth_mode: entra_id in config.yaml (or pick " + "'Microsoft Entra ID' in 'hermes model')." ) - # Anthropic SDK appends /v1/messages itself, so strip any trailing /v1 - # we inherited from the configured base_url to avoid double-/v1 paths. - if cfg_api_mode == "anthropic_messages": - base_url = re.sub(r"/v1/?$", "", base_url) - source = "explicit" if (explicit_api_key or explicit_base_url) else "config" return { "provider": "azure-foundry", "api_mode": cfg_api_mode, "base_url": base_url, "api_key": api_key, + "auth_mode": "api_key", "source": source, "requested_provider": requested_provider, } @@ -1232,7 +1319,7 @@ def resolve_runtime_provider( cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/") base_url = cfg_base_url or "https://api.anthropic.com" - # For Azure AI Foundry endpoints, use ANTHROPIC_API_KEY directly — + # For Microsoft Foundry endpoints, use ANTHROPIC_API_KEY directly — # Claude Code OAuth tokens (sk-ant-oat01) are not accepted by Azure. # Azure keys don't start with "sk-ant-" so resolve_anthropic_token() # would find the Claude Code OAuth token first (priority 3) and return diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ebf053a625..a2db00ac2c 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1288,9 +1288,15 @@ def _truncate_token(value: Optional[str], visible: int = 6) -> str: OAuth access token. JWT prefixes (the part before the first dot) are stripped first when present so the visible suffix is always part of the signing region rather than a meaningless header chunk. + + Returns the Entra-ID placeholder when handed a callable (Azure Foundry + bearer provider) — the callable is NEVER invoked here. """ if not value: return "" + if callable(value) and not isinstance(value, str): + # Entra ID bearer provider — never reveal a minted token in the UI. + return "" s = str(value) if "." in s and s.count(".") >= 2: # Looks like a JWT — show the trailing piece of the signature only. diff --git a/plugins/model-providers/azure-foundry/__init__.py b/plugins/model-providers/azure-foundry/__init__.py index a8e29f241c..50968805f5 100644 --- a/plugins/model-providers/azure-foundry/__init__.py +++ b/plugins/model-providers/azure-foundry/__init__.py @@ -1,4 +1,4 @@ -"""Azure AI Foundry provider profile. +"""Microsoft Foundry provider profile. Azure Foundry exposes an OpenAI-compatible endpoint; users supply their own base URL at setup since endpoints are per-resource. @@ -11,7 +11,7 @@ azure_foundry = ProviderProfile( name="azure-foundry", aliases=("azure", "azure-ai-foundry", "azure-ai"), display_name="Azure Foundry", - description="Azure AI Foundry — OpenAI-compatible endpoint (user-supplied base URL)", + description="Microsoft Foundry - OpenAI-compatible endpoint (user-supplied base URL)", signup_url="https://ai.azure.com/", env_vars=("AZURE_FOUNDRY_API_KEY", "AZURE_FOUNDRY_BASE_URL"), base_url="", # per-resource; user provides at setup diff --git a/plugins/model-providers/azure-foundry/plugin.yaml b/plugins/model-providers/azure-foundry/plugin.yaml index 791f82b75a..806e44d0b2 100644 --- a/plugins/model-providers/azure-foundry/plugin.yaml +++ b/plugins/model-providers/azure-foundry/plugin.yaml @@ -1,5 +1,5 @@ name: azure-foundry-provider kind: model-provider version: 1.0.0 -description: Azure AI Foundry +description: Microsoft Foundry author: Nous Research diff --git a/pyproject.toml b/pyproject.toml index cb3c515e02..344a9721a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ acp = ["agent-client-protocol==0.9.0"] # 4. Run `uv lock` to regenerate transitives. # 5. Optionally re-add to [all] only after a few days of clean operation. bedrock = ["boto3==1.42.89"] +azure-identity = ["azure-identity==1.25.3"] termux = [ # Baseline Android / Termux path for reliable fresh installs. "python-telegram-bot[webhooks]==22.6", diff --git a/run_agent.py b/run_agent.py index 484f9f84fd..185e6afb12 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1428,7 +1428,11 @@ class AIAgent: prefix = f"HTTP {status_code}: " if status_code else "" return f"{prefix}{raw[:500]}" - def _mask_api_key_for_logs(self, key: Optional[str]) -> Optional[str]: + def _mask_api_key_for_logs(self, key: Any) -> Optional[str]: + # Azure Foundry Entra ID bearer providers are callables — never + # invoke them in log paths; identify the auth surface instead. + if callable(key) and not isinstance(key, str): + return "" if not key: return None if len(key) <= 12: diff --git a/tests/acp_adapter/test_detect_provider_entra.py b/tests/acp_adapter/test_detect_provider_entra.py new file mode 100644 index 0000000000..1a46ac7953 --- /dev/null +++ b/tests/acp_adapter/test_detect_provider_entra.py @@ -0,0 +1,87 @@ +"""Regression tests for ACP adapter detection under Azure Foundry Entra ID. + +The ACP adapter's ``detect_provider`` previously gated on +``isinstance(api_key, str)`` and returned ``None`` for any runtime that +returned a callable ``api_key`` — i.e. Azure Foundry with +``auth_mode=entra_id``. Downstream, ACP would default to +``"openrouter"`` and reject the legitimate provider in its auth handshake. +This test pins the callable-aware fix so it never regresses. +""" + +from __future__ import annotations + +from unittest.mock import patch + + +class TestDetectProviderEntra: + def test_callable_api_key_is_a_valid_credential(self): + """A runtime returning a callable ``api_key`` (Entra bearer token + provider) must be detected as a configured provider, not + ``None``.""" + from acp_adapter import auth as _acp_auth + + def _fake_runtime(**_kwargs): + return { + "provider": "azure-foundry", + "api_mode": "chat_completions", + "auth_mode": "entra_id", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_key": lambda: "jwt-fresh", + } + + with patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + side_effect=_fake_runtime, + ): + assert _acp_auth.detect_provider() == "azure-foundry" + assert _acp_auth.has_provider() is True + + def test_string_api_key_still_works(self): + from acp_adapter import auth as _acp_auth + + def _fake_runtime(**_kwargs): + return { + "provider": "openrouter", + "api_key": "sk-or-static-key", + } + + with patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + side_effect=_fake_runtime, + ): + assert _acp_auth.detect_provider() == "openrouter" + + def test_empty_string_api_key_returns_none(self): + from acp_adapter import auth as _acp_auth + + def _fake_runtime(**_kwargs): + return {"provider": "openrouter", "api_key": ""} + + with patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + side_effect=_fake_runtime, + ): + assert _acp_auth.detect_provider() is None + + def test_missing_provider_returns_none(self): + """A callable api_key without a provider is still ``None`` — + we don't synthesize a provider name from the credential shape.""" + from acp_adapter import auth as _acp_auth + + def _fake_runtime(**_kwargs): + return {"api_key": lambda: "jwt-fresh", "provider": ""} + + with patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + side_effect=_fake_runtime, + ): + assert _acp_auth.detect_provider() is None + + def test_resolver_exception_returns_none(self): + from acp_adapter import auth as _acp_auth + + with patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + side_effect=RuntimeError("simulated"), + ): + assert _acp_auth.detect_provider() is None diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 3d19c32dca..10f82ca95e 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -9,6 +9,7 @@ import pytest from agent.prompt_caching import apply_anthropic_cache_control from agent.anthropic_adapter import ( + _is_azure_anthropic_endpoint, _is_oauth_token, _refresh_oauth_token, _to_plain_data, @@ -121,6 +122,20 @@ class TestBuildAnthropicClient: betas = kwargs["default_headers"]["anthropic-beta"] assert "context-1m-2025-08-07" in betas + def test_azure_anthropic_endpoint_detection_is_host_and_path_scoped(self): + assert _is_azure_anthropic_endpoint( + "https://example.services.ai.azure.com/models/anthropic" + ) is True + assert _is_azure_anthropic_endpoint( + "https://example.services.ai.azure.us/anthropic" + ) is True + assert _is_azure_anthropic_endpoint( + "https://example.openai.azure.com/openai/v1" + ) is False + assert _is_azure_anthropic_endpoint( + "https://management.azure.com/anthropic" + ) is False + def test_bedrock_client_keeps_context_1m_beta(self): with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: mock_sdk.AnthropicBedrock = MagicMock() diff --git a/tests/agent/test_auxiliary_client_azure_foundry.py b/tests/agent/test_auxiliary_client_azure_foundry.py new file mode 100644 index 0000000000..dea08a5caa --- /dev/null +++ b/tests/agent/test_auxiliary_client_azure_foundry.py @@ -0,0 +1,350 @@ +"""Tests for auxiliary client routing of the ``azure-foundry`` provider. + +Covers the dedicated branch in ``agent.auxiliary_client.resolve_provider_client`` +that delegates to :func:`hermes_cli.runtime_provider._resolve_azure_foundry_runtime` +instead of falling into the generic ``resolve_api_key_provider_credentials`` +path (which only knows about ``AZURE_FOUNDRY_API_KEY`` and would 401 for +Entra ID users and miss ``model.base_url`` overrides for api-key users +with non-standard Foundry-projects endpoints). + +Pinned scenarios: + + * ``auth_mode: api_key`` → plain OpenAI client with the static string + key for ``chat_completions``. + * ``auth_mode: entra_id`` + ``chat_completions`` → plain OpenAI + client with a callable ``api_key`` (the bearer-token provider) — + confirms the callable survives the auxiliary path end-to-end. + * ``auth_mode: entra_id`` + GPT-5.x model → CodexAuxiliaryClient + wrapping the OpenAI client (api_mode auto-upgrades to + codex_responses). + * Anthropic-style + entra_id → rejected at the runtime resolver, + so the aux path returns ``(None, None)``. + * Failure path when no model is configured returns ``(None, None)`` + cleanly so the auto chain falls through. +""" + +from __future__ import annotations + +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True) +def _reset_credential_cache(): + from agent.azure_identity_adapter import reset_credential_cache + reset_credential_cache() + yield + reset_credential_cache() + + +@pytest.fixture +def fake_azure_identity(monkeypatch): + """Stand-in for azure.identity (keeps CI hermetic when the SDK is + not installed).""" + from agent import azure_identity_adapter as _adapter + + last = {"scope": None} + + def _provider(scope): + return lambda: f"jwt-for-{scope}" + + fake_module = SimpleNamespace( + DefaultAzureCredential=lambda **kw: SimpleNamespace( + kwargs=kw, + get_token=lambda scope: SimpleNamespace(token="fake", expires_on=9999999999), + ), + get_bearer_token_provider=lambda credential, scope: ( + last.__setitem__("scope", scope), + _provider(scope), + )[-1], + ) + monkeypatch.setattr(_adapter, "_require_azure_identity", lambda: fake_module) + monkeypatch.setitem(sys.modules, "azure.identity", fake_module) + return last + + +@pytest.fixture +def patch_load_config(monkeypatch): + """Helper to set model_cfg seen by _try_azure_foundry.""" + def _apply(model_cfg): + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": model_cfg}, + ) + return _apply + + +# --------------------------------------------------------------------------- +# auth_mode: api_key (default) — regression for the legacy path +# --------------------------------------------------------------------------- + + +class TestAuxAzureFoundryApiKey: + def test_chat_completions_returns_plain_openai_client(self, monkeypatch, patch_load_config): + from agent.auxiliary_client import _try_azure_foundry + from openai import OpenAI as _OpenAI + + monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-azure-static-key") + patch_load_config({ + "provider": "azure-foundry", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + "default": "gpt-4o", + }) + client, resolved = _try_azure_foundry(model="gpt-4o") + assert client is not None + assert resolved == "gpt-4o" + assert isinstance(client, _OpenAI) + assert client.api_key == "sk-azure-static-key" + + def test_codex_responses_wraps_in_codex_aux_client(self, monkeypatch, patch_load_config): + from agent.auxiliary_client import _try_azure_foundry, CodexAuxiliaryClient + + monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-azure-static-key") + patch_load_config({ + "provider": "azure-foundry", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + "default": "gpt-5.4-mini", + }) + # GPT-5.x → runtime auto-upgrades to codex_responses + client, resolved = _try_azure_foundry(model="gpt-5.4-mini") + assert resolved == "gpt-5.4-mini" + assert isinstance(client, CodexAuxiliaryClient) + assert client.api_key == "sk-azure-static-key" + + def test_no_key_returns_none(self, monkeypatch, patch_load_config): + from agent.auxiliary_client import _try_azure_foundry + + monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False) + patch_load_config({ + "provider": "azure-foundry", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + "default": "gpt-4o", + }) + client, resolved = _try_azure_foundry(model="gpt-4o") + assert client is None + assert resolved is None + + def test_no_model_returns_none(self, monkeypatch, patch_load_config): + """Azure has no fallback aux model — fail soft so the auto chain + can try other providers.""" + from agent.auxiliary_client import _try_azure_foundry + + monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-azure-static-key") + patch_load_config({ + "provider": "azure-foundry", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + # No default model + }) + client, resolved = _try_azure_foundry() + assert client is None + assert resolved is None + + +# --------------------------------------------------------------------------- +# auth_mode: entra_id — callable api_key survives end-to-end +# --------------------------------------------------------------------------- + + +class TestAuxAzureFoundryEntra: + def test_callable_api_key_reaches_openai_constructor( + self, monkeypatch, fake_azure_identity, patch_load_config, + ): + """The token provider callable must arrive at ``OpenAI(api_key=...)`` + intact — never stringified to ``"no-key-required"`` or to the + SDK-internal empty-string representation BEFORE we hand it off. + + We assert on the public SDK contract (constructor receives the + callable) rather than ``client.api_key``, because OpenAI 2.24.0 + stores callable api_keys in a private attribute and exposes + ``client.api_key`` as ``""``. The SDK still calls the callable + per request to mint ``Authorization: Bearer ``; that + behaviour is the documented Microsoft/OpenAI contract we rely on. + """ + from agent import auxiliary_client as _aux + + received = {} + + class _FakeOpenAI: + def __init__(self, **kwargs): + received.update(kwargs) + # Mirror the fields downstream callers read. + self.api_key = kwargs.get("api_key", "") + self.base_url = kwargs.get("base_url", "") + + monkeypatch.setattr(_aux, "OpenAI", _FakeOpenAI) + patch_load_config({ + "provider": "azure-foundry", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + "auth_mode": "entra_id", + "default": "gpt-4o", + }) + client, resolved = _aux._try_azure_foundry(model="gpt-4o") + assert client is not None + assert resolved == "gpt-4o" + # Public-contract assertion: the OpenAI SDK constructor saw the + # callable, exactly as Microsoft's Foundry sample requires. + assert callable(received["api_key"]) + assert not isinstance(received["api_key"], str) + assert received["api_key"]().startswith("jwt-for-") + # Base URL forwarded verbatim (no /responses suffix stripping + # in this path — that's a separate concern handled by the + # runtime resolver only when the user re-saves config). + assert received["base_url"] == "https://r.openai.azure.com/openai/v1" + + def test_codex_responses_with_entra_wraps_correctly( + self, monkeypatch, fake_azure_identity, patch_load_config, + ): + """GPT-5.x deployment on Entra ID — auto-upgraded to + codex_responses, wrapped in CodexAuxiliaryClient, callable + api_key handed to the underlying OpenAI SDK.""" + from agent import auxiliary_client as _aux + + received = {} + + class _FakeOpenAI: + def __init__(self, **kwargs): + received.update(kwargs) + self.api_key = kwargs.get("api_key", "") + self.base_url = kwargs.get("base_url", "") + + monkeypatch.setattr(_aux, "OpenAI", _FakeOpenAI) + patch_load_config({ + "provider": "azure-foundry", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + "auth_mode": "entra_id", + "default": "gpt-5.4-mini", + }) + client, resolved = _aux._try_azure_foundry(model="gpt-5.4-mini") + assert resolved == "gpt-5.4-mini" + assert isinstance(client, _aux.CodexAuxiliaryClient) + # The Codex wrapper received an OpenAI client built with the + # callable api_key — verify against the SDK constructor record, + # not the wrapper attribute (which mirrors the SDK's empty- + # string representation). + assert callable(received["api_key"]) + assert received["api_key"]().startswith("jwt-for-") + + def test_entra_anthropic_messages_uses_bearer_hook( + self, monkeypatch, fake_azure_identity, patch_load_config, + ): + """Entra ID + anthropic_messages: runtime returns a callable + api_key; ``_maybe_wrap_anthropic`` → ``build_anthropic_client`` + detects the callable and installs the bearer-injecting httpx + event hook on a custom ``httpx.Client`` passed to the + Anthropic SDK via ``http_client=``.""" + from agent import auxiliary_client as _aux + from agent import anthropic_adapter as _anthropic + + received = {} + + class _FakeOpenAI: + def __init__(self, **kwargs): + received["openai"] = kwargs + self.api_key = kwargs.get("api_key", "") + self.base_url = kwargs.get("base_url", "") + + class _FakeAnthropicSDK: + class Anthropic: + def __init__(self, **kwargs): + received["anthropic"] = kwargs + + monkeypatch.setattr(_aux, "OpenAI", _FakeOpenAI) + monkeypatch.setattr(_anthropic, "_get_anthropic_sdk", lambda: _FakeAnthropicSDK) + + patch_load_config({ + "provider": "azure-foundry", + "base_url": "https://r.services.ai.azure.com/anthropic", + "api_mode": "anthropic_messages", + "auth_mode": "entra_id", + "default": "claude-sonnet-4-5", + }) + client, resolved = _aux._try_azure_foundry(model="claude-sonnet-4-5") + assert client is not None + assert resolved == "claude-sonnet-4-5" + # The Anthropic SDK constructor received a custom http_client + # (the bearer-injecting hook) and a placeholder auth_token. + anthropic_kwargs = received.get("anthropic") or {} + assert "http_client" in anthropic_kwargs, ( + "build_anthropic_client must pass a custom http_client when " + "given a callable api_key, otherwise the SDK cannot mint " + "fresh tokens per request" + ) + assert anthropic_kwargs.get("auth_token") == "entra-id-bearer-via-http-hook" + # Verify the http_client actually has our event hook installed. + http_client = anthropic_kwargs["http_client"] + hooks = getattr(http_client, "event_hooks", {}) + assert "request" in hooks and len(hooks["request"]) >= 1 + + +# --------------------------------------------------------------------------- +# resolve_provider_client → azure-foundry dispatch +# --------------------------------------------------------------------------- + + +class TestResolveProviderClientAzureFoundry: + def test_dispatches_to_azure_branch_not_generic_api_key_path( + self, monkeypatch, fake_azure_identity, patch_load_config, + ): + """End-to-end: the public ``resolve_provider_client`` entry + point must take the dedicated azure-foundry branch, NOT the + generic api-key registry path that would call + ``resolve_api_key_provider_credentials`` and return None for + Entra users.""" + from agent import auxiliary_client as _aux + + received = {} + + class _FakeOpenAI: + def __init__(self, **kwargs): + received.update(kwargs) + self.api_key = kwargs.get("api_key", "") + self.base_url = kwargs.get("base_url", "") + + monkeypatch.setattr(_aux, "OpenAI", _FakeOpenAI) + patch_load_config({ + "provider": "azure-foundry", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + "auth_mode": "entra_id", + "default": "gpt-4o", + }) + client, resolved = _aux.resolve_provider_client("azure-foundry", "gpt-4o") + assert client is not None + assert resolved == "gpt-4o" + # The callable made it through resolve_provider_client → _try_azure_foundry + # → OpenAI(api_key=...). + assert callable(received["api_key"]) + + def test_warns_and_returns_none_on_failure( + self, monkeypatch, patch_load_config, caplog, + ): + """When azure-foundry is requested but cannot be resolved + (e.g. no model + no key), we return (None, None) and log a + clear warning pointing at ``hermes doctor``.""" + import logging + from agent.auxiliary_client import resolve_provider_client + + monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False) + patch_load_config({ + "provider": "azure-foundry", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + # No default → resolver yields no model → bail + }) + with caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"): + client, resolved = resolve_provider_client("azure-foundry") + assert client is None + assert resolved is None + assert any( + "azure-foundry" in rec.message and "hermes doctor" in rec.message + for rec in caplog.records + ) diff --git a/tests/agent/test_azure_identity_adapter.py b/tests/agent/test_azure_identity_adapter.py new file mode 100644 index 0000000000..a569709e00 --- /dev/null +++ b/tests/agent/test_azure_identity_adapter.py @@ -0,0 +1,662 @@ +"""Tests for the Microsoft Entra ID adapter (agent/azure_identity_adapter.py). + +Covers: + - Scope resolution per Azure host shape + - Display masking for callable + string + None inputs + - Cache-fingerprint stability under callable refresh + - is_token_provider truthiness on callables vs strings + - EntraIdentityConfig serialization round-trip + - Token provider construction with mocked azure-identity + - Credential cache reuse + reset + - has_azure_identity_credentials timeout / failure paths + - describe_active_credential structural reporting + - Lazy-install error path when azure-identity absent + lazy installs + disabled + +We mock azure.identity at the import boundary rather than hitting any +real Azure endpoint. Tests must remain hermetic per AGENTS.md. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable +from types import SimpleNamespace +from typing import cast +from unittest.mock import MagicMock, patch + +import pytest + +# Ensure we always import a fresh adapter module — credential caches in +# the adapter persist across tests otherwise, polluting assertions +# about cache invalidation. +@pytest.fixture(autouse=True) +def _reset_adapter_cache(): + from agent.azure_identity_adapter import reset_credential_cache + reset_credential_cache() + yield + reset_credential_cache() + + +# --------------------------------------------------------------------------- +# Scope constant +# --------------------------------------------------------------------------- + + +class TestEntraScopeConstant: + """Pin the Microsoft-documented Foundry inference scope. + + Microsoft's official samples for both ``*.openai.azure.com`` and + ``*.services.ai.azure.com`` use ``https://ai.azure.com/.default``. + The older ``cognitiveservices.azure.com/.default`` is the + control-plane scope and is rejected for inference by newer + Azure OpenAI / Foundry resources. + + Users with sovereign-cloud or unusual-tenant requirements pass the + scope explicitly via ``model.entra.scope`` in ``config.yaml``. + + Refs: + * https://learn.microsoft.com/azure/ai-foundry/openai/how-to/managed-identity + * https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/configure-entra-id + """ + + def test_default_scope_matches_microsoft_documentation(self): + from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT + assert SCOPE_AI_AZURE_DEFAULT == "https://ai.azure.com/.default" + + +# --------------------------------------------------------------------------- +# Cache fingerprint + http-bearer helpers +# --------------------------------------------------------------------------- + + +class TestMaterializeBearerForHttp: + """The only helper that mints a real bearer JWT — must call the + callable exactly once and never fall through to display masking.""" + + def test_callable_is_invoked_and_returns_token(self): + from agent.azure_identity_adapter import materialize_bearer_for_http + + invoked = {"count": 0} + + def provider(): + invoked["count"] += 1 + return "fresh-jwt" + + assert materialize_bearer_for_http(provider) == "fresh-jwt" + assert invoked["count"] == 1 + + def test_string_passes_through(self): + from agent.azure_identity_adapter import materialize_bearer_for_http + assert materialize_bearer_for_http("plain-key") == "plain-key" + + def test_callable_returning_empty_raises(self): + from agent.azure_identity_adapter import materialize_bearer_for_http + with pytest.raises(ValueError): + materialize_bearer_for_http(lambda: "") + + def test_empty_string_raises(self): + from agent.azure_identity_adapter import materialize_bearer_for_http + with pytest.raises(ValueError): + materialize_bearer_for_http("") + with pytest.raises(ValueError): + materialize_bearer_for_http(None) + + +# --------------------------------------------------------------------------- +# build_bearer_http_client — the Anthropic-on-Foundry bridge +# --------------------------------------------------------------------------- + + +class TestBuildBearerHttpClient: + """``build_bearer_http_client`` returns an ``httpx.Client`` whose + request event hook mints a fresh JWT per outbound request. This is + how Entra ID auth reaches the Anthropic SDK (which does not accept + callable ``auth_token``).""" + + def test_returns_httpx_client_with_request_hook(self): + import httpx + from agent.azure_identity_adapter import build_bearer_http_client + + client = build_bearer_http_client(lambda: "jwt") + try: + assert isinstance(client, httpx.Client) + hooks = client.event_hooks.get("request", []) + assert len(hooks) >= 1 + finally: + client.close() + + def test_hook_overrides_authorization_header(self): + import httpx + from agent.azure_identity_adapter import build_bearer_http_client + + minted_tokens = [] + + def provider(): + minted_tokens.append(f"jwt-{len(minted_tokens) + 1}") + return minted_tokens[-1] + + client = build_bearer_http_client(provider) + try: + hook = client.event_hooks["request"][0] + # Build a request with conflicting pre-set headers and verify + # the hook strips them and installs the fresh bearer. + req = httpx.Request( + "POST", "https://example.com/v1/messages", + headers={ + "Authorization": "Bearer stale-token", + "api-key": "static-key", + "x-api-key": "static-key", + }, + json={"hello": "world"}, + ) + hook(req) + assert req.headers["Authorization"] == "Bearer jwt-1" + # The static-key headers must be stripped — sending both + # auth values would be ambiguous on Azure. + assert "api-key" not in req.headers + assert "x-api-key" not in req.headers + + # Second invocation mints a fresh token. + req2 = httpx.Request("GET", "https://example.com/v1/models") + hook(req2) + assert req2.headers["Authorization"] == "Bearer jwt-2" + assert len(minted_tokens) == 2 + finally: + client.close() + + def test_hook_strips_auth_headers_and_warns_when_token_provider_fails(self, caplog): + """When the token provider fails (chain exhausted, IMDS down, az + login expired), the hook must: + 1. Log at WARNING level so the misconfiguration is visible at + default log level (not buried at DEBUG). + 2. Strip any pre-set Authorization headers — including the + placeholder ``entra-id-bearer-via-http-hook`` sentinel that + :func:`_build_anthropic_client_with_bearer_hook` sets on the + Anthropic SDK constructor. This produces a clean + "missing auth" 401 from Azure rather than a sentinel-bearing + 401 that's harder to diagnose AND avoids leaking the + sentinel string into upstream access logs. + """ + import logging + import httpx + from agent.azure_identity_adapter import build_bearer_http_client + + def bad_provider(): + return "" # empty token → materialize_bearer_for_http raises + + client = build_bearer_http_client(bad_provider) + try: + hook = client.event_hooks["request"][0] + req = httpx.Request( + "POST", "https://example.com/v1/messages", + headers={ + "Authorization": "Bearer entra-id-bearer-via-http-hook", + "api-key": "leaked-placeholder", + }, + ) + with caplog.at_level(logging.WARNING, logger="agent.azure_identity_adapter"): + hook(req) # Must not raise. + # Pre-set auth headers stripped — no sentinel makes it to Azure. + assert "Authorization" not in req.headers + assert "api-key" not in req.headers + # WARNING was logged so the user sees the misconfiguration. + assert any( + rec.levelno == logging.WARNING and "Entra ID token provider" in rec.message + for rec in caplog.records + ) + finally: + client.close() + + def test_rejects_non_callable_provider(self): + from agent.azure_identity_adapter import build_bearer_http_client + with pytest.raises(ValueError): + build_bearer_http_client(cast(Callable[[], str], "plain-string-not-callable")) + with pytest.raises(ValueError): + build_bearer_http_client(cast(Callable[[], str], None)) + + def test_forwards_httpx_kwargs(self): + import httpx + from agent.azure_identity_adapter import build_bearer_http_client + + timeout = httpx.Timeout(60.0, connect=5.0) + client = build_bearer_http_client(lambda: "jwt", timeout=timeout) + try: + # httpx stores the timeout per-pool; just sanity-check it was + # accepted without TypeError. + assert client is not None + finally: + client.close() + + +class TestIsTokenProvider: + def test_callable_is_token_provider(self): + from agent.azure_identity_adapter import is_token_provider + assert is_token_provider(lambda: "x") is True + + def test_string_is_not_token_provider(self): + from agent.azure_identity_adapter import is_token_provider + assert is_token_provider("static-key") is False + # ``str`` instances are technically callable in some edge cases + # — confirm they're never classified as token providers. + assert is_token_provider("") is False + + +# --------------------------------------------------------------------------- +# EntraIdentityConfig +# --------------------------------------------------------------------------- + + +class TestEntraIdentityConfig: + """The serializable config that crosses multiprocessing boundaries — + must round-trip through dict cleanly and never lose fields.""" + + def test_to_dict_round_trip(self): + from agent.azure_identity_adapter import EntraIdentityConfig + cfg = EntraIdentityConfig( + scope="https://ai.azure.com/.default", + exclude_interactive_browser=False, + ) + rebuilt = EntraIdentityConfig.from_dict(cfg.to_dict()) + assert rebuilt == cfg + + def test_from_dict_handles_empty_strings(self): + from agent.azure_identity_adapter import EntraIdentityConfig + cfg = EntraIdentityConfig.from_dict({ + "scope": "", + "client_id": None, + }) + # Empty scope falls back to default + assert cfg.scope.endswith("/.default") + + def test_from_dict_ignores_legacy_identity_keys(self): + """Old config.yaml that still has model.entra.client_id / + tenant_id / authority should not crash from_dict — those values + are now read from AZURE_* env vars by azure-identity directly.""" + from agent.azure_identity_adapter import EntraIdentityConfig + cfg = EntraIdentityConfig.from_dict({ + "tenant_id": "legacy-tenant", + "authority": "https://login.partner.microsoftonline.cn", + "client_id": "user-mi-client", + }) + # Legacy keys silently ignored — no crash, no surprise field on the dataclass. + assert not hasattr(cfg, "client_id") + assert not hasattr(cfg, "tenant_id") + assert not hasattr(cfg, "authority") + + def test_constructor_normalizes_empty_scope(self): + from agent.azure_identity_adapter import EntraIdentityConfig + cfg = EntraIdentityConfig(scope="") + assert cfg.scope.endswith("/.default") + + def test_from_dict_default_scope_override(self): + from agent.azure_identity_adapter import EntraIdentityConfig + cfg = EntraIdentityConfig.from_dict( + {"scope": ""}, + default_scope="https://custom.example/.default", + ) + assert cfg.scope == "https://custom.example/.default" + + def test_dataclass_is_frozen(self): + # Frozen dataclasses are hashable / safe to pass through caches. + from agent.azure_identity_adapter import EntraIdentityConfig + cfg = EntraIdentityConfig() + with pytest.raises((AttributeError, Exception)): + setattr(cfg, "scope", "mutated") + + +# --------------------------------------------------------------------------- +# Credential / token provider construction +# --------------------------------------------------------------------------- + + +class _FakeAzureIdentity: + """Stand-in for the ``azure.identity`` module. + + Captures kwargs passed to ``DefaultAzureCredential`` so tests can + assert how config flows into the SDK. + """ + + def __init__(self): + self.last_credential_kwargs = None + self.last_scope = None + self.credential_count = 0 + + def DefaultAzureCredential(self, **kwargs): # noqa: N802 — match SDK + self.last_credential_kwargs = kwargs + self.credential_count += 1 + return SimpleNamespace( + get_token=lambda scope: SimpleNamespace(token="fake-jwt", expires_on=9999999999), + kwargs=kwargs, + ) + + def get_bearer_token_provider(self, credential, scope): + self.last_scope = scope + # Return a callable that mints a token when invoked. + return lambda: f"jwt-for-{scope}" + + +@pytest.fixture +def fake_azure_identity(monkeypatch): + """Install a fake azure.identity into sys.modules and stub the + adapter's `_require_azure_identity` so all tests use the fake.""" + fake = _FakeAzureIdentity() + + fake_module = SimpleNamespace( + DefaultAzureCredential=fake.DefaultAzureCredential, + get_bearer_token_provider=fake.get_bearer_token_provider, + ) + monkeypatch.setitem(sys.modules, "azure", SimpleNamespace(identity=fake_module)) + monkeypatch.setitem(sys.modules, "azure.identity", fake_module) + + # The adapter's `_require_azure_identity` does its own import, so + # patch that too to make sure tests never hit the real package's + # singleton state. + from agent import azure_identity_adapter as _adapter + monkeypatch.setattr(_adapter, "_require_azure_identity", lambda: fake_module) + + return fake + + +class TestBuildCredential: + def test_default_kwargs_are_minimal(self, fake_azure_identity): + """SDK default for ``exclude_interactive_browser_credential`` is + True; we only pass it when the user opts IN to interactive + browser auth. Tenant / authority / service principal config + flow through the standard ``AZURE_*`` env vars (read by + azure-identity directly), not Hermes config kwargs.""" + from agent.azure_identity_adapter import EntraIdentityConfig, build_credential + cred = build_credential(EntraIdentityConfig()) + kwargs = fake_azure_identity.last_credential_kwargs + # Default config should produce empty kwargs — SDK uses its own + # defaults plus env-var-driven settings. + assert kwargs == {} + assert cred is not None + + def test_interactive_browser_opt_in(self, fake_azure_identity): + """When the user explicitly sets + ``exclude_interactive_browser=False``, the SDK kwarg is set to + False. Without the opt-in we don't pass the kwarg at all (SDK + default is True / browser excluded).""" + from agent.azure_identity_adapter import EntraIdentityConfig, build_credential + build_credential(EntraIdentityConfig(exclude_interactive_browser=False)) + kwargs = fake_azure_identity.last_credential_kwargs + assert kwargs["exclude_interactive_browser_credential"] is False + + def test_credential_is_cached_per_config(self, fake_azure_identity): + from agent.azure_identity_adapter import EntraIdentityConfig, build_credential + cfg = EntraIdentityConfig(scope="s1") + c1 = build_credential(cfg) + c2 = build_credential(cfg) + assert c1 is c2 + assert fake_azure_identity.credential_count == 1 + + def test_distinct_configs_get_distinct_credentials(self, fake_azure_identity): + from agent.azure_identity_adapter import EntraIdentityConfig, build_credential + c1 = build_credential(EntraIdentityConfig(scope="s1")) + c2 = build_credential(EntraIdentityConfig(scope="s2")) + assert c1 is not c2 + assert fake_azure_identity.credential_count == 2 + + def test_reset_cache_invalidates(self, fake_azure_identity): + from agent.azure_identity_adapter import ( + EntraIdentityConfig, + build_credential, + reset_credential_cache, + ) + cfg = EntraIdentityConfig(scope="x") + c1 = build_credential(cfg) + reset_credential_cache() + c2 = build_credential(cfg) + assert c1 is not c2 + + +class TestBuildTokenProvider: + def test_returns_callable_for_scope(self, fake_azure_identity): + from agent.azure_identity_adapter import build_token_provider + provider = build_token_provider(scope="https://ai.azure.com/.default") + assert callable(provider) + assert provider() == "jwt-for-https://ai.azure.com/.default" + assert fake_azure_identity.last_scope == "https://ai.azure.com/.default" + + def test_falls_back_to_default_scope_when_unspecified(self, fake_azure_identity): + """When neither ``scope`` nor ``config`` is provided, + ``build_token_provider`` uses ``SCOPE_AI_AZURE_DEFAULT`` — + Microsoft's documented Foundry inference scope. ``base_url`` is + accepted for back-compat but ignored.""" + from agent.azure_identity_adapter import ( + SCOPE_AI_AZURE_DEFAULT, + build_token_provider, + ) + build_token_provider(base_url="https://r.openai.azure.com/openai/v1") + assert fake_azure_identity.last_scope == SCOPE_AI_AZURE_DEFAULT + + def test_explicit_scope_wins_over_base_url(self, fake_azure_identity): + from agent.azure_identity_adapter import build_token_provider + build_token_provider( + scope="https://override.example/.default", + base_url="https://r.openai.azure.com/openai/v1", + ) + assert fake_azure_identity.last_scope == "https://override.example/.default" + + def test_config_object_wins_over_kwargs(self, fake_azure_identity): + from agent.azure_identity_adapter import ( + EntraIdentityConfig, + build_token_provider, + ) + cfg = EntraIdentityConfig(scope="cfg-scope") + build_token_provider(scope="ignored", config=cfg) + assert fake_azure_identity.last_scope == "cfg-scope" + assert fake_azure_identity.last_credential_kwargs == {} + + +# --------------------------------------------------------------------------- +# Lazy-install / missing-package surface +# --------------------------------------------------------------------------- + + +class TestRequireAzureIdentityMissing: + def test_clear_error_when_lazy_install_disabled(self, monkeypatch): + """When azure-identity isn't importable AND lazy installs are + off, the adapter must raise ImportError with an actionable + message, not propagate FeatureUnavailable.""" + from agent import azure_identity_adapter as _adapter + + # Force the import path to fail. + original_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __import__ + def _fake_import(name, *args, **kwargs): + if name == "azure.identity" or name.startswith("azure.identity."): + raise ImportError("simulated missing azure-identity") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", _fake_import) + + # Simulate lazy installs disabled. + from tools.lazy_deps import FeatureUnavailable + + def _fake_ensure(*args, **kwargs): + raise FeatureUnavailable( + "provider.azure_identity", + ("azure-identity==1.25.3",), + "lazy installs disabled (test simulation)", + ) + + # The adapter calls ``ensure`` from ``tools.lazy_deps``; intercept + # it by patching the actual symbol path. + monkeypatch.setattr("tools.lazy_deps.ensure", _fake_ensure) + + with pytest.raises(ImportError) as exc_info: + _adapter._require_azure_identity() + msg = str(exc_info.value) + assert "azure-identity" in msg + assert "Foundry" in msg or "foundry" in msg.lower() + + +# --------------------------------------------------------------------------- +# has_azure_identity_credentials probe (timeout-bounded) +# --------------------------------------------------------------------------- + + +class TestHasAzureIdentityCredentials: + def test_returns_false_when_package_missing_and_install_disabled(self, monkeypatch): + from agent import azure_identity_adapter as _adapter + monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False) + assert _adapter.has_azure_identity_credentials( + "https://x/.default", allow_install=False, + ) is False + + def test_lazy_install_triggered_when_package_missing(self, monkeypatch): + """With allow_install=True (default), the probe must trigger the + lazy-install path before bailing — otherwise the wizard's + ``preflight`` would silently fail for fresh installs that haven't + run ``pip install azure-identity`` yet.""" + from agent import azure_identity_adapter as _adapter + + installed = {"called": False} + + def _fake_install(): + installed["called"] = True + # After install, pretend the package is now importable. + monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: True) + return SimpleNamespace( + DefaultAzureCredential=lambda **kw: SimpleNamespace( + kwargs=kw, + get_token=lambda scope: SimpleNamespace(token="post-install-jwt", expires_on=0), + ), + get_bearer_token_provider=lambda c, s: lambda: "x", + ) + + monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False) + monkeypatch.setattr(_adapter, "_require_azure_identity", _fake_install) + + # Provide a credential factory so the probe proceeds after install. + monkeypatch.setattr( + _adapter, "build_credential", + lambda config: SimpleNamespace( + get_token=lambda scope: SimpleNamespace(token="probe-jwt", expires_on=0), + ), + ) + + result = _adapter.has_azure_identity_credentials( + "https://x/.default", timeout_seconds=0.5, + ) + assert installed["called"] is True, ( + "has_azure_identity_credentials must trigger lazy install " + "before bailing" + ) + assert result is True + + def test_returns_true_on_successful_token_mint(self, fake_azure_identity): + from agent.azure_identity_adapter import has_azure_identity_credentials + assert has_azure_identity_credentials("https://x/.default", timeout_seconds=0.5) is True + + def test_returns_false_when_get_token_raises(self, monkeypatch): + from agent import azure_identity_adapter as _adapter + + def _failing_credential(_config): + class _Cred: + def get_token(self, scope): + raise RuntimeError("simulated chain exhaustion") + return _Cred() + + monkeypatch.setattr(_adapter, "build_credential", _failing_credential) + monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: True) + assert _adapter.has_azure_identity_credentials("https://x/.default", timeout_seconds=0.5) is False + + def test_returns_false_on_timeout(self, monkeypatch): + """Slow IMDS / network must time out, not hang the caller.""" + import threading + from agent import azure_identity_adapter as _adapter + + slow_release = threading.Event() + + def _slow_credential(_config): + class _Cred: + def get_token(self, scope): + # Block forever from the test's perspective; the + # adapter must give up via its thread-bounded probe. + slow_release.wait(timeout=10) + return SimpleNamespace(token="never-returned", expires_on=0) + return _Cred() + + monkeypatch.setattr(_adapter, "build_credential", _slow_credential) + monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: True) + try: + assert _adapter.has_azure_identity_credentials( + "https://x/.default", timeout_seconds=0.1 + ) is False + finally: + slow_release.set() + + +# --------------------------------------------------------------------------- +# describe_active_credential — used by hermes doctor + hermes auth +# --------------------------------------------------------------------------- + + +class TestDescribeActiveCredential: + def test_reports_not_installed(self, monkeypatch): + from agent import azure_identity_adapter as _adapter + monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False) + info = _adapter.describe_active_credential( + scope="https://x/.default", allow_install=False, + ) + assert info["ok"] is False + assert "not installed" in info["error"].lower() + assert "pip install" in info["hint"].lower() + + def test_reports_install_failure(self, monkeypatch): + """When lazy install is allowed but fails (e.g. lazy installs + disabled), the diagnostic surfaces the failure as the error.""" + from agent import azure_identity_adapter as _adapter + monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False) + + def _fail_install(): + raise ImportError("simulated: lazy installs disabled") + + monkeypatch.setattr(_adapter, "_require_azure_identity", _fail_install) + info = _adapter.describe_active_credential( + scope="https://x/.default", allow_install=True, + ) + assert info["ok"] is False + assert "lazy installs disabled" in info["error"] + assert "lazy" in info["hint"].lower() + + def test_reports_env_sources_for_managed_identity(self, fake_azure_identity, monkeypatch): + from agent.azure_identity_adapter import describe_active_credential + monkeypatch.setenv("IDENTITY_ENDPOINT", "http://169.254.169.254") + info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5) + assert info["ok"] is True + sources = info.get("env_sources") or [] + assert any("ManagedIdentity" in s for s in sources) + + def test_reports_env_sources_for_workload_identity(self, fake_azure_identity, monkeypatch): + from agent.azure_identity_adapter import describe_active_credential + monkeypatch.setenv("AZURE_FEDERATED_TOKEN_FILE", "/var/secrets/azure/federated-token") + info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5) + sources = info.get("env_sources") or [] + assert any("WorkloadIdentity" in s for s in sources) + + def test_reports_env_sources_for_service_principal(self, fake_azure_identity, monkeypatch): + from agent.azure_identity_adapter import describe_active_credential + monkeypatch.setenv("AZURE_TENANT_ID", "t") + monkeypatch.setenv("AZURE_CLIENT_ID", "c") + monkeypatch.setenv("AZURE_CLIENT_SECRET", "s") + info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5) + sources = info.get("env_sources") or [] + assert any("EnvironmentCredential" in s for s in sources) + + def test_reports_error_on_chain_failure(self, monkeypatch): + from agent import azure_identity_adapter as _adapter + + def _failing_credential(_config): + class _Cred: + def get_token(self, scope): + raise RuntimeError("auth failed") + return _Cred() + + monkeypatch.setattr(_adapter, "build_credential", _failing_credential) + monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: True) + info = _adapter.describe_active_credential(scope="https://x/.default", timeout_seconds=0.5) + assert info["ok"] is False + assert "auth failed" in info.get("error", "") diff --git a/tests/agent/test_bedrock_1m_context.py b/tests/agent/test_bedrock_1m_context.py index 7d9753831e..c088bcc047 100644 --- a/tests/agent/test_bedrock_1m_context.py +++ b/tests/agent/test_bedrock_1m_context.py @@ -1,7 +1,7 @@ """Tests for the 1M-context beta header on AWS Bedrock Claude models. Claude Opus 4.6/4.7 and Sonnet 4.6 support a 1M context window, but on AWS -Bedrock (and Azure AI Foundry) that window is still gated behind the +Bedrock (and Microsoft Foundry) that window is still gated behind the ``context-1m-2025-08-07`` beta header as of 2026-04. Without it, Bedrock caps these models at 200K even though ``model_metadata.py`` advertises 1M. @@ -61,4 +61,3 @@ class TestBedrockContext1MBeta: # Other common betas still present — no regression. assert "interleaved-thinking-2025-05-14" in beta_header assert "fine-grained-tool-streaming-2025-05-14" in beta_header - diff --git a/tests/hermes_cli/test_azure_detect.py b/tests/hermes_cli/test_azure_detect.py index 45eaa86e73..41cd737d78 100644 --- a/tests/hermes_cli/test_azure_detect.py +++ b/tests/hermes_cli/test_azure_detect.py @@ -102,7 +102,7 @@ def test_detect_anthropic_path_wins_without_http(): def test_detect_openai_models_probe_success(): """/models probe returning a model list → chat_completions.""" - def _fake_get(url, api_key, timeout=6.0): + def _fake_get(url, api_key, timeout=6.0, **kwargs): assert "key-abc" == api_key return 200, json.loads(_openai_models_body("gpt-5.4", "claude-opus-4-6")) @@ -118,7 +118,7 @@ def test_detect_openai_models_probe_success(): def test_detect_openai_models_probe_empty_list_still_counts(): """Endpoint returned OpenAI shape but no models → still chat_completions.""" - def _fake_get(url, api_key, timeout=6.0): + def _fake_get(url, api_key, timeout=6.0, **kwargs): return 200, {"object": "list", "data": []} with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get): @@ -132,7 +132,7 @@ def test_detect_openai_models_probe_empty_list_still_counts(): def test_detect_falls_back_to_anthropic_probe(): """/models fails but Anthropic Messages probe succeeds.""" - def _fake_get(url, api_key, timeout=6.0): + def _fake_get(url, api_key, timeout=6.0, **kwargs): return 401, None # /models forbidden with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get), \ @@ -164,7 +164,7 @@ def test_probe_openai_models_tries_multiple_api_versions(): """First call (no api-version) fails, api-version fallback succeeds.""" calls = [] - def _fake_get(url, api_key, timeout=6.0): + def _fake_get(url, api_key, timeout=6.0, **kwargs): calls.append(url) if "api-version" not in url: return 404, None diff --git a/tests/hermes_cli/test_azure_foundry_entra.py b/tests/hermes_cli/test_azure_foundry_entra.py new file mode 100644 index 0000000000..6cc2ff0ec9 --- /dev/null +++ b/tests/hermes_cli/test_azure_foundry_entra.py @@ -0,0 +1,404 @@ +"""Tests for Azure Foundry Entra ID runtime resolution. + +Covers the contract introduced in PR for Microsoft Entra ID auth on +``azure-foundry``: + + * ``_resolve_azure_foundry_runtime`` returns a callable ``api_key`` for + ``model.auth_mode = entra_id`` (OpenAI-style only). + * Anthropic-style endpoints with ``auth_mode = entra_id`` return the same + callable runtime credential as OpenAI-style endpoints. + * The legacy ``api_key`` path is unchanged when ``auth_mode`` is absent + or set to ``api_key``. + * Explicit ``--api-key`` overrides at runtime still work in entra mode + (escape hatch for one-off testing). + * ``model.entra.scope`` propagates to the token-provider config; Azure + identity selection stays in standard AZURE_* env vars. + * ``_get_azure_foundry_auth_status`` is structural — never mints a + token (verified by checking the credential cache untouched). + * ``has_usable_secret`` for ``AZURE_FOUNDRY_API_KEY`` is irrelevant + when ``auth_mode == entra_id``. +""" + +from __future__ import annotations + +import sys +from types import SimpleNamespace +from typing import cast +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True) +def _reset_credential_cache(): + from agent.azure_identity_adapter import reset_credential_cache + reset_credential_cache() + yield + reset_credential_cache() + + +@pytest.fixture +def fake_azure_identity(monkeypatch): + """Identical fake to test_azure_identity_adapter — keeps Azure SDK + out of these tests so they run in CI without the package installed.""" + from agent import azure_identity_adapter as _adapter + + last = {"scope": None, "kwargs": None, "credential_count": 0} + + def _provider(scope): + return lambda: f"jwt-for-{scope}" + + fake_module = SimpleNamespace( + DefaultAzureCredential=lambda **kw: SimpleNamespace( + kwargs=kw, + get_token=lambda scope: SimpleNamespace(token="fake", expires_on=9999999999), + ), + get_bearer_token_provider=lambda credential, scope: ( + last.__setitem__("scope", scope), + last.__setitem__("kwargs", credential.kwargs), + last.__setitem__("credential_count", cast(int, last["credential_count"]) + 1), + _provider(scope), + )[-1], + ) + monkeypatch.setattr(_adapter, "_require_azure_identity", lambda: fake_module) + monkeypatch.setitem(sys.modules, "azure.identity", fake_module) + return last + + +# --------------------------------------------------------------------------- +# _resolve_azure_foundry_runtime: entra_id branch +# --------------------------------------------------------------------------- + + +class TestResolveAzureFoundryRuntimeEntra: + def test_returns_callable_api_key_for_entra(self, fake_azure_identity): + from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime + runtime = _resolve_azure_foundry_runtime( + requested_provider="azure-foundry", + model_cfg={ + "provider": "azure-foundry", + "base_url": "https://my-resource.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + "auth_mode": "entra_id", + "default": "gpt-4o", # stays on chat_completions (no codex auto-upgrade) + }, + ) + assert runtime["provider"] == "azure-foundry" + assert runtime["auth_mode"] == "entra_id" + assert runtime["api_mode"] == "chat_completions" + assert callable(runtime["api_key"]) + assert runtime["source"] == "entra_id" + + def test_entra_inherits_codex_responses_for_gpt5_family(self, fake_azure_identity): + """GPT-5.x / o-series / codex models on Azure are Responses-API-only. + The runtime auto-upgrades api_mode regardless of auth mode — this is + the same behaviour as the static-key path (see + ``hermes_cli/models.py::azure_foundry_model_api_mode``).""" + from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime + runtime = _resolve_azure_foundry_runtime( + requested_provider="azure-foundry", + model_cfg={ + "provider": "azure-foundry", + "base_url": "https://my-resource.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + "auth_mode": "entra_id", + "default": "gpt-5.4", + }, + ) + # GPT-5.x is upgraded to codex_responses — Entra path inherits. + assert runtime["api_mode"] == "codex_responses" + assert callable(runtime["api_key"]) + assert runtime["auth_mode"] == "entra_id" + + def test_entra_propagates_scope_only(self, fake_azure_identity): + """``model.entra.scope`` is the only Hermes-managed Azure SDK + setting. Identity selection (client ID, tenant, authority, + service principal secret, federated token file) flows through + standard ``AZURE_*`` env vars read by azure-identity directly. + Legacy ``model.entra.client_id`` / ``tenant_id`` / ``authority`` + keys in config.yaml are silently ignored.""" + from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime + _resolve_azure_foundry_runtime( + requested_provider="azure-foundry", + model_cfg={ + "provider": "azure-foundry", + "base_url": "https://my-resource.services.ai.azure.com/v1", + "api_mode": "chat_completions", + "auth_mode": "entra_id", + "entra": { + "scope": "https://custom.example/.default", + "client_id": "client-uuid", + # Legacy keys must not crash — they are accepted in + # from_dict but never propagated to the SDK. + "tenant_id": "legacy-tenant", + "authority": "https://login.microsoftonline.us", + }, + }, + ) + assert fake_azure_identity["scope"] == "https://custom.example/.default" + kw = fake_azure_identity["kwargs"] + assert "managed_identity_client_id" not in kw + assert "workload_identity_client_id" not in kw + assert "interactive_browser_tenant_id" not in kw + assert "authority" not in kw + + def test_entra_default_scope_when_unset(self, fake_azure_identity): + """When ``model.entra.scope`` is not set, the runtime resolves + Microsoft's documented inference scope — + ``https://ai.azure.com/.default`` — regardless of whether the + endpoint is ``*.openai.azure.com`` or ``*.services.ai.azure.com``. + Both shapes use the SAME scope per Microsoft's docs; the + ``cognitiveservices.azure.com`` scope is the control-plane + audience and is rejected for inference by newer resources.""" + from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime + from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT + _resolve_azure_foundry_runtime( + requested_provider="azure-foundry", + model_cfg={ + "provider": "azure-foundry", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + "auth_mode": "entra_id", + }, + ) + assert fake_azure_identity["scope"] == SCOPE_AI_AZURE_DEFAULT + + def test_entra_scope_override_wins(self, fake_azure_identity): + """Users on sovereign clouds / unusual tenants can set + ``model.entra.scope`` to override the default.""" + from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime + _resolve_azure_foundry_runtime( + requested_provider="azure-foundry", + model_cfg={ + "provider": "azure-foundry", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + "auth_mode": "entra_id", + "entra": { + "scope": "https://cognitiveservices.azure.com/.default", + }, + }, + ) + assert ( + fake_azure_identity["scope"] + == "https://cognitiveservices.azure.com/.default" + ) + + def test_entra_with_anthropic_messages_is_supported(self, fake_azure_identity): + """Entra ID now works for both OpenAI-style and Anthropic-style + Azure Foundry endpoints. The runtime returns a callable + ``api_key``; downstream + :func:`agent.anthropic_adapter.build_anthropic_client` detects + the callable and installs an httpx event hook that mints a + fresh bearer JWT per request (the Anthropic SDK does not + accept callable auth_token natively).""" + from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime + runtime = _resolve_azure_foundry_runtime( + requested_provider="azure-foundry", + model_cfg={ + "provider": "azure-foundry", + "base_url": "https://r.services.ai.azure.com/anthropic", + "api_mode": "anthropic_messages", + "auth_mode": "entra_id", + "default": "claude-sonnet-4-5", + }, + ) + assert runtime["provider"] == "azure-foundry" + assert runtime["auth_mode"] == "entra_id" + assert runtime["api_mode"] == "anthropic_messages" + # Callable api_key — the anthropic_adapter detects this and + # plumbs through an httpx event hook. + assert callable(runtime["api_key"]) + assert not isinstance(runtime["api_key"], str) + + def test_entra_with_explicit_api_key_uses_string_escape_hatch(self, fake_azure_identity): + """Passing --api-key on the CLI overrides the entra path so a + user can debug a single request with a static key without + editing config.yaml.""" + from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime + runtime = _resolve_azure_foundry_runtime( + requested_provider="azure-foundry", + model_cfg={ + "provider": "azure-foundry", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + "auth_mode": "entra_id", + }, + explicit_api_key="explicit-string-key", + ) + assert runtime["api_key"] == "explicit-string-key" + assert runtime["auth_mode"] == "api_key" + assert runtime["source"] == "explicit" + + def test_entra_runtime_dict_keeps_only_scope_override(self, fake_azure_identity): + from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime + runtime = _resolve_azure_foundry_runtime( + requested_provider="azure-foundry", + model_cfg={ + "provider": "azure-foundry", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + "auth_mode": "entra_id", + "entra": { + "scope": "https://custom.example/.default", + "client_id": "legacy-client", + }, + }, + ) + assert runtime["entra"] == {"scope": "https://custom.example/.default"} + + +# --------------------------------------------------------------------------- +# _resolve_azure_foundry_runtime: legacy api_key branch (regression) +# --------------------------------------------------------------------------- + + +class TestResolveAzureFoundryRuntimeApiKey: + def test_default_auth_mode_uses_static_key(self, monkeypatch): + from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime + monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-azure-static-key") + runtime = _resolve_azure_foundry_runtime( + requested_provider="azure-foundry", + model_cfg={ + "provider": "azure-foundry", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + }, + ) + assert runtime["api_key"] == "sk-azure-static-key" + assert runtime["auth_mode"] == "api_key" + assert "entra" not in runtime # only present in entra mode + + def test_explicit_auth_mode_api_key(self, monkeypatch): + from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime + monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-static") + runtime = _resolve_azure_foundry_runtime( + requested_provider="azure-foundry", + model_cfg={ + "provider": "azure-foundry", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + "auth_mode": "api_key", + }, + ) + assert runtime["api_key"] == "sk-static" + assert runtime["auth_mode"] == "api_key" + + def test_anthropic_messages_strips_v1_suffix(self, monkeypatch): + from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime + monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "k") + runtime = _resolve_azure_foundry_runtime( + requested_provider="azure-foundry", + model_cfg={ + "provider": "azure-foundry", + "base_url": "https://r.services.ai.azure.com/anthropic/v1", + "api_mode": "anthropic_messages", + }, + ) + assert runtime["base_url"] == "https://r.services.ai.azure.com/anthropic" + + def test_missing_api_key_raises_with_entra_hint(self, monkeypatch): + from hermes_cli.auth import AuthError + from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime + monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False) + with pytest.raises(AuthError) as exc_info: + _resolve_azure_foundry_runtime( + requested_provider="azure-foundry", + model_cfg={ + "provider": "azure-foundry", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_mode": "chat_completions", + }, + ) + msg = str(exc_info.value) + assert "AZURE_FOUNDRY_API_KEY" in msg + # Surface the Entra alternative so users discover the keyless path. + assert "entra_id" in msg + + +# --------------------------------------------------------------------------- +# _get_azure_foundry_auth_status (auth.py) — never mints a token +# --------------------------------------------------------------------------- + + +class TestAzureFoundryAuthStatus: + def test_entra_status_does_not_mint_token(self, monkeypatch, tmp_path): + """Structural check — must return logged_in=True based on + importable + config, never call get_bearer_token_provider.""" + from hermes_cli import auth as _auth + # Force load_config to return our entra config. + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: { + "model": { + "provider": "azure-foundry", + "auth_mode": "entra_id", + "base_url": "https://r.openai.azure.com/openai/v1", + }, + }, + ) + # Patch has_azure_identity_installed to True; do NOT patch the + # token provider — if the code path tried to mint, the SDK + # missing would raise. + monkeypatch.setattr( + "agent.azure_identity_adapter.has_azure_identity_installed", + lambda: True, + ) + info = _auth._get_azure_foundry_auth_status() + assert info["logged_in"] is True + assert info["auth_mode"] == "entra_id" + assert info["azure_identity_installed"] is True + assert info["scope"].endswith("/.default") + + def test_entra_status_reports_missing_package(self, monkeypatch): + from hermes_cli import auth as _auth + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: { + "model": { + "provider": "azure-foundry", + "auth_mode": "entra_id", + "base_url": "https://r.openai.azure.com/openai/v1", + }, + }, + ) + monkeypatch.setattr( + "agent.azure_identity_adapter.has_azure_identity_installed", + lambda: False, + ) + info = _auth._get_azure_foundry_auth_status() + assert info["logged_in"] is False + assert info["azure_identity_installed"] is False + assert "azure-identity" in info["hint"] + + def test_api_key_status_uses_env_var(self, monkeypatch): + from hermes_cli import auth as _auth + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: { + "model": { + "provider": "azure-foundry", + "auth_mode": "api_key", + "base_url": "https://r.openai.azure.com/openai/v1", + }, + }, + ) + monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-real-key-xxx") + info = _auth._get_azure_foundry_auth_status() + assert info["auth_mode"] == "api_key" + assert info["logged_in"] is True + + def test_api_key_status_false_when_missing(self, monkeypatch): + from hermes_cli import auth as _auth + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: { + "model": { + "provider": "azure-foundry", + "auth_mode": "api_key", + }, + }, + ) + monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False) + info = _auth._get_azure_foundry_auth_status() + assert info["logged_in"] is False diff --git a/tests/run_agent/test_callable_api_key.py b/tests/run_agent/test_callable_api_key.py new file mode 100644 index 0000000000..2c685643b9 --- /dev/null +++ b/tests/run_agent/test_callable_api_key.py @@ -0,0 +1,375 @@ +"""Tests that callable api_key (Entra ID bearer provider) flows through +the agent stack without coercion. + +The OpenAI Python SDK accepts ``api_key: str | None | Callable[[], str]``, +and ``azure-identity``'s ``get_bearer_token_provider`` returns a callable. +Hermes preserves the callable end-to-end so the SDK refreshes tokens +transparently. This file pins the contract at the high-risk seams the +rubber-duck audit identified. + +Covered: + * ``_create_openai_client`` passes a callable ``api_key`` straight + through to ``openai.OpenAI(...)``. + * ``_normalize_main_runtime`` preserves the callable so auxiliary + clients inherit Entra auth. + * ``_truncate_token`` (dashboard preview) renders ``""`` + instead of ``""`` and never invokes the callable. + * ``run_agent.py`` masked-banner path renders the Entra placeholder + and never tries to slice/len the callable. + * Serialization scrub: dumping a runtime dict via ``json.dumps`` with + a callable api_key raises (default behaviour) — guards against + silently leaking ``""`` strings into event logs. + * ``batch_runner`` strips the callable from the worker config dict + so multiprocessing.Pool can pickle the rest. +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import cast +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# OpenAI SDK construction preserves the callable +# --------------------------------------------------------------------------- + + +class TestCreateOpenAIClientCallable: + """``AIAgent._create_openai_client`` must pass the callable through + to ``openai.OpenAI(...)`` without coercion.""" + + def test_callable_api_key_passed_to_openai_constructor(self, monkeypatch): + """Construct the smallest possible AIAgent surface and verify + the OpenAI client receives the callable unchanged.""" + captured = {} + + def fake_openai(**kwargs): + captured["kwargs"] = kwargs + return MagicMock(api_key=kwargs.get("api_key")) + + # Patch the module-level OpenAI proxy used by ``_create_openai_client``. + monkeypatch.setattr("run_agent.OpenAI", fake_openai) + + # Build a minimal stand-in for AIAgent so we can call the bound + # method directly without paying the full __init__ cost. + from run_agent import AIAgent + + agent = AIAgent.__new__(AIAgent) + # Attributes consulted by _create_openai_client / _client_log_context. + agent.provider = "azure-foundry" + agent.model = "gpt-4o" + agent.base_url = "https://r.openai.azure.com/openai/v1" + agent._client_kwargs = {} + + def token_provider(): + return "fresh-jwt" + + client_kwargs = { + "api_key": token_provider, + "base_url": "https://r.openai.azure.com/openai/v1", + } + client = agent._create_openai_client(client_kwargs, reason="test", shared=False) + + # The OpenAI constructor must receive the *callable*, not a string. + forwarded = captured["kwargs"]["api_key"] + assert callable(forwarded) + assert not isinstance(forwarded, str) + assert forwarded is token_provider, ( + "_create_openai_client must not wrap or coerce the callable" + ) + assert client is not None + + +# --------------------------------------------------------------------------- +# Auxiliary runtime preserves the callable +# --------------------------------------------------------------------------- + + +class TestNormalizeMainRuntimePreservesCallable: + """The aux client orchestrator must keep the callable on the + runtime dict so compression / vision / embedding / title-gen clients + inherit Entra ID auth from the main agent.""" + + def test_callable_api_key_survives_normalization(self): + from agent.auxiliary_client import _normalize_main_runtime + + def provider(): + return "jwt" + + normalized = _normalize_main_runtime({ + "provider": "azure-foundry", + "model": "gpt-4o", + "base_url": "https://r.openai.azure.com/openai/v1", + "api_key": provider, + "api_mode": "chat_completions", + "auth_mode": "entra_id", + }) + assert normalized["api_key"] is provider + assert normalized["auth_mode"] == "entra_id" + + def test_string_api_key_still_works(self): + from agent.auxiliary_client import _normalize_main_runtime + normalized = _normalize_main_runtime({ + "provider": "azure-foundry", + "api_key": "sk-static", + }) + assert normalized["api_key"] == "sk-static" + + def test_normalization_drops_empty_string_but_preserves_callable(self): + from agent.auxiliary_client import _normalize_main_runtime + + def provider(): + return "" + + # Empty string fields are dropped, but a callable is preserved + # even if it would mint an empty token (we don't invoke during + # normalization). + normalized = _normalize_main_runtime({ + "provider": "azure-foundry", + "api_key": provider, + "model": "", + }) + assert normalized["api_key"] is provider + assert "model" not in normalized + + def test_unknown_field_dropped(self): + from agent.auxiliary_client import _normalize_main_runtime, _MAIN_RUNTIME_FIELDS + normalized = _normalize_main_runtime({ + "provider": "azure-foundry", + "api_key": "k", + "secret_field_we_dont_want": "leak", + }) + assert "secret_field_we_dont_want" not in normalized + # auth_mode IS in the field allowlist (rubber-duck blocker fix). + assert "auth_mode" in _MAIN_RUNTIME_FIELDS + + +# --------------------------------------------------------------------------- +# Display surfaces never invoke the callable +# --------------------------------------------------------------------------- + + +class TestTruncateTokenCallable: + def test_callable_returns_placeholder(self): + """Dashboard preview must render the Entra placeholder, NOT + ``""``.""" + from hermes_cli.web_server import _truncate_token + + invoked = {"count": 0} + + def provider(): + invoked["count"] += 1 + return "should-not-appear-in-ui" + + token_provider = cast(str | None, provider) + rendered = _truncate_token(token_provider) + assert rendered == "" + assert invoked["count"] == 0 + + def test_string_jwt_still_truncated_to_signature_tail(self): + from hermes_cli.web_server import _truncate_token + # JWT shape: header.payload.signature → only signature tail shown. + out = _truncate_token("aaaa.bbbb.cccccccsig", visible=4) + assert out == "â€Ļcsig" + + def test_empty_returns_empty(self): + from hermes_cli.web_server import _truncate_token + assert _truncate_token(None) == "" + assert _truncate_token("") == "" + + +# --------------------------------------------------------------------------- +# Serialization scrub — runtime dicts with callables must NOT silently +# JSON-encode as ``""`` (would leak garbage into events). +# --------------------------------------------------------------------------- + + +class TestRuntimeDictSerializationGuard: + def test_json_dumps_default_str_does_not_silently_stringify_callable(self): + """Sanity check: a runtime dict with a callable api_key must + either raise on plain ``json.dumps`` (good — fail loud) or be + sanitized BEFORE serialization. This test pins the loud-fail + behaviour so future changes that introduce + ``json.dumps(..., default=str)`` over a runtime dict are caught + by a regression here.""" + + def provider(): + return "jwt" + + runtime = { + "provider": "azure-foundry", + "api_key": provider, + "auth_mode": "entra_id", + } + # Plain json.dumps — must raise, not silently produce + # ``""``. + with pytest.raises(TypeError): + json.dumps(runtime) + + +# --------------------------------------------------------------------------- +# batch_runner strips callables from the worker config dict +# --------------------------------------------------------------------------- + + +class TestBatchRunnerCallableHandling: + def test_callable_api_key_stripped_from_worker_config(self, capsys, monkeypatch, tmp_path): + """``BatchRunner._run_batches`` (or the equivalent code path) + must replace a callable api_key with None before pickling the + worker config dict — otherwise multiprocessing.Pool fails.""" + # We can't easily run BatchRunner end-to-end in a unit test + # (it spawns subprocesses), but we CAN inline the same logic: + # the production code uses ``callable(self.api_key) and not + # isinstance(self.api_key, str)`` to gate the substitution. + # Re-execute the same predicate here as a contract guard. + + def provider(): + return "jwt" + + api_key = provider + worker_api_key = None if (callable(api_key) and not isinstance(api_key, str)) else api_key + assert worker_api_key is None, ( + "BatchRunner must replace callable api_key with None so " + "multiprocessing.Pool can pickle the worker config" + ) + + # And a string passes through unchanged. + api_key_str = "sk-static" + worker_api_key_str = None if (callable(api_key_str) and not isinstance(api_key_str, str)) else api_key_str + assert worker_api_key_str == "sk-static" + + def test_batch_runner_source_uses_the_correct_predicate(self): + """Pin the predicate string in batch_runner so refactors that + change it are caught here. Reading the source rather than + importing avoids spinning up the full BatchRunner.""" + from pathlib import Path + src = (Path(__file__).resolve().parent.parent.parent + / "batch_runner.py").read_text() + assert "callable(self.api_key) and not isinstance(self.api_key, str)" in src, ( + "BatchRunner.api_key callable check changed — update test or " + "verify the new predicate still routes Entra token providers " + "to the worker-rebuild path." + ) + + +# --------------------------------------------------------------------------- +# Inline masked-banner / display sites (callable-aware) +# --------------------------------------------------------------------------- + + +class TestCliEnsureRuntimeCredentialsCallable: + """Regression: ``cli.py:_ensure_runtime_credentials`` previously + treated a callable ``api_key`` as "not a string" and overwrote it + with the ``"no-key-required"`` placeholder, which then got sent as + ``Authorization: Bearer no-key-required`` and rejected by Azure + with a 401. This is the most subtle of the callable-api_key audit + sites — gated by ``not isinstance(api_key, str)`` rather than the + cleaner ``callable(...)`` check used elsewhere. + + We verify the source pattern (rather than spinning up a real + ``HermesCLI`` instance) — the predicate change is the load-bearing + fix and is invariant under the surrounding orchestration code.""" + + def test_callable_predicate_present_in_cli_runtime_validation(self): + from pathlib import Path + src = (Path(__file__).resolve().parent.parent.parent + / "cli.py").read_text() + # The fix introduces ``_is_callable_provider`` which gates the + # string-only check so callable token providers survive. + assert "_is_callable_provider = callable(api_key)" in src, ( + "cli.py:_ensure_runtime_credentials must preserve a callable " + "api_key (Entra ID bearer provider). Without the guard, the " + "callable is stringified to 'no-key-required' and Azure 401s." + ) + + +class TestInlinedDisplayMasks: + """The masked-credential display sites are now inlined per-site (no + shared helper). Each site uses the ``is_token_provider`` predicate + to short-circuit on callables and print a static + ``"Microsoft Entra ID"`` label, then falls through to its own + context-appropriate string mask. This replaces a unified helper + that would have forced one mask shape across sites with legitimately + different display needs (banner vs diagnostic vs UI vs preview).""" + + def test_run_agent_banner_uses_is_token_provider_guard(self): + """The masked-banner sites live in ``agent/agent_init.py`` + (the ``__init__`` body was extracted into ``init_agent`` after + this feature was first written). Both the OpenAI and Anthropic + client init paths must guard their banner prints with + ``is_token_provider`` so a callable Entra ID provider doesn't + crash ``len(api_key)``.""" + from pathlib import Path + src = (Path(__file__).resolve().parent.parent.parent + / "agent" / "agent_init.py").read_text() + assert src.count("is_token_provider(") >= 2, ( + "agent/agent_init.py must guard BOTH masked-banner paths " + "(chat_completions and anthropic_messages) with " + "is_token_provider()." + ) + assert src.count('"🔑 Using credentials: Microsoft Entra ID"') >= 2, ( + "agent/agent_init.py banner blocks should print a static " + "'Microsoft Entra ID' label for callable api_keys — no " + "placeholder plumbing, no describe-mask fallback." + ) + + def test_cli_show_config_handles_callable(self): + """``cli.HermesCLI.show_config`` previously did + ``self.api_key[-4:]`` / ``len(self.api_key)`` which crashes on + callable Entra ID providers. The inlined version uses + ``is_token_provider`` and prints the same static label as the + run_agent banners.""" + from pathlib import Path + src = (Path(__file__).resolve().parent.parent.parent + / "cli.py").read_text() + assert "is_token_provider(self.api_key)" in src, ( + "cli.HermesCLI.show_config must guard self.api_key via " + "is_token_provider so callable Entra ID providers don't " + "crash /config." + ) + assert '"Microsoft Entra ID"' in src, ( + "cli.HermesCLI.show_config must print the static " + "'Microsoft Entra ID' label (matching run_agent banners) " + "instead of attempting to slice the callable." + ) + + def test_mask_api_key_for_logs_handles_callable(self): + """``run_agent._mask_api_key_for_logs`` is called from the + request-dump JSON path. For Entra users, ``self.client.api_key`` + is the SDK's empty string (callable stashed privately) — but + defensively the helper must also accept a callable directly + and return the placeholder rather than crashing on + ``len(callable)``.""" + from pathlib import Path + src = (Path(__file__).resolve().parent.parent.parent + / "run_agent.py").read_text() + # The function now starts with a callable check. + assert ( + "if callable(key) and not isinstance(key, str):" in src + and '""' in src + ), ( + "run_agent._mask_api_key_for_logs must short-circuit for " + "callable api_keys to avoid len(callable) crashes in " + "request-dump paths." + ) + + def test_anthropic_401_diagnostic_handles_callable(self): + """The Anthropic 401 diagnostic path lives in + ``agent/conversation_loop.py`` (the ``run_conversation`` body + was extracted after this feature was first written). It used + to do ``key[:12]`` on ``self._anthropic_api_key``. For Entra ID + + Anthropic-style mode that's a callable; slicing crashes.""" + from pathlib import Path + src = (Path(__file__).resolve().parent.parent.parent + / "agent" / "conversation_loop.py").read_text() + # The Anthropic 401 block now branches on is_token_provider + # before slicing the key. + assert "Microsoft Entra ID (httpx event hook)" in src, ( + "agent/conversation_loop.py Anthropic 401 diagnostic must " + "surface a Microsoft Entra ID branch before slicing the " + "key prefix." + ) diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index c7d7730c75..1a8708ef25 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -81,6 +81,11 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { "provider.anthropic": ("anthropic==0.87.0",), # CVE-2026-34450, CVE-2026-34452 # AWS Bedrock provider "provider.bedrock": ("boto3==1.42.89",), + # Microsoft Foundry — Entra ID auth (managed identity, workload identity, + # service principal, az login, VS Code, azd, PowerShell). Only loaded + # when model.auth_mode=entra_id is selected; key-based azure-foundry + # users never pay this import. + "provider.azure_identity": ("azure-identity==1.25.3",), # ─── Web search backends ─────────────────────────────────────────────── "search.exa": ("exa-py==2.10.2",), diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 4a9bc2b659..de2888a6de 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1087,7 +1087,16 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict: current_provider = str(runtime.get("provider", "") or "") current_model = _resolve_model() current_base_url = str(runtime.get("base_url", "") or "") - current_api_key = str(runtime.get("api_key", "") or "") + # Preserve a callable api_key (Azure Foundry Entra ID bearer + # provider) unchanged — ``str(...)`` would produce + # ``""`` and poison downstream switch_model + # validation. Match the agent-present branch's behavior at the + # top of this block. + _runtime_key = runtime.get("api_key", "") + if callable(_runtime_key) and not isinstance(_runtime_key, str): + current_api_key = _runtime_key + else: + current_api_key = str(_runtime_key or "") # Load user-defined providers so switch_model can resolve named custom # endpoints (e.g. "ollama-launch") and validate against saved model lists. diff --git a/uv.lock b/uv.lock index e7641abd22..02f0081665 100644 --- a/uv.lock +++ b/uv.lock @@ -500,6 +500,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/0a/0896b829a39b5669a2d811e1a79598de661693685cd62b31f11d0c18e65b/av-17.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dba98603fc4665b4f750de86fbaf6c0cfaece970671a9b529e0e3d1711e8367e", size = 22071058, upload-time = "2026-03-14T14:38:43.663Z" }, ] +[[package]] +name = "azure-core" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-05-07T23:30:56.357Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, +] + [[package]] name = "base58" version = "2.1.1" @@ -1618,6 +1647,9 @@ all = [ anthropic = [ { name = "anthropic" }, ] +azure-identity = [ + { name = "azure-identity" }, +] bedrock = [ { name = "boto3" }, ] @@ -1767,6 +1799,7 @@ requires-dist = [ { name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = "==2.2.42" }, { name = "anthropic", marker = "extra == 'anthropic'", specifier = "==0.86.0" }, { name = "asyncpg", marker = "extra == 'matrix'", specifier = "==0.31.0" }, + { name = "azure-identity", marker = "extra == 'azure-identity'", specifier = "==1.25.3" }, { name = "boto3", marker = "extra == 'bedrock'", specifier = "==1.42.89" }, { name = "brotlicffi", marker = "extra == 'messaging'", specifier = "==1.2.0.1" }, { name = "croniter", specifier = "==6.0.0" }, @@ -1855,7 +1888,7 @@ requires-dist = [ { name = "vercel", marker = "extra == 'vercel'", specifier = "==0.5.7" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "bedrock", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "bedrock", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] [[package]] name = "hf-xet" @@ -2421,6 +2454,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "msal" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/cb/b02b0f748ac668922364ccb3c3bff5b71628a05f5adfec2ba2a5c3031483/msal-1.36.0.tar.gz", hash = "sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b", size = 174217, upload-time = "2026-04-09T10:20:33.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547, upload-time = "2026-04-09T10:20:32.336Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + [[package]] name = "msgpack" version = "1.1.2" diff --git a/website/docs/guides/azure-foundry.md b/website/docs/guides/azure-foundry.md index 218eadadc3..070f5c0d99 100644 --- a/website/docs/guides/azure-foundry.md +++ b/website/docs/guides/azure-foundry.md @@ -1,23 +1,23 @@ --- sidebar_position: 15 -title: "Azure AI Foundry" -description: "Use Hermes Agent with Azure AI Foundry — OpenAI-style and Anthropic-style endpoints, auto-detection of transport and deployed models" +title: "Microsoft Foundry" +description: "Use Hermes Agent with Microsoft Foundry — OpenAI-style and Anthropic-style endpoints, auto-detection of transport and deployed models" --- -# Azure AI Foundry +# Microsoft Foundry -Hermes Agent supports Azure AI Foundry (and Azure OpenAI) as a first-class provider. A single Azure resource can host models with two different wire formats: +Hermes Agent's `azure-foundry` provider supports Microsoft Foundry (formerly Azure AI Foundry) and Azure OpenAI. A single Foundry resource can host models with two different wire formats: - **OpenAI-style** — `POST /v1/chat/completions` on endpoints like `https://.openai.azure.com/openai/v1`. Used for GPT-4.x, GPT-5.x, Llama, Mistral, and most open-weight models. -- **Anthropic-style** — `POST /v1/messages` on endpoints like `https://.services.ai.azure.com/anthropic`. Used when Azure Foundry serves Claude models via the Anthropic Messages API format. +- **Anthropic-style** — `POST /v1/messages` on endpoints like `https://.services.ai.azure.com/anthropic`. Used when Microsoft Foundry serves Claude models via the Anthropic Messages API format. The setup wizard probes your endpoint and auto-detects which transport it uses, which deployments are available, and each model's context length. ## Prerequisites -- An Azure AI Foundry or Azure OpenAI resource with at least one deployment -- An API key for that resource (available in the Azure Portal under "Keys and Endpoint") +- A Microsoft Foundry or Azure OpenAI resource with at least one deployment - The deployment's endpoint URL +- **Either** an API key (from the Azure Portal under "Keys and Endpoint") **or** the **Azure AI User** RBAC role on the Foundry resource if you plan to use Microsoft Entra ID (the keyless path Microsoft recommends). Some tenants may show the role as **Foundry User** during Microsoft's rename rollout. ## Quick Start @@ -25,20 +25,172 @@ The setup wizard probes your endpoint and auto-detects which transport it uses, hermes model # → Select "Azure Foundry" # → Enter your endpoint URL -# → Enter your API key +# → Choose Authentication: +# 1. API key +# 2. Microsoft Entra ID (managed identity / workload identity / az login) +# → (Entra) Hermes probes DefaultAzureCredential; on success it never asks for a key +# → (API key) Enter your API key # Hermes probes the endpoint and auto-detects transport + models # → Pick a model from the list (or type a deployment name manually) ``` The wizard will: -1. **Sniff the URL path** — URLs ending in `/anthropic` are recognised as Azure Foundry Claude routes. +1. **Sniff the URL path** — URLs ending in `/anthropic` are recognised as Microsoft Foundry Claude routes. 2. **Probe `GET /models`** — if the endpoint returns an OpenAI-shaped model list, Hermes switches to `chat_completions` and prefills a picker with the returned deployment IDs. 3. **Probe Anthropic Messages shape** — fallback for endpoints that do not expose `/models` but do accept the Anthropic Messages format. 4. **Fall back to manual entry** — private/gated endpoints that reject every probe still work; you pick the API mode and type a deployment name by hand. Context length for the chosen model is resolved via Hermes' standard metadata chain (`models.dev`, provider metadata, and hardcoded family fallbacks) and stored in `config.yaml` so the model can size its own context window correctly. +## Microsoft Entra ID (keyless, RBAC) — recommended + +Microsoft recommends [keyless authentication with Microsoft Entra ID](https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/configure-entra-id) for production Foundry workloads. Hermes supports Entra ID for **both** API surfaces: + +- **OpenAI-style** (`api_mode: chat_completions` / `codex_responses`) — GPT-4/5, Llama, Mistral, DeepSeek, etc. +- **Anthropic-style** (`api_mode: anthropic_messages`) — Claude models on Microsoft Foundry. + +Foundry's RBAC is per-resource (`Azure AI User` grants both surfaces; some tenants may display `Foundry User`) and Microsoft documents the same inference scope (`https://ai.azure.com/.default`) for both. Under the hood: + +- OpenAI-style uses the OpenAI Python SDK's native callable `api_key=` contract — the SDK mints a fresh JWT per request automatically. +- Anthropic-style uses an `httpx.Client` with a request event hook installed by `agent.azure_identity_adapter.build_bearer_http_client`, because the Anthropic SDK does not accept callable `auth_token` natively. The hook rewrites `Authorization: Bearer ` per outbound request. Same Microsoft RBAC, same Foundry scope — the SDK contract is the only difference. + +### Why use Entra ID? + +- No long-lived API keys to rotate or revoke. +- RBAC-driven access — grant or remove `Azure AI User` on the Foundry resource, no config rewrite needed. +- Access and audit logs are segmented by assignee instead of all callers sharing one static key. +- Single auth surface for Azure VMs, AKS pods, App Service, Functions, Container Apps, and Foundry Agent Service via managed identity. +- Workload identity and service-principal flows for CI/CD pipelines. + +### One-time setup (Azure side) + +1. In the Azure Portal, open your Foundry resource → **Access control (IAM)** → **Add → Add role assignment**. +2. Pick the **Azure AI User** role (or **Foundry User** if your tenant has the renamed role). +3. Assign it to: + - **Your user account** for local development with `az login`. + - **A managed identity or workload identity** for Azure-hosted compute (recommended for production). + - **A Foundry Agent Service hosted agent's agent identity** when Hermes runs inside a hosted agent. + - **A service principal** for CI/CD pipelines when workload identity is not available. +4. Wait ~5 minutes for the role to propagate. + +Azure CLI equivalent: + +```bash +az role assignment create \ + --assignee \ + --role "Azure AI User" \ + --scope +``` + +### One-time setup (Hermes side) + +```bash +hermes model +# → Select "Azure Foundry" +# → Enter your endpoint URL +# → Authentication: 2 (Microsoft Entra ID) +# → (optional) user-assigned managed identity client ID +# → (optional) Azure tenant ID +# → Hermes probes DefaultAzureCredential() and reports which inner +# credential succeeded (e.g. AzureCliCredential, ManagedIdentityCredential) +``` + +The wizard runs a bounded preflight probe (10 s timeout). On failure it offers to "save anyway, validate later" — useful when configuring on a machine that doesn't yet have credentials but will at runtime (e.g. preparing config for a managed-identity deployment). + +`azure-identity` is installed automatically on first use via Hermes' lazy-install path. To pre-install: + +```bash +pip install azure-identity +``` + +### Configuration written to `config.yaml` + +```yaml +model: + provider: azure-foundry + base_url: https://my-resource.openai.azure.com/openai/v1 + api_mode: chat_completions + auth_mode: entra_id + default: gpt-4o + context_length: 128000 + entra: + scope: https://ai.azure.com/.default # only when overriding the default +``` + +Hermes only manages one Entra-specific knob in `config.yaml`: + +- **`scope`** — the OAuth resource scope. Defaults to Microsoft's documented inference scope (`https://ai.azure.com/.default`). Override only if your resource was provisioned against a non-standard audience. + +Everything else (tenant, service principal secret, federated token file, sovereign cloud authority, broker preferences) is read by `azure-identity` directly from the standard `AZURE_*` environment variables — see the [credential resolution order](#credential-resolution-order) below. Set those in `~/.hermes/.env` or your deployment environment, exactly as Microsoft's SDK reference describes. + +No secrets land in `~/.hermes/.env` for Entra mode — `azure-identity` caches tokens in-process (and where available, in your OS keychain / `~/.IdentityService`). + +### Credential resolution order + +`azure-identity`'s `DefaultAzureCredential` walks this chain on each token request, stopping at the first credential that returns a token: + +1. **Environment credential** — `AZURE_TENANT_ID` + `AZURE_CLIENT_ID` + `AZURE_CLIENT_SECRET` (or `AZURE_CLIENT_CERTIFICATE_PATH` / `AZURE_FEDERATED_TOKEN_FILE`). +2. **Workload Identity** — `AZURE_FEDERATED_TOKEN_FILE` (AKS federated tokens / OIDC). +3. **Managed Identity** — IMDS endpoint (`169.254.169.254`) for virtual machines; `IDENTITY_ENDPOINT` for App Service / Functions / Container Apps. Foundry Agent Service hosted agents use the hosted agent's agent identity. +4. **Visual Studio Code** — Azure account extension. +5. **Azure CLI** — `az login` session. +6. **Azure Developer CLI** — `azd auth login`. +7. **Azure PowerShell** — `Connect-AzAccount`. +8. **Broker** (Windows / WSL only) — Web Account Manager. + +Interactive browser credential is excluded by default for unattended Hermes runs; use Azure CLI, Azure Developer CLI, managed identity, workload identity, or service principal credentials instead. + +### Deployment patterns + +**Local development:** +```bash +az login +hermes model # pick Azure Foundry → Entra ID +hermes # uses your az login token +``` + +**Azure VM / Functions / App Service / Container Apps (system-assigned managed identity):** +1. Enable system-assigned identity on the compute resource. +2. Grant the identity `Azure AI User` (or `Foundry User`) on the Foundry resource. +3. Set `model.auth_mode: entra_id` in config.yaml — no env vars needed. + +**Azure VM / Functions / App Service / Container Apps (user-assigned managed identity):** +- Set `AZURE_CLIENT_ID` to the user-assigned identity's client ID so `DefaultAzureCredential` picks the right one. + +**Foundry Agent Service hosted agent:** +- Create the hosted agent and grant that agent's identity `Azure AI User` (or `Foundry User`) on the Foundry resource. Hermes uses `ManagedIdentityCredential` from inside the hosted agent; role assignment belongs on the agent identity, not just the parent project or your user. + +**AKS Workload Identity (replaces AAD Pod Identity):** +- Annotate the pod's service account with the workload identity client ID. +- The pod's federated token file is auto-detected via `AZURE_FEDERATED_TOKEN_FILE`. +- `model.auth_mode: entra_id` works without further config changes. + +**Service principal in CI:** +- Set `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` in the runner env. + +**Sovereign clouds (Government, China):** +- Export `AZURE_AUTHORITY_HOST` (e.g. `https://login.microsoftonline.us` for Azure Government, `https://login.partner.microsoftonline.cn` for Azure China). `azure-identity` reads it directly. + +### Health checks + +`hermes doctor` runs a 10 s probe against `DefaultAzureCredential` when `model.auth_mode: entra_id`, reporting which inner credential won (env vars present, managed identity endpoint reachable, etc.). + +`hermes auth` shows a structured status block: + +``` +azure-foundry (Microsoft Entra ID): + Endpoint: https://my-resource.openai.azure.com/openai/v1 + Scope: https://ai.azure.com/.default + Status: configured; live token probe is skipped here +``` + +### Limitations + +- **Anthropic-style endpoints use an httpx event hook.** The Anthropic Python SDK does not accept a callable `auth_token` natively (≤ 0.86.0). Hermes installs a request event hook on a custom `httpx.Client` that mints a fresh JWT per outbound request and rewrites `Authorization: Bearer `. This is functionally equivalent to the OpenAI SDK's native `Callable[[], str]` contract but adds one indirection layer. If the Anthropic SDK adds first-class callable-auth support in a future release, Hermes will switch to it transparently. +- **Batch jobs and `multiprocessing.Pool`.** The Entra token provider is a closure that cannot be pickled across process boundaries. `batch_runner.py` automatically drops the callable from the worker config and lets each worker process rebuild its own provider from `config.yaml` — no user action required, but each worker pays one chain walk at startup. +- **No bearer JWT persistence in `auth.json`.** Hermes does not duplicate `azure-identity`'s internal token cache; cold starts walk the credential chain on first inference. + ## Configuration (written to `config.yaml`) After running the wizard you'll see something like this: @@ -72,11 +224,11 @@ model: Important behaviour: -- **GPT-5.x, codex, and o-series auto-route to the Responses API.** Azure Foundry deploys GPT-5 / codex / o1 / o3 / o4 models as Responses-API-only — calling `/chat/completions` against them returns `400 "The requested operation is unsupported."`. Hermes detects these model families by name and upgrades `api_mode` to `codex_responses` transparently, even when `config.yaml` still reads `api_mode: chat_completions`. GPT-4, GPT-4o, Llama, Mistral, and other deployments stay on `/chat/completions`. +- **GPT-5.x, codex, and o-series auto-route to the Responses API.** Microsoft Foundry deploys GPT-5 / codex / o1 / o3 / o4 models as Responses-API-only — calling `/chat/completions` against them returns `400 "The requested operation is unsupported."`. Hermes detects these model families by name and upgrades `api_mode` to `codex_responses` transparently, even when `config.yaml` still reads `api_mode: chat_completions`. GPT-4, GPT-4o, Llama, Mistral, and other deployments stay on `/chat/completions`. - **`max_completion_tokens` is used automatically.** Azure OpenAI (like direct OpenAI) requires `max_completion_tokens` for gpt-4o, o-series, and gpt-5.x models. Hermes sends the right parameter based on the endpoint. - **Pre-v1 endpoints that require `api-version`.** If you have a legacy base URL like `https://.openai.azure.com/openai?api-version=2025-04-01-preview`, Hermes extracts the query string and forwards it via `default_query` on every request (the OpenAI SDK otherwise drops it when joining paths). -## Anthropic-style endpoints (Claude via Azure Foundry) +## Anthropic-style endpoints (Claude via Microsoft Foundry) For Claude deployments, use the Anthropic-style route: @@ -96,7 +248,7 @@ Important behaviour: ## Alternative: `provider: anthropic` + Azure base URL -If you already have `provider: anthropic` configured and just want to point it at Azure AI Foundry for Claude, you can skip the `azure-foundry` provider entirely: +If you already have `provider: anthropic` configured and just want to point it at Microsoft Foundry for Claude, you can skip the `azure-foundry` provider entirely: ```yaml model: @@ -117,7 +269,7 @@ Azure does **not** expose a pure-API-key endpoint to list your *deployed* model What Hermes can do: - Azure OpenAI v1 endpoints (`.openai.azure.com/openai/v1`) expose `GET /models` with the resource's **available** model catalog. Hermes uses this list to prefill the model picker. -- Azure Foundry `/anthropic` routes: detected via URL path, model name entered manually. +- Microsoft Foundry `/anthropic` routes: detected via URL path, model name entered manually. - Private / firewalled endpoints: manual entry with a friendly "couldn't probe" message. You can always type a deployment name directly — Hermes does not validate against the returned list. @@ -126,9 +278,18 @@ You can always type a deployment name directly — Hermes does not validate agai | Variable | Purpose | |----------|---------| -| `AZURE_FOUNDRY_API_KEY` | Primary API key for Azure AI Foundry / Azure OpenAI | +| `AZURE_FOUNDRY_API_KEY` | Primary API key for Microsoft Foundry / Azure OpenAI (api_key mode) | | `AZURE_FOUNDRY_BASE_URL` | Endpoint URL (set via `hermes model`; env var is used as a fallback) | | `AZURE_ANTHROPIC_KEY` | Used by `provider: anthropic` + Azure base URL (alternative to `ANTHROPIC_API_KEY`) | +| `AZURE_TENANT_ID` | Entra ID tenant for service-principal flows | +| `AZURE_CLIENT_ID` | Entra ID client ID (service principal, workload identity, or user-assigned managed identity) | +| `AZURE_CLIENT_SECRET` | Service principal secret | +| `AZURE_CLIENT_CERTIFICATE_PATH` | Service principal cert (alternative to secret) | +| `AZURE_FEDERATED_TOKEN_FILE` | Workload Identity federated token path (AKS) | +| `AZURE_AUTHORITY_HOST` | Sovereign cloud authority host override | +| `IDENTITY_ENDPOINT` / `MSI_ENDPOINT` | Managed Identity endpoint for App Service, Functions, and Container Apps; VMs usually use IMDS instead | + +The Azure SDK reads the `AZURE_*` env vars directly. Hermes never inspects them other than to report which sources are present in `hermes doctor` output. ## Troubleshooting @@ -150,8 +311,21 @@ model: api_mode: anthropic_messages # or chat_completions ``` +**Entra ID: "credential chain exhausted" or 401 Unauthorized after switching to `auth_mode: entra_id`.** +- Run `az login` to refresh your developer session (the cached token may have expired). +- Verify the `Azure AI User` (or `Foundry User`) role assignment took effect: `az role assignment list --assignee ` should list it on your Foundry resource. Role propagation can take up to 5 minutes. +- For user-assigned managed identities, double-check `AZURE_CLIENT_ID` matches the identity attached to the compute resource. +- Run `hermes doctor` — the Azure Entra probe reports whether token acquisition succeeded and includes a remediation hint. + +**Entra ID: wizard preflight hangs or times out.** +The 10 s preflight is a soft check. Choose "Save anyway and validate later" and run `hermes doctor` after deploying to the target environment. Common causes include an unreachable token service or stale local login state — prefer workload identity in CI, set `AZURE_TENANT_ID`+`AZURE_CLIENT_ID`+`AZURE_CLIENT_SECRET` when using a service principal, or run `az login` for local development. + +**401 on Anthropic-style endpoint with Entra ID.** +Verify the same `Azure AI User` (or `Foundry User`) role is assigned on the Foundry resource (it covers both `/openai/v1` and `/anthropic` paths). If the OpenAI-style probe works during the wizard but `claude-*` requests fail at runtime, the most common cause is a stale `model.entra.scope` left over from an earlier wizard run — delete the `entra.scope` line from `config.yaml` so the runtime falls back to the default `https://ai.azure.com/.default` scope. + ## Related - [Environment variables](/docs/reference/environment-variables) - [Configuration](/docs/user-guide/configuration) - [AWS Bedrock](/docs/guides/aws-bedrock) — the other major cloud provider integration +- [Microsoft: Configure Entra ID for Foundry](https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/configure-entra-id) — upstream documentation for the keyless path diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 90aecba441..969b0bf1f0 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -50,9 +50,16 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | `XIAOMI_BASE_URL` | Override Xiaomi MiMo base URL (default: `https://api.xiaomimimo.com/v1`) | | `TOKENHUB_API_KEY` | Tencent TokenHub API key ([tokenhub.tencentmaas.com](https://tokenhub.tencentmaas.com)) | | `TOKENHUB_BASE_URL` | Override Tencent TokenHub base URL (default: `https://tokenhub.tencentmaas.com/v1`) | -| `AZURE_FOUNDRY_API_KEY` | Azure AI Foundry / Azure OpenAI API key ([ai.azure.com](https://ai.azure.com/)) | -| `AZURE_FOUNDRY_BASE_URL` | Azure AI Foundry endpoint URL (e.g. `https://.openai.azure.com/openai/v1` for OpenAI-style, or `https://.services.ai.azure.com/anthropic` for Anthropic-style) | -| `AZURE_ANTHROPIC_KEY` | Azure Anthropic API key for `provider: anthropic` + `base_url` pointing at an Azure Foundry Claude deployment (alternative to `ANTHROPIC_API_KEY` when both Anthropic and Azure Anthropic are configured) | +| `AZURE_FOUNDRY_API_KEY` | Microsoft Foundry / Azure OpenAI API key ([ai.azure.com](https://ai.azure.com/)). Not needed when `model.auth_mode: entra_id` | +| `AZURE_FOUNDRY_BASE_URL` | Microsoft Foundry endpoint URL (e.g. `https://.openai.azure.com/openai/v1` for OpenAI-style, or `https://.services.ai.azure.com/anthropic` for Anthropic-style) | +| `AZURE_ANTHROPIC_KEY` | Azure Anthropic API key for `provider: anthropic` + `base_url` pointing at a Microsoft Foundry Claude deployment (alternative to `ANTHROPIC_API_KEY` when both Anthropic and Azure Anthropic are configured) | +| `AZURE_TENANT_ID` | Entra ID tenant ID (service-principal flows; honored by `azure-identity` when `model.auth_mode: entra_id`) | +| `AZURE_CLIENT_ID` | Entra ID client ID (service principal, workload identity, or user-assigned managed identity) | +| `AZURE_CLIENT_SECRET` | Service principal secret used by `EnvironmentCredential` | +| `AZURE_CLIENT_CERTIFICATE_PATH` | Service principal certificate (alternative to `AZURE_CLIENT_SECRET`) | +| `AZURE_FEDERATED_TOKEN_FILE` | Federated token file path for AKS Workload Identity / OIDC flows | +| `AZURE_AUTHORITY_HOST` | Sovereign-cloud authority override (e.g. `https://login.microsoftonline.us` for Azure Government). See [Azure Foundry guide](/docs/guides/azure-foundry#sovereign-clouds-government-china) | +| `IDENTITY_ENDPOINT` / `MSI_ENDPOINT` | Managed Identity endpoint for App Service, Functions, and Container Apps; VMs usually use IMDS instead and do not set these | | `HF_TOKEN` | Hugging Face token for Inference Providers ([huggingface.co/settings/tokens](https://huggingface.co/settings/tokens)) | | `HF_BASE_URL` | Override Hugging Face base URL (default: `https://router.huggingface.co/v1`) | | `GOOGLE_API_KEY` | Google AI Studio API key ([aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)) | diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md index 6ae92e3bb2..6d17abbf14 100644 --- a/website/docs/user-guide/features/fallback-providers.md +++ b/website/docs/user-guide/features/fallback-providers.md @@ -81,7 +81,7 @@ Both `provider` and `model` are **required**. If either is missing, the fallback | Kimi / Moonshot (China) | `kimi-coding-cn` | `KIMI_CN_API_KEY` | | StepFun | `stepfun` | `STEPFUN_API_KEY` | | Tencent TokenHub | `tencent-tokenhub` | `TOKENHUB_API_KEY` | -| Azure AI Foundry | `azure-foundry` | `AZURE_FOUNDRY_API_KEY` + `AZURE_FOUNDRY_BASE_URL` | +| Microsoft Foundry | `azure-foundry` | `AZURE_FOUNDRY_API_KEY` + `AZURE_FOUNDRY_BASE_URL` | | LM Studio (local) | `lmstudio` | `LM_API_KEY` (or none for local) + `LM_BASE_URL` | | Hugging Face | `huggingface` | `HF_TOKEN` | | Custom endpoint | `custom` | `base_url` + `key_env` (see below) | From 65e0c49b775a7d50780fe8cae616705fa139fbe3 Mon Sep 17 00:00:00 2001 From: teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 18 May 2026 09:42:57 -0700 Subject: [PATCH 002/338] chore(release): add AUTHOR_MAP entry for glennc --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index e9f35d5433..2677f3f58d 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -640,6 +640,7 @@ AUTHOR_MAP = { "geoff.wellman@gmail.com": "geoffwellman", "han.shan@live.cn": "jamesarch", "haolong@microsoft.com": "LongOddCode", + "glennc@microsoft.com": "glennc", "hata1234@gmail.com": "hata1234", "hmbown@gmail.com": "Hmbown", "iacobs@m0n5t3r.info": "m0n5t3r", From 1634397ddb1353c9a48fd34f084d55dcbce4b61f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 18 May 2026 10:19:40 -0700 Subject: [PATCH 003/338] fix(compress): abort instead of dropping messages when summary LLM fails (#28102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When auxiliary compression's summary generation returns None (aux model errored, returned non-JSON, timed out, etc.) the compressor previously still dropped every middle message between compress_start..compress_end and replaced them with a static 'Summary generation was unavailable' placeholder. The session kept going but the user silently lost N turns of context for nothing. New behavior: on summary failure, compress() aborts entirely — returns the input messages unchanged and sets _last_compress_aborted=True. The existing _summary_failure_cooldown_until gate (30-60s) keeps the aux model from being burned on every turn. Auto-compress callers detect the no-op (len(after) == len(before)) and stop looping. The chat is 'frozen' at its current size until the next /compress or /new. Manual /compress (CLI + gateway) now passes force=True which clears the cooldown so users can retry immediately after an auto-abort. If the manual retry also fails, the user gets a visible warning telling them nothing was dropped and how to retry. - agent/context_compressor.py: compress() gains force= kwarg; failure branch sets _last_compress_aborted and returns messages unchanged instead of inserting placeholder. - run_agent.py: _compress_context() detects abort, surfaces warning, skips session-rotation entirely, returns messages unchanged. - cli.py + gateway/run.py: manual /compress paths pass force=True. - gateway/run.py: hygiene + /compress handlers detect _last_compress_aborted and emit the new 'Compression aborted' warning (gateway.compress.aborted) instead of the old 'N historical messages were removed' message. - locales/*.yaml: new gateway.compress.aborted key in all 16 locales. - tests: updated to assert the abort contract (messages preserved, compression_count not incremented, abort flag set, no placeholder leaked). New test_force_true_bypasses_failure_cooldown covers the manual-retry path. --- agent/context_compressor.py | 58 +++++++++---- agent/conversation_compression.py | 34 +++++++- cli.py | 1 + gateway/run.py | 42 ++++----- locales/af.yaml | 1 + locales/de.yaml | 1 + locales/en.yaml | 1 + locales/es.yaml | 1 + locales/fr.yaml | 1 + locales/ga.yaml | 1 + locales/hu.yaml | 1 + locales/it.yaml | 1 + locales/ja.yaml | 1 + locales/ko.yaml | 1 + locales/pt.yaml | 1 + locales/ru.yaml | 1 + locales/tr.yaml | 1 + locales/uk.yaml | 1 + locales/zh-hant.yaml | 1 + locales/zh.yaml | 1 + run_agent.py | 11 ++- tests/agent/test_context_compressor.py | 113 ++++++++++++++++++++----- tests/gateway/test_compress_command.py | 44 +++++----- tests/gateway/test_session_hygiene.py | 33 ++++---- 24 files changed, 249 insertions(+), 103 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 41983fabba..8ef9796df7 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -586,6 +586,12 @@ class ContextCompressor(ContextEngine): # (gateway hygiene, /compress) can surface a visible warning. self._last_summary_dropped_count: int = 0 self._last_summary_fallback_used: bool = False + # When summary generation fails we now ABORT compression entirely + # and return the original messages unchanged instead of dropping + # the middle window with a static placeholder. Callers inspect + # this flag to know "compression was attempted but aborted, freeze + # the chat until the user manually retries via /compress". + self._last_compress_aborted: bool = False # When a user-configured summary model fails and we recover by # retrying on the main model, record the failure so gateway / # CLI callers can still warn the user even though compression @@ -1479,7 +1485,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio # Main compression entry point # ------------------------------------------------------------------ - def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, focus_topic: str = None) -> List[Dict[str, Any]]: + def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, focus_topic: str = None, force: bool = False) -> List[Dict[str, Any]]: """Compress conversation messages by summarizing middle turns. Algorithm: @@ -1497,6 +1503,9 @@ The user has requested that this compaction PRIORITISE preserving all informatio provided, the summariser will prioritise preserving information related to this topic and be more aggressive about compressing everything else. Inspired by Claude Code's ``/compact``. + force: If True, clear any active summary-failure cooldown before + running so a manual ``/compress`` can retry immediately after + an auto-compression abort. Auto-compress callers pass False. """ # Reset per-call summary failure state — callers inspect these fields # after compress() returns to decide whether to surface a warning. @@ -1505,6 +1514,13 @@ The user has requested that this compaction PRIORITISE preserving all informatio self._last_summary_error = None self._last_aux_model_failure_error = None self._last_aux_model_failure_model = None + self._last_compress_aborted = False + + # Manual /compress (force=True) bypasses the failure cooldown so the + # user can retry immediately after an auto-compress abort. Without + # this, /compress would silently no-op for 30-60s after a failure. + if force and self._summary_failure_cooldown_until > 0.0: + self._summary_failure_cooldown_until = 0.0 n_messages = len(messages) # Only need head + 3 tail messages minimum (token budget decides the real tail size) _min_for_compress = self._protect_head_size(messages) + 3 + 1 @@ -1580,6 +1596,30 @@ The user has requested that this compaction PRIORITISE preserving all informatio # Phase 3: Generate structured summary summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic) + # If summary generation failed, ABORT compression entirely. Returning + # the original messages unchanged preserves the full conversation + # context. Previously this branch dropped every middle message and + # replaced them with a static "summary unavailable" placeholder, + # which silently lost N turns of work whenever the aux LLM hiccuped. + # Auto-compress callers detect the no-op (post-compress length == + # pre-compress length) and stop looping. The next call to + # _generate_summary is gated by _summary_failure_cooldown_until, so + # we don't burn the aux model every turn. Users can force a retry + # via /compress (which passes force=True to clear the cooldown). + if not summary: + n_skipped = compress_end - compress_start + self._last_summary_dropped_count = 0 # nothing actually dropped + self._last_summary_fallback_used = False + self._last_compress_aborted = True + if not self.quiet_mode: + logger.warning( + "Summary generation failed — aborting compression. " + "%d message(s) preserved unchanged. Conversation is " + "frozen until the next /compress or /new.", + n_skipped, + ) + return messages + # Phase 4: Assemble compressed message list compressed = [] for i in range(compress_start): @@ -1594,22 +1634,6 @@ The user has requested that this compaction PRIORITISE preserving all informatio ) compressed.append(msg) - # If LLM summary failed, insert a static fallback so the model - # knows context was lost rather than silently dropping everything. - if not summary: - if not self.quiet_mode: - logger.warning("Summary generation failed — inserting static fallback context marker") - n_dropped = compress_end - compress_start - self._last_summary_dropped_count = n_dropped - self._last_summary_fallback_used = True - summary = ( - f"{SUMMARY_PREFIX}\n" - f"Summary generation was unavailable. {n_dropped} message(s) were " - f"removed to free context space but could not be summarized. The removed " - f"messages contained earlier work in this session. Continue based on the " - f"recent messages below and the current state of any files or resources." - ) - _merge_summary_into_tail = False last_head_role = messages[compress_start - 1].get("role", "user") if compress_start > 0 else "user" first_tail_role = messages[compress_end].get("role", "user") if compress_end < n_messages else "user" diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index e9aa6c8f68..3f6a1ecbfa 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -256,6 +256,7 @@ def compress_context( approx_tokens: Optional[int] = None, task_id: str = "default", focus_topic: Optional[str] = None, + force: bool = False, ) -> Tuple[list, str]: """Compress conversation context and split the session in SQLite. @@ -268,9 +269,17 @@ def compress_context( focus_topic: Optional focus string for guided compression — the summariser will prioritise preserving information related to this topic. Inspired by Claude Code's ``/compact ``. + force: If True, bypass any active summary-failure cooldown. Set + by the manual ``/compress`` slash command so users can retry + immediately after an auto-compress abort. Auto-compress + callers use the default ``False``. Returns: - ``(compressed_messages, new_system_prompt)`` tuple. + ``(compressed_messages, new_system_prompt)`` tuple. When + compression aborts (aux LLM failed to produce a usable summary), + returns the original messages unchanged and the existing system + prompt — the session is NOT rotated. Callers should detect the + no-op via ``len(returned) == len(input)`` and stop the retry loop. """ _pre_msg_count = len(messages) logger.info( @@ -291,12 +300,31 @@ def compress_context( pass try: - compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens, focus_topic=focus_topic) + compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens, focus_topic=focus_topic, force=force) except TypeError: # Plugin context engine with strict signature that doesn't accept - # focus_topic — fall back to calling without it. + # focus_topic / force — fall back to calling without them. compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens) + # If compression aborted (aux LLM failed to produce a usable summary) + # the compressor returns the input messages unchanged. Surface the + # error to the user, skip the session-rotation work entirely (no + # session has logically ended), and let auto-compress callers detect + # the no-op via len(returned) == len(input). + if getattr(agent.context_compressor, "_last_compress_aborted", False): + _err = getattr(agent.context_compressor, "_last_summary_error", None) or "unknown error" + if getattr(agent, "_last_compression_summary_warning", None) != _err: + agent._last_compression_summary_warning = _err + agent._emit_warning( + f"⚠ Compression aborted: {_err}. " + "No messages were dropped — conversation continues unchanged. " + "Run /compress to retry, or /new to start a fresh session." + ) + _existing_sp = getattr(agent, "_cached_system_prompt", None) + if not _existing_sp: + _existing_sp = agent._build_system_prompt(system_message) + return messages, _existing_sp + summary_error = getattr(agent.context_compressor, "_last_summary_error", None) if summary_error: if getattr(agent, "_last_compression_summary_warning", None) != summary_error: diff --git a/cli.py b/cli.py index e9169de674..423b96a73d 100644 --- a/cli.py +++ b/cli.py @@ -9183,6 +9183,7 @@ class HermesCLI: None, approx_tokens=approx_tokens, focus_topic=focus_topic or None, + force=True, ) self.conversation_history = compressed # _compress_context ends the old session and creates a new child diff --git a/gateway/run.py b/gateway/run.py index e36acf444c..de4bf9fe7f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7778,22 +7778,24 @@ class GatewayRunner: ) # If summary generation failed, the - # compressor inserted a static fallback - # placeholder and the dropped turns are - # gone for good. Surface a visible - # warning to the gateway user — agent.log - # alone is invisible on TG/Discord/etc. + # compressor aborts entirely and returns + # messages unchanged — nothing is dropped. + # Surface a visible warning to the gateway + # user — agent.log alone is invisible on + # TG/Discord/etc. — so they know the chat + # is "frozen" at the current size and can + # /compress to retry or /reset to start + # fresh. _comp = getattr(_hyg_agent, "context_compressor", None) - if _comp is not None and getattr(_comp, "_last_summary_fallback_used", False): - _dropped = getattr(_comp, "_last_summary_dropped_count", 0) + if _comp is not None and getattr(_comp, "_last_compress_aborted", False): _err = getattr(_comp, "_last_summary_error", None) or "unknown error" _warn_msg = ( - "âš ī¸ Context compression summary failed " - f"({_err}). {_dropped} historical message(s) " - "were removed and replaced with a placeholder. " - "Earlier context is no longer recoverable. " - "Consider /reset for a clean session, or check " - "your auxiliary.compression model configuration." + "âš ī¸ Context compression aborted " + f"({_err}). No messages were dropped — " + "conversation is unchanged. Run /compress " + "to retry, /reset for a clean session, or " + "check your auxiliary.compression model " + "configuration." ) try: _adapter = self.adapters.get(source.platform) @@ -11404,7 +11406,7 @@ class GatewayRunner: loop = asyncio.get_running_loop() compressed, _ = await loop.run_in_executor( None, - lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens, focus_topic=focus_topic) + lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens, focus_topic=focus_topic, force=True) ) # _compress_context already calls end_session() on the old session @@ -11433,8 +11435,11 @@ class GatewayRunner: # Detect summary-generation failure so we can surface a # visible warning to the user even on the manual /compress # path (otherwise the failure is silently logged). - _summary_failed = bool(getattr(compressor, "_last_summary_fallback_used", False)) - _dropped_count = int(getattr(compressor, "_last_summary_dropped_count", 0) or 0) + # _last_compress_aborted means the aux LLM returned no + # usable summary and the compressor preserved messages + # unchanged (no drop, no placeholder). force=True was + # passed above so any active cooldown is bypassed. + _summary_aborted = bool(getattr(compressor, "_last_compress_aborted", False)) _summary_err = getattr(compressor, "_last_summary_error", None) # Separately: did the user's CONFIGURED aux model fail # and we recovered via main? Surface that as an info @@ -11452,12 +11457,11 @@ class GatewayRunner: lines.append(summary["token_line"]) if summary["note"]: lines.append(summary["note"]) - if _summary_failed: + if _summary_aborted: lines.append( t( - "gateway.compress.summary_failed", + "gateway.compress.aborted", error=(_summary_err or "unknown error"), - count=_dropped_count, ) ) elif _aux_fail_model: diff --git a/locales/af.yaml b/locales/af.yaml index 264b4b321a..b08f431656 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -90,6 +90,7 @@ gateway: nothing_to_do: "Niks om saam te pers nie (die transkripsie is steeds heeltemal beskermde konteks)." focus_line: "Fokus: \"{topic}\"" summary_failed: "âš ī¸ Opsomming kon nie gegenereer word nie ({error}). {count} historiese boodskap(pe) is verwyder en met 'n plekhouer vervang; vroeÃĢre konteks kan nie meer herstel word nie. Oorweeg om jou auxiliary.compression-modelopstelling na te gaan." + aborted: "âš ī¸ Kompressie gestaak ({error}). Geen boodskappe is laat val nie — die gesprek is onveranderd. Voer /compress uit om weer te probeer, /reset vir 'n skoon sessie, of kyk na jou auxiliary.compression-modelkonfigurasie." aux_failed: "â„šī¸ Opgestelde saamperseringsmodel `{model}` het misluk ({error}). Herstel met jou hoofmodel — konteks is intakt — maar jy mag dalk `auxiliary.compression.model` in config.yaml wil nagaan." failed: "Saampersing het misluk: {error}" diff --git a/locales/de.yaml b/locales/de.yaml index 86aa0fae9a..70546c875f 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -90,6 +90,7 @@ gateway: nothing_to_do: "Noch nichts zu komprimieren (das Transkript ist weiterhin vollständig geschÃŧtzter Kontext)." focus_line: "Fokus: \"{topic}\"" summary_failed: "âš ī¸ Zusammenfassungsgenerierung fehlgeschlagen ({error}). {count} historische Nachricht(en) wurden entfernt und durch einen Platzhalter ersetzt; frÃŧherer Kontext ist nicht mehr wiederherstellbar. ÜberprÃŧfen Sie die Konfiguration des auxiliary.compression-Modells." + aborted: "âš ī¸ Komprimierung abgebrochen ({error}). Keine Nachrichten wurden entfernt — die Konversation ist unverändert. FÃŧhre /compress aus, um es erneut zu versuchen, /reset fÃŧr eine neue Sitzung, oder prÃŧfe deine auxiliary.compression-Modellkonfiguration." aux_failed: "â„šī¸ Das konfigurierte Komprimierungsmodell `{model}` ist fehlgeschlagen ({error}). Wiederherstellung mit Ihrem Hauptmodell — Kontext ist intakt — Sie sollten jedoch `auxiliary.compression.model` in config.yaml ÃŧberprÃŧfen." failed: "Komprimierung fehlgeschlagen: {error}" diff --git a/locales/en.yaml b/locales/en.yaml index d485efe756..cbb61055fc 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -105,6 +105,7 @@ gateway: nothing_to_do: "Nothing to compress yet (the transcript is still all protected context)." focus_line: "Focus: \"{topic}\"" summary_failed: "âš ī¸ Summary generation failed ({error}). {count} historical message(s) were removed and replaced with a placeholder; earlier context is no longer recoverable. Consider checking your auxiliary.compression model configuration." + aborted: "âš ī¸ Compression aborted ({error}). No messages were dropped — conversation is unchanged. Run /compress to retry, /reset for a clean session, or check your auxiliary.compression model configuration." aux_failed: "â„šī¸ Configured compression model `{model}` failed ({error}). Recovered using your main model — context is intact — but you may want to check `auxiliary.compression.model` in config.yaml." failed: "Compression failed: {error}" diff --git a/locales/es.yaml b/locales/es.yaml index 6e7a8a34cd..34b9a7bb1b 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -90,6 +90,7 @@ gateway: nothing_to_do: "AÃēn no hay nada que comprimir (la transcripciÃŗn sigue siendo todo contexto protegido)." focus_line: "Enfoque: \"{topic}\"" summary_failed: "âš ī¸ FallÃŗ la generaciÃŗn del resumen ({error}). Se eliminaron {count} mensaje(s) histÃŗricos y se reemplazaron por un marcador; el contexto anterior ya no se puede recuperar. Considera revisar la configuraciÃŗn del modelo auxiliary.compression." + aborted: "âš ī¸ CompresiÃŗn abortada ({error}). No se eliminÃŗ ningÃēn mensaje — la conversaciÃŗn estÃĄ intacta. Ejecuta /compress para reintentar, /reset para una sesiÃŗn limpia, o revisa la configuraciÃŗn de tu modelo auxiliary.compression." aux_failed: "â„šī¸ El modelo de compresiÃŗn configurado `{model}` fallÃŗ ({error}). Recuperado con tu modelo principal — el contexto estÃĄ intacto — pero quizÃĄ quieras revisar `auxiliary.compression.model` en config.yaml." failed: "CompresiÃŗn fallida: {error}" diff --git a/locales/fr.yaml b/locales/fr.yaml index 0a8399f274..03d5e0b622 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -90,6 +90,7 @@ gateway: nothing_to_do: "Rien à compresser pour l'instant (la transcription est encore entièrement du contexte protÊgÊ)." focus_line: "Focus : \"{topic}\"" summary_failed: "âš ī¸ Échec de la gÊnÊration du rÊsumÊ ({error}). {count} message(s) historique(s) ont ÊtÊ supprimÊs et remplacÊs par un espace rÊservÊ ; le contexte antÊrieur n'est plus rÊcupÊrable. VÊrifiez la configuration du modèle auxiliary.compression." + aborted: "âš ī¸ Compression interrompue ({error}). Aucun message n'a ÊtÊ supprimÊ — la conversation est inchangÊe. Lancez /compress pour rÊessayer, /reset pour une nouvelle session, ou vÊrifiez la configuration de votre modèle auxiliary.compression." aux_failed: "â„šī¸ Le modèle de compression configurÊ `{model}` a ÊchouÊ ({error}). RÊcupÊrÊ avec votre modèle principal — le contexte est intact — mais vous pouvez vÊrifier `auxiliary.compression.model` dans config.yaml." failed: "Échec de la compression : {error}" diff --git a/locales/ga.yaml b/locales/ga.yaml index 551d8d3362..3dd5c46447 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -94,6 +94,7 @@ gateway: nothing_to_do: "Níl aon rud le dlÃēthÃē fÃŗs (tÃĄ an traschríbhinn fÃŗs uile mar chomhthÊacs cosanta)." focus_line: "FÃŗcas: \"{topic}\"" summary_failed: "âš ī¸ Theip ar ghiniÃēint achoimre ({error}). Baineadh {count} teachtaireacht stairiÃēil agus cuireadh ionadaí ina n-ÃĄit; níl an comhthÊacs roimhe seo in-aisghabhÃĄla a thuilleadh. Smaoinigh ar an gcumraíocht auxiliary.compression a sheiceÃĄil." + aborted: "âš ī¸ Cuireadh deireadh leis an dlÃēthÃē ({error}). Níor baineadh aon teachtaireacht — tÃĄ an comhrÃĄ gan athrÃē. Rith /compress chun Ê a thriail arís, /reset le haghaidh seisiÃēn glan, nÃŗ seiceÃĄil do chumraíocht samhla auxiliary.compression." aux_failed: "â„šī¸ Theip ar an tsamhail dlÃēthÃēchÃĄin chumraithe `{model}` ({error}). Aisghafa ag baint ÃēsÃĄide as do phríomhshamhail — tÃĄ an comhthÊacs slÃĄn — ach b'fhÊidir gur mhaith leat `auxiliary.compression.model` i config.yaml a sheiceÃĄil." failed: "Theip ar dhlÃēthÃē: {error}" diff --git a/locales/hu.yaml b/locales/hu.yaml index 21fb4c8132..b18f7be707 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -90,6 +90,7 @@ gateway: nothing_to_do: "MÊg nincs mit tÃļmÃļríteni (a teljes ÃĄtirat mÊg vÊdett kontextus)." focus_line: "FÃŗkusz: \"{topic}\"" summary_failed: "âš ī¸ Az ÃļsszefoglalÃŗ generÃĄlÃĄsa sikertelen ({error}). {count} korÃĄbbi Ãŧzenet eltÃĄvolítva Ês helykitÃļltővel helyettesítve; a korÃĄbbi kontextus mÃĄr nem helyreÃĄllíthatÃŗ. Érdemes ellenőrizni az auxiliary.compression modell konfigurÃĄciÃŗjÃĄt." + aborted: "âš ī¸ TÃļmÃļrítÊs megszakítva ({error}). Egyetlen Ãŧzenet sem lett eldobva — a beszÊlgetÊs vÃĄltozatlan. Futtass /compress parancsot az ÃējraprÃŗbÃĄlkozÃĄshoz, /reset egy Ãēj munkamenethez, vagy ellenőrizd az auxiliary.compression modell konfigurÃĄciÃŗt." aux_failed: "â„šī¸ A beÃĄllított tÃļmÃļrítőmodell (`{model}`) hibÃĄt adott ({error}). A főmodellel helyreÃĄllítva — a kontextus Êrintetlen — de Êrdemes ellenőrizni az `auxiliary.compression.model` beÃĄllítÃĄst a config.yaml fÃĄjlban." failed: "TÃļmÃļrítÊs sikertelen: {error}" diff --git a/locales/it.yaml b/locales/it.yaml index 2e4d994019..053046be7d 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -90,6 +90,7 @@ gateway: nothing_to_do: "Niente da comprimere per ora (la trascrizione è ancora tutta contesto protetto)." focus_line: "Focus: \"{topic}\"" summary_failed: "âš ī¸ Generazione del riepilogo non riuscita ({error}). {count} messaggio/i storico/i sono stati rimossi e sostituiti con un segnaposto; il contesto precedente non è piÚ recuperabile. Considera di controllare la configurazione del modello auxiliary.compression." + aborted: "âš ī¸ Compressione interrotta ({error}). Nessun messaggio è stato eliminato — la conversazione è invariata. Esegui /compress per riprovare, /reset per una nuova sessione, o controlla la configurazione del modello auxiliary.compression." aux_failed: "â„šī¸ Il modello di compressione configurato `{model}` non è riuscito ({error}). Recupero effettuato usando il modello principale — il contesto è intatto — ma potresti voler controllare `auxiliary.compression.model` in config.yaml." failed: "Compressione non riuscita: {error}" diff --git a/locales/ja.yaml b/locales/ja.yaml index 55c42915e6..931e88ed3d 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -90,6 +90,7 @@ gateway: nothing_to_do: "ãžã åœ§į¸Žã™ã‚‹ã‚‚ãŽãŒã‚ã‚Šãžã›ã‚“ (ãƒˆãƒŠãƒŗã‚šã‚¯ãƒĒプトはすずãĻäŋč­ˇã•ã‚ŒãŸã‚ŗãƒŗãƒ†ã‚­ã‚šãƒˆãŽãžãžã§ã™)。" focus_line: "フりãƒŧã‚Ģ゚: \"{topic}\"" summary_failed: "âš ī¸ čĻį´„ãŽį”ŸæˆãĢå¤ąæ•—ã—ãžã—ãŸ ({error})。{count} äģļぎåąĨæ­´ãƒĄãƒƒã‚ģãƒŧジが削除され、プãƒŦãƒŧ゚ホãƒĢダãƒŧãĢįŊŽãæ›ãˆã‚‰ã‚Œãžã—た。äģĨå‰ãŽã‚ŗãƒŗãƒ†ã‚­ã‚šãƒˆã¯åžŠå…ƒã§ããžã›ã‚“ã€‚auxiliary.compression ãƒĸデãƒĢãŽč¨­åŽšã‚’įĸēčĒã—ãĻください。" + aborted: "âš ī¸ åœ§į¸ŽãŒä¸­æ­ĸされぞした ({error})ã€‚ãƒĄãƒƒã‚ģãƒŧジは削除されãĻいぞせん — äŧščŠąã¯ããŽãžãžã§ã™ã€‚å†čŠĻčĄŒã™ã‚‹ãĢは /compress、新しいã‚ģãƒƒã‚ˇãƒ§ãƒŗã‚’é–‹å§‹ã™ã‚‹ãĢは /reset ã‚’åŽŸčĄŒã™ã‚‹ã‹ã€auxiliary.compression ãƒĸデãƒĢč¨­åŽšã‚’įĸēčĒã—ãĻください。" aux_failed: "â„šī¸ æ§‹æˆã•ã‚ŒãŸåœ§į¸ŽãƒĸデãƒĢ `{model}` ãŒå¤ąæ•—ã—ãžã—ãŸ ({error})ã€‚ãƒĄã‚¤ãƒŗãƒĸデãƒĢで垊旧しぞした — ã‚ŗãƒŗãƒ†ã‚­ã‚šãƒˆã¯į„Ąå‚ˇã§ã™ — config.yaml ぎ `auxiliary.compression.model` をįĸēčĒã™ã‚‹ã¨ã‚ˆã„ã§ã—ã‚‡ã†ã€‚" failed: "åœ§į¸ŽãĢå¤ąæ•—ã—ãžã—ãŸ: {error}" diff --git a/locales/ko.yaml b/locales/ko.yaml index 11f5380e31..6fc9d1679d 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -90,6 +90,7 @@ gateway: nothing_to_do: "땄링 ė••ėļ•í•  ë‚´ėšŠė´ ė—†ėŠĩ니다 (대화 ë‚´ėšŠė´ ëĒ¨ë‘ ëŗ´í˜¸ëœ ėģ¨í…ėŠ¤íŠ¸ėž…ë‹ˆë‹¤)." focus_line: "봈렐: \"{topic}\"" summary_failed: "âš ī¸ ėš”ė•Ŋ ėƒė„ąė— ė‹¤íŒ¨í–ˆėŠĩ니다 ({error}). ęŗŧęą° ëŠ”ė‹œė§€ {count}개가 ė œęą°ë˜ė–´ ėžëĻŦí‘œė‹œėžëĄœ ëŒ€ė˛´ë˜ė—ˆėœŧ늰, ė´ė „ ėģ¨í…ėŠ¤íŠ¸ëŠ” 더 ė´ėƒ ëŗĩęĩŦ할 눘 ė—†ėŠĩ니다. auxiliary.compression ëĒ¨ë¸ ė„¤ė •ė„ í™•ė¸í•´ ëŗ´ė„¸ėš”." + aborted: "âš ī¸ ė••ėļ•ė´ ė¤‘ë‹¨ë˜ė—ˆėŠĩ니다 ({error}). ëŠ”ė‹œė§€ę°€ ė‚­ė œë˜ė§€ ė•Šė•˜ėœŧ늰 대화는 그대로 ėœ ė§€ëŠë‹ˆë‹¤. ë‹¤ė‹œ ė‹œë„í•˜ë ¤ëŠ´ /compressëĨŧ ė‹¤í–‰í•˜ęą°ë‚˜, 냈 ė„¸ė…˜ė„ ė‹œėž‘í•˜ë ¤ëŠ´ /resetė„ ė‚ŦėšŠí•˜ęą°ë‚˜, auxiliary.compression ëĒ¨ë¸ ė„¤ė •ė„ í™•ė¸í•˜ė„¸ėš”." aux_failed: "â„šī¸ ęĩŦė„ąëœ ė••ėļ• ëĒ¨ë¸ `{model}`ė´(가) ė‹¤íŒ¨í–ˆėŠĩ니다 ({error}). ëŠ”ė¸ ëĒ¨ë¸ëĄœ ëŗĩęĩŦë˜ė–´ ėģ¨í…ėŠ¤íŠ¸ëŠ” ëŗ´ėĄ´ë˜ė—ˆė§€ë§Œ, config.yamlė˜ `auxiliary.compression.model` ė„¤ė •ė„ í™•ė¸í•˜ëŠ” ę˛ƒė´ ėĸ‹ėŠĩ니다." failed: "ė••ėļ• ė‹¤íŒ¨: {error}" diff --git a/locales/pt.yaml b/locales/pt.yaml index e74c218d6b..e202a53480 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -90,6 +90,7 @@ gateway: nothing_to_do: "Ainda nÃŖo hÃĄ nada para comprimir (a transcriÃ§ÃŖo continua a ser todo o contexto protegido)." focus_line: "Foco: \"{topic}\"" summary_failed: "âš ī¸ Falha ao gerar o resumo ({error}). {count} mensagem(ns) histÃŗrica(s) foram removidas e substituídas por um marcador; o contexto anterior jÃĄ nÃŖo pode ser recuperado. Considera verificar a configuraÃ§ÃŖo do modelo auxiliary.compression." + aborted: "âš ī¸ CompressÃŖo abortada ({error}). Nenhuma mensagem foi removida — a conversa estÃĄ inalterada. Executa /compress para tentar de novo, /reset para uma sessÃŖo nova, ou verifica a configuraÃ§ÃŖo do modelo auxiliary.compression." aux_failed: "â„šī¸ O modelo de compressÃŖo configurado `{model}` falhou ({error}). Recuperado com o teu modelo principal — o contexto estÃĄ intacto — mas talvez queiras verificar `auxiliary.compression.model` em config.yaml." failed: "CompressÃŖo falhou: {error}" diff --git a/locales/ru.yaml b/locales/ru.yaml index c520362675..76fde56a9b 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -90,6 +90,7 @@ gateway: nothing_to_do: "ПоĐēа ĐŊĐĩ҇ĐĩĐŗĐž ҁĐļиĐŧĐ°Ņ‚ŅŒ (ҁ҂ĐĩĐŊĐžĐŗŅ€Đ°ĐŧĐŧа Đ˛ŅŅ‘ Đĩ҉ґ ĐŋĐžĐģĐŊĐžŅŅ‚ŅŒŅŽ ŅĐ˛ĐģŅĐĩŅ‚ŅŅ ĐˇĐ°Ņ‰Đ¸Ņ‰Ņ‘ĐŊĐŊŅ‹Đŧ ĐēĐžĐŊŅ‚ĐĩĐēŅŅ‚ĐžĐŧ)." focus_line: "ФОĐē҃ҁ: \"{topic}\"" summary_failed: "âš ī¸ НĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ ŅĐŗĐĩĐŊĐĩŅ€Đ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ ŅĐ˛ĐžĐ´Đē҃ ({error}). {count} Đ¸ŅŅ‚ĐžŅ€Đ¸Ņ‡. ŅĐžĐžĐąŅ‰ĐĩĐŊиК ĐąŅ‹ĐģĐž ŅƒĐ´Đ°ĐģĐĩĐŊĐž и СаĐŧĐĩĐŊĐĩĐŊĐž СаĐŋĐžĐģĐŊĐ¸Ņ‚ĐĩĐģĐĩĐŧ; ĐŋŅ€ĐĩĐ´Ņ‹Đ´ŅƒŅ‰Đ¸Đš ĐēĐžĐŊŅ‚ĐĩĐēҁ҂ йОĐģҌ҈Đĩ ĐŊĐĩĐģŅŒĐˇŅ Đ˛ĐžŅŅŅ‚Đ°ĐŊĐžĐ˛Đ¸Ņ‚ŅŒ. ĐŸŅ€ĐžĐ˛ĐĩŅ€ŅŒŅ‚Đĩ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ ĐŧОдĐĩĐģи auxiliary.compression." + aborted: "âš ī¸ ĐĄĐļĐ°Ņ‚Đ¸Đĩ ĐŋŅ€ĐĩŅ€Đ˛Đ°ĐŊĐž ({error}). ĐĄĐžĐžĐąŅ‰ĐĩĐŊĐ¸Ņ ĐŊĐĩ ĐąŅ‹Đģи ŅƒĐ´Đ°ĐģĐĩĐŊŅ‹ — Ņ€Đ°ĐˇĐŗĐžĐ˛ĐžŅ€ ĐŊĐĩ иСĐŧĐĩĐŊиĐģŅŅ. ЗаĐŋŅƒŅŅ‚Đ¸Ņ‚Đĩ /compress Đ´ĐģŅ ĐŋĐžĐ˛Ņ‚ĐžŅ€ĐŊОК ĐŋĐžĐŋҋ҂Đēи, /reset Đ´ĐģŅ ĐŊОвОК ҁĐĩŅŅĐ¸Đ¸ иĐģи ĐŋŅ€ĐžĐ˛ĐĩŅ€ŅŒŅ‚Đĩ ĐēĐžĐŊŅ„Đ¸ĐŗŅƒŅ€Đ°Ņ†Đ¸ŅŽ ĐŧОдĐĩĐģи auxiliary.compression." aux_failed: "â„šī¸ ĐĐ°ŅŅ‚Ņ€ĐžĐĩĐŊĐŊĐ°Ņ ĐŧОдĐĩĐģҌ ҁĐļĐ°Ņ‚Đ¸Ņ `{model}` даĐģа ŅĐąĐžĐš ({error}). Đ’ĐžŅŅŅ‚Đ°ĐŊОвĐģĐĩĐŊĐž ҁ ĐŋĐžĐŧĐžŅ‰ŅŒŅŽ ĐžŅĐŊОвĐŊОК ĐŧОдĐĩĐģи — ĐēĐžĐŊŅ‚ĐĩĐēҁ҂ ĐŊĐĩ ĐŋĐžĐ˛Ņ€ĐĩĐļĐ´Ņ‘ĐŊ — ĐŊĐž Ņ€ĐĩĐēĐžĐŧĐĩĐŊĐ´ŅƒĐĩŅ‚ŅŅ ĐŋŅ€ĐžĐ˛ĐĩŅ€Đ¸Ņ‚ŅŒ `auxiliary.compression.model` в config.yaml." failed: "ĐĄĐļĐ°Ņ‚Đ¸Đĩ ĐŊĐĩ ŅƒĐ´Đ°ĐģĐžŅŅŒ: {error}" diff --git a/locales/tr.yaml b/locales/tr.yaml index 012854c51b..add252ea56 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -90,6 +90,7 @@ gateway: nothing_to_do: "HenÃŧz sÄąkÄąÅŸtÄąrÄąlacak bir şey yok (transkript hÃĸlÃĸ tamamen korunan bağlam)." focus_line: "Odak: \"{topic}\"" summary_failed: "âš ī¸ Özet oluşturma başarÄąsÄąz ({error}). {count} geçmiş mesaj kaldÄąrÄąlÄąp yer tutucuyla değiştirildi; Ãļnceki bağlam artÄąk kurtarÄąlamaz. auxiliary.compression model yapÄąlandÄąrmanÄązÄą kontrol edin." + aborted: "âš ī¸ SÄąkÄąÅŸtÄąrma iptal edildi ({error}). Hiçbir mesaj silinmedi — konuşma değişmedi. Tekrar denemek için /compress, temiz bir oturum için /reset komutunu çalÄąÅŸtÄąrÄąn veya auxiliary.compression model yapÄąlandÄąrmanÄązÄą kontrol edin." aux_failed: "â„šī¸ YapÄąlandÄąrÄąlmÄąÅŸ sÄąkÄąÅŸtÄąrma modeli `{model}` başarÄąsÄąz oldu ({error}). Ana modelinizle kurtarÄąldÄą — bağlam sağlam — ancak config.yaml içindeki `auxiliary.compression.model` Ãļğesini kontrol etmek isteyebilirsiniz." failed: "SÄąkÄąÅŸtÄąrma başarÄąsÄąz: {error}" diff --git a/locales/uk.yaml b/locales/uk.yaml index 44b011cfe8..972e535f90 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -90,6 +90,7 @@ gateway: nothing_to_do: "ПоĐēи Ņ‰Đž ĐŊĐĩĐŧĐ°Ņ” Ņ‰Đž ŅŅ‚Đ¸ŅĐēĐ°Ņ‚Đ¸ (ҁ҂ĐĩĐŊĐžĐŗŅ€Đ°Đŧа Đ˛ŅĐĩ ҉Đĩ Ņ” ĐŋОвĐŊŅ–ŅŅ‚ŅŽ ĐˇĐ°Ņ…Đ¸Ņ‰ĐĩĐŊиĐŧ ĐēĐžĐŊŅ‚ĐĩĐēŅŅ‚ĐžĐŧ)." focus_line: "ФОĐē҃ҁ: \"{topic}\"" summary_failed: "âš ī¸ НĐĩ вдаĐģĐžŅŅ ĐˇĐŗĐĩĐŊĐĩŅ€ŅƒĐ˛Đ°Ņ‚Đ¸ СвĐĩĐ´ĐĩĐŊĐŊŅ ({error}). {count} Ņ–ŅŅ‚ĐžŅ€Đ¸Ņ‡ĐŊĐ¸Ņ… ĐŋĐžĐ˛Ņ–Đ´ĐžĐŧĐģĐĩĐŊҌ ĐąŅƒĐģĐž видаĐģĐĩĐŊĐž Ņ‚Đ° СаĐŧŅ–ĐŊĐĩĐŊĐž СаĐŋОвĐŊŅŽĐ˛Đ°Ņ‡ĐĩĐŧ; ĐŋĐžĐŋĐĩŅ€ĐĩĐ´ĐŊŅ–Đš ĐēĐžĐŊŅ‚ĐĩĐēҁ҂ ĐąŅ–ĐģҌ҈Đĩ ĐŊĐĩ ĐŧĐžĐļĐŊа Đ˛Ņ–Đ´ĐŊĐžĐ˛Đ¸Ņ‚Đ¸. ПĐĩŅ€ĐĩĐ˛Ņ–Ņ€Ņ‚Đĩ ĐēĐžĐŊŅ„Ņ–ĐŗŅƒŅ€Đ°Ņ†Ņ–ŅŽ ĐŧОдĐĩĐģŅ– auxiliary.compression." + aborted: "âš ī¸ ĐĄŅ‚Đ¸ŅĐŊĐĩĐŊĐŊŅ ҁĐēĐ°ŅĐžĐ˛Đ°ĐŊĐž ({error}). ЖодĐŊĐĩ ĐŋĐžĐ˛Ņ–Đ´ĐžĐŧĐģĐĩĐŊĐŊŅ ĐŊĐĩ ĐąŅƒĐģĐž видаĐģĐĩĐŊĐž — Ņ€ĐžĐˇĐŧОва ĐŊĐĩ СĐŧŅ–ĐŊиĐģĐ°ŅŅ. ВиĐēĐžĐŊĐ°ĐšŅ‚Đĩ /compress, Ņ‰ĐžĐą ĐŋĐžĐ˛Ņ‚ĐžŅ€Đ¸Ņ‚Đ¸ ҁĐŋŅ€ĐžĐąŅƒ, /reset Đ´ĐģŅ ĐŊĐžĐ˛ĐžŅ— ҁĐĩҁҖҗ, айО ĐŋĐĩŅ€ĐĩĐ˛Ņ–Ņ€Ņ‚Đĩ ĐēĐžĐŊŅ„Ņ–ĐŗŅƒŅ€Đ°Ņ†Ņ–ŅŽ ĐŧОдĐĩĐģŅ– auxiliary.compression." aux_failed: "â„šī¸ НаĐģĐ°ŅˆŅ‚ĐžĐ˛Đ°ĐŊа ĐŧОдĐĩĐģҌ ŅŅ‚Đ¸ŅĐŊĐĩĐŊĐŊŅ `{model}` СаСĐŊаĐģа ĐˇĐąĐžŅŽ ({error}). Đ’Ņ–Đ´ĐŊОвĐģĐĩĐŊĐž Са Đ´ĐžĐŋĐžĐŧĐžĐŗĐžŅŽ ĐžŅĐŊОвĐŊĐžŅ— ĐŧОдĐĩĐģŅ– — ĐēĐžĐŊŅ‚ĐĩĐēҁ҂ ĐŊĐĩ ĐŋĐžŅˆĐēОдĐļĐĩĐŊиК — аĐģĐĩ Đ˛Đ°Ņ€Ņ‚Đž ĐŋĐĩŅ€ĐĩĐ˛Ņ–Ņ€Đ¸Ņ‚Đ¸ `auxiliary.compression.model` ҃ config.yaml." failed: "ĐĄŅ‚Đ¸ŅĐŊĐĩĐŊĐŊŅ ĐŊĐĩ вдаĐģĐžŅŅ: {error}" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index 362ea298de..30fbcabac3 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -90,6 +90,7 @@ gateway: nothing_to_do: "į›Žå‰æ˛’æœ‰å¯åŖ“į¸Žįš„å…§åŽšīŧˆå°čŠąč¨˜éŒ„äģå…¨éƒ¨į‚ē受äŋč­ˇįš„上下文īŧ‰ã€‚" focus_line: "聚į„Ļīŧš\"{topic}\"" summary_failed: "âš ī¸ 摘čρį”ĸį”Ÿå¤ąæ•—īŧˆ{error}īŧ‰ã€‚{count} å‰‡æ­ˇå˛č¨Šæ¯åˇ˛čĸĢį§ģ除ä¸ĻäģĨäŊ”äŊįŦĻ取äģŖīŧ›å…ˆå‰įš„ä¸Šä¸‹æ–‡åˇ˛į„Ąæŗ•åžŠåŽŸã€‚åģēč­°æĒĸæŸĨ auxiliary.compression æ¨Ąåž‹č¨­åŽšã€‚" + aborted: "âš ī¸ åŖ“į¸Žåˇ˛ä¸­æ­ĸ ({error})。æœĒåˆĒ除äģģäŊ•č¨Šæ¯ — å°čŠąäŋæŒä¸čŽŠã€‚åŸˇčĄŒ /compress 重čŠĻīŧŒåŸˇčĄŒ /reset 開始新åˇĨäŊœéšŽæŽĩīŧŒæˆ–æĒĸæŸĨäŊ įš„ auxiliary.compression æ¨Ąåž‹č¨­åŽšã€‚" aux_failed: "â„šī¸ č¨­åŽšįš„åŖ“į¸Žæ¨Ąåž‹ `{model}` å¤ąæ•—īŧˆ{error}īŧ‰ã€‚厞äŊŋᔍä¸ģčĻæ¨Ąåž‹åžŠåŽŸ — 上下文厌整 — äŊ†æ‚¨å¯čƒŊæƒŗæĒĸæŸĨ config.yaml ä¸­įš„ `auxiliary.compression.model`。" failed: "åŖ“į¸Žå¤ąæ•—īŧš{error}" diff --git a/locales/zh.yaml b/locales/zh.yaml index 7859a1a203..60999f06d3 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -90,6 +90,7 @@ gateway: nothing_to_do: "暂无可压įŧŠå†…厚īŧˆå¯šč¯čްåŊ•äģå…¨éƒ¨ä¸ē受äŋæŠ¤ä¸Šä¸‹æ–‡īŧ‰ã€‚" focus_line: "聚į„Ļīŧš\"{topic}\"" summary_failed: "âš ī¸ 摘čĻį”Ÿæˆå¤ąč´Ĩīŧˆ{error}īŧ‰ã€‚{count} æĄåŽ†å˛æļˆæ¯åˇ˛čĸĢį§ģ除åšļæ›ŋæĸä¸ē占äŊįŦĻīŧ›äš‹å‰įš„ä¸Šä¸‹æ–‡åˇ˛æ— æŗ•æĸ复。åģēčŽŽæŖ€æŸĨ auxiliary.compression æ¨Ąåž‹é…įŊŽã€‚" + aborted: "âš ī¸ 压įŧŠåˇ˛ä¸­æ­ĸ ({error})。æœĒ删除äģģäŊ•æļˆæ¯ — å¯šč¯äŋæŒä¸å˜ã€‚čŋčĄŒ /compress é‡č¯•īŧŒčŋčĄŒ /reset åŧ€å§‹æ–°äŧšč¯īŧŒæˆ–æŖ€æŸĨäŊ įš„ auxiliary.compression æ¨Ąåž‹é…įŊŽã€‚" aux_failed: "â„šī¸ 配įŊŽįš„压įŧŠæ¨Ąåž‹ `{model}` å¤ąč´Ĩīŧˆ{error}īŧ‰ã€‚厞äŊŋᔍä¸ģæ¨Ąåž‹æĸ复 — 上下文厌åĨŊ — äŊ†æ‚¨å¯čƒŊæƒŗæŖ€æŸĨ config.yaml ä¸­įš„ `auxiliary.compression.model`。" failed: "压įŧŠå¤ąč´Ĩīŧš{error}" diff --git a/run_agent.py b/run_agent.py index 185e6afb12..48790f344d 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3714,12 +3714,19 @@ class AIAgent: """ return self.api_mode != "codex_responses" - def _compress_context(self, messages: list, system_message: str, *, approx_tokens: int = None, task_id: str = "default", focus_topic: str = None) -> tuple: - """Forwarder — see ``agent.conversation_compression.compress_context``.""" + def _compress_context(self, messages: list, system_message: str, *, approx_tokens: int = None, task_id: str = "default", focus_topic: str = None, force: bool = False) -> tuple: + """Forwarder — see ``agent.conversation_compression.compress_context``. + + ``force=True`` is passed by the manual ``/compress`` slash command + so users can bypass the summary-failure cooldown after an + auto-compress abort. Auto-compress callers use the default + ``force=False``. + """ from agent.conversation_compression import compress_context return compress_context( self, messages, system_message, approx_tokens=approx_tokens, task_id=task_id, focus_topic=focus_topic, + force=force, ) def _set_tool_guardrail_halt(self, decision: ToolGuardrailDecision) -> None: diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 2d1a40445d..e952732075 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -64,21 +64,31 @@ class TestCompress: result = compressor.compress(msgs) assert result == msgs - def test_truncation_fallback_no_client(self, compressor): - # compressor has client=None, so should use truncation fallback + def test_no_client_aborts_compression_with_messages_preserved(self, compressor): + """compressor has no provider configured, so _generate_summary returns + None → compression aborts entirely. Messages must be returned + unchanged (no placeholder, no drop) and _last_compress_aborted set.""" msgs = [{"role": "system", "content": "System prompt"}] + self._make_messages(10) result = compressor.compress(msgs) - assert len(result) < len(msgs) - # Should keep system message and last N - assert result[0]["role"] == "system" - assert compressor.compression_count == 1 + # Abort path: messages preserved byte-for-byte + assert result == msgs + assert compressor._last_compress_aborted is True + # Compression count NOT incremented on abort — nothing was compressed. + assert compressor.compression_count == 0 def test_compression_increments_count(self, compressor): msgs = self._make_messages(10) - compressor.compress(msgs) - assert compressor.compression_count == 1 - compressor.compress(msgs) - assert compressor.compression_count == 2 + mock_resp = MagicMock() + mock_resp.choices = [MagicMock()] + mock_resp.choices[0].message.content = "summary text" + with patch("agent.context_compressor.call_llm", return_value=mock_resp): + compressor.compress(msgs) + assert compressor.compression_count == 1 + # Reset cooldown isn't needed (no prior failure) but reset + # iterative-summary state so the next call follows the same + # path as the first. + compressor.compress(msgs) + assert compressor.compression_count == 2 def test_protects_first_and_last(self, compressor): msgs = self._make_messages(10) @@ -128,7 +138,11 @@ class TestGenerateSummaryNoneContent: {"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"} for i in range(10) ] - result = c.compress(msgs) + mock_resp = MagicMock() + mock_resp.choices = [MagicMock()] + mock_resp.choices[0].message.content = "summary text" + with patch("agent.context_compressor.call_llm", return_value=mock_resp): + result = c.compress(msgs) assert len(result) < len(msgs) @@ -716,11 +730,14 @@ class TestAuxModelFallbackSurfacedToCallers: class TestSummaryFailureTrackingForGatewayWarning: - """When summary generation fails, the compressor must record dropped count - + fallback flag so gateway hygiene & /compress can surface a visible - warning instead of silently dropping context.""" + """When summary generation fails, the compressor must ABORT compression + entirely (return the original messages unchanged) and set the abort flag + so gateway hygiene & /compress can surface a visible warning. Previous + behavior of inserting a static "summary unavailable" placeholder while + silently dropping the middle window has been removed — losing N turns + of context is worse than freezing the chat until the user retries.""" - def test_compress_records_fallback_and_dropped_count_on_summary_failure(self): + def test_compress_aborts_and_preserves_messages_on_summary_failure(self): with patch("agent.context_compressor.get_model_context_length", return_value=100000): c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2) @@ -740,16 +757,23 @@ class TestSummaryFailureTrackingForGatewayWarning: with patch("agent.context_compressor.call_llm", side_effect=Exception("404 model not found")): result = c.compress(msgs) - assert c._last_summary_fallback_used is True - assert c._last_summary_dropped_count > 0 + # Abort flag set, error recorded + assert c._last_compress_aborted is True assert c._last_summary_error is not None - # Result must still be well-formed (fallback summary present). - assert any( + # No fallback inserted, no messages dropped + assert c._last_summary_fallback_used is False + assert c._last_summary_dropped_count == 0 + # Original messages preserved byte-for-byte — the agent loop's + # "did compression help?" check (len(after) < len(before)) sees a + # no-op and stops looping. + assert result == msgs + # No "Summary generation was unavailable" placeholder leaked in. + assert not any( isinstance(m.get("content"), str) and "Summary generation was unavailable" in m["content"] for m in result ) - def test_compress_clears_fallback_flag_on_subsequent_success(self): + def test_compress_clears_abort_flag_on_subsequent_success(self): mock_response = MagicMock() mock_response.choices = [MagicMock()] mock_response.choices[0].message.content = "summary text" @@ -768,18 +792,57 @@ class TestSummaryFailureTrackingForGatewayWarning: {"role": "user", "content": "msg 7"}, ] - # First call fails, second succeeds — flag must reset on second compress. + # First call fails, second succeeds — abort flag must reset on second compress. with patch("agent.context_compressor.call_llm", side_effect=Exception("boom")): c.compress(msgs) - assert c._last_summary_fallback_used is True + assert c._last_compress_aborted is True # Reset cooldown to allow retry on second compress c._summary_failure_cooldown_until = 0.0 with patch("agent.context_compressor.call_llm", return_value=mock_response): c.compress(msgs) + assert c._last_compress_aborted is False assert c._last_summary_fallback_used is False assert c._last_summary_dropped_count == 0 + def test_force_true_bypasses_failure_cooldown(self): + """Manual /compress passes force=True so it can retry immediately + after an auto-compress abort instead of waiting out the 30-60s + cooldown.""" + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "summary text" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2) + + msgs = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "msg 1"}, + {"role": "assistant", "content": "msg 2"}, + {"role": "user", "content": "msg 3"}, + {"role": "assistant", "content": "msg 4"}, + {"role": "user", "content": "msg 5"}, + {"role": "assistant", "content": "msg 6"}, + {"role": "user", "content": "msg 7"}, + ] + + # Pre-populate an active cooldown (as if a prior auto-compress aborted). + import time as _time + c._summary_failure_cooldown_until = _time.monotonic() + 999.0 + + # Without force, _generate_summary would short-circuit on cooldown + # and return None → abort. With force=True the cooldown is cleared + # and the call goes through. + with patch("agent.context_compressor.call_llm", return_value=mock_response): + result = c.compress(msgs, force=True) + + assert c._last_compress_aborted is False + # Cooldown was cleared and a real summary attempt was made. + assert c._summary_failure_cooldown_until == 0.0 + # Result is actually compressed (shorter than input). + assert len(result) < len(msgs) + class TestSummaryPrefixNormalization: def test_legacy_prefix_is_replaced(self): @@ -1338,7 +1401,11 @@ class TestSummaryTargetRatio: + [{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"} for i in range(8)] ) - result = c.compress(msgs) + mock_resp = MagicMock() + mock_resp.choices = [MagicMock()] + mock_resp.choices[0].message.content = "summary text" + with patch("agent.context_compressor.call_llm", return_value=mock_resp): + result = c.compress(msgs) # System prompt (msg[0]) survives as head assert result[0]["role"] == "system" assert result[0]["content"].startswith("System prompt") diff --git a/tests/gateway/test_compress_command.py b/tests/gateway/test_compress_command.py index e09e40a0e9..95211e9772 100644 --- a/tests/gateway/test_compress_command.py +++ b/tests/gateway/test_compress_command.py @@ -130,19 +130,15 @@ async def test_compress_command_explains_when_token_estimate_rises(): @pytest.mark.asyncio -async def test_compress_command_appends_warning_when_summary_generation_fails(): - """When the auxiliary summariser fails and the compressor inserts a static - fallback placeholder, /compress must append a visible âš ī¸ warning to its - reply. Otherwise the failure is silently logged and the user has no idea - earlier context is unrecoverable.""" +async def test_compress_command_appends_warning_when_compression_aborts(): + """When the auxiliary summariser fails and the compressor ABORTS (returns + messages unchanged), /compress must append a visible âš ī¸ warning to its + reply telling the user nothing was dropped and how to retry. Otherwise + the failure is silently logged and the user has no idea why nothing + happened.""" history = _make_history() - # Compressed shape is irrelevant for this test — we only care that the - # warning surfaces. Drop one message so the headline is non-noop. - compressed = [ - history[0], - {"role": "assistant", "content": "[fallback placeholder]"}, - history[-1], - ] + # Abort path: compressor returns the input messages unchanged. + compressed = list(history) runner = _make_runner(history) agent_instance = MagicMock() agent_instance.shutdown_memory_provider = MagicMock() @@ -150,10 +146,11 @@ async def test_compress_command_appends_warning_when_summary_generation_fails(): agent_instance._cached_system_prompt = "" agent_instance.tools = None agent_instance.context_compressor.has_content_to_compress.return_value = True - # Simulate summary-generation failure: fallback flag set, dropped count - # populated, error string captured. - agent_instance.context_compressor._last_summary_fallback_used = True - agent_instance.context_compressor._last_summary_dropped_count = 7 + # Simulate compression aborting (force=True bypassed cooldown but the + # aux LLM is genuinely broken). + agent_instance.context_compressor._last_compress_aborted = True + agent_instance.context_compressor._last_summary_fallback_used = False + agent_instance.context_compressor._last_summary_dropped_count = 0 agent_instance.context_compressor._last_summary_error = ( "404 model not found: gemini-3-flash-preview" ) @@ -164,7 +161,7 @@ async def test_compress_command_appends_warning_when_summary_generation_fails(): if messages == history: return 100 if messages == compressed: - return 60 + return 100 raise AssertionError(f"unexpected transcript: {messages!r}") with ( @@ -175,16 +172,14 @@ async def test_compress_command_appends_warning_when_summary_generation_fails(): ): result = await runner._handle_compress_command(_make_event()) - # The compress reply itself still goes through (the transcript was rewritten). - assert "Compressed:" in result - # ...but a clearly-marked warning must be appended. + # A clearly-marked warning must be appended. assert "âš ī¸" in result - assert "Summary generation failed" in result + assert "Compression aborted" in result # Underlying error must surface so users can fix their config. assert "404 model not found" in result - # Dropped count must be visible — silently losing N messages is the bug. - assert "7" in result - assert "historical message(s) were removed" in result + # User must be told nothing was dropped — the whole point of the + # new behavior is no silent data loss. + assert "No messages were dropped" in result agent_instance.shutdown_memory_provider.assert_called_once() agent_instance.close.assert_called_once() @@ -210,6 +205,7 @@ async def test_compress_command_surfaces_aux_model_failure_even_when_recovered() agent_instance.tools = None agent_instance.context_compressor.has_content_to_compress.return_value = True # Fallback placeholder was NOT used — recovery succeeded. + agent_instance.context_compressor._last_compress_aborted = False agent_instance.context_compressor._last_summary_fallback_used = False agent_instance.context_compressor._last_summary_dropped_count = 0 agent_instance.context_compressor._last_summary_error = None diff --git a/tests/gateway/test_session_hygiene.py b/tests/gateway/test_session_hygiene.py index 327dfc28eb..fb8b273f41 100644 --- a/tests/gateway/test_session_hygiene.py +++ b/tests/gateway/test_session_hygiene.py @@ -396,11 +396,12 @@ async def test_session_hygiene_messages_stay_in_originating_topic(monkeypatch, t @pytest.mark.asyncio -async def test_session_hygiene_warns_user_when_summary_generation_fails(monkeypatch, tmp_path): +async def test_session_hygiene_warns_user_when_compression_aborts(monkeypatch, tmp_path): """When auxiliary compression's summary LLM call fails, the compressor - inserts a static fallback and the dropped turns are unrecoverable. - Gateway must surface a visible âš ī¸ warning to the user, including - thread_id metadata so it lands in the originating topic/thread.""" + ABORTS — returns messages unchanged, sets _last_compress_aborted=True, + and drops nothing. Gateway must surface a visible âš ī¸ warning to the + user (including thread_id metadata so it lands in the originating + topic/thread) saying the conversation is unchanged and how to retry.""" fake_dotenv = types.ModuleType("dotenv") fake_dotenv.load_dotenv = lambda *args, **kwargs: None monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv) @@ -415,17 +416,18 @@ async def test_session_hygiene_warns_user_when_summary_generation_fails(monkeypa self.shutdown_memory_provider = MagicMock() self.close = MagicMock() # Simulate a compressor that hit summary-generation failure - # and inserted the static fallback placeholder. + # and ABORTED — no fallback inserted, no messages dropped. self.context_compressor = SimpleNamespace( - _last_summary_fallback_used=True, - _last_summary_dropped_count=42, + _last_compress_aborted=True, + _last_summary_fallback_used=False, + _last_summary_dropped_count=0, _last_summary_error="404 model not found: gemini-3-flash-preview", ) type(self).last_instance = self def _compress_context(self, messages, *_args, **_kwargs): - self.session_id = f"{self.session_id}_compressed" - return ([{"role": "assistant", "content": "compressed"}], None) + # Abort path: messages preserved unchanged, session NOT rotated. + return (messages, None) fake_run_agent = types.ModuleType("run_agent") fake_run_agent.AIAgent = FakeCompressAgentWithSummaryFailure @@ -494,16 +496,17 @@ async def test_session_hygiene_warns_user_when_summary_generation_fails(monkeypa result = await runner._handle_message(event) assert result == "ok" - # The compressor reported summary-failure → exactly one warning - # message must have been delivered to the user. - warning_messages = [s for s in adapter.sent if "Context compression summary failed" in s["content"]] + # The compressor reported abort → exactly one warning message must + # have been delivered to the user. + warning_messages = [s for s in adapter.sent if "Context compression aborted" in s["content"]] assert len(warning_messages) == 1, ( - f"Expected 1 compression-failure warning, got {len(warning_messages)}: {adapter.sent}" + f"Expected 1 compression-aborted warning, got {len(warning_messages)}: {adapter.sent}" ) warn = warning_messages[0] - # Warning must include the dropped count and the underlying error. - assert "42" in warn["content"] + # Warning must include the underlying error and tell the user nothing + # was dropped. assert "404" in warn["content"] + assert "No messages were dropped" in warn["content"] # Warning must land in the originating topic/thread, not the main channel. assert warn["chat_id"] == "-1001" assert warn["metadata"] == {"thread_id": "17585"} From fae0fa4325f849ddef34bc6ae38ebfbe606547fa Mon Sep 17 00:00:00 2001 From: Wesley Simplicio Date: Tue, 12 May 2026 18:53:44 -0300 Subject: [PATCH 004/338] fix(tirith): suppress .app lookalike_tld false positives in warn verdicts Tirith flags .app domains with a lookalike_tld finding because the TLD "can be confused with file extensions". This is a false positive for legitimate production APIs (e.g. api.example.app, lark.app). Add _is_app_tld_finding() and a post-parse suppression block in check_command_security(): if the only finding(s) on a warn verdict are lookalike_tld entries for .app, downgrade the action to allow. Mixed findings (e.g. .app + shortened_url) and block verdicts are unaffected. Non-.app lookalike_tld findings (.zip, .exe, etc.) are preserved. Add 15 regression tests covering: .app-only suppression, mixed-finding preservation, non-.app TLD preservation, block-verdict invariance, and the helper's field-name and case-insensitivity behaviour. Closes #24461 --- tests/tools/test_tirith_security.py | 120 ++++++++++++++++++++++++++++ tools/tirith_security.py | 29 +++++++ 2 files changed, 149 insertions(+) diff --git a/tests/tools/test_tirith_security.py b/tests/tools/test_tirith_security.py index afeb14f945..b47c7a5ff5 100644 --- a/tests/tools/test_tirith_security.py +++ b/tests/tools/test_tirith_security.py @@ -1221,3 +1221,123 @@ class TestSpawnWarningDedup: if "tirith path resolved to None" in rec.message ] assert len(none_warnings) == 1 + + +# --------------------------------------------------------------------------- +# .app TLD suppression (issue #24461) +# --------------------------------------------------------------------------- + +_CFG = {"tirith_enabled": True, "tirith_path": "tirith", + "tirith_timeout": 5, "tirith_fail_open": True} + + +class TestAppTldSuppression: + """warn verdicts whose only finding is lookalike_tld/.app are downgraded to allow.""" + + @patch("tools.tirith_security.subprocess.run") + @patch("tools.tirith_security._load_security_config") + def test_app_only_warn_downgraded_to_allow(self, mock_cfg, mock_run): + mock_cfg.return_value = _CFG + findings = [{"rule_id": "lookalike_tld", "value": ".app", + "message": "Domain uses '.app' TLD which can be confused with file extensions"}] + mock_run.return_value = _mock_run(2, _json_stdout(findings, ".app TLD warning")) + result = check_command_security("curl https://example.app") + assert result["action"] == "allow" + assert result["findings"] == [] + assert result["summary"] == "" + + @patch("tools.tirith_security.subprocess.run") + @patch("tools.tirith_security._load_security_config") + def test_app_tld_in_description_field_also_suppressed(self, mock_cfg, mock_run): + mock_cfg.return_value = _CFG + findings = [{"rule_id": "lookalike_tld", + "description": "TLD .app looks like a file extension"}] + mock_run.return_value = _mock_run(2, _json_stdout(findings)) + result = check_command_security("curl https://api.app/v1") + assert result["action"] == "allow" + + @patch("tools.tirith_security.subprocess.run") + @patch("tools.tirith_security._load_security_config") + def test_mixed_findings_preserve_warn(self, mock_cfg, mock_run): + """If .app finding is accompanied by another finding, warn is preserved.""" + mock_cfg.return_value = _CFG + findings = [ + {"rule_id": "lookalike_tld", "value": ".app"}, + {"rule_id": "shortened_url", "severity": "medium"}, + ] + mock_run.return_value = _mock_run(2, _json_stdout(findings, "mixed")) + result = check_command_security("curl https://bit.ly/test.app") + assert result["action"] == "warn" + assert len(result["findings"]) == 2 + + @patch("tools.tirith_security.subprocess.run") + @patch("tools.tirith_security._load_security_config") + def test_non_app_lookalike_tld_preserved(self, mock_cfg, mock_run): + """lookalike_tld for a non-.app TLD is not suppressed.""" + mock_cfg.return_value = _CFG + findings = [{"rule_id": "lookalike_tld", "value": ".zip", + "message": "TLD .zip can be confused with zip archives"}] + mock_run.return_value = _mock_run(2, _json_stdout(findings, ".zip TLD warning")) + result = check_command_security("curl https://victim.zip") + assert result["action"] == "warn" + assert len(result["findings"]) == 1 + + @patch("tools.tirith_security.subprocess.run") + @patch("tools.tirith_security._load_security_config") + def test_block_verdict_never_suppressed(self, mock_cfg, mock_run): + """block exit code is never downgraded, even if finding looks like .app.""" + mock_cfg.return_value = _CFG + findings = [{"rule_id": "lookalike_tld", "value": ".app"}] + mock_run.return_value = _mock_run(1, _json_stdout(findings, "block")) + result = check_command_security("curl https://example.app") + assert result["action"] == "block" + + @patch("tools.tirith_security.subprocess.run") + @patch("tools.tirith_security._load_security_config") + def test_multiple_app_tld_findings_all_suppressed(self, mock_cfg, mock_run): + """All findings being .app lookalike_tld → allow.""" + mock_cfg.return_value = _CFG + findings = [ + {"rule_id": "lookalike_tld", "value": ".app"}, + {"rule_id": "lookalike_tld", "tld": ".app"}, + ] + mock_run.return_value = _mock_run(2, _json_stdout(findings)) + result = check_command_security("curl https://a.app https://b.app") + assert result["action"] == "allow" + + +class TestIsAppTldFinding: + """Unit tests for the _is_app_tld_finding helper.""" + + def setup_method(self): + from tools.tirith_security import _is_app_tld_finding + self.fn = _is_app_tld_finding + + def test_matching_value_field(self): + assert self.fn({"rule_id": "lookalike_tld", "value": ".app"}) + + def test_matching_tld_field(self): + assert self.fn({"rule_id": "lookalike_tld", "tld": ".app"}) + + def test_matching_description_field(self): + assert self.fn({"rule_id": "lookalike_tld", + "description": "TLD .app looks like an executable"}) + + def test_matching_message_field(self): + assert self.fn({"rule_id": "lookalike_tld", + "message": "Domain uses '.app' TLD"}) + + def test_wrong_rule_id(self): + assert not self.fn({"rule_id": "shortened_url", "value": ".app"}) + + def test_non_app_tld(self): + assert not self.fn({"rule_id": "lookalike_tld", "value": ".zip"}) + + def test_no_tld_value_fields(self): + assert not self.fn({"rule_id": "lookalike_tld", "severity": "low"}) + + def test_non_dict_input(self): + assert not self.fn("not a dict") # type: ignore[arg-type] + + def test_case_insensitive_match(self): + assert self.fn({"rule_id": "lookalike_tld", "value": ".APP"}) diff --git a/tools/tirith_security.py b/tools/tirith_security.py index b45d7d2921..83b222c888 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -771,4 +771,33 @@ def check_command_security(command: str) -> dict: elif action == "warn": summary = "security warning detected (details unavailable)" + # Suppress warn verdicts that consist solely of a lookalike_tld finding for + # the .app TLD. .app is a legitimate gTLD used by many production services + # and the "can be confused with file extensions" heuristic generates false + # positives for normal API calls. Any other finding (including other + # lookalike_tld entries for non-.app TLDs) preserves the warn action. + if action == "warn" and findings: + non_suppressible = [f for f in findings if not _is_app_tld_finding(f)] + if not non_suppressible: + action = "allow" + findings = [] + summary = "" + return {"action": action, "findings": findings, "summary": summary} + + +def _is_app_tld_finding(finding: dict) -> bool: + """Return True if this finding is a lookalike_tld warning for the .app TLD only. + + Checks the rule_id and inspects common value/detail field names that + Tirith may use to carry the TLD string. + """ + if not isinstance(finding, dict): + return False + if finding.get("rule_id") != "lookalike_tld": + return False + for field in ("value", "tld", "detail", "description", "message"): + val = finding.get(field) + if val is not None and ".app" in str(val).lower(): + return True + return False From 5613dfea938ab26bc759cebba7184931a1b18d94 Mon Sep 17 00:00:00 2001 From: flamiinngo Date: Sun, 17 May 2026 09:14:27 +0100 Subject: [PATCH 005/338] fix(security): redact xAI (Grok) API keys in logs xAI is a first-class provider in hermes-agent with its own credential pool entry (XAI_API_KEY / xai-oauth). API keys follow the format xai-<60+ alphanumeric chars> and were absent from _PREFIX_PATTERNS in agent/redact.py. When a key appears raw in log output, tool results, or error messages, it passed through completely unmasked. The ENV-assignment and Bearer header patterns catch the most common cases, but a raw token in a stack trace or debug print had no protection. Verified before fix: redact_sensitive_text("using key xai-ABCD...rstu to call xAI", force=True) # "using key xai-ABCD...rstu to call xAI" <- exposed After fix: # "using key xai-AB...rstu to call xAI" <- masked Five unit tests added to TestXaiToken covering bare token masking, env assignment, short-prefix false positive, company name false positive, and visible prefix in masked output. --- agent/redact.py | 1 + tests/agent/test_redact.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/agent/redact.py b/agent/redact.py index c6643304a9..4cafbaef7a 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -103,6 +103,7 @@ _PREFIX_PATTERNS = [ r"hsk-[A-Za-z0-9]{10,}", # Hindsight API key r"mem0_[A-Za-z0-9]{10,}", # Mem0 Platform API key r"brv_[A-Za-z0-9]{10,}", # ByteRover API key + r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) API key ] # ENV assignment patterns: KEY=value where KEY contains a secret-like name diff --git a/tests/agent/test_redact.py b/tests/agent/test_redact.py index a2c6b60b27..928eb1ff35 100644 --- a/tests/agent/test_redact.py +++ b/tests/agent/test_redact.py @@ -511,3 +511,29 @@ class TestFormBodyRedaction: text = "first=1\nsecond=2" # Should pass through (still subject to other redactors) assert "first=1" in redact_sensitive_text(text) + + +class TestXaiToken: + KEY = "xai-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstu" + + def test_bare_token_masked(self): + result = redact_sensitive_text(f"using key {self.KEY}", force=True) + assert self.KEY not in result + assert "xai-AB" in result + + def test_env_assignment_masked(self): + result = redact_sensitive_text(f"XAI_API_KEY={self.KEY}", force=True) + assert self.KEY not in result + + def test_too_short_not_masked(self): + short = "xai-tooshort" + result = redact_sensitive_text(f"text {short} here", force=True) + assert short in result + + def test_company_name_not_masked(self): + result = redact_sensitive_text("xai is a company", force=True) + assert result == "xai is a company" + + def test_prefix_visible_in_masked_output(self): + result = redact_sensitive_text(self.KEY, force=True) + assert result.startswith("xai-AB") From eac198b6d5035dac78e6dc5b20fb2f6f85c3fd52 Mon Sep 17 00:00:00 2001 From: Fewmanism Date: Sun, 17 May 2026 20:47:31 +0900 Subject: [PATCH 006/338] fix: make xAI OAuth callback server threaded --- hermes_cli/auth.py | 5 ++-- .../test_auth_xai_oauth_provider.py | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index df4de463a5..c32bb94b86 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -39,7 +39,7 @@ import webbrowser from contextlib import contextmanager from dataclasses import dataclass, field from datetime import datetime, timezone -from http.server import BaseHTTPRequestHandler, HTTPServer +from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple from urllib.parse import parse_qs, urlencode, urlparse @@ -2426,8 +2426,9 @@ def _xai_start_callback_server( expected_path = XAI_OAUTH_REDIRECT_PATH handler_cls, result = _make_xai_callback_handler(expected_path) - class _ReuseHTTPServer(HTTPServer): + class _ReuseHTTPServer(ThreadingHTTPServer): allow_reuse_address = True + daemon_threads = True ports_to_try = [preferred_port] if preferred_port != 0: diff --git a/tests/hermes_cli/test_auth_xai_oauth_provider.py b/tests/hermes_cli/test_auth_xai_oauth_provider.py index 9f1cc55f57..76c1e6228f 100644 --- a/tests/hermes_cli/test_auth_xai_oauth_provider.py +++ b/tests/hermes_cli/test_auth_xai_oauth_provider.py @@ -2,7 +2,9 @@ import base64 import json +import socket import time +import urllib.request from pathlib import Path import pytest @@ -20,6 +22,7 @@ from hermes_cli.auth import ( _xai_access_token_is_expiring, _xai_callback_cors_origin, _xai_oauth_build_authorize_url, + _xai_start_callback_server, _xai_validate_loopback_redirect_uri, get_xai_oauth_auth_status, refresh_xai_oauth_pure, @@ -278,6 +281,29 @@ def test_xai_callback_cors_origin_rejects_unknown_origin(): assert _xai_callback_cors_origin("") == "" +def test_xai_callback_server_accepts_fallback_code_while_browser_connection_is_stuck(): + """Regression: Chrome/xAI can leave a loopback connection open after + showing the Grok Build fallback code. A single-threaded callback server then + blocks forever and cannot accept the manual fallback callback. + """ + server, thread, result, redirect_uri = _xai_start_callback_server(preferred_port=0) + stuck = socket.create_connection((XAI_OAUTH_REDIRECT_HOST, server.server_address[1]), timeout=2) + try: + stuck.sendall(b"GET /callback?code=stuck") + callback_url = f"{redirect_uri}?code=fallback-code&state=state-123" + with urllib.request.urlopen(callback_url, timeout=2) as response: + body = response.read().decode("utf-8") + assert response.status == 200 + assert "xAI authorization received" in body + assert result["code"] == "fallback-code" + assert result["state"] == "state-123" + finally: + stuck.close() + server.shutdown() + server.server_close() + thread.join(timeout=1.0) + + # --------------------------------------------------------------------------- # Token roundtrip + reads # --------------------------------------------------------------------------- From 0d63661702162bf36bcb695000280f9c1687d742 Mon Sep 17 00:00:00 2001 From: Fewmanism Date: Mon, 18 May 2026 02:06:00 +0900 Subject: [PATCH 007/338] fix: latch xAI OAuth callback result --- hermes_cli/auth.py | 22 ++++++++++++++----- .../test_auth_xai_oauth_provider.py | 22 +++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index c32bb94b86..2a5e7a213f 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2372,6 +2372,7 @@ def _make_xai_callback_handler(expected_path: str) -> tuple[type[BaseHTTPRequest "error": None, "error_description": None, } + result_lock = threading.Lock() class _XAICallbackHandler(BaseHTTPRequestHandler): def _maybe_write_cors_headers(self) -> None: @@ -2398,16 +2399,27 @@ def _make_xai_callback_handler(expected_path: str) -> tuple[type[BaseHTTPRequest return params = parse_qs(parsed.query) - result["code"] = params.get("code", [None])[0] - result["state"] = params.get("state", [None])[0] - result["error"] = params.get("error", [None])[0] - result["error_description"] = params.get("error_description", [None])[0] + incoming = { + "code": params.get("code", [None])[0], + "state": params.get("state", [None])[0], + "error": params.get("error", [None])[0], + "error_description": params.get("error_description", [None])[0], + } + # ThreadingHTTPServer allows a fallback/manual callback to complete + # while a browser connection is stuck. Once we have a terminal + # OAuth result (code or error), keep the first one so a later + # concurrent/invalid callback cannot overwrite state before + # validation in _xai_oauth_loopback_login(). + if incoming["code"] or incoming["error"]: + with result_lock: + if not (result["code"] or result["error"]): + result.update(incoming) self.send_response(200) self._maybe_write_cors_headers() self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() - if result["error"]: + if incoming["error"]: body = "

xAI authorization failed.

You can close this tab." else: body = "

xAI authorization received.

You can close this tab." diff --git a/tests/hermes_cli/test_auth_xai_oauth_provider.py b/tests/hermes_cli/test_auth_xai_oauth_provider.py index 76c1e6228f..344f8d6f15 100644 --- a/tests/hermes_cli/test_auth_xai_oauth_provider.py +++ b/tests/hermes_cli/test_auth_xai_oauth_provider.py @@ -304,6 +304,28 @@ def test_xai_callback_server_accepts_fallback_code_while_browser_connection_is_s thread.join(timeout=1.0) +def test_xai_callback_server_latches_first_terminal_callback_result(): + server, thread, result, redirect_uri = _xai_start_callback_server(preferred_port=0) + try: + with urllib.request.urlopen(f"{redirect_uri}?code=first-code&state=state-1", timeout=2) as response: + assert response.status == 200 + with urllib.request.urlopen( + f"{redirect_uri}?error=access_denied&error_description=late&state=state-2", + timeout=2, + ) as response: + body = response.read().decode("utf-8") + assert response.status == 200 + assert "xAI authorization failed" in body + assert result["code"] == "first-code" + assert result["state"] == "state-1" + assert result["error"] is None + assert result["error_description"] is None + finally: + server.shutdown() + server.server_close() + thread.join(timeout=1.0) + + # --------------------------------------------------------------------------- # Token roundtrip + reads # --------------------------------------------------------------------------- From bc77f79798095493d04ec41293b4a947660e101e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 18 May 2026 10:22:41 -0700 Subject: [PATCH 008/338] chore(release): AUTHOR_MAP entries for Fewmanism + Slimydog21 --- scripts/release.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 2677f3f58d..5f0f66c176 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1137,6 +1137,9 @@ AUTHOR_MAP = { "12735938+zwolniony@users.noreply.github.com": "zwolniony", "ambuj@dodopayments.com": "that-ambuj", # PR #26582 (preserve underscores) "zccyman@163.com": "zccyman", # PR #25294 (custom provider api_key_env alias) + # xAI cluster batch salvage (May 2026) + "lgndscntn@gmail.com": "Fewmanism", # PR #27420 (threaded xAI OAuth callback) + "slimydog@Faisals-Mac-mini.local": "Slimydog21", # PR #28021 (strip slash enums xAI Responses) "bitkyc08@gmail.com": "lidge-jun", # PR #26814 (api server browser security headers) "sp_ps@Mac-mini.lan": "phoenixshen", # PR #26768 (respect user-configured vision model) "1594534+phoenixshen@users.noreply.github.com": "phoenixshen", From 1fabd6e100cd8244aa674f9fcae58787c0e11262 Mon Sep 17 00:00:00 2001 From: EloquentBrush0x <283442588+EloquentBrush0x@users.noreply.github.com> Date: Sun, 17 May 2026 12:02:36 +0300 Subject: [PATCH 009/338] fix(error_classifier): classify xAI Grok entitlement SSE errors as auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When xAI returns a subscription/entitlement error through an SSE ``type=error`` frame, ``_StreamErrorEvent`` is raised with ``status_code=None``. This caused ``_classify_by_status`` (step 2 of ``classify_api_error``) to be skipped entirely, and the Grok-specific phrases ("do not have an active Grok subscription", "out of available resources") appeared in none of the message-pattern lists. The error fell through to ``FailoverReason.unknown (retryable=True)``, burning ``max_retries`` on every affected X Premium+ / SuperGrok user before the agent stopped — and ``_is_entitlement_failure`` was never called because it only fires under ``FailoverReason.auth``. The HTTP 403 path already handled this correctly (``_classify_by_status`` returns ``auth/non-retryable`` for 403). Add an explicit pattern block at step 1 (highest priority, before the ``status_code`` guard) so both code paths route to ``FailoverReason.auth, retryable=False, should_fallback=True`` — matching the 403 path exactly. Add three regression tests in ``Fix D`` section of ``test_codex_xai_oauth_recovery.py``: - primary "do not have an active Grok subscription" phrase - "out of available resources" + "grok" variant - unrelated ``_StreamErrorEvent`` must not be reclassified --- agent/error_classifier.py | 29 ++++++++++ .../test_codex_xai_oauth_recovery.py | 56 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index d29a2e34ac..42eb42d680 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -510,6 +510,35 @@ def classify_api_error( should_compress=False, ) + # xAI Grok subscription entitlement errors. + # + # xAI returns "You have either run out of available resources or do not + # have an active Grok subscription" through two distinct code paths: + # + # â€ĸ HTTP 403 — status_code is set; _classify_by_status (step 2) routes + # it to FailoverReason.auth correctly, and _is_entitlement_failure + # then prevents the credential-refresh loop. + # + # â€ĸ SSE ``type=error`` frame — surfaced as _StreamErrorEvent with + # status_code=None. _classify_by_status is skipped entirely, and + # "grok subscription" / "out of available resources" appear in none + # of the message-pattern lists below. Without this guard the error + # falls through to FailoverReason.unknown (retryable=True), burning + # max_retries before the agent stops — and _is_entitlement_failure + # is never called because it only runs under FailoverReason.auth. + # + # Both X Premium+ and SuperGrok subscribers hit this path when their + # subscription tier does not cover the requested model or feature. + if ( + "do not have an active grok subscription" in error_msg + or ("out of available resources" in error_msg and "grok" in error_msg) + ): + return _result( + FailoverReason.auth, + retryable=False, + should_fallback=True, + ) + # ── 2. HTTP status code classification ────────────────────────── if status_code is not None: diff --git a/tests/run_agent/test_codex_xai_oauth_recovery.py b/tests/run_agent/test_codex_xai_oauth_recovery.py index 9eb641cc89..5cb48efc6c 100644 --- a/tests/run_agent/test_codex_xai_oauth_recovery.py +++ b/tests/run_agent/test_codex_xai_oauth_recovery.py @@ -224,6 +224,62 @@ def test_summarize_api_error_passes_through_unrelated_errors(): assert "upstream is sad" in summary +# --------------------------------------------------------------------------- +# Fix D: _StreamErrorEvent xAI entitlement classified as auth, not retryable +# +# run_codex_create_stream_fallback raises _StreamErrorEvent (status_code=None) +# when the Responses stream emits a ``type=error`` SSE frame. Before this +# fix, classify_api_error had no match for "grok subscription" in its pattern +# lists, so it returned FailoverReason.unknown (retryable=True) — burning +# max_retries before the agent stopped. _is_entitlement_failure was never +# called because it only runs when FailoverReason.auth is returned. +# --------------------------------------------------------------------------- + + +def test_classify_api_error_stream_event_grok_subscription_is_auth(): + """_StreamErrorEvent with xAI subscription message classifies as auth/non-retryable. + + The SSE error path has status_code=None, so _classify_by_status is + skipped. The explicit pattern added at step 1 must fire first and + return auth/non-retryable so _is_entitlement_failure can stop the loop. + """ + from run_agent import _StreamErrorEvent + from agent.error_classifier import classify_api_error, FailoverReason + + err = _StreamErrorEvent( + "You have either run out of available resources or do not have an " + "active Grok subscription. Manage subscriptions at https://grok.com", + code="The caller does not have permission to execute the specified operation", + ) + result = classify_api_error(err, provider="xai-oauth", model="grok-4.3") + assert result.reason == FailoverReason.auth + assert result.retryable is False + assert result.should_fallback is True + + +def test_classify_api_error_stream_event_resources_exhausted_grok_is_auth(): + """'out of available resources' + 'grok' variant also classifies as auth.""" + from run_agent import _StreamErrorEvent + from agent.error_classifier import classify_api_error, FailoverReason + + err = _StreamErrorEvent( + "You have run out of available resources for Grok.", + ) + result = classify_api_error(err, provider="xai-oauth", model="grok-4.3") + assert result.reason == FailoverReason.auth + assert result.retryable is False + + +def test_classify_api_error_stream_event_unrelated_not_reclassified(): + """An unrelated _StreamErrorEvent must not be caught by the xAI guard.""" + from run_agent import _StreamErrorEvent + from agent.error_classifier import classify_api_error, FailoverReason + + err = _StreamErrorEvent("Internal server error — try again later") + result = classify_api_error(err, provider="xai-oauth", model="grok-4.3") + assert result.reason != FailoverReason.auth + + # --------------------------------------------------------------------------- # Fix C: reasoning replay gating for xai-oauth # --------------------------------------------------------------------------- From bf6eeb3f938f34bb90af65d65c92335ab6129f59 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Sun, 17 May 2026 06:14:46 -0700 Subject: [PATCH 010/338] fix(xai-oauth): show "not received" page when loopback callback has no code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When xAI's auth backend fails to redirect (e.g. the German "We couldn't reach your app" fallback shown in #27385), users sometimes navigate manually to the bare loopback callback URL — `http://127.0.0.1:/callback` with no query string. The handler used to return 200 "xAI authorization received" for any GET that hit the expected path, because `parse_qs("")` yields no `code` and no `error`, leaving `result` untouched while the success page was still served. The CLI's wait loop, of course, still saw no code and timed out with `AuthError: xAI authorization timed out waiting for the local callback.` The user is left looking at a browser tab that claims success and a terminal that says failure — exactly the contradiction in #27385. This change makes the empty-callback case return 400 with an explicit "not received" page and a hint to retry `hermes auth add xai-oauth`. The wait-loop semantics are unchanged: `result["code"]` and `result["error"]` both stay None, so the CLI still raises a real timeout rather than treating the bare hit as a successful callback. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/auth.py | 30 ++++++- .../test_auth_xai_oauth_provider.py | 78 +++++++++++++++++++ 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 2a5e7a213f..78c21252b3 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2405,15 +2405,37 @@ def _make_xai_callback_handler(expected_path: str) -> tuple[type[BaseHTTPRequest "error": params.get("error", [None])[0], "error_description": params.get("error_description", [None])[0], } + + # Treat a hit on the callback path with neither `code` nor `error` + # as a missing OAuth callback (e.g. xAI's auth backend failed to + # redirect and the user navigated to the bare loopback URL by hand). + # Show an explicit "not received" page rather than the success page — + # otherwise the browser claims authorization succeeded while the CLI + # is still waiting for a real callback and eventually times out. + if incoming["code"] is None and incoming["error"] is None: + self.send_response(400) + self._maybe_write_cors_headers() + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + body = ( + "" + "

xAI authorization not received.

" + "

No authorization code was present in this callback URL. " + "Return to the terminal and re-run " + "hermes auth add xai-oauth to retry.

" + "" + ) + self.wfile.write(body.encode("utf-8")) + return + # ThreadingHTTPServer allows a fallback/manual callback to complete # while a browser connection is stuck. Once we have a terminal # OAuth result (code or error), keep the first one so a later # concurrent/invalid callback cannot overwrite state before # validation in _xai_oauth_loopback_login(). - if incoming["code"] or incoming["error"]: - with result_lock: - if not (result["code"] or result["error"]): - result.update(incoming) + with result_lock: + if not (result["code"] or result["error"]): + result.update(incoming) self.send_response(200) self._maybe_write_cors_headers() diff --git a/tests/hermes_cli/test_auth_xai_oauth_provider.py b/tests/hermes_cli/test_auth_xai_oauth_provider.py index 344f8d6f15..b2795cf23a 100644 --- a/tests/hermes_cli/test_auth_xai_oauth_provider.py +++ b/tests/hermes_cli/test_auth_xai_oauth_provider.py @@ -326,6 +326,84 @@ def test_xai_callback_server_latches_first_terminal_callback_result(): thread.join(timeout=1.0) +# --------------------------------------------------------------------------- +# Loopback callback handler GET responses +# --------------------------------------------------------------------------- + + +def _get_callback(redirect_uri: str, query: str = "") -> tuple[int, str]: + """GET the loopback callback URL with an optional query string.""" + from urllib.request import Request, urlopen + from urllib.error import HTTPError + + target = redirect_uri + (("?" + query) if query else "") + req = Request(target, method="GET") + try: + with urlopen(req, timeout=5.0) as resp: + return resp.getcode(), resp.read().decode("utf-8", "replace") + except HTTPError as exc: + return exc.code, exc.read().decode("utf-8", "replace") + + +def test_xai_callback_handler_returns_400_when_callback_url_lacks_code_and_error(): + """Bare loopback URL (no code, no error) must not claim authorization received. + + Regression for #27385: when xAI's auth backend fails to redirect and the user + manually navigates to http://127.0.0.1:/callback, the handler used to + return 200 "xAI authorization received" while the CLI's wait loop still timed + out — leaving the user with a contradictory success page and a CLI error. + """ + server, thread, result, redirect_uri = _xai_start_callback_server(preferred_port=0) + try: + status, body = _get_callback(redirect_uri) + assert status == 400 + assert "not received" in body.lower() + assert "hermes auth add xai-oauth" in body + # Wait loop must still see no code/error so it raises a real timeout, + # rather than treating this empty hit as a successful callback. + assert result["code"] is None + assert result["error"] is None + finally: + server.shutdown() + server.server_close() + thread.join(timeout=1.0) + + +def test_xai_callback_handler_accepts_callback_with_code(): + """A real OAuth redirect (code + state) still records both and shows success.""" + server, thread, result, redirect_uri = _xai_start_callback_server(preferred_port=0) + try: + status, body = _get_callback(redirect_uri, query="code=abc&state=xyz") + assert status == 200 + assert "xAI authorization received" in body + assert result["code"] == "abc" + assert result["state"] == "xyz" + assert result["error"] is None + finally: + server.shutdown() + server.server_close() + thread.join(timeout=1.0) + + +def test_xai_callback_handler_records_error_callback(): + """A redirect carrying an `error` param must surface the failure page and capture detail.""" + server, thread, result, redirect_uri = _xai_start_callback_server(preferred_port=0) + try: + status, body = _get_callback( + redirect_uri, + query="error=access_denied&error_description=user%20cancelled", + ) + assert status == 200 + assert "xAI authorization failed" in body + assert result["error"] == "access_denied" + assert result["error_description"] == "user cancelled" + assert result["code"] is None + finally: + server.shutdown() + server.server_close() + thread.join(timeout=1.0) + + # --------------------------------------------------------------------------- # Token roundtrip + reads # --------------------------------------------------------------------------- From 226680500d6e4face29b6cd9e6313c0e7fed8b1f Mon Sep 17 00:00:00 2001 From: konsisumer Date: Sun, 17 May 2026 01:41:42 +0200 Subject: [PATCH 011/338] fix(auth): improve xAI OAuth SSH hint with visual header and auto-detected host --- hermes_cli/auth.py | 30 ++++++++--- .../hermes_cli/test_auth_loopback_ssh_hint.py | 54 +++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 78c21252b3..a839083701 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2895,6 +2895,21 @@ def _is_remote_session() -> bool: return bool(os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY")) +def _ssh_user_at_host() -> str: + """Return best-effort 'user@hostname' for the SSH tunnel hint command. + + Falls back to placeholder tokens when the values cannot be determined so + the hint is always syntactically valid even if not copy-pasteable. + """ + try: + import socket as _socket + hostname = _socket.gethostname() or "" + except OSError: + hostname = "" + user = os.getenv("USER") or os.getenv("LOGNAME") or "" + return f"{user}@{hostname}" + + def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) -> None: """Print an SSH tunnel hint when running a loopback-redirect OAuth flow on a remote host. The auth server (xAI, Spotify, ...) will redirect the user's @@ -2918,19 +2933,22 @@ def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) port = parsed.port if host not in {"127.0.0.1", "::1", "localhost"} or not port: return + divider = "-" * 60 print() - print("Remote session detected. Your browser will redirect to") - print(f" {redirect_uri}") - print("which the loopback listener on THIS machine is waiting on. If your") - print("browser is on a different machine, forward the port first from your") - print("local machine in a separate terminal:") + print(divider) + print("Remote session detected — SSH tunnel required") + print(divider) + print(f"Hermes is waiting for the OAuth callback on {redirect_uri}") + print("but your browser is on a different machine. Run this command") + print("in a NEW terminal on your local machine BEFORE opening the URL:") print() - print(f" ssh -N -L {port}:127.0.0.1:{port} @") + print(f" ssh -N -L {port}:127.0.0.1:{port} {_ssh_user_at_host()}") print() print("Then open the authorize URL above in your local browser.") if docs_url: print(f"Provider docs: {docs_url}") print(f"SSH/jump-box guide: {OAUTH_OVER_SSH_DOCS_URL}") + print(divider) print() diff --git a/tests/hermes_cli/test_auth_loopback_ssh_hint.py b/tests/hermes_cli/test_auth_loopback_ssh_hint.py index fb88a6bf4c..87dcd52646 100644 --- a/tests/hermes_cli/test_auth_loopback_ssh_hint.py +++ b/tests/hermes_cli/test_auth_loopback_ssh_hint.py @@ -9,6 +9,7 @@ from __future__ import annotations import io import contextlib +import socket import pytest @@ -93,3 +94,56 @@ def test_loopback_ssh_hint_accepts_localhost_hostname(monkeypatch): "http://localhost:56121/callback" )) assert "ssh -N -L 56121:127.0.0.1:56121" in out + + +def test_loopback_ssh_hint_includes_user_at_host(monkeypatch): + """The SSH command should include a detected user@host so the user can + copy-paste it without manually substituting placeholders.""" + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + monkeypatch.setattr(auth_mod, "_ssh_user_at_host", lambda: "alice@myserver.lan") + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "http://127.0.0.1:56121/callback" + )) + assert "ssh -N -L 56121:127.0.0.1:56121 alice@myserver.lan" in out + + +def test_loopback_ssh_hint_has_visual_header(monkeypatch): + """The hint should print a divider and header so it stands out in noisy output.""" + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "http://127.0.0.1:56121/callback" + )) + assert "Remote session detected" in out + assert "---" in out # divider is present + + +class TestSshUserAtHost: + def test_resolves_user_and_hostname(self, monkeypatch): + monkeypatch.setenv("USER", "alice") + monkeypatch.delenv("LOGNAME", raising=False) + monkeypatch.setattr(socket, "gethostname", lambda: "myserver") + assert auth_mod._ssh_user_at_host() == "alice@myserver" + + def test_falls_back_to_logname(self, monkeypatch): + monkeypatch.delenv("USER", raising=False) + monkeypatch.setenv("LOGNAME", "bob") + monkeypatch.setattr(socket, "gethostname", lambda: "host1") + assert auth_mod._ssh_user_at_host() == "bob@host1" + + def test_placeholder_when_no_env_vars(self, monkeypatch): + monkeypatch.delenv("USER", raising=False) + monkeypatch.delenv("LOGNAME", raising=False) + monkeypatch.setattr(socket, "gethostname", lambda: "host1") + assert auth_mod._ssh_user_at_host() == "@host1" + + def test_placeholder_when_socket_raises(self, monkeypatch): + monkeypatch.setenv("USER", "charlie") + def _raise(): + raise OSError("no network") + monkeypatch.setattr(socket, "gethostname", _raise) + assert auth_mod._ssh_user_at_host() == "charlie@" + + def test_placeholder_when_empty_hostname(self, monkeypatch): + monkeypatch.setenv("USER", "dave") + monkeypatch.setattr(socket, "gethostname", lambda: "") + assert auth_mod._ssh_user_at_host() == "dave@" From 5e40f83cb77b4c93973e93b56b8f2d97a1ba2aab Mon Sep 17 00:00:00 2001 From: EloquentBrush0x <283442588+EloquentBrush0x@users.noreply.github.com> Date: Mon, 18 May 2026 12:15:00 +0300 Subject: [PATCH 012/338] fix(xai-oauth): quarantine terminal refresh errors so dead tokens are not replayed across sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When refresh_xai_oauth_pure raises a terminal error (HTTP 400/401/403, i.e. revoked or reused refresh token), _refresh_entry's existing race- recovery path re-syncs from auth.json and returns if another process has already rotated the tokens. If auth.json still holds the same stale token pair, the function fell through to _mark_exhausted — leaving the dead credentials in auth.json. On the next Hermes startup _seed_from_singletons re-seeded the pool from those stale tokens, causing the same failure loop on every session. Fix: after the auth.json re-sync check in the xAI-oauth error handler, detect terminal errors with the new _is_terminal_xai_oauth_refresh_error helper and apply a quarantine: - Clear access_token and refresh_token from providers["xai-oauth"]["tokens"] in auth.json so they are not re-seeded. - Write a last_auth_error entry for hermes doctor / auth status diagnostics. - Remove all loopback_pkce entries from the in-memory pool so the current session stops retrying with the dead credentials. Mirrors the identical quarantine already in place for Nous OAuth (c90556262). Closes the parity gap introduced when c90556262 added Nous-only terminal error handling without a corresponding xAI-oauth path. --- agent/credential_pool.py | 46 +++++++++- hermes_cli/auth.py | 17 ++++ tests/agent/test_credential_pool.py | 138 ++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 98dbaf3083..416f601665 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -10,7 +10,7 @@ import time import uuid import re from dataclasses import dataclass, fields, replace -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Set, Tuple from hermes_constants import OPENROUTER_BASE_URL @@ -907,6 +907,50 @@ class CredentialPool: self._replace_entry(synced, updated) self._persist() return updated + # Terminal error: auth.json has no newer tokens — the stored + # refresh_token is dead. Clear it from auth.json so the next + # session does not re-seed the same revoked credentials, and + # remove all singleton-seeded (loopback_pkce) entries from the + # in-memory pool. Mirrors the Nous quarantine path above. + if auth_mod._is_terminal_xai_oauth_refresh_error(exc): + logger.debug( + "xAI OAuth refresh token is terminally invalid; clearing local token state" + ) + try: + with _auth_store_lock(): + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "xai-oauth") or {} + if isinstance(state, dict): + tokens = state.get("tokens") or {} + if isinstance(tokens, dict): + store_refresh = str(tokens.get("refresh_token") or "").strip() + entry_refresh = str(entry.refresh_token or "").strip() + if not store_refresh or store_refresh == entry_refresh: + tokens.pop("access_token", None) + tokens.pop("refresh_token", None) + state["tokens"] = tokens + state["last_auth_error"] = { + "provider": "xai-oauth", + "code": getattr(exc, "code", "unknown"), + "message": str(exc), + "reason": "credential_pool_refresh_failure", + "relogin_required": True, + "at": datetime.now(timezone.utc).isoformat(), + } + _save_provider_state(auth_store, "xai-oauth", state) + _save_auth_store(auth_store) + except Exception as clear_exc: + logger.debug( + "Failed to clear terminal xAI OAuth state: %s", clear_exc + ) + self._entries = [ + item for item in self._entries + if item.source != "loopback_pkce" + ] + if self._current_id == entry.id: + self._current_id = None + self._persist() + return None # For nous: another process may have consumed the refresh token # between our proactive sync and the HTTP call. Re-sync from # auth.json and adopt the fresh tokens if available. diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index a839083701..f223e101b1 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -4044,6 +4044,23 @@ def _is_terminal_nous_refresh_error(exc: Exception) -> bool: ) +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) + ) + + def _quarantine_nous_oauth_state( state: Dict[str, Any], error: AuthError, diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index c288619aed..034dc7377c 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -1825,3 +1825,141 @@ def test_codex_exhausted_entry_stays_stuck_without_auth_store_update(tmp_path, m # still skips it. available = pool._available_entries(clear_expired=True, refresh=False) assert available == [] + + +# --------------------------------------------------------------------------- +# xAI OAuth terminal error quarantine +# --------------------------------------------------------------------------- + + +def _xai_auth_store(access_token: str, refresh_token: str) -> dict: + return { + "version": 1, + "active_provider": "xai-oauth", + "providers": { + "xai-oauth": { + "tokens": { + "access_token": access_token, + "refresh_token": refresh_token, + }, + "discovery": {"token_endpoint": "https://accounts.x.ai/oauth2/token"}, + "redirect_uri": "http://localhost:12345/callback", + } + }, + } + + +def test_is_terminal_xai_oauth_refresh_error(): + from hermes_cli.auth import AuthError, _is_terminal_xai_oauth_refresh_error + + assert _is_terminal_xai_oauth_refresh_error( + AuthError("Refresh failed", provider="xai-oauth", code="xai_refresh_failed", relogin_required=True) + ) + assert _is_terminal_xai_oauth_refresh_error( + AuthError("No token", provider="xai-oauth", code="xai_auth_missing_refresh_token", relogin_required=True) + ) + # transient 429/5xx: relogin_required=False → not terminal + assert not _is_terminal_xai_oauth_refresh_error( + AuthError("Rate limit", provider="xai-oauth", code="xai_refresh_failed", relogin_required=False) + ) + # Nous error does not trigger xAI check + assert not _is_terminal_xai_oauth_refresh_error( + AuthError("Revoked", provider="nous", code="invalid_grant", relogin_required=True) + ) + # Generic exception + assert not _is_terminal_xai_oauth_refresh_error(ValueError("oops")) + + +def test_xai_oauth_terminal_refresh_clears_auth_json_and_removes_pool_entries( + tmp_path, monkeypatch +): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.delenv("XAI_API_KEY", raising=False) + monkeypatch.delenv("XAI_OAUTH_ACCESS_TOKEN", raising=False) + + _write_auth_store(tmp_path, _xai_auth_store("old-access-token", "old-refresh-token")) + + from agent.credential_pool import PooledCredential, load_pool + import hermes_cli.auth as auth_mod + from hermes_cli.auth import AuthError + + pool = load_pool("xai-oauth") + selected = pool.select() + assert selected is not None + assert selected.source == "loopback_pkce" + + # Add a manual API-key entry that must survive the quarantine. + pool.add_entry(PooledCredential.from_dict("xai-oauth", { + "id": "manual-key", + "source": "manual", + "auth_type": "api_key", + "access_token": "manual-xai-key", + })) + + refresh_calls = {"count": 0} + + def _terminal_refresh_failure(*_args, **_kwargs): + refresh_calls["count"] += 1 + raise AuthError( + "Refresh session has been revoked", + provider="xai-oauth", + code="xai_refresh_failed", + relogin_required=True, + ) + + monkeypatch.setattr(auth_mod, "refresh_xai_oauth_pure", _terminal_refresh_failure) + + assert pool.try_refresh_current() is None + + # Only the manual entry survives. + assert [entry.id for entry in pool.entries()] == ["manual-key"] + + # Auth.json tokens must be cleared. + auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + xai_state = auth_payload["providers"]["xai-oauth"] + tokens = xai_state.get("tokens", {}) + assert not tokens.get("access_token") + assert not tokens.get("refresh_token") + assert xai_state["last_auth_error"]["code"] == "xai_refresh_failed" + assert xai_state["last_auth_error"]["relogin_required"] is True + + # Persisted pool must also have only the manual entry. + assert [entry["id"] for entry in auth_payload["credential_pool"]["xai-oauth"]] == ["manual-key"] + + # A second try_refresh_current must not call refresh_xai_oauth_pure again + # (pool is now empty of loopback entries and current is None). + assert pool.try_refresh_current() is None + assert refresh_calls["count"] == 1 + + +def test_xai_oauth_nonterminal_refresh_does_not_quarantine(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.delenv("XAI_API_KEY", raising=False) + monkeypatch.delenv("XAI_OAUTH_ACCESS_TOKEN", raising=False) + + _write_auth_store(tmp_path, _xai_auth_store("old-access-token", "old-refresh-token")) + + from agent.credential_pool import load_pool + import hermes_cli.auth as auth_mod + from hermes_cli.auth import AuthError + + pool = load_pool("xai-oauth") + assert pool.select() is not None + + def _transient_failure(*_args, **_kwargs): + raise AuthError( + "Rate limited", + provider="xai-oauth", + code="xai_refresh_failed", + relogin_required=False, + ) + + monkeypatch.setattr(auth_mod, "refresh_xai_oauth_pure", _transient_failure) + + pool.try_refresh_current() + + # Tokens must NOT be cleared from auth.json. + auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + tokens = auth_payload["providers"]["xai-oauth"].get("tokens", {}) + assert tokens.get("access_token") == "old-access-token" + assert tokens.get("refresh_token") == "old-refresh-token" From 9aae59feab2a17acfeac67d6af8c0cf4f56b4fcb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 18 May 2026 10:28:20 -0700 Subject: [PATCH 013/338] fix(compress): make abort-on-summary-failure opt-in via config flag (#28117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #28102 made the summary-failure abort path the unconditional default, changing established behavior. Gate it behind config.yaml flag `compression.abort_on_summary_failure` (default False = historical fallback-placeholder behavior). - hermes_cli/config.py: new `compression.abort_on_summary_failure` key, default False, documented inline. - agent/agent_init.py: read the flag from compression config and pass to ContextCompressor. - agent/context_compressor.py: `__init__` accepts `abort_on_summary_failure` (default False). `compress()` failure branch gates the abort on the flag; when False, falls through to the restored legacy fallback path (static "summary unavailable" placeholder + drop middle window). - tests: restore original fallback expectations as default; add new TestAbortOnSummaryFailure class for the opt-in mode. Gateway/CLI plumbing (force=True on /compress, hygiene/handler abort detection, locale `gateway.compress.aborted` key) from PR #28102 stays intact — those paths only fire when `_last_compress_aborted` is True, which now only happens when the flag is enabled. --- agent/agent_init.py | 4 + agent/context_compressor.py | 49 +++++-- hermes_cli/config.py | 11 ++ tests/agent/test_context_compressor.py | 170 ++++++++++++++----------- 4 files changed, 150 insertions(+), 84 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 71b04e3e54..9b89028e3f 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1105,6 +1105,9 @@ def init_agent( compression_protect_first = max( 0, int(_compression_cfg.get("protect_first_n", 3)) ) + compression_abort_on_summary_failure = str( + _compression_cfg.get("abort_on_summary_failure", False) + ).lower() in {"true", "1", "yes"} # Read optional explicit context_length override for the auxiliary # compression model. Custom endpoints often cannot report this via @@ -1319,6 +1322,7 @@ def init_agent( config_context_length=_config_context_length, provider=agent.provider, api_mode=agent.api_mode, + abort_on_summary_failure=compression_abort_on_summary_failure, ) agent.compression_enabled = compression_enabled diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 8ef9796df7..6263680909 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -523,6 +523,7 @@ class ContextCompressor(ContextEngine): config_context_length: int | None = None, provider: str = "", api_mode: str = "", + abort_on_summary_failure: bool = False, ): self.model = model self.base_url = base_url @@ -534,6 +535,11 @@ class ContextCompressor(ContextEngine): self.protect_last_n = protect_last_n self.summary_target_ratio = max(0.10, min(summary_target_ratio, 0.80)) self.quiet_mode = quiet_mode + # When True, summary-generation failure aborts compression entirely + # (returns messages unchanged, sets _last_compress_aborted=True). + # When False (default = historical behavior), insert a static + # "summary unavailable" placeholder and drop the middle window. + self.abort_on_summary_failure = abort_on_summary_failure self.context_length = get_model_context_length( model, base_url=base_url, api_key=api_key, @@ -1596,24 +1602,26 @@ The user has requested that this compaction PRIORITISE preserving all informatio # Phase 3: Generate structured summary summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic) - # If summary generation failed, ABORT compression entirely. Returning - # the original messages unchanged preserves the full conversation - # context. Previously this branch dropped every middle message and - # replaced them with a static "summary unavailable" placeholder, - # which silently lost N turns of work whenever the aux LLM hiccuped. - # Auto-compress callers detect the no-op (post-compress length == - # pre-compress length) and stop looping. The next call to - # _generate_summary is gated by _summary_failure_cooldown_until, so - # we don't burn the aux model every turn. Users can force a retry - # via /compress (which passes force=True to clear the cooldown). - if not summary: + # If summary generation failed, behavior splits on + # ``abort_on_summary_failure`` (config: compression.abort_on_summary_failure): + # True → ABORT compression entirely. Return messages unchanged + # and set _last_compress_aborted=True so callers can warn + # the user and stop the auto-compress retry loop. + # False → Fall through to the legacy fallback path below: insert + # a static "summary unavailable" placeholder and drop the + # middle window. Records _last_summary_fallback_used / + # _last_summary_dropped_count for gateway hygiene to + # surface a warning. + # Default is False (historical behavior). + if not summary and self.abort_on_summary_failure: n_skipped = compress_end - compress_start self._last_summary_dropped_count = 0 # nothing actually dropped self._last_summary_fallback_used = False self._last_compress_aborted = True if not self.quiet_mode: logger.warning( - "Summary generation failed — aborting compression. " + "Summary generation failed — aborting compression " + "(compression.abort_on_summary_failure=true). " "%d message(s) preserved unchanged. Conversation is " "frozen until the next /compress or /new.", n_skipped, @@ -1634,6 +1642,23 @@ The user has requested that this compaction PRIORITISE preserving all informatio ) compressed.append(msg) + # Legacy fallback path: LLM summary failed and abort_on_summary_failure + # is False (the default). Insert a static placeholder so the model + # knows context was lost rather than silently dropping everything. + if not summary: + if not self.quiet_mode: + logger.warning("Summary generation failed — inserting static fallback context marker") + n_dropped = compress_end - compress_start + self._last_summary_dropped_count = n_dropped + self._last_summary_fallback_used = True + summary = ( + f"{SUMMARY_PREFIX}\n" + f"Summary generation was unavailable. {n_dropped} message(s) were " + f"removed to free context space but could not be summarized. The removed " + f"messages contained earlier work in this session. Continue based on the " + f"recent messages below and the current state of any files or resources." + ) + _merge_summary_into_tail = False last_head_role = messages[compress_start - 1].get("role", "user") if compress_start > 0 else "user" first_tail_role = messages[compress_end].get("role", "user") if compress_end < n_messages else "user" diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e69c51a4d3..ce3ddd5410 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -803,6 +803,17 @@ DEFAULT_CONFIG = { # 0 for long-running rolling-compaction sessions # where you want nothing pinned except the # system prompt + rolling summary + recent tail. + "abort_on_summary_failure": False, # When True, auto-compression that fails + # to generate a summary (aux LLM errored / returned + # non-JSON / timed out) aborts entirely instead of + # dropping the middle window with a static + # "summary unavailable" placeholder. Messages are + # preserved unchanged and the session "freezes" at + # its current size until the user runs /compress + # (which bypasses the failure cooldown) or /new. + # Default False matches historical behavior; set to + # True if you'd rather pause than silently lose + # context turns when your aux model is flaky. }, # Anthropic prompt caching (Claude via OpenRouter or native Anthropic API). diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index e952732075..d8691fdf87 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -64,31 +64,28 @@ class TestCompress: result = compressor.compress(msgs) assert result == msgs - def test_no_client_aborts_compression_with_messages_preserved(self, compressor): - """compressor has no provider configured, so _generate_summary returns - None → compression aborts entirely. Messages must be returned - unchanged (no placeholder, no drop) and _last_compress_aborted set.""" + def test_truncation_fallback_no_client(self, compressor): + # compressor has client=None and abort_on_summary_failure=False (default), + # so the LEGACY fallback path inserts a static "summary unavailable" + # placeholder and the middle window is dropped. msgs = [{"role": "system", "content": "System prompt"}] + self._make_messages(10) result = compressor.compress(msgs) - # Abort path: messages preserved byte-for-byte - assert result == msgs - assert compressor._last_compress_aborted is True - # Compression count NOT incremented on abort — nothing was compressed. - assert compressor.compression_count == 0 + assert len(result) < len(msgs) + # Should keep system message and last N + assert result[0]["role"] == "system" + assert compressor.compression_count == 1 + # Abort flag must NOT fire under the default config. + assert compressor._last_compress_aborted is False + assert compressor._last_summary_fallback_used is True def test_compression_increments_count(self, compressor): msgs = self._make_messages(10) - mock_resp = MagicMock() - mock_resp.choices = [MagicMock()] - mock_resp.choices[0].message.content = "summary text" - with patch("agent.context_compressor.call_llm", return_value=mock_resp): - compressor.compress(msgs) - assert compressor.compression_count == 1 - # Reset cooldown isn't needed (no prior failure) but reset - # iterative-summary state so the next call follows the same - # path as the first. - compressor.compress(msgs) - assert compressor.compression_count == 2 + # Default config (abort_on_summary_failure=False) — fallback path + # increments the count even on summary failure. + compressor.compress(msgs) + assert compressor.compression_count == 1 + compressor.compress(msgs) + assert compressor.compression_count == 2 def test_protects_first_and_last(self, compressor): msgs = self._make_messages(10) @@ -138,11 +135,7 @@ class TestGenerateSummaryNoneContent: {"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"} for i in range(10) ] - mock_resp = MagicMock() - mock_resp.choices = [MagicMock()] - mock_resp.choices[0].message.content = "summary text" - with patch("agent.context_compressor.call_llm", return_value=mock_resp): - result = c.compress(msgs) + result = c.compress(msgs) assert len(result) < len(msgs) @@ -730,14 +723,12 @@ class TestAuxModelFallbackSurfacedToCallers: class TestSummaryFailureTrackingForGatewayWarning: - """When summary generation fails, the compressor must ABORT compression - entirely (return the original messages unchanged) and set the abort flag - so gateway hygiene & /compress can surface a visible warning. Previous - behavior of inserting a static "summary unavailable" placeholder while - silently dropping the middle window has been removed — losing N turns - of context is worse than freezing the chat until the user retries.""" + """Default behavior (compression.abort_on_summary_failure=False): + summary-generation failure inserts a static fallback placeholder and + records dropped count + fallback flag so gateway hygiene & /compress + can surface a visible warning.""" - def test_compress_aborts_and_preserves_messages_on_summary_failure(self): + def test_compress_records_fallback_and_dropped_count_on_summary_failure(self): with patch("agent.context_compressor.get_model_context_length", return_value=100000): c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2) @@ -752,28 +743,20 @@ class TestSummaryFailureTrackingForGatewayWarning: {"role": "user", "content": "msg 7"}, ] - # Simulate summary LLM call failing — covers the 404 / model-not-found - # case from issue (auxiliary compression model misconfigured). with patch("agent.context_compressor.call_llm", side_effect=Exception("404 model not found")): result = c.compress(msgs) - # Abort flag set, error recorded - assert c._last_compress_aborted is True + assert c._last_summary_fallback_used is True + assert c._last_summary_dropped_count > 0 assert c._last_summary_error is not None - # No fallback inserted, no messages dropped - assert c._last_summary_fallback_used is False - assert c._last_summary_dropped_count == 0 - # Original messages preserved byte-for-byte — the agent loop's - # "did compression help?" check (len(after) < len(before)) sees a - # no-op and stops looping. - assert result == msgs - # No "Summary generation was unavailable" placeholder leaked in. - assert not any( + # Default mode: abort flag must NOT fire. + assert c._last_compress_aborted is False + assert any( isinstance(m.get("content"), str) and "Summary generation was unavailable" in m["content"] for m in result ) - def test_compress_clears_abort_flag_on_subsequent_success(self): + def test_compress_clears_fallback_flag_on_subsequent_success(self): mock_response = MagicMock() mock_response.choices = [MagicMock()] mock_response.choices[0].message.content = "summary text" @@ -792,12 +775,76 @@ class TestSummaryFailureTrackingForGatewayWarning: {"role": "user", "content": "msg 7"}, ] - # First call fails, second succeeds — abort flag must reset on second compress. + with patch("agent.context_compressor.call_llm", side_effect=Exception("boom")): + c.compress(msgs) + assert c._last_summary_fallback_used is True + + c._summary_failure_cooldown_until = 0.0 + with patch("agent.context_compressor.call_llm", return_value=mock_response): + c.compress(msgs) + assert c._last_summary_fallback_used is False + assert c._last_summary_dropped_count == 0 + + +class TestAbortOnSummaryFailure: + """Opt-in behavior (compression.abort_on_summary_failure=True): + summary-generation failure ABORTS compression entirely — returns the + original messages unchanged and sets _last_compress_aborted=True so + gateway hygiene & /compress can surface a visible warning.""" + + def _make_msgs(self): + return [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "msg 1"}, + {"role": "assistant", "content": "msg 2"}, + {"role": "user", "content": "msg 3"}, + {"role": "assistant", "content": "msg 4"}, + {"role": "user", "content": "msg 5"}, + {"role": "assistant", "content": "msg 6"}, + {"role": "user", "content": "msg 7"}, + ] + + def _make_compressor(self): + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + return ContextCompressor( + model="test", + quiet_mode=True, + protect_first_n=2, + protect_last_n=2, + abort_on_summary_failure=True, + ) + + def test_compress_aborts_and_preserves_messages_on_summary_failure(self): + c = self._make_compressor() + msgs = self._make_msgs() + with patch("agent.context_compressor.call_llm", side_effect=Exception("404 model not found")): + result = c.compress(msgs) + + assert c._last_compress_aborted is True + assert c._last_summary_error is not None + # No fallback inserted, no messages dropped + assert c._last_summary_fallback_used is False + assert c._last_summary_dropped_count == 0 + # Original messages preserved byte-for-byte. + assert result == msgs + # No "Summary generation was unavailable" placeholder leaked in. + assert not any( + isinstance(m.get("content"), str) and "Summary generation was unavailable" in m["content"] + for m in result + ) + + def test_compress_clears_abort_flag_on_subsequent_success(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "summary text" + + c = self._make_compressor() + msgs = self._make_msgs() + with patch("agent.context_compressor.call_llm", side_effect=Exception("boom")): c.compress(msgs) assert c._last_compress_aborted is True - # Reset cooldown to allow retry on second compress c._summary_failure_cooldown_until = 0.0 with patch("agent.context_compressor.call_llm", return_value=mock_response): c.compress(msgs) @@ -813,34 +860,17 @@ class TestSummaryFailureTrackingForGatewayWarning: mock_response.choices = [MagicMock()] mock_response.choices[0].message.content = "summary text" - with patch("agent.context_compressor.get_model_context_length", return_value=100000): - c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2) + c = self._make_compressor() + msgs = self._make_msgs() - msgs = [ - {"role": "system", "content": "sys"}, - {"role": "user", "content": "msg 1"}, - {"role": "assistant", "content": "msg 2"}, - {"role": "user", "content": "msg 3"}, - {"role": "assistant", "content": "msg 4"}, - {"role": "user", "content": "msg 5"}, - {"role": "assistant", "content": "msg 6"}, - {"role": "user", "content": "msg 7"}, - ] - - # Pre-populate an active cooldown (as if a prior auto-compress aborted). import time as _time c._summary_failure_cooldown_until = _time.monotonic() + 999.0 - # Without force, _generate_summary would short-circuit on cooldown - # and return None → abort. With force=True the cooldown is cleared - # and the call goes through. with patch("agent.context_compressor.call_llm", return_value=mock_response): result = c.compress(msgs, force=True) assert c._last_compress_aborted is False - # Cooldown was cleared and a real summary attempt was made. assert c._summary_failure_cooldown_until == 0.0 - # Result is actually compressed (shorter than input). assert len(result) < len(msgs) @@ -1401,11 +1431,7 @@ class TestSummaryTargetRatio: + [{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"} for i in range(8)] ) - mock_resp = MagicMock() - mock_resp.choices = [MagicMock()] - mock_resp.choices[0].message.content = "summary text" - with patch("agent.context_compressor.call_llm", return_value=mock_resp): - result = c.compress(msgs) + result = c.compress(msgs) # System prompt (msg[0]) survives as head assert result[0]["role"] == "system" assert result[0]["content"].startswith("System prompt") From b570e0fdd0d64742fd96c928821ecc0cf5d91ef7 Mon Sep 17 00:00:00 2001 From: EloquentBrush0x <283442588+EloquentBrush0x@users.noreply.github.com> Date: Mon, 18 May 2026 10:31:13 -0700 Subject: [PATCH 014/338] fix(codex-oauth): quarantine terminal refresh errors so dead tokens are not replayed across sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Codex OAuth refresh token is permanently invalidated (HTTP 400/401/403, token revoked or reused), _mark_exhausted was called but auth.json was left with the dead credentials. On the next session, _seed_from_singletons re-read auth.json and re-seeded the pool with the same revoked token, triggering the same terminal failure in a loop. Add _is_terminal_codex_oauth_refresh_error to auth.py and a matching quarantine block in _refresh_entry: when a terminal error is detected and auth.json holds no newer tokens, clear access_token/refresh_token from auth.json and remove all device_code-sourced pool entries from memory. Mirrors the Nous quarantine added in c90556262 and the xAI quarantine in #28116. Also add a pre-refresh sync from auth.json before calling refresh_codex_oauth_pure, matching the xAI and Nous patterns, to avoid refresh_token_reused races when multiple Hermes processes share the same auth.json singleton. Salvaged from #27911 by @EloquentBrush0x — contributor's branch was severely stale (would have reverted ~5000 LOC across azure/kanban/i18n subsystems); fix re-applied surgically on current main with their predicate and tests preserved. --- agent/credential_pool.py | 73 ++++++++++++++ hermes_cli/auth.py | 23 +++++ tests/agent/test_credential_pool.py | 141 ++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 416f601665..9a5cc20fe6 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -797,6 +797,13 @@ class CredentialPool: except Exception as wexc: logger.debug("Failed to write refreshed token to credentials file: %s", wexc) elif self.provider == "openai-codex": + # Adopt fresher tokens from auth.json before spending the + # refresh_token — single-use tokens consumed by another Hermes + # process sharing the same auth.json singleton would otherwise + # trigger ``refresh_token_reused`` on the next POST. + synced = self._sync_codex_entry_from_auth_store(entry) + if synced is not entry: + entry = synced refreshed = auth_mod.refresh_codex_oauth_pure( entry.access_token, entry.refresh_token, @@ -951,6 +958,72 @@ class CredentialPool: self._current_id = None self._persist() return None + # For openai-codex: same race as xAI/nous — another Hermes process + # may have consumed the refresh token between our proactive sync + # and the HTTP call. Re-check auth.json and adopt the fresh tokens + # if they have rotated since. + if self.provider == "openai-codex": + synced = self._sync_codex_entry_from_auth_store(entry) + if synced.refresh_token != entry.refresh_token: + logger.debug( + "Codex OAuth refresh failed but auth.json has newer tokens — adopting" + ) + updated = replace( + synced, + last_status=STATUS_OK, + last_status_at=None, + last_error_code=None, + last_error_reason=None, + last_error_message=None, + last_error_reset_at=None, + ) + self._replace_entry(synced, updated) + self._persist() + return updated + # Terminal error: auth.json has no newer tokens — the stored + # refresh_token is dead. Clear it from auth.json so the next + # session does not re-seed the same revoked credentials, and + # remove all singleton-seeded (device_code) entries from the + # in-memory pool. Mirrors the xAI and Nous quarantine paths. + if auth_mod._is_terminal_codex_oauth_refresh_error(exc): + logger.debug( + "Codex OAuth refresh token is terminally invalid; clearing local token state" + ) + try: + with _auth_store_lock(): + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "openai-codex") or {} + if isinstance(state, dict): + tokens = state.get("tokens") or {} + if isinstance(tokens, dict): + store_refresh = str(tokens.get("refresh_token") or "").strip() + entry_refresh = str(entry.refresh_token or "").strip() + if not store_refresh or store_refresh == entry_refresh: + tokens.pop("access_token", None) + tokens.pop("refresh_token", None) + state["tokens"] = tokens + state["last_auth_error"] = { + "provider": "openai-codex", + "code": getattr(exc, "code", "unknown"), + "message": str(exc), + "reason": "credential_pool_refresh_failure", + "relogin_required": True, + "at": datetime.now(timezone.utc).isoformat(), + } + _save_provider_state(auth_store, "openai-codex", state) + _save_auth_store(auth_store) + except Exception as clear_exc: + logger.debug( + "Failed to clear terminal Codex OAuth state: %s", clear_exc + ) + self._entries = [ + item for item in self._entries + if item.source != "device_code" + ] + if self._current_id == entry.id: + self._current_id = None + self._persist() + return None # For nous: another process may have consumed the refresh token # between our proactive sync and the HTTP call. Re-sync from # auth.json and adopt the fresh tokens if available. diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index f223e101b1..d06e9a739e 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -4061,6 +4061,29 @@ def _is_terminal_xai_oauth_refresh_error(exc: Exception) -> bool: ) +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) + ) + + def _quarantine_nous_oauth_state( state: Dict[str, Any], error: AuthError, diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index 034dc7377c..bcb1ed595d 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -1963,3 +1963,144 @@ def test_xai_oauth_nonterminal_refresh_does_not_quarantine(tmp_path, monkeypatch tokens = auth_payload["providers"]["xai-oauth"].get("tokens", {}) assert tokens.get("access_token") == "old-access-token" assert tokens.get("refresh_token") == "old-refresh-token" + + +# --------------------------------------------------------------------------- +# Codex OAuth terminal error quarantine +# --------------------------------------------------------------------------- + + +def _codex_auth_store(access_token: str, refresh_token: str) -> dict: + return { + "version": 1, + "active_provider": "openai-codex", + "providers": { + "openai-codex": { + "tokens": { + "access_token": access_token, + "refresh_token": refresh_token, + }, + } + }, + } + + +def test_is_terminal_codex_oauth_refresh_error(): + from hermes_cli.auth import AuthError, _is_terminal_codex_oauth_refresh_error + + assert _is_terminal_codex_oauth_refresh_error( + AuthError("Refresh failed", provider="openai-codex", code="codex_refresh_failed", relogin_required=True) + ) + assert _is_terminal_codex_oauth_refresh_error( + AuthError("No token", provider="openai-codex", code="codex_auth_missing_refresh_token", relogin_required=True) + ) + assert _is_terminal_codex_oauth_refresh_error( + AuthError("Revoked", provider="openai-codex", code="invalid_grant", relogin_required=True) + ) + assert _is_terminal_codex_oauth_refresh_error( + AuthError("Reused", provider="openai-codex", code="refresh_token_reused", relogin_required=True) + ) + # transient 429/5xx: relogin_required=False -> not terminal + assert not _is_terminal_codex_oauth_refresh_error( + AuthError("Rate limit", provider="openai-codex", code="codex_refresh_failed", relogin_required=False) + ) + # xAI error does not trigger Codex check + assert not _is_terminal_codex_oauth_refresh_error( + AuthError("Revoked", provider="xai-oauth", code="xai_refresh_failed", relogin_required=True) + ) + # Generic exception + assert not _is_terminal_codex_oauth_refresh_error(ValueError("oops")) + + +def test_codex_oauth_terminal_refresh_clears_auth_json_and_removes_pool_entries( + tmp_path, monkeypatch +): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("CODEX_OAUTH_ACCESS_TOKEN", raising=False) + + _write_auth_store(tmp_path, _codex_auth_store("old-access-token", "old-refresh-token")) + + from agent.credential_pool import PooledCredential, load_pool + import hermes_cli.auth as auth_mod + from hermes_cli.auth import AuthError + + pool = load_pool("openai-codex") + selected = pool.select() + assert selected is not None + assert selected.source == "device_code" + + # Add a manual API-key entry that must survive the quarantine. + pool.add_entry(PooledCredential.from_dict("openai-codex", { + "id": "manual-key", + "source": "manual", + "auth_type": "api_key", + "access_token": "manual-codex-key", + })) + + refresh_calls = {"count": 0} + + def _terminal_refresh_failure(*_args, **_kwargs): + refresh_calls["count"] += 1 + raise AuthError( + "Refresh session has been revoked", + provider="openai-codex", + code="codex_refresh_failed", + relogin_required=True, + ) + + monkeypatch.setattr(auth_mod, "refresh_codex_oauth_pure", _terminal_refresh_failure) + + assert pool.try_refresh_current() is None + + # Only the manual entry survives. + assert [entry.id for entry in pool.entries()] == ["manual-key"] + + # Auth.json tokens must be cleared. + auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + codex_state = auth_payload["providers"]["openai-codex"] + tokens = codex_state.get("tokens", {}) + assert not tokens.get("access_token") + assert not tokens.get("refresh_token") + assert codex_state["last_auth_error"]["code"] == "codex_refresh_failed" + assert codex_state["last_auth_error"]["relogin_required"] is True + + # Persisted pool must also have only the manual entry. + assert [entry["id"] for entry in auth_payload["credential_pool"]["openai-codex"]] == ["manual-key"] + + # A second try_refresh_current must not call refresh_codex_oauth_pure again. + assert pool.try_refresh_current() is None + assert refresh_calls["count"] == 1 + + +def test_codex_oauth_nonterminal_refresh_does_not_quarantine(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("CODEX_OAUTH_ACCESS_TOKEN", raising=False) + + _write_auth_store(tmp_path, _codex_auth_store("old-access-token", "old-refresh-token")) + + from agent.credential_pool import load_pool + import hermes_cli.auth as auth_mod + from hermes_cli.auth import AuthError + + pool = load_pool("openai-codex") + assert pool.select() is not None + + def _transient_failure(*_args, **_kwargs): + raise AuthError( + "Rate limited", + provider="openai-codex", + code="codex_refresh_failed", + relogin_required=False, + ) + + monkeypatch.setattr(auth_mod, "refresh_codex_oauth_pure", _transient_failure) + + pool.try_refresh_current() + + # Tokens must NOT be cleared from auth.json. + auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + tokens = auth_payload["providers"]["openai-codex"].get("tokens", {}) + assert tokens.get("access_token") == "old-access-token" + assert tokens.get("refresh_token") == "old-refresh-token" From d9331eeceef9e361925843d3f7788be24270f5ba Mon Sep 17 00:00:00 2001 From: EloquentBrush0x <283442588+EloquentBrush0x@users.noreply.github.com> Date: Mon, 18 May 2026 10:33:36 -0700 Subject: [PATCH 015/338] fix(minimax-oauth): quarantine dead tokens on terminal refresh failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_minimax_oauth_runtime_credentials called _refresh_minimax_oauth_state without a try/except, so a terminal failure (invalid_grant, refresh_token_reused, invalid_refresh_token) raised AuthError but left the dead refresh_token in auth.json. Every subsequent API call retried the same token via a network round-trip, failing identically each time. Fix: wrap the refresh call and, when exc.relogin_required is True and a refresh_token is present, clear the dead OAuth fields (access_token, refresh_token, expires_*) and write a last_auth_error quarantine marker to auth.json before re-raising. The next call sees no access_token and fails fast with 'not_logged_in' — no network retry — and the user is prompted to re-authenticate. Mirrors the existing quarantine pattern for Nous (_quarantine_nous_oauth_state), xAI-OAuth (#28116), and Codex-OAuth (#28118). Persist failure is best-effort (logged at DEBUG, error still re-raised). Salvaged from #28003 by @EloquentBrush0x — contributor's branch was severely stale (would have reverted ~5000 LOC across azure/kanban/i18n subsystems); fix re-applied surgically with their pattern preserved and added two regression tests (terminal-quarantines + transient-does-not-quarantine). --- hermes_cli/auth.py | 23 +++++++- tests/test_minimax_oauth.py | 104 ++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index d06e9a739e..54fa0d38a9 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -6788,7 +6788,28 @@ def resolve_minimax_oauth_runtime_credentials( "MiniMax (OAuth).", provider="minimax-oauth", code="not_logged_in", relogin_required=True, ) - state = _refresh_minimax_oauth_state(state) + try: + state = _refresh_minimax_oauth_state(state) + 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, mirroring + # the Nous / xAI-OAuth / Codex-OAuth quarantine pattern. + 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(), + } + try: + _minimax_save_auth_state(state) + except Exception as _save_exc: + logger.debug("MiniMax OAuth: failed to persist quarantined state: %s", _save_exc) + raise return { "provider": "minimax-oauth", "api_key": state["access_token"], diff --git a/tests/test_minimax_oauth.py b/tests/test_minimax_oauth.py index f5ac4e28c6..21e8ba1398 100644 --- a/tests/test_minimax_oauth.py +++ b/tests/test_minimax_oauth.py @@ -469,6 +469,110 @@ def test_resolve_credentials_requires_login(): assert exc_info.value.relogin_required is True +# --------------------------------------------------------------------------- +# 11b. Terminal refresh failure quarantines dead tokens (#28003) +# --------------------------------------------------------------------------- + +def test_resolve_credentials_quarantines_dead_tokens_on_terminal_refresh_failure(): + """Terminal refresh failure (relogin_required + refresh_token present) must + clear access_token/refresh_token/expires_* from auth.json and write a + last_auth_error marker, so subsequent calls fail fast with not_logged_in + instead of replaying the dead refresh token over the network. + Mirrors Nous / xAI-OAuth / Codex-OAuth quarantine pattern. + """ + stale_state = { + "access_token": "dead-access-token", + "refresh_token": "dead-refresh-token", + "expires_at": "2026-01-01T00:00:00Z", + "expires_in": 3600, + "obtained_at": "2026-01-01T00:00:00Z", + "inference_base_url": "https://api.minimax.io/v1", + "portal_base_url": "https://portal.minimax.io", + "client_id": "test-client", + "region": "global", + } + saved_states = [] + + def _capture_save(s): + saved_states.append(dict(s)) + + def _terminal_refresh(_state): + raise AuthError( + "invalid_grant", + provider="minimax-oauth", + code="invalid_grant", + relogin_required=True, + ) + + with patch("hermes_cli.auth.get_provider_auth_state", return_value=stale_state), \ + patch("hermes_cli.auth._refresh_minimax_oauth_state", side_effect=_terminal_refresh), \ + patch("hermes_cli.auth._minimax_save_auth_state", side_effect=_capture_save): + with pytest.raises(AuthError) as exc_info: + resolve_minimax_oauth_runtime_credentials() + + # The original AuthError is re-raised so callers get the right error surface. + assert exc_info.value.code == "invalid_grant" + assert exc_info.value.relogin_required is True + + # A quarantine save must have happened. + assert len(saved_states) == 1 + quarantined = saved_states[0] + + # Dead OAuth fields cleared. + assert "access_token" not in quarantined + assert "refresh_token" not in quarantined + assert "expires_at" not in quarantined + assert "expires_in" not in quarantined + assert "obtained_at" not in quarantined + + # Routing/identity metadata preserved. + assert quarantined["inference_base_url"] == "https://api.minimax.io/v1" + assert quarantined["portal_base_url"] == "https://portal.minimax.io" + assert quarantined["client_id"] == "test-client" + assert quarantined["region"] == "global" + + # Structured diagnostic blob written. + err = quarantined.get("last_auth_error") + assert isinstance(err, dict) + assert err["provider"] == "minimax-oauth" + assert err["code"] == "invalid_grant" + assert err["reason"] == "runtime_refresh_failure" + assert err["relogin_required"] is True + assert "at" in err + + +def test_resolve_credentials_does_not_quarantine_on_transient_refresh_failure(): + """When refresh raises with relogin_required=False (e.g. 429 / 5xx), the + dead-token quarantine path must NOT fire — tokens stay on disk for the + next attempt. + """ + stale_state = { + "access_token": "still-good-access-token", + "refresh_token": "still-good-refresh-token", + "expires_at": "2026-01-01T00:00:00Z", + "inference_base_url": "https://api.minimax.io/v1", + } + saved_states = [] + + def _transient_refresh(_state): + raise AuthError( + "service unavailable", + provider="minimax-oauth", + code="refresh_failed", + relogin_required=False, + ) + + with patch("hermes_cli.auth.get_provider_auth_state", return_value=stale_state), \ + patch("hermes_cli.auth._refresh_minimax_oauth_state", side_effect=_transient_refresh), \ + patch("hermes_cli.auth._minimax_save_auth_state", side_effect=lambda s: saved_states.append(dict(s))): + with pytest.raises(AuthError) as exc_info: + resolve_minimax_oauth_runtime_credentials() + + assert exc_info.value.relogin_required is False + # No quarantine save should have happened. + assert saved_states == [] + + # --------------------------------------------------------------------------- # 12. test_provider_registry_contains_minimax_oauth # --------------------------------------------------------------------------- From aae1615977b9a4aeda6f4c16ee14ce5615f3dbf4 Mon Sep 17 00:00:00 2001 From: Slimydog21 <194121339+Slimydog21@users.noreply.github.com> Date: Mon, 18 May 2026 10:36:51 -0700 Subject: [PATCH 016/338] fix(xai-responses): strip enum values containing '/' from tool schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xAI's /v1/responses and /v1/chat/completions endpoints reject tool schemas whose enum values contain a forward slash with a generic HTTP 400 'Invalid arguments passed to the model.' before any token is emitted — the schema compiler trips on the '/' character regardless of where it appears. Most commonly hit by MCP-derived tools whose enum lists HuggingFace model IDs ('Qwen/Qwen3.5-0.8B', 'openai/gpt-oss-20b') or owner/name environment identifiers. Mirrors the existing strip_pattern_and_format sanitizer (PR for #27197). The new strip_slash_enum walks tool parameters and drops the entire enum keyword when any value contains '/' — keeping it partial would still 400 since xAI's failure is all-or-nothing on the enum. The field description still reaches the model so the prompting hint is preserved. Wired in at both code paths for parity: - agent/chat_completion_helpers.py (main agent xAI Responses path) - agent/auxiliary_client.py (aux client xAI Responses path, matching the same parity guarantee 2fae8fba9 established for pattern/format) Salvaged from #28021 by @Slimydog21 — contributor's branch was severely stale (would have reverted ~5000 LOC across azure/kanban/i18n); fix re-applied surgically on current main with their sanitizer + 9 tests preserved verbatim. Author noreply email used (original was a Mac hostname leak). --- agent/auxiliary_client.py | 6 +- agent/chat_completion_helpers.py | 9 +- tests/tools/test_schema_sanitizer.py | 145 ++++++++++++++++++++++++++- tools/schema_sanitizer.py | 63 ++++++++++++ 4 files changed, 220 insertions(+), 3 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 807ed07687..56b2d5c1bf 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -711,8 +711,12 @@ class _CodexCompletionsAdapter: # keywords (HTTP 400). Strip them here to match the parity guarantee that # chat_completion_helpers.py provides for the main-agent xAI path. try: - from tools.schema_sanitizer import strip_pattern_and_format + from tools.schema_sanitizer import ( + strip_pattern_and_format, + strip_slash_enum, + ) tools, _ = strip_pattern_and_format(list(tools)) + tools, _ = strip_slash_enum(tools) except Exception as exc: logger.warning( "Auxiliary client: failed to sanitize tool schemas for " diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 350a54e406..2e0caebcbe 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -291,10 +291,17 @@ def build_api_kwargs(agent, api_messages: list) -> dict: # in tool schemas (HTTP 400 "Invalid arguments passed to the model"). # Most commonly hit when MCP-derived tools carry JSON Schema validation # keywords through. Strip them before building kwargs. See #27197. + # It also rejects ``enum`` values containing ``/`` (HuggingFace IDs + # like ``Qwen/Qwen3.5-0.8B`` shipped by MCP servers) — same 400 with + # the same opaque message; strip those enums too. if is_xai_responses: try: - from tools.schema_sanitizer import strip_pattern_and_format + from tools.schema_sanitizer import ( + strip_pattern_and_format, + strip_slash_enum, + ) tools_for_api, _ = strip_pattern_and_format(tools_for_api) + tools_for_api, _ = strip_slash_enum(tools_for_api) except Exception as exc: logger.warning( "%sâš ī¸ Failed to sanitize tool schemas for xAI: %s", diff --git a/tests/tools/test_schema_sanitizer.py b/tests/tools/test_schema_sanitizer.py index 8c865e87b8..b856440ef4 100644 --- a/tests/tools/test_schema_sanitizer.py +++ b/tests/tools/test_schema_sanitizer.py @@ -9,7 +9,11 @@ from __future__ import annotations import copy -from tools.schema_sanitizer import sanitize_tool_schemas, strip_pattern_and_format +from tools.schema_sanitizer import ( + sanitize_tool_schemas, + strip_pattern_and_format, + strip_slash_enum, +) def _tool(name: str, parameters: dict) -> dict: @@ -491,3 +495,142 @@ def test_strip_responses_mixed_formats(): # Verify structure preserved assert result[0]["function"]["parameters"]["type"] == "object" assert result[1]["parameters"]["type"] == "object" + + +# ───────────────────────────────────────────────────────────────────────── +# strip_slash_enum — reactive recovery when xAI's /v1/responses (and +# /v1/chat/completions) grammar-compiler rejects enum values containing +# a forward slash. Symptom: HTTP 400 "Invalid arguments passed to the +# model" before any token is emitted. Most commonly hit by MCP-derived +# tools whose enum lists HuggingFace IDs like "Qwen/Qwen3.5-0.8B". +# ───────────────────────────────────────────────────────────────────────── + + +def test_strip_slash_enum_removes_huggingface_id_enum(): + """enum containing HF-style 'owner/name' IDs → stripped.""" + tools = [_tool("train", { + "type": "object", + "properties": { + "model": { + "type": "string", + "enum": ["Qwen/Qwen3.5-0.8B", "openai/gpt-oss-20b"], + }, + }, + })] + _, stripped = strip_slash_enum(tools) + assert stripped == 1 + prop = tools[0]["function"]["parameters"]["properties"]["model"] + assert "enum" not in prop + # Type + description survive so the model still gets the prompting hint. + assert prop["type"] == "string" + + +def test_strip_slash_enum_preserves_slashless_enum(): + """enum without any '/' → preserved.""" + tools = [_tool("pick", { + "type": "object", + "properties": { + "mode": {"type": "string", "enum": ["fast", "slow"]}, + }, + })] + _, stripped = strip_slash_enum(tools) + assert stripped == 0 + assert tools[0]["function"]["parameters"]["properties"]["mode"]["enum"] == ["fast", "slow"] + + +def test_strip_slash_enum_partial_match_strips_whole_enum(): + """Any single value containing '/' triggers removal of the entire enum. + + Rationale: if we kept the slashless values, the model could still pick + them, but xAI's grammar-compile failure is all-or-nothing on the enum + keyword — keeping a mixed-content enum would still 400. Drop it whole. + """ + tools = [_tool("pick", { + "type": "object", + "properties": { + "target": {"type": "string", "enum": ["local", "hf://Qwen/Qwen3"]}, + }, + })] + _, stripped = strip_slash_enum(tools) + assert stripped == 1 + assert "enum" not in tools[0]["function"]["parameters"]["properties"]["target"] + + +def test_strip_slash_enum_responses_format(): + """Responses-format tools (no `function` wrapper) are also handled.""" + tools = [{ + "type": "function", + "name": "mcp_prime_lab_train_model", + "parameters": { + "type": "object", + "properties": { + "model": { + "type": "string", + "enum": ["Qwen/Qwen3.5-0.8B", "meta-llama/Llama-3.2-1B-Instruct"], + }, + }, + }, + }] + _, stripped = strip_slash_enum(tools) + assert stripped == 1 + assert "enum" not in tools[0]["parameters"]["properties"]["model"] + + +def test_strip_slash_enum_recurses_into_anyof(): + """enum-with-slash inside an anyOf variant is also stripped.""" + tools = [_tool("t", { + "type": "object", + "properties": { + "value": { + "anyOf": [ + {"type": "string", "enum": ["owner/repo"]}, + {"type": "null"}, + ], + }, + }, + })] + _, stripped = strip_slash_enum(tools) + assert stripped == 1 + variants = tools[0]["function"]["parameters"]["properties"]["value"]["anyOf"] + assert "enum" not in variants[0] + assert variants[0]["type"] == "string" + + +def test_strip_slash_enum_is_idempotent(): + """Second call on already-stripped tools is a no-op.""" + tools = [_tool("t", { + "type": "object", + "properties": {"m": {"type": "string", "enum": ["a/b"]}}, + })] + _, first = strip_slash_enum(tools) + _, second = strip_slash_enum(tools) + assert first == 1 + assert second == 0 + + +def test_strip_slash_enum_empty_returns_zero(): + tools, stripped = strip_slash_enum([]) + assert tools == [] + assert stripped == 0 + + +def test_strip_slash_enum_none_returns_zero(): + tools, stripped = strip_slash_enum(None) + assert tools is None + assert stripped == 0 + + +def test_strip_slash_enum_ignores_non_string_enum_values(): + """Integer/boolean enum values can't contain '/' — leave them alone.""" + tools = [_tool("t", { + "type": "object", + "properties": { + "level": {"type": "integer", "enum": [1, 2, 3]}, + "flag": {"type": "boolean", "enum": [True, False]}, + }, + })] + _, stripped = strip_slash_enum(tools) + assert stripped == 0 + props = tools[0]["function"]["parameters"]["properties"] + assert props["level"]["enum"] == [1, 2, 3] + assert props["flag"]["enum"] == [True, False] diff --git a/tools/schema_sanitizer.py b/tools/schema_sanitizer.py index 0d03998d36..e9677ac4a1 100644 --- a/tools/schema_sanitizer.py +++ b/tools/schema_sanitizer.py @@ -380,3 +380,66 @@ def strip_pattern_and_format(tools: list[dict]) -> tuple[list[dict], int]: stripped, ) return tools, stripped + + +def strip_slash_enum(tools: list[dict]) -> tuple[list[dict], int]: + """Strip ``enum`` keywords whose string values contain a forward slash. + + xAI's ``/v1/responses`` and ``/v1/chat/completions`` endpoints compile + tool schemas to a grammar that rejects ``enum`` values containing ``/`` + (the request fails with HTTP 400 "Invalid arguments passed to the + model" before any token is emitted). Most commonly hit by MCP-derived + tools whose enum lists HuggingFace model IDs (``Qwen/Qwen3.5-0.8B``, + ``openai/gpt-oss-20b``) or owner/name environment IDs. The constraint + is purely a prompting hint; dropping it lets the model still see the + field description and pick a value, without xAI tripping on the slash. + + Args: + tools: OpenAI-format or Responses-format tool list, mutated in + place. Callers that need to preserve the original should + deep-copy first. + + Returns: + ``(tools, stripped_count)`` — same list reference plus a count of + how many ``enum`` keywords were removed. + """ + if not tools: + return tools, 0 + + stripped = 0 + + def _walk(node: Any) -> None: + nonlocal stripped + if isinstance(node, dict): + enum_val = node.get("enum") + if isinstance(enum_val, list) and any( + isinstance(v, str) and "/" in v for v in enum_val + ): + node.pop("enum", None) + stripped += 1 + for v in node.values(): + _walk(v) + elif isinstance(node, list): + for item in node: + _walk(item) + + for tool in tools: + if not isinstance(tool, dict): + continue + fn = tool.get("function") + if isinstance(fn, dict): + params = fn.get("parameters") + if isinstance(params, dict): + _walk(params) + continue + params = tool.get("parameters") + if isinstance(params, dict): + _walk(params) + + if stripped: + logger.info( + "schema_sanitizer: stripped %d enum keyword(s) containing '/' " + "from tool schemas (xAI Responses grammar-compile recovery)", + stripped, + ) + return tools, stripped From 47bc8e080d3e2623a4a2e0eb8c41ccf89213a5bd Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 18 May 2026 10:37:08 -0700 Subject: [PATCH 017/338] chore(release): AUTHOR_MAP noreply entry for Slimydog21 --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 5f0f66c176..5a8842bf27 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1140,6 +1140,7 @@ AUTHOR_MAP = { # xAI cluster batch salvage (May 2026) "lgndscntn@gmail.com": "Fewmanism", # PR #27420 (threaded xAI OAuth callback) "slimydog@Faisals-Mac-mini.local": "Slimydog21", # PR #28021 (strip slash enums xAI Responses) + "194121339+Slimydog21@users.noreply.github.com": "Slimydog21", # PR #28021 salvage (noreply form) "bitkyc08@gmail.com": "lidge-jun", # PR #26814 (api server browser security headers) "sp_ps@Mac-mini.lan": "phoenixshen", # PR #26768 (respect user-configured vision model) "1594534+phoenixshen@users.noreply.github.com": "phoenixshen", From bb9ecb2178603254126770f681533529acd75c93 Mon Sep 17 00:00:00 2001 From: Gianfranco Piana <52470719+gianfrancopiana@users.noreply.github.com> Date: Thu, 14 May 2026 16:42:39 -0300 Subject: [PATCH 018/338] feat: add cron job profile support --- cron/jobs.py | 44 +++++ cron/scheduler.py | 79 +++++++- hermes_cli/cron.py | 9 + hermes_cli/main.py | 8 + tests/cron/test_cron_profile.py | 340 ++++++++++++++++++++++++++++++++ tests/hermes_cli/test_cron.py | 6 + tools/cronjob_tools.py | 13 ++ 7 files changed, 489 insertions(+), 10 deletions(-) create mode 100644 tests/cron/test_cron_profile.py diff --git a/cron/jobs.py b/cron/jobs.py index c5da32d44d..6d7845c496 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -128,6 +128,9 @@ def _normalize_job_record(job: Dict[str, Any]) -> Dict[str, Any]: state = "scheduled" if normalized.get("enabled", True) else "paused" normalized["state"] = state + profile = _coerce_job_text(normalized.get("profile")).strip() + normalized["profile"] = profile or None + return normalized @@ -479,6 +482,30 @@ def _normalize_workdir(workdir: Optional[str]) -> Optional[str]: return str(resolved) +def _normalize_profile(profile: Optional[str]) -> Optional[str]: + """Normalize and validate an optional cron job profile name. + + Empty / None disables per-job profile selection. Otherwise the profile name + is canonicalized with the same rules as ``hermes -p`` and must refer to an + existing profile at create/update time. ``default`` is the built-in root + profile and is always valid. + """ + if profile is None: + return None + raw = str(profile).strip() + if not raw: + return None + + from hermes_cli.profiles import normalize_profile_name, resolve_profile_env + + normalized = normalize_profile_name(raw) + # resolve_profile_env validates the canonical name and checks that named + # profiles exist. Store only the stable profile id, not the filesystem path, + # so profile directories can move with the Hermes root. + resolve_profile_env(normalized) + return normalized + + def create_job( prompt: Optional[str], schedule: str, @@ -495,6 +522,7 @@ def create_job( context_from: Optional[Union[str, List[str]]] = None, enabled_toolsets: Optional[List[str]] = None, workdir: Optional[str] = None, + profile: Optional[str] = None, no_agent: bool = False, ) -> Dict[str, Any]: """ @@ -536,6 +564,11 @@ def create_job( With ``no_agent=True``, ``workdir`` is still applied as the script's cwd so relative paths inside the script behave predictably. + profile: Optional Hermes profile name. When set, the job runs with + that profile's HERMES_HOME so profile-specific config, + credentials, scripts, skills, and memory paths resolve + consistently. ``default`` selects the root profile; empty / + None preserves the scheduler's existing behaviour. no_agent: When True, skip the agent entirely — run ``script`` on schedule and deliver its stdout directly. Empty stdout = silent (no delivery). Requires ``script`` to be set. Ideal for classic @@ -573,6 +606,7 @@ def create_job( normalized_toolsets = [str(t).strip() for t in enabled_toolsets if str(t).strip()] if enabled_toolsets else None normalized_toolsets = normalized_toolsets or None normalized_workdir = _normalize_workdir(workdir) + normalized_profile = _normalize_profile(profile) normalized_no_agent = bool(no_agent) # no_agent jobs are meaningless without a script — the script IS the job. @@ -627,6 +661,7 @@ def create_job( "origin": origin, # Tracks where job was created for "origin" delivery "enabled_toolsets": normalized_toolsets, "workdir": normalized_workdir, + "profile": normalized_profile, } jobs = load_jobs() @@ -707,6 +742,15 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] else: updates["workdir"] = _normalize_workdir(_wd) + # Validate / normalize profile if present in updates. Empty string or + # None both mean "clear the field" (restore old behaviour). + if "profile" in updates: + _profile = updates["profile"] + if _profile is None or _profile == "" or _profile is False: + updates["profile"] = None + else: + updates["profile"] = _normalize_profile(_profile) + updated = _apply_skill_fields({**job, **updates}) schedule_changed = "schedule" in updates diff --git a/cron/scheduler.py b/cron/scheduler.py index 322fa64906..3468f33980 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -17,6 +17,7 @@ import os import shutil import subprocess import sys +from contextlib import contextmanager # fcntl is Unix-only; on Windows use msvcrt for file locking try: @@ -145,6 +146,49 @@ def _get_lock_paths() -> tuple[Path, Path]: return lock_dir, lock_dir / ".tick.lock" +@contextmanager +def _job_profile_context(job_id: str, profile: Optional[str]): + """Temporarily run a job under a specific Hermes profile. + + Cron jobs are stored and scheduled by the profile running the scheduler, but + an individual job can opt into a different runtime profile. While active, + HERMES_HOME and the scheduler's test/override hook both point at the + resolved profile directory so _get_hermes_home(), .env/config loading, script + resolution, AIAgent construction, and downstream get_hermes_home() callers + agree on the same home. + """ + raw_profile = str(profile or "").strip() + if not raw_profile: + yield None + return + + global _hermes_home + prior_env = os.environ.get("HERMES_HOME", "_UNSET_") + prior_override = _hermes_home + + from hermes_cli.profiles import normalize_profile_name, resolve_profile_env + + normalized_profile = normalize_profile_name(raw_profile) + profile_home = Path(resolve_profile_env(normalized_profile)).resolve() + + try: + os.environ["HERMES_HOME"] = str(profile_home) + _hermes_home = profile_home + logger.info( + "Job '%s': using Hermes profile '%s' (%s)", + job_id, + normalized_profile, + profile_home, + ) + yield normalized_profile + finally: + _hermes_home = prior_override + if prior_env == "_UNSET_": + os.environ.pop("HERMES_HOME", None) + else: + os.environ["HERMES_HOME"] = prior_env + + def _resolve_origin(job: dict) -> Optional[dict]: """Extract origin info from a job, preserving any extra routing metadata. @@ -1022,6 +1066,13 @@ def _scan_assembled_cron_prompt(assembled: str, job: dict) -> str: def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: + """Execute a single cron job, applying any per-job profile override.""" + job_id = job["id"] + with _job_profile_context(job_id, job.get("profile")): + return _run_job_impl(job) + + +def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]: """ Execute a single cron job. @@ -1258,8 +1309,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: # .cursorrules from the job's project dir, AND # - the terminal, file, and code-exec tools run commands from there. # - # tick() serializes workdir-jobs outside the parallel pool, so mutating - # os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less + # tick() serializes jobs that mutate process-global runtime state (workdir + # and/or profile jobs) outside the parallel pool, so mutating + # os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less # jobs we leave TERMINAL_CWD untouched — preserves the original behaviour # (skip_context_files=True, tools use whatever cwd the scheduler has). _job_workdir = (job.get("workdir") or "").strip() or None @@ -1781,17 +1833,24 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int: mark_job_run(job["id"], False, str(e)) return False - # Partition due jobs: those with a per-job workdir mutate - # os.environ["TERMINAL_CWD"] inside run_job, which is process-global — - # so they MUST run sequentially to avoid corrupting each other. Jobs - # without a workdir leave env untouched and stay parallel-safe. - workdir_jobs = [j for j in due_jobs if (j.get("workdir") or "").strip()] - parallel_jobs = [j for j in due_jobs if not (j.get("workdir") or "").strip()] + # Partition due jobs: jobs with a per-job workdir and/or profile mutate + # process-global runtime state inside run_job (TERMINAL_CWD, + # HERMES_HOME, and the scheduler's _hermes_home hook), so they MUST run + # sequentially to avoid corrupting each other. Jobs without either field + # leave those env overrides untouched and stay parallel-safe. + sequential_jobs = [ + j for j in due_jobs + if (j.get("workdir") or "").strip() or (j.get("profile") or "").strip() + ] + parallel_jobs = [ + j for j in due_jobs + if not ((j.get("workdir") or "").strip() or (j.get("profile") or "").strip()) + ] _results: list = [] - # Sequential pass for workdir jobs. - for job in workdir_jobs: + # Sequential pass for env-mutating jobs. + for job in sequential_jobs: _ctx = contextvars.copy_context() _results.append(_ctx.run(_process_job, job)) diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index 7bff9c6b87..2fc4a981a7 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -98,6 +98,9 @@ def cron_list(show_all: bool = False): workdir = job.get("workdir") if workdir: print(f" Workdir: {workdir}") + profile = job.get("profile") + if profile: + print(f" Profile: {profile}") # Execution history last_status = job.get("last_status") @@ -174,6 +177,7 @@ def cron_create(args): skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)), script=getattr(args, "script", None), workdir=getattr(args, "workdir", None), + profile=getattr(args, "profile", None), no_agent=getattr(args, "no_agent", False) or None, ) if not result.get("success"): @@ -191,6 +195,8 @@ def cron_create(args): print(" Mode: no-agent (script stdout delivered directly)") if job_data.get("workdir"): print(f" Workdir: {job_data['workdir']}") + if job_data.get("profile"): + print(f" Profile: {job_data['profile']}") print(f" Next run: {result['next_run_at']}") return 0 @@ -236,6 +242,7 @@ def cron_edit(args): skills=final_skills, script=getattr(args, "script", None), workdir=getattr(args, "workdir", None), + profile=getattr(args, "profile", None), no_agent=getattr(args, "no_agent", None), ) if not result.get("success"): @@ -256,6 +263,8 @@ def cron_edit(args): print(" Mode: no-agent (script stdout delivered directly)") if updated.get("workdir"): print(f" Workdir: {updated['workdir']}") + if updated.get("profile"): + print(f" Profile: {updated['profile']}") return 0 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 48bf6675b3..871ad681f5 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10624,6 +10624,10 @@ def main(): "--workdir", help="Absolute path for the job to run from. Injects AGENTS.md / CLAUDE.md / .cursorrules from that directory and uses it as the cwd for terminal/file/code_exec tools. Omit to preserve old behaviour (no project context files).", ) + cron_create.add_argument( + "--profile", + help="Hermes profile name to run the job under. Use 'default' for the root profile. Named profiles must already exist. Omit to preserve the scheduler's existing profile.", + ) # cron edit cron_edit = cron_subparsers.add_parser( @@ -10688,6 +10692,10 @@ def main(): "--workdir", help="Absolute path for the job to run from (injects AGENTS.md etc. and sets terminal cwd). Pass empty string to clear.", ) + cron_edit.add_argument( + "--profile", + help="Hermes profile name to run the job under. Use 'default' for the root profile. Pass empty string to clear.", + ) # lifecycle actions cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job") diff --git a/tests/cron/test_cron_profile.py b/tests/cron/test_cron_profile.py new file mode 100644 index 0000000000..6041e3b76e --- /dev/null +++ b/tests/cron/test_cron_profile.py @@ -0,0 +1,340 @@ +"""Tests for per-job profile support in cron jobs. + +Covers data-layer validation/storage, cronjob tool plumbing, scheduler runtime +HERMES_HOME scoping, and tick() serialization for profile jobs. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + + +@pytest.fixture() +def isolated_cron_profile_home(tmp_path, monkeypatch): + """Create an isolated Hermes root with a named profile and temp cron store.""" + root = tmp_path / "hermes-root" + profile_home = root / "profiles" / "support" + profile_home.mkdir(parents=True) + (root / "cron").mkdir(parents=True) + + monkeypatch.setenv("HERMES_HOME", str(root)) + monkeypatch.setattr("cron.jobs.CRON_DIR", root / "cron") + monkeypatch.setattr("cron.jobs.JOBS_FILE", root / "cron" / "jobs.json") + monkeypatch.setattr("cron.jobs.OUTPUT_DIR", root / "cron" / "output") + + return root, profile_home + + +class TestNormalizeProfile: + def test_none_and_empty_return_none(self, isolated_cron_profile_home): + from cron.jobs import _normalize_profile + + assert _normalize_profile(None) is None + assert _normalize_profile("") is None + assert _normalize_profile(" ") is None + + def test_default_profile_is_valid_and_normalized(self, isolated_cron_profile_home): + from cron.jobs import _normalize_profile + + assert _normalize_profile("Default") == "default" + + def test_named_profile_must_exist_and_is_normalized(self, isolated_cron_profile_home): + from cron.jobs import _normalize_profile + + assert _normalize_profile("Support") == "support" + + def test_invalid_profile_name_is_rejected(self, isolated_cron_profile_home): + from cron.jobs import _normalize_profile + + with pytest.raises(ValueError): + _normalize_profile("invalid!") + + def test_missing_named_profile_is_rejected(self, isolated_cron_profile_home): + from cron.jobs import _normalize_profile + + with pytest.raises(FileNotFoundError): + _normalize_profile("missing") + + +class TestCreateAndUpdateJobProfile: + def test_create_stores_profile_id(self, isolated_cron_profile_home): + from cron.jobs import create_job, get_job + + job = create_job(prompt="hello", schedule="every 1h", profile="Support") + stored = get_job(job["id"]) + + assert stored is not None + assert stored["profile"] == "support" + + def test_create_without_profile_preserves_old_behaviour(self, isolated_cron_profile_home): + from cron.jobs import create_job, get_job + + job = create_job(prompt="hello", schedule="every 1h") + stored = get_job(job["id"]) + + assert stored is not None + assert stored.get("profile") is None + + def test_create_accepts_explicit_default(self, isolated_cron_profile_home): + from cron.jobs import create_job, get_job + + job = create_job(prompt="hello", schedule="every 1h", profile="default") + stored = get_job(job["id"]) + + assert stored is not None + assert stored["profile"] == "default" + + def test_update_sets_and_clears_profile(self, isolated_cron_profile_home): + from cron.jobs import create_job, get_job, update_job + + job = create_job(prompt="x", schedule="every 1h") + update_job(job["id"], {"profile": "Support"}) + stored = get_job(job["id"]) + assert stored is not None + assert stored["profile"] == "support" + + update_job(job["id"], {"profile": ""}) + stored = get_job(job["id"]) + assert stored is not None + assert stored["profile"] is None + + def test_update_rejects_missing_profile(self, isolated_cron_profile_home): + from cron.jobs import create_job, update_job + + job = create_job(prompt="x", schedule="every 1h") + with pytest.raises(FileNotFoundError): + update_job(job["id"], {"profile": "missing"}) + + +class TestCronjobToolProfile: + def test_create_and_list_with_profile(self, isolated_cron_profile_home): + from tools.cronjob_tools import cronjob + + created = json.loads( + cronjob( + action="create", + prompt="hi", + schedule="every 1h", + profile="Support", + ) + ) + assert created["success"] is True + assert created["job"]["profile"] == "support" + + listing = json.loads(cronjob(action="list")) + assert listing["jobs"][0]["profile"] == "support" + + def test_update_clears_profile_with_empty_string(self, isolated_cron_profile_home): + from tools.cronjob_tools import cronjob + + created = json.loads( + cronjob( + action="create", + prompt="hi", + schedule="every 1h", + profile="Support", + ) + ) + updated = json.loads( + cronjob(action="update", job_id=created["job_id"], profile="") + ) + + assert updated["success"] is True + assert "profile" not in updated["job"] + + def test_schema_advertises_profile(self): + from tools.cronjob_tools import CRONJOB_SCHEMA + + assert "profile" in CRONJOB_SCHEMA["parameters"]["properties"] + desc = CRONJOB_SCHEMA["parameters"]["properties"]["profile"]["description"] + assert "hermes profile" in desc.lower() + + +class TestRunJobProfileContext: + @staticmethod + def _install_agent_stubs(monkeypatch, observed: dict): + import sys + import cron.scheduler as sched + + class FakeAgent: + def __init__(self, **kwargs): + observed["hermes_home_during_init"] = os.environ.get("HERMES_HOME") + observed["scheduler_home_during_init"] = str(sched._get_hermes_home()) + observed["skip_context_files"] = kwargs.get("skip_context_files") + + def run_conversation(self, *_a, **_kw): + observed["hermes_home_during_run"] = os.environ.get("HERMES_HOME") + observed["scheduler_home_during_run"] = str(sched._get_hermes_home()) + return {"final_response": "done", "messages": []} + + def get_activity_summary(self): + return {"seconds_since_activity": 0.0} + + def close(self): + observed["closed"] = True + + fake_mod = type(sys)("run_agent") + fake_mod.AIAgent = FakeAgent + monkeypatch.setitem(sys.modules, "run_agent", fake_mod) + + from hermes_cli import runtime_provider as runtime_provider + + monkeypatch.setattr( + runtime_provider, + "resolve_runtime_provider", + lambda **_kw: { + "provider": "test", + "api_key": "test-key", + "base_url": "http://test.local", + "api_mode": "chat_completions", + }, + ) + + monkeypatch.setattr(sched, "_build_job_prompt", lambda job, prerun_script=None: "hi") + monkeypatch.setattr(sched, "_resolve_origin", lambda job: None) + monkeypatch.setattr(sched, "_resolve_delivery_target", lambda job: None) + monkeypatch.setattr(sched, "_resolve_cron_enabled_toolsets", lambda job, cfg: None) + monkeypatch.setattr(sched, "_hermes_home", None) + monkeypatch.setenv("HERMES_CRON_TIMEOUT", "0") + + import dotenv + + def fake_load_dotenv(path, *_a, **_kw): + observed.setdefault("dotenv_paths", []).append(str(path)) + return True + + monkeypatch.setattr(dotenv, "load_dotenv", fake_load_dotenv) + + def test_run_job_sets_and_restores_profile_home( + self, isolated_cron_profile_home, monkeypatch + ): + import cron.scheduler as sched + + root, profile_home = isolated_cron_profile_home + observed: dict = {} + self._install_agent_stubs(monkeypatch, observed) + + job = { + "id": "abc", + "name": "profile-job", + "profile": "support", + "schedule_display": "manual", + } + + success, _output, response, error = sched.run_job(job) + + assert success is True, f"run_job failed: error={error!r} response={response!r}" + assert observed["dotenv_paths"] == [str(profile_home / ".env")] + assert observed["hermes_home_during_init"] == str(profile_home.resolve()) + assert observed["hermes_home_during_run"] == str(profile_home.resolve()) + assert observed["scheduler_home_during_init"] == str(profile_home.resolve()) + assert observed["scheduler_home_during_run"] == str(profile_home.resolve()) + assert observed["skip_context_files"] is True + assert os.environ["HERMES_HOME"] == str(root) + assert sched._get_hermes_home() == root + + def test_no_agent_profile_uses_profile_scripts_dir_and_restores_env( + self, isolated_cron_profile_home, monkeypatch + ): + import cron.scheduler as sched + + root, profile_home = isolated_cron_profile_home + scripts_dir = profile_home / "scripts" + scripts_dir.mkdir(parents=True) + (scripts_dir / "print_home.py").write_text( + "import os\nprint(os.environ.get('HERMES_HOME', ''))\n", + encoding="utf-8", + ) + monkeypatch.setattr(sched, "_hermes_home", None) + + job = { + "id": "script1", + "name": "profile-script", + "profile": "support", + "script": "print_home.py", + "no_agent": True, + } + + success, _doc, response, error = sched.run_job(job) + + assert success is True, error + assert response.strip() == str(profile_home.resolve()) + assert os.environ["HERMES_HOME"] == str(root) + assert sched._get_hermes_home() == root + + def test_run_job_without_profile_leaves_hermes_home_untouched( + self, isolated_cron_profile_home, monkeypatch + ): + import cron.scheduler as sched + + root, _profile_home = isolated_cron_profile_home + observed: dict = {} + self._install_agent_stubs(monkeypatch, observed) + + job = { + "id": "noprof", + "name": "no-profile-job", + "profile": None, + "schedule_display": "manual", + } + + success, *_ = sched.run_job(job) + + assert success is True + assert observed["hermes_home_during_init"] == str(root) + assert os.environ["HERMES_HOME"] == str(root) + + def test_run_job_rejects_missing_runtime_profile( + self, isolated_cron_profile_home, monkeypatch + ): + import cron.scheduler as sched + + root, _profile_home = isolated_cron_profile_home + monkeypatch.setattr(sched, "_hermes_home", None) + + with pytest.raises(FileNotFoundError): + sched.run_job( + { + "id": "missing-profile", + "name": "missing-profile-job", + "profile": "missing", + } + ) + + assert os.environ["HERMES_HOME"] == str(root) + + +class TestTickProfilePartition: + def test_profile_jobs_run_sequentially(self, isolated_cron_profile_home, monkeypatch): + import threading + import cron.scheduler as sched + + profile_job = {"id": "a", "name": "A", "profile": "default"} + parallel_job = {"id": "b", "name": "B", "profile": None} + + monkeypatch.setattr(sched, "get_due_jobs", lambda: [profile_job, parallel_job]) + monkeypatch.setattr(sched, "advance_next_run", lambda *_a, **_kw: None) + + calls: list[tuple[str, str]] = [] + + def fake_run_job(job): + calls.append((job["id"], threading.current_thread().name)) + return True, "output", "response", None + + monkeypatch.setattr(sched, "run_job", fake_run_job) + monkeypatch.setattr(sched, "save_job_output", lambda _jid, _o: None) + monkeypatch.setattr(sched, "mark_job_run", lambda *_a, **_kw: None) + monkeypatch.setattr(sched, "_deliver_result", lambda *_a, **_kw: None) + + n = sched.tick(verbose=False) + + assert n == 2 + ids = [job_id for job_id, _thread_name in calls] + assert ids.index("a") < ids.index("b") + main_thread_name = threading.current_thread().name + profile_thread_name = next(thread for job_id, thread in calls if job_id == "a") + assert profile_thread_name == main_thread_name diff --git a/tests/hermes_cli/test_cron.py b/tests/hermes_cli/test_cron.py index 8593195a1b..49628f1a43 100644 --- a/tests/hermes_cli/test_cron.py +++ b/tests/hermes_cli/test_cron.py @@ -55,6 +55,7 @@ class TestCronCommandLifecycle: repeat=None, skill=None, skills=["maps", "blogwatcher"], + profile="default", clear_skills=False, ) ) @@ -63,6 +64,7 @@ class TestCronCommandLifecycle: assert updated["name"] == "Edited Job" assert updated["prompt"] == "Revised prompt" assert updated["schedule_display"] == "every 120m" + assert updated["profile"] == "default" cron_command( Namespace( @@ -75,12 +77,14 @@ class TestCronCommandLifecycle: repeat=None, skill=None, skills=None, + profile="", clear_skills=True, ) ) cleared = get_job(job["id"]) assert cleared["skills"] == [] assert cleared["skill"] is None + assert cleared["profile"] is None out = capsys.readouterr().out assert "Updated job" in out @@ -96,6 +100,7 @@ class TestCronCommandLifecycle: repeat=None, skill=None, skills=["blogwatcher", "maps"], + profile="default", ) ) out = capsys.readouterr().out @@ -105,3 +110,4 @@ class TestCronCommandLifecycle: assert len(jobs) == 1 assert jobs[0]["skills"] == ["blogwatcher", "maps"] assert jobs[0]["name"] == "Skill combo" + assert jobs[0]["profile"] == "default" diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index a7a8a0feab..5d91a6700d 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -281,6 +281,8 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: result["enabled_toolsets"] = job["enabled_toolsets"] if job.get("workdir"): result["workdir"] = job["workdir"] + if job.get("profile"): + result["profile"] = job["profile"] return result @@ -303,6 +305,7 @@ def cronjob( context_from: Optional[Union[str, List[str]]] = None, enabled_toolsets: Optional[List[str]] = None, workdir: Optional[str] = None, + profile: Optional[str] = None, no_agent: Optional[bool] = None, task_id: str = None, ) -> str: @@ -369,6 +372,7 @@ def cronjob( context_from=context_from, enabled_toolsets=enabled_toolsets or None, workdir=_normalize_optional_job_value(workdir), + profile=_normalize_optional_job_value(profile), no_agent=_no_agent, ) return json.dumps( @@ -503,6 +507,10 @@ def cronjob( # Empty string clears the field (restores old behaviour); # otherwise pass raw — update_job() validates / normalizes. updates["workdir"] = _normalize_optional_job_value(workdir) or None + if profile is not None: + # Empty string clears the field (restores old behaviour); + # otherwise pass raw — update_job() validates / normalizes. + updates["profile"] = _normalize_optional_job_value(profile) or None if no_agent is not None: # Toggling no_agent on/off at update time. If flipping to True, # we need a script to already exist on the job (or be part of @@ -656,6 +664,10 @@ Important safety rule: cron-run sessions should not recursively schedule more cr "type": "string", "description": "Optional absolute path to run the job from. When set, AGENTS.md / CLAUDE.md / .cursorrules from that directory are injected into the system prompt, and the terminal/file/code_exec tools use it as their working directory — useful for running a job inside a specific project repo. Must be an absolute path that exists. When unset (default), preserves the original behaviour: no project context files, tools use the scheduler's cwd. On update, pass an empty string to clear. Jobs with workdir run sequentially (not parallel) to keep per-job directories isolated." }, + "profile": { + "type": "string", + "description": "Optional Hermes profile name to run the job under. When set, the scheduler resolves that profile and temporarily sets HERMES_HOME before loading .env/config.yaml and running the job. Use 'default' for the root Hermes profile. Named profiles must already exist. When unset (default), preserves the scheduler's existing profile. On update, pass an empty string to clear. Jobs with profile run sequentially (not parallel) to keep process-global profile state isolated." + }, }, "required": ["action"] } @@ -710,6 +722,7 @@ registry.register( context_from=args.get("context_from"), enabled_toolsets=args.get("enabled_toolsets"), workdir=args.get("workdir"), + profile=args.get("profile"), no_agent=args.get("no_agent"), task_id=kw.get("task_id"), ))(), From 544406ef2322c54b8efcc0a1749ab4f5b8409988 Mon Sep 17 00:00:00 2001 From: Gianfranco Piana <52470719+gianfrancopiana@users.noreply.github.com> Date: Thu, 14 May 2026 18:28:51 -0300 Subject: [PATCH 019/338] fix: avoid process-wide cron profile home mutation --- cron/scheduler.py | 34 ++++++---- hermes_constants.py | 34 +++++++++- tests/cron/test_cron_profile.py | 12 +++- tests/test_subprocess_home_isolation.py | 84 +++++++++++++++++++++++++ tools/environments/local.py | 16 +++++ 5 files changed, 165 insertions(+), 15 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 3468f33980..14d2a9bb7e 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -152,10 +152,11 @@ def _job_profile_context(job_id: str, profile: Optional[str]): Cron jobs are stored and scheduled by the profile running the scheduler, but an individual job can opt into a different runtime profile. While active, - HERMES_HOME and the scheduler's test/override hook both point at the - resolved profile directory so _get_hermes_home(), .env/config loading, script - resolution, AIAgent construction, and downstream get_hermes_home() callers - agree on the same home. + The scheduler's test/override hook and a context-local Hermes home override + both point at the resolved profile directory so _get_hermes_home(), + .env/config loading, script resolution, AIAgent construction, and downstream + get_hermes_home() callers agree on the same home without mutating the + process-wide environment seen by other threads. """ raw_profile = str(profile or "").strip() if not raw_profile: @@ -163,16 +164,17 @@ def _job_profile_context(job_id: str, profile: Optional[str]): return global _hermes_home - prior_env = os.environ.get("HERMES_HOME", "_UNSET_") prior_override = _hermes_home from hermes_cli.profiles import normalize_profile_name, resolve_profile_env + from hermes_constants import reset_hermes_home_override, set_hermes_home_override normalized_profile = normalize_profile_name(raw_profile) profile_home = Path(resolve_profile_env(normalized_profile)).resolve() + override_token = None try: - os.environ["HERMES_HOME"] = str(profile_home) + override_token = set_hermes_home_override(profile_home) _hermes_home = profile_home logger.info( "Job '%s': using Hermes profile '%s' (%s)", @@ -183,10 +185,8 @@ def _job_profile_context(job_id: str, profile: Optional[str]): yield normalized_profile finally: _hermes_home = prior_override - if prior_env == "_UNSET_": - os.environ.pop("HERMES_HOME", None) - else: - os.environ["HERMES_HOME"] = prior_env + if override_token is not None: + reset_hermes_home_override(override_token) def _resolve_origin(job: dict) -> Optional[dict]: @@ -776,8 +776,6 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: (success, output) — on failure *output* contains the error message so the LLM can report the problem to the user. """ - from hermes_constants import get_hermes_home - scripts_dir = _get_hermes_home() / "scripts" scripts_dir.mkdir(parents=True, exist_ok=True) scripts_dir_resolved = scripts_dir.resolve() @@ -829,6 +827,17 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: else: argv = [sys.executable, str(path)] + run_env = os.environ.copy() + run_env["HERMES_HOME"] = str(_get_hermes_home()) + try: + from hermes_constants import get_subprocess_home + + profile_home = get_subprocess_home() + if profile_home: + run_env["HOME"] = profile_home + except Exception: + pass + try: result = subprocess.run( argv, @@ -836,6 +845,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: text=True, timeout=script_timeout, cwd=str(path.parent), + env=run_env, ) stdout = (result.stdout or "").strip() stderr = (result.stderr or "").strip() diff --git a/hermes_constants.py b/hermes_constants.py index bdb8dc9114..13df867f5c 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -5,10 +5,38 @@ without risk of circular imports. """ import os +from contextvars import ContextVar, Token from pathlib import Path _profile_fallback_warned: bool = False +_UNSET = object() +_HERMES_HOME_OVERRIDE: ContextVar[str | object] = ContextVar( + "_HERMES_HOME_OVERRIDE", default=_UNSET +) + + +def set_hermes_home_override(path: str | Path | None) -> Token: + """Set a context-local Hermes home override and return its reset token. + + This is for in-process, per-task scoping. It deliberately does not mutate + ``os.environ`` because that is shared by every thread in the process. + """ + value: str | object = _UNSET if path is None else str(path) + return _HERMES_HOME_OVERRIDE.set(value) + + +def reset_hermes_home_override(token: Token) -> None: + """Restore the previous context-local Hermes home override.""" + _HERMES_HOME_OVERRIDE.reset(token) + + +def get_hermes_home_override() -> str | None: + """Return the active context-local Hermes home override, if any.""" + override = _HERMES_HOME_OVERRIDE.get() + if override is _UNSET or not override: + return None + return str(override) def get_hermes_home() -> Path: @@ -27,6 +55,10 @@ def get_hermes_home() -> Path: template in ``hermes_cli/gateway.py`` and the kanban dispatcher in ``hermes_cli/kanban_db.py``). See https://github.com/NousResearch/hermes-agent/issues/18594. """ + override = get_hermes_home_override() + if override: + return Path(override) + val = os.environ.get("HERMES_HOME", "").strip() if val: return Path(val) @@ -179,7 +211,7 @@ def get_subprocess_home() -> str | None: Activation is directory-based: if the ``home/`` subdirectory doesn't exist, returns ``None`` and behavior is unchanged. """ - hermes_home = os.getenv("HERMES_HOME") + hermes_home = get_hermes_home_override() or os.getenv("HERMES_HOME") if not hermes_home: return None profile_home = os.path.join(hermes_home, "home") diff --git a/tests/cron/test_cron_profile.py b/tests/cron/test_cron_profile.py index 6041e3b76e..de9b3b0d9e 100644 --- a/tests/cron/test_cron_profile.py +++ b/tests/cron/test_cron_profile.py @@ -162,12 +162,18 @@ class TestRunJobProfileContext: class FakeAgent: def __init__(self, **kwargs): - observed["hermes_home_during_init"] = os.environ.get("HERMES_HOME") + from hermes_constants import get_hermes_home + + observed["env_home_during_init"] = os.environ.get("HERMES_HOME") + observed["hermes_home_during_init"] = str(get_hermes_home()) observed["scheduler_home_during_init"] = str(sched._get_hermes_home()) observed["skip_context_files"] = kwargs.get("skip_context_files") def run_conversation(self, *_a, **_kw): - observed["hermes_home_during_run"] = os.environ.get("HERMES_HOME") + from hermes_constants import get_hermes_home + + observed["env_home_during_run"] = os.environ.get("HERMES_HOME") + observed["hermes_home_during_run"] = str(get_hermes_home()) observed["scheduler_home_during_run"] = str(sched._get_hermes_home()) return {"final_response": "done", "messages": []} @@ -229,6 +235,8 @@ class TestRunJobProfileContext: assert success is True, f"run_job failed: error={error!r} response={response!r}" assert observed["dotenv_paths"] == [str(profile_home / ".env")] + assert observed["env_home_during_init"] == str(root) + assert observed["env_home_during_run"] == str(root) assert observed["hermes_home_during_init"] == str(profile_home.resolve()) assert observed["hermes_home_during_run"] == str(profile_home.resolve()) assert observed["scheduler_home_during_init"] == str(profile_home.resolve()) diff --git a/tests/test_subprocess_home_isolation.py b/tests/test_subprocess_home_isolation.py index 2789d10b6d..28401fa664 100644 --- a/tests/test_subprocess_home_isolation.py +++ b/tests/test_subprocess_home_isolation.py @@ -8,6 +8,7 @@ See: https://github.com/NousResearch/hermes-agent/issues/4426 """ import os +import threading from pathlib import Path from unittest.mock import patch @@ -68,10 +69,50 @@ class TestGetSubprocessHome: monkeypatch.setenv("HERMES_HOME", str(base / "beta")) home_b = get_subprocess_home() + assert home_a is not None + assert home_b is not None assert home_a != home_b assert home_a.endswith("alpha/home") assert home_b.endswith("beta/home") + def test_context_override_is_thread_local(self, tmp_path, monkeypatch): + root = tmp_path / "root" + profile = tmp_path / "profile" + root.mkdir() + profile.mkdir() + monkeypatch.setenv("HERMES_HOME", str(root)) + + from hermes_constants import ( + get_hermes_home, + reset_hermes_home_override, + set_hermes_home_override, + ) + + ready = threading.Event() + release = threading.Event() + seen: list[str] = [] + + def read_from_other_thread(): + ready.set() + release.wait(timeout=5) + seen.append(str(get_hermes_home())) + + thread = threading.Thread(target=read_from_other_thread) + thread.start() + assert ready.wait(timeout=5) + + token = set_hermes_home_override(profile) + try: + assert get_hermes_home() == profile + release.set() + thread.join(timeout=5) + finally: + reset_hermes_home_override(token) + release.set() + + assert seen == [str(root)] + assert get_hermes_home() == root + # --------------------------------------------------------------------------- # _make_run_env() injection @@ -116,6 +157,28 @@ class TestMakeRunEnvHomeInjection: assert result["HOME"] == "/home/user" + def test_context_override_bridges_to_subprocess_env(self, tmp_path, monkeypatch): + root = tmp_path / "root" + profile = tmp_path / "profile" + root.mkdir() + profile.mkdir() + (profile / "home").mkdir() + monkeypatch.setenv("HERMES_HOME", str(root)) + monkeypatch.setenv("HOME", "/root") + monkeypatch.setenv("PATH", "/usr/bin:/bin") + + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + from tools.environments.local import _make_run_env + + token = set_hermes_home_override(profile) + try: + result = _make_run_env({}) + finally: + reset_hermes_home_override(token) + + assert result["HERMES_HOME"] == str(profile) + assert result["HOME"] == str(profile / "home") + # --------------------------------------------------------------------------- # _sanitize_subprocess_env() injection @@ -147,6 +210,27 @@ class TestSanitizeSubprocessEnvHomeInjection: assert result["HOME"] == "/root" + def test_context_override_bridges_to_background_env(self, tmp_path, monkeypatch): + root = tmp_path / "root" + profile = tmp_path / "profile" + root.mkdir() + profile.mkdir() + (profile / "home").mkdir() + monkeypatch.setenv("HERMES_HOME", str(root)) + + base_env = {"HOME": "/root", "PATH": "/usr/bin"} + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + from tools.environments.local import _sanitize_subprocess_env + + token = set_hermes_home_override(profile) + try: + result = _sanitize_subprocess_env(base_env) + finally: + reset_hermes_home_override(token) + + assert result["HERMES_HOME"] == str(profile) + assert result["HOME"] == str(profile / "home") + # --------------------------------------------------------------------------- # Profile bootstrap diff --git a/tools/environments/local.py b/tools/environments/local.py index 177e5efab1..9761aa1475 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -170,6 +170,18 @@ def _build_provider_env_blocklist() -> frozenset: _HERMES_PROVIDER_ENV_BLOCKLIST = _build_provider_env_blocklist() +def _inject_context_hermes_home(env: dict) -> None: + """Bridge the context-local Hermes home override into subprocess env.""" + try: + from hermes_constants import get_hermes_home_override + + value = get_hermes_home_override() + if value: + env["HERMES_HOME"] = value + except Exception: + pass + + def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = None) -> dict: """Filter Hermes-managed secrets from a subprocess environment.""" try: @@ -192,6 +204,8 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non elif key not in _HERMES_PROVIDER_ENV_BLOCKLIST or _is_passthrough(key): sanitized[key] = value + _inject_context_hermes_home(sanitized) + # Per-profile HOME isolation for background processes (same as _make_run_env). from hermes_constants import get_subprocess_home _profile_home = get_subprocess_home() @@ -292,6 +306,8 @@ def _make_run_env(env: dict) -> dict: if not _IS_WINDOWS and "/usr/bin" not in existing_path.split(":"): run_env["PATH"] = f"{existing_path}:{_SANE_PATH}" if existing_path else _SANE_PATH + _inject_context_hermes_home(run_env) + # Per-profile HOME isolation: redirect system tool configs (git, ssh, gh, # npm â€Ļ) into {HERMES_HOME}/home/ when that directory exists. Only the # subprocess sees the override — the Python process keeps the real HOME. From 9c48d47aaf0e959a4eda939ac9b9087171c96f30 Mon Sep 17 00:00:00 2001 From: Gianfranco Piana <52470719+gianfrancopiana@users.noreply.github.com> Date: Mon, 18 May 2026 11:47:44 -0300 Subject: [PATCH 020/338] fix(cron): isolate profile job env --- cron/scheduler.py | 27 ++++++++++----- tests/cron/test_cron_profile.py | 60 ++++++++++++++++++++++++++++++++- tools/cronjob_tools.py | 2 +- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 14d2a9bb7e..1e28711b16 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -151,12 +151,16 @@ def _job_profile_context(job_id: str, profile: Optional[str]): """Temporarily run a job under a specific Hermes profile. Cron jobs are stored and scheduled by the profile running the scheduler, but - an individual job can opt into a different runtime profile. While active, - The scheduler's test/override hook and a context-local Hermes home override + an individual job can opt into a different runtime profile. While active, + the scheduler's test/override hook and a context-local Hermes home override both point at the resolved profile directory so _get_hermes_home(), .env/config loading, script resolution, AIAgent construction, and downstream - get_hermes_home() callers agree on the same home without mutating the - process-wide environment seen by other threads. + get_hermes_home() callers agree on the same home. + + Some existing provider/config paths still load profile .env values through + os.environ, so profile jobs also snapshot and restore the process + environment on exit. tick() runs profile jobs sequentially to keep that + temporary mutation isolated from other scheduled jobs. """ raw_profile = str(profile or "").strip() if not raw_profile: @@ -165,6 +169,7 @@ def _job_profile_context(job_id: str, profile: Optional[str]): global _hermes_home prior_override = _hermes_home + env_snapshot = os.environ.copy() from hermes_cli.profiles import normalize_profile_name, resolve_profile_env from hermes_constants import reset_hermes_home_override, set_hermes_home_override @@ -187,6 +192,8 @@ def _job_profile_context(job_id: str, profile: Optional[str]): _hermes_home = prior_override if override_token is not None: reset_hermes_home_override(override_token) + os.environ.clear() + os.environ.update(env_snapshot) def _resolve_origin(job: dict) -> Optional[dict]: @@ -1843,11 +1850,13 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int: mark_job_run(job["id"], False, str(e)) return False - # Partition due jobs: jobs with a per-job workdir and/or profile mutate - # process-global runtime state inside run_job (TERMINAL_CWD, - # HERMES_HOME, and the scheduler's _hermes_home hook), so they MUST run + # Partition due jobs: jobs with a per-job workdir and/or profile touch + # process-global runtime state inside run_job. Workdir jobs temporarily + # set os.environ["TERMINAL_CWD"]; profile jobs use a context-local + # Hermes home override, scheduler _hermes_home hook, and temporary + # profile .env load into os.environ with snapshot/restore. They MUST run # sequentially to avoid corrupting each other. Jobs without either field - # leave those env overrides untouched and stay parallel-safe. + # stay parallel-safe. sequential_jobs = [ j for j in due_jobs if (j.get("workdir") or "").strip() or (j.get("profile") or "").strip() @@ -1859,7 +1868,7 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int: _results: list = [] - # Sequential pass for env-mutating jobs. + # Sequential pass for env/context-mutating jobs. for job in sequential_jobs: _ctx = contextvars.copy_context() _results.append(_ctx.run(_process_job, job)) diff --git a/tests/cron/test_cron_profile.py b/tests/cron/test_cron_profile.py index de9b3b0d9e..a8f438c185 100644 --- a/tests/cron/test_cron_profile.py +++ b/tests/cron/test_cron_profile.py @@ -151,7 +151,11 @@ class TestCronjobToolProfile: assert "profile" in CRONJOB_SCHEMA["parameters"]["properties"] desc = CRONJOB_SCHEMA["parameters"]["properties"]["profile"]["description"] - assert "hermes profile" in desc.lower() + desc_lower = desc.lower() + assert "hermes profile" in desc_lower + assert "context-local" in desc_lower + assert "subprocess" in desc_lower + assert "temporarily sets hermes_home" not in desc_lower class TestRunJobProfileContext: @@ -165,6 +169,12 @@ class TestRunJobProfileContext: from hermes_constants import get_hermes_home observed["env_home_during_init"] = os.environ.get("HERMES_HOME") + observed["profile_env_only_during_init"] = os.environ.get( + "HERMES_PROFILE_TEST_ONLY" + ) + observed["profile_env_shared_during_init"] = os.environ.get( + "HERMES_PROFILE_TEST_SHARED" + ) observed["hermes_home_during_init"] = str(get_hermes_home()) observed["scheduler_home_during_init"] = str(sched._get_hermes_home()) observed["skip_context_files"] = kwargs.get("skip_context_files") @@ -173,6 +183,12 @@ class TestRunJobProfileContext: from hermes_constants import get_hermes_home observed["env_home_during_run"] = os.environ.get("HERMES_HOME") + observed["profile_env_only_during_run"] = os.environ.get( + "HERMES_PROFILE_TEST_ONLY" + ) + observed["profile_env_shared_during_run"] = os.environ.get( + "HERMES_PROFILE_TEST_SHARED" + ) observed["hermes_home_during_run"] = str(get_hermes_home()) observed["scheduler_home_during_run"] = str(sched._get_hermes_home()) return {"final_response": "done", "messages": []} @@ -245,6 +261,48 @@ class TestRunJobProfileContext: assert os.environ["HERMES_HOME"] == str(root) assert sched._get_hermes_home() == root + def test_profile_dotenv_environment_is_restored( + self, isolated_cron_profile_home, monkeypatch + ): + import dotenv + import cron.scheduler as sched + + root, profile_home = isolated_cron_profile_home + observed: dict = {} + self._install_agent_stubs(monkeypatch, observed) + monkeypatch.setenv("HERMES_PROFILE_TEST_SHARED", "outer") + monkeypatch.delenv("HERMES_PROFILE_TEST_ONLY", raising=False) + + def fake_load_dotenv(path, *_a, **_kw): + observed.setdefault("dotenv_paths", []).append(str(path)) + os.environ["HERMES_PROFILE_TEST_SHARED"] = "profile-value" + os.environ["HERMES_PROFILE_TEST_ONLY"] = "profile-only" + os.environ["HERMES_CRON_TIMEOUT"] = "123" + return True + + monkeypatch.setattr(dotenv, "load_dotenv", fake_load_dotenv) + + job = { + "id": "env-profile", + "name": "profile-env-job", + "profile": "support", + "schedule_display": "manual", + } + + success, _output, _response, error = sched.run_job(job) + + assert success is True, error + assert observed["dotenv_paths"] == [str(profile_home / ".env")] + assert observed["profile_env_only_during_init"] == "profile-only" + assert observed["profile_env_shared_during_init"] == "profile-value" + assert observed["profile_env_only_during_run"] == "profile-only" + assert observed["profile_env_shared_during_run"] == "profile-value" + assert os.environ["HERMES_PROFILE_TEST_SHARED"] == "outer" + assert "HERMES_PROFILE_TEST_ONLY" not in os.environ + assert os.environ["HERMES_CRON_TIMEOUT"] == "0" + assert os.environ["HERMES_HOME"] == str(root) + assert sched._get_hermes_home() == root + def test_no_agent_profile_uses_profile_scripts_dir_and_restores_env( self, isolated_cron_profile_home, monkeypatch ): diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 5d91a6700d..ea5df13271 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -666,7 +666,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr }, "profile": { "type": "string", - "description": "Optional Hermes profile name to run the job under. When set, the scheduler resolves that profile and temporarily sets HERMES_HOME before loading .env/config.yaml and running the job. Use 'default' for the root Hermes profile. Named profiles must already exist. When unset (default), preserves the scheduler's existing profile. On update, pass an empty string to clear. Jobs with profile run sequentially (not parallel) to keep process-global profile state isolated." + "description": "Optional Hermes profile name to run the job under. When set, the scheduler resolves that profile, applies a context-local Hermes home override, loads that profile's config/.env for the run, and bridges HERMES_HOME into subprocesses. Any temporary process-environment changes from profile .env loading are restored after the job exits. Use 'default' for the root Hermes profile. Named profiles must already exist. When unset (default), preserves the scheduler's existing profile. On update, pass an empty string to clear. Jobs with profile run sequentially (not parallel) to keep profile-scoped runtime state isolated." }, }, "required": ["action"] From 1f9b2e4d0b9b47ab957a4e8b3ef01b59b493264c Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 18 May 2026 17:29:26 +0000 Subject: [PATCH 021/338] chore: add gianfrancopiana to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 5a8842bf27..17ac05f65c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -104,6 +104,7 @@ AUTHOR_MAP = { "hugosequier@gmail.com": "Hugo-SEQUIER", "128259593+Gutslabs@users.noreply.github.com": "Gutslabs", "50326054+nocturnum91@users.noreply.github.com": "nocturnum91", + "52470719+gianfrancopiana@users.noreply.github.com": "gianfrancopiana", "223003280+Abd0r@users.noreply.github.com": "Abd0r", "HuangYuChuh@users.noreply.github.com": "HuangYuChuh", "aaronwong1989@gmail.com": "hrygo", From 1d74d7f73aa7cae7933afbb2b35e86e22b8d318c Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 18 May 2026 17:29:51 +0000 Subject: [PATCH 022/338] fix(cron): use delta-based env restore instead of clear+update Avoids a brief window where other threads see an empty os.environ during profile job teardown. Idea from PR #19958. --- cron/scheduler.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 1e28711b16..7132213eaa 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -192,8 +192,14 @@ def _job_profile_context(job_id: str, profile: Optional[str]): _hermes_home = prior_override if override_token is not None: reset_hermes_home_override(override_token) - os.environ.clear() - os.environ.update(env_snapshot) + # Delta-based restore: remove added keys, restore changed keys. + # Avoids a brief window where other threads see an empty env. + added = set(os.environ.keys()) - set(env_snapshot.keys()) + for k in added: + os.environ.pop(k, None) + for k, v in env_snapshot.items(): + if os.environ.get(k) != v: + os.environ[k] = v def _resolve_origin(job: dict) -> Optional[dict]: From ef5fe8dfaf9bcbf6268a276dc4d6a80e07ee9e89 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 18 May 2026 17:30:36 +0000 Subject: [PATCH 023/338] fix(cron): gracefully degrade when runtime profile is deleted Instead of raising FileNotFoundError (which silently bricks the job), log a warning and fall back to the scheduler default home. Validates at create/update time still catches typos. Idea from PR #19958. --- cron/scheduler.py | 11 ++++++++++- tests/cron/test_cron_profile.py | 25 +++++++++++++++---------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 7132213eaa..9a1f3d1bfe 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -175,7 +175,16 @@ def _job_profile_context(job_id: str, profile: Optional[str]): from hermes_constants import reset_hermes_home_override, set_hermes_home_override normalized_profile = normalize_profile_name(raw_profile) - profile_home = Path(resolve_profile_env(normalized_profile)).resolve() + try: + profile_home = Path(resolve_profile_env(normalized_profile)).resolve() + except (FileNotFoundError, ValueError) as exc: + logger.warning( + "Job '%s': configured profile %r no longer valid (%s) — " + "falling back to scheduler default", + job_id, raw_profile, exc, + ) + yield None + return override_token = None try: diff --git a/tests/cron/test_cron_profile.py b/tests/cron/test_cron_profile.py index a8f438c185..8e8d3f7ca1 100644 --- a/tests/cron/test_cron_profile.py +++ b/tests/cron/test_cron_profile.py @@ -354,23 +354,28 @@ class TestRunJobProfileContext: assert observed["hermes_home_during_init"] == str(root) assert os.environ["HERMES_HOME"] == str(root) - def test_run_job_rejects_missing_runtime_profile( + def test_run_job_falls_back_on_missing_runtime_profile( self, isolated_cron_profile_home, monkeypatch ): import cron.scheduler as sched root, _profile_home = isolated_cron_profile_home - monkeypatch.setattr(sched, "_hermes_home", None) + observed: dict = {} + self._install_agent_stubs(monkeypatch, observed) - with pytest.raises(FileNotFoundError): - sched.run_job( - { - "id": "missing-profile", - "name": "missing-profile-job", - "profile": "missing", - } - ) + job = { + "id": "missing-profile", + "name": "missing-profile-job", + "profile": "missing", + "schedule_display": "manual", + } + # Should succeed with fallback, not raise + success, _output, response, error = sched.run_job(job) + + assert success is True, f"run_job should fallback, not fail: error={error!r}" + # Verify it used the default home, not the missing profile + assert observed["hermes_home_during_init"] == str(root) assert os.environ["HERMES_HOME"] == str(root) From e3f391c1ac1e2efe19af54293e77a7ca3b77bbbe Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 18 May 2026 17:31:02 +0000 Subject: [PATCH 024/338] test(cron): cover profile + workdir combined scenario --- tests/cron/test_cron_profile.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/cron/test_cron_profile.py b/tests/cron/test_cron_profile.py index 8e8d3f7ca1..887849e635 100644 --- a/tests/cron/test_cron_profile.py +++ b/tests/cron/test_cron_profile.py @@ -380,6 +380,33 @@ class TestRunJobProfileContext: class TestTickProfilePartition: + def test_profile_and_workdir_combined(self, isolated_cron_profile_home, monkeypatch): + """Both profile and workdir set — verify both are applied and restored.""" + import cron.scheduler as sched + + root, profile_home = isolated_cron_profile_home + observed: dict = {} + TestRunJobProfileContext._install_agent_stubs(monkeypatch, observed) + fake_workdir = str(root / "myproject") + (root / "myproject").mkdir() + + job = { + "id": "combo", + "name": "combo-job", + "profile": "support", + "workdir": fake_workdir, + "schedule_display": "manual", + } + + success, _output, _response, error = sched.run_job(job) + + assert success is True, error + assert observed["hermes_home_during_init"] == str(profile_home.resolve()) + assert os.environ.get("TERMINAL_CWD", "") != fake_workdir, \ + "TERMINAL_CWD should be restored after job" + assert os.environ["HERMES_HOME"] == str(root) + assert sched._get_hermes_home() == root + def test_profile_jobs_run_sequentially(self, isolated_cron_profile_home, monkeypatch): import threading import cron.scheduler as sched From 6143ce1546d083374671b2e772e8ae7c868e3af9 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sat, 16 May 2026 11:55:55 +0800 Subject: [PATCH 025/338] fix(url_safety): block IPv4-mapped IPv6 addresses to prevent SSRF bypass --- tests/tools/test_url_safety.py | 67 ++++++++++++++++++++++++++++++++++ tools/url_safety.py | 21 +++++++++++ 2 files changed, 88 insertions(+) diff --git a/tests/tools/test_url_safety.py b/tests/tools/test_url_safety.py index 5a0cceb288..8513a848be 100644 --- a/tests/tools/test_url_safety.py +++ b/tests/tools/test_url_safety.py @@ -482,3 +482,70 @@ class TestIsAlwaysBlockedUrl: """security.allow_private_urls can NOT unblock cloud metadata.""" monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true") assert is_always_blocked_url("http://169.254.169.254/") is True + + +class TestIPv4MappedIPv6SSRF: + """Regression tests for SSRF bypass via IPv4-mapped IPv6 addresses. + + DNS resolvers may return ``::ffff:x.x.x.x`` for IPv4-only hosts. + Python's ipaddress module treats these as distinct from the plain + IPv4 address, so ``ip in frozenset({IPv4Address(...)})`` and + ``ip in IPv4Network(...)`` both return False. Without explicit + handling, an attacker could use IPv4-mapped addresses to bypass + all SSRF protections. + """ + + # ── _is_blocked_ip direct tests ── + + @pytest.mark.parametrize("ip_str", [ + "::ffff:100.64.0.1", # CGNAT start + "::ffff:100.100.100.200", # Alibaba Cloud metadata (in CGNAT range) + "::ffff:100.127.255.254", # CGNAT end + "::ffff:169.254.42.99", # Link-local (non-metadata) + "::ffff:0.0.0.0", # Unspecified + "::ffff:224.0.0.1", # Multicast + ]) + def test_ipv4_mapped_blocked_ips(self, ip_str): + """IPv4-mapped IPv6 addresses that should be blocked.""" + ip = ipaddress.ip_address(ip_str) + assert _is_blocked_ip(ip) is True, f"{ip_str} should be blocked" + + @pytest.mark.parametrize("ip_str", [ + "::ffff:8.8.8.8", # Public DNS + "::ffff:93.184.216.34", # example.com + "::ffff:100.0.0.1", # Not in CGNAT range + ]) + def test_ipv4_mapped_allowed_ips(self, ip_str): + """IPv4-mapped IPv6 addresses that should be allowed.""" + ip = ipaddress.ip_address(ip_str) + assert _is_blocked_ip(ip) is False, f"{ip_str} should be allowed" + + # ── is_safe_url integration tests: always-blocked metadata IPs ── + + def test_ipv4_mapped_aws_metadata_blocked(self): + """::ffff:169.254.169.254 (AWS metadata) must always be blocked.""" + with patch("socket.getaddrinfo", return_value=[ + (10, 1, 6, "", ("::ffff:169.254.169.254", 0, 0, 0)), + ]): + assert is_safe_url("http://aws-metadata.internal/") is False + + def test_ipv4_mapped_ecs_metadata_blocked(self): + """::ffff:169.254.170.2 (AWS ECS task metadata) must always be blocked.""" + with patch("socket.getaddrinfo", return_value=[ + (10, 1, 6, "", ("::ffff:169.254.170.2", 0, 0, 0)), + ]): + assert is_safe_url("http://ecs-metadata.internal/") is False + + def test_ipv4_mapped_azure_wire_server_blocked(self): + """::ffff:169.254.169.253 (Azure IMDS wire server) must always be blocked.""" + with patch("socket.getaddrinfo", return_value=[ + (10, 1, 6, "", ("::ffff:169.254.169.253", 0, 0, 0)), + ]): + assert is_safe_url("http://azure-metadata.internal/") is False + + def test_ipv4_mapped_alibaba_metadata_blocked(self): + """::ffff:100.100.100.200 (Alibaba Cloud metadata) must always be blocked.""" + with patch("socket.getaddrinfo", return_value=[ + (10, 1, 6, "", ("::ffff:100.100.100.200", 0, 0, 0)), + ]): + assert is_safe_url("http://aliyun-metadata.internal/") is False diff --git a/tools/url_safety.py b/tools/url_safety.py index 0f3dd597e4..a0ce297a92 100644 --- a/tools/url_safety.py +++ b/tools/url_safety.py @@ -45,15 +45,26 @@ _BLOCKED_HOSTNAMES = frozenset({ # allow_private_urls toggle. These are cloud metadata / credential # endpoints — the #1 SSRF target — and the link-local range where # they all live. +# +# IPv4-mapped IPv6 variants are included because DNS resolvers may +# return ``::ffff:x.x.x.x`` for IPv4-only hosts, and Python's +# ipaddress module treats these as distinct from the plain IPv4 +# address (they won't match ``ip in frozenset`` or ``ip in network``). _ALWAYS_BLOCKED_IPS = frozenset({ ipaddress.ip_address("169.254.169.254"), # AWS/GCP/Azure/DO/Oracle metadata ipaddress.ip_address("169.254.170.2"), # AWS ECS task metadata (task IAM creds) ipaddress.ip_address("169.254.169.253"), # Azure IMDS wire server ipaddress.ip_address("fd00:ec2::254"), # AWS metadata (IPv6) ipaddress.ip_address("100.100.100.200"), # Alibaba Cloud metadata + # IPv4-mapped IPv6 variants — same endpoints reachable via ::ffff:x.x.x.x + ipaddress.ip_address("::ffff:169.254.169.254"), + ipaddress.ip_address("::ffff:169.254.170.2"), + ipaddress.ip_address("::ffff:169.254.169.253"), + ipaddress.ip_address("::ffff:100.100.100.200"), }) _ALWAYS_BLOCKED_NETWORKS = ( ipaddress.ip_network("169.254.0.0/16"), # Entire link-local range (no legit agent target) + ipaddress.ip_network("::ffff:169.254.0.0/112"), # IPv4-mapped link-local range ) # Exact HTTPS hostnames allowed to resolve to private/benchmark-space IPs. @@ -137,6 +148,16 @@ def _reset_allow_private_cache() -> None: def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: """Return True if the IP should be blocked for SSRF protection.""" + # IPv4-mapped IPv6 addresses (``::ffff:x.x.x.x``) should be checked + # by their embedded IPv4 address, not as IPv6 + if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None: + embedded_ip = ip.ipv4_mapped + return (embedded_ip.is_private or embedded_ip.is_loopback or + embedded_ip.is_link_local or embedded_ip.is_reserved or + embedded_ip.is_multicast or embedded_ip.is_unspecified or + embedded_ip in _CGNAT_NETWORK) + + # Standard IPv4/IPv6 address checking if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: return True if ip.is_multicast or ip.is_unspecified: From 956dd4462598bbb91d642d639e2c6e03d043bdbc Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 18 May 2026 10:47:39 -0700 Subject: [PATCH 026/338] chore(release): add AUTHOR_MAP entry for dskwe --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 17ac05f65c..3894349ada 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -74,6 +74,7 @@ AUTHOR_MAP = { "yanglongwei06@gmail.com": "Alex-yang00", "teknium@nousresearch.com": "teknium1", "piyushvp1@gmail.com": "thelumiereguy", + "dskwelmcy@163.com": "dskwe", "421774554@qq.com": "wuli666", "twebefy@gmail.com": "tw2818", "harish.kukreja@gmail.com": "counterposition", From 341c8d3030c91d042e24f2be3ddb7041db124a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E7=B6=A0BG?= Date: Fri, 1 May 2026 20:29:22 +0800 Subject: [PATCH 027/338] =?UTF-8?q?=F0=9F=90=9B=20fix(memory):=20keep=20in?= =?UTF-8?q?line=20memory-context=20mentions=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/memory_manager.py | 44 +++++++++++++++++-- .../agent/test_streaming_context_scrubber.py | 35 +++++++++++---- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/agent/memory_manager.py b/agent/memory_manager.py index 7eda64fba4..c3ea0a2612 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -91,10 +91,12 @@ class StreamingContextScrubber: def __init__(self) -> None: self._in_span: bool = False self._buf: str = "" + self._at_block_boundary: bool = True def reset(self) -> None: self._in_span = False self._buf = "" + self._at_block_boundary = True def feed(self, text: str) -> str: """Return the visible portion of ``text`` after scrubbing. @@ -121,19 +123,19 @@ class StreamingContextScrubber: buf = buf[idx + len(self._CLOSE_TAG):] self._in_span = False else: - idx = buf.lower().find(self._OPEN_TAG) + idx = self._find_boundary_open_tag(buf) if idx == -1: # No open tag — hold back a potential partial open tag held = self._max_partial_suffix(buf, self._OPEN_TAG) if held: - out.append(buf[:-held]) + self._append_visible(out, buf[:-held]) self._buf = buf[-held:] else: - out.append(buf) + self._append_visible(out, buf) return "".join(out) # Emit text before the tag, enter span if idx > 0: - out.append(buf[:idx]) + self._append_visible(out, buf[:idx]) buf = buf[idx + len(self._OPEN_TAG):] self._in_span = True @@ -169,6 +171,40 @@ class StreamingContextScrubber: return i return 0 + def _find_boundary_open_tag(self, buf: str) -> int: + """Find an opening fence only when it starts a block-like span.""" + buf_lower = buf.lower() + search_start = 0 + while True: + idx = buf_lower.find(self._OPEN_TAG, search_start) + if idx == -1: + return -1 + if self._is_block_boundary(buf, idx): + return idx + search_start = idx + 1 + + def _is_block_boundary(self, buf: str, idx: int) -> bool: + if idx == 0: + return self._at_block_boundary + preceding = buf[:idx] + last_newline = preceding.rfind("\n") + if last_newline == -1: + return self._at_block_boundary and preceding.strip() == "" + return preceding[last_newline + 1:].strip() == "" + + def _append_visible(self, out: list[str], text: str) -> None: + if not text: + return + out.append(text) + self._update_block_boundary(text) + + def _update_block_boundary(self, text: str) -> None: + last_newline = text.rfind("\n") + if last_newline != -1: + self._at_block_boundary = text[last_newline + 1:].strip() == "" + else: + self._at_block_boundary = self._at_block_boundary and text.strip() == "" + def build_memory_context_block(raw_context: str) -> str: """Wrap prefetched memory in a fenced block with system note.""" diff --git a/tests/agent/test_streaming_context_scrubber.py b/tests/agent/test_streaming_context_scrubber.py index 99f33e7ce9..94ca221dba 100644 --- a/tests/agent/test_streaming_context_scrubber.py +++ b/tests/agent/test_streaming_context_scrubber.py @@ -37,13 +37,13 @@ class TestStreamingContextScrubberBasics: """The real streaming case: tag pair split across deltas.""" s = StreamingContextScrubber() deltas = [ - "Hello ", + "Hello\n", "\npayload ", "more payload\n", " world", ] out = "".join(s.feed(d) for d in deltas) + s.flush() - assert out == "Hello world" + assert out == "Hello\n world" assert "payload" not in out def test_realistic_fragmented_chunks_strip_memory_payload(self): @@ -72,22 +72,22 @@ class TestStreamingContextScrubberBasics: """The open tag itself arriving in two fragments.""" s = StreamingContextScrubber() out = ( - s.feed("pre leak post") + s.flush() ) - assert out == "pre post" + assert out == "pre \n post" assert "leak" not in out def test_close_tag_split_across_two_deltas(self): """The close tag arriving in two fragments.""" s = StreamingContextScrubber() out = ( - s.feed("pre leakleak post") + s.flush() ) - assert out == "pre post" + assert out == "pre \n post" assert "leak" not in out @@ -105,13 +105,30 @@ class TestStreamingContextScrubberPartialTagFalsePositives: out = s.feed("price < ") + s.feed("10 dollars") + s.flush() assert out == "price < 10 dollars" + def test_inline_memory_context_tag_mention_is_not_scrubbed(self): + """A prose mention of the fence tag must not swallow the answer.""" + s = StreamingContextScrubber() + out = ( + s.feed("In that previous `` block, ") + + s.feed("there was no matching fact.") + + s.flush() + ) + assert out == "In that previous `` block, there was no matching fact." + + def test_mid_sentence_memory_context_pair_is_not_scrubbed(self): + """Only block-like memory-context spans are treated as leaked context.""" + s = StreamingContextScrubber() + out = s.feed("The tag name is documented here.") + s.flush() + assert out == "The tag name is documented here." + class TestStreamingContextScrubberUnterminatedSpan: def test_unterminated_span_drops_payload(self): """Provider drops close tag — better to lose output than to leak.""" s = StreamingContextScrubber() - out = s.feed("pre secret never closed") + s.flush() - assert out == "pre " + out = s.feed("pre \nsecret never closed") + s.flush() + assert out == "pre \n" assert "secret" not in out def test_reset_clears_hung_span(self): @@ -171,7 +188,7 @@ class TestStreamingContextScrubberCrossTurn: def test_reset_clears_in_span_state(self): s = StreamingContextScrubber() - s.feed("textsecret-tail") + s.feed("text\nsecret-tail") # Mid-span state held — without reset, subsequent text would be # discarded until we see . s.reset() From 50e93f23f2b382ccc7c084b3260549464771aa4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E7=B6=A0BG?= Date: Sat, 2 May 2026 12:26:46 +0800 Subject: [PATCH 028/338] =?UTF-8?q?=F0=9F=90=9B=20fix(memory):=20require?= =?UTF-8?q?=20newline=20after=20context=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/memory_manager.py | 22 +++++++++++-- .../agent/test_streaming_context_scrubber.py | 31 ++++++++++++++++--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/agent/memory_manager.py b/agent/memory_manager.py index c3ea0a2612..7954713908 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -126,7 +126,10 @@ class StreamingContextScrubber: idx = self._find_boundary_open_tag(buf) if idx == -1: # No open tag — hold back a potential partial open tag - held = self._max_partial_suffix(buf, self._OPEN_TAG) + held = ( + self._max_pending_open_suffix(buf) + or self._max_partial_suffix(buf, self._OPEN_TAG) + ) if held: self._append_visible(out, buf[:-held]) self._buf = buf[-held:] @@ -179,10 +182,25 @@ class StreamingContextScrubber: idx = buf_lower.find(self._OPEN_TAG, search_start) if idx == -1: return -1 - if self._is_block_boundary(buf, idx): + if self._is_block_boundary(buf, idx) and self._has_block_opener_suffix(buf, idx): return idx search_start = idx + 1 + def _max_pending_open_suffix(self, buf: str) -> int: + """Hold a complete boundary tag until the following char confirms it.""" + if not buf.lower().endswith(self._OPEN_TAG): + return 0 + idx = len(buf) - len(self._OPEN_TAG) + if not self._is_block_boundary(buf, idx): + return 0 + return len(self._OPEN_TAG) + + def _has_block_opener_suffix(self, buf: str, idx: int) -> bool: + after_idx = idx + len(self._OPEN_TAG) + if after_idx >= len(buf): + return False + return buf[after_idx] in "\r\n" + def _is_block_boundary(self, buf: str, idx: int) -> bool: if idx == 0: return self._at_block_boundary diff --git a/tests/agent/test_streaming_context_scrubber.py b/tests/agent/test_streaming_context_scrubber.py index 94ca221dba..ed633b6b19 100644 --- a/tests/agent/test_streaming_context_scrubber.py +++ b/tests/agent/test_streaming_context_scrubber.py @@ -73,7 +73,18 @@ class TestStreamingContextScrubberBasics: s = StreamingContextScrubber() out = ( s.feed("pre \nleak post") + + s.feed("-context>\nleak post") + + s.flush() + ) + assert out == "pre \n post" + assert "leak" not in out + + def test_open_tag_waits_for_newline_confirmation_across_deltas(self): + """A boundary tag is only a leaked block when the next char is a newline.""" + s = StreamingContextScrubber() + out = ( + s.feed("pre \n") + + s.feed("\nleak post") + s.flush() ) assert out == "pre \n post" @@ -83,7 +94,7 @@ class TestStreamingContextScrubberBasics: """The close tag arriving in two fragments.""" s = StreamingContextScrubber() out = ( - s.feed("pre \nleak\nleak post") + s.flush() ) @@ -116,18 +127,28 @@ class TestStreamingContextScrubberPartialTagFalsePositives: ) assert out == "In that previous `` block, there was no matching fact." - def test_mid_sentence_memory_context_pair_is_not_scrubbed(self): + def test_mid_sentence_memory_context_mention_is_not_scrubbed(self): """Only block-like memory-context spans are treated as leaked context.""" s = StreamingContextScrubber() out = s.feed("The tag name is documented here.") + s.flush() assert out == "The tag name is documented here." + def test_line_start_memory_context_mention_without_close_is_not_scrubbed(self): + """A plain-text line that starts with the tag name must be preserved.""" + s = StreamingContextScrubber() + out = ( + s.feed("Visible intro\n") + + s.feed(" is the literal tag name mentioned here.") + + s.flush() + ) + assert out == "Visible intro\n is the literal tag name mentioned here." + class TestStreamingContextScrubberUnterminatedSpan: def test_unterminated_span_drops_payload(self): """Provider drops close tag — better to lose output than to leak.""" s = StreamingContextScrubber() - out = s.feed("pre \nsecret never closed") + s.flush() + out = s.feed("pre \n\nsecret never closed") + s.flush() assert out == "pre \n" assert "secret" not in out @@ -144,7 +165,7 @@ class TestStreamingContextScrubberCaseInsensitivity: def test_uppercase_tags_still_scrubbed(self): s = StreamingContextScrubber() out = ( - s.feed("secret") + s.feed("\nsecret") + s.feed("visible") + s.flush() ) From 375c7f9cc379f940f3923ac967793675bdfd2b5c Mon Sep 17 00:00:00 2001 From: HenkDz Date: Sun, 10 May 2026 22:45:27 +0100 Subject: [PATCH 029/338] fix(acp): render structured JSON tool output --- acp_adapter/tools.py | 151 ++++++++++++++++++++++++++++++++++++---- tests/acp/test_tools.py | 56 +++++++++++++++ 2 files changed, 195 insertions(+), 12 deletions(-) diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index 6513f1bb55..4524b8c6f1 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -278,6 +278,26 @@ def _format_search_files_result(result: Optional[str]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict): return None + + files = data.get("files") + if isinstance(files, list): + total = data.get("total_count", len(files)) + shown = min(len(files), 20) + truncated = bool(data.get("truncated")) or len(files) > shown + lines = [ + "File search results", + f"Found {total} file{'s' if total != 1 else ''}; showing {shown}.", + "", + ] + for path in files[:shown]: + lines.append(f"- {path}") + if truncated: + lines.extend([ + "", + "Results truncated. Narrow the search, add path/file_glob, or use offset to page.", + ]) + return _truncate_text("\n".join(lines), limit=7000) + matches = data.get("matches") if not isinstance(matches, list): return None @@ -668,14 +688,114 @@ def _format_media_or_cron_result(tool_name: str, result: Optional[str]) -> Optio return "\n".join(lines) -def _format_generic_structured_result(tool_name: str, result: Optional[str]) -> Optional[str]: +def _format_structured_value( + key: str, + value: Any, + *, + indent: int = 0, + max_depth: int = 3, + max_items: int = 8, +) -> List[str]: + """Render nested JSON-ish values as compact Markdown bullets, not inline blobs.""" + prefix = " " * indent + bullet = f"{prefix}- " + label = f"**{key}:**" if key else "" + + if value in (None, "", [], {}): + return [] + + if max_depth <= 0: + if isinstance(value, (dict, list)): + preview = json.dumps(value, ensure_ascii=False, default=str) + else: + preview = str(value) + return [f"{bullet}{label} {_truncate_text(preview, limit=240)}" if label else f"{bullet}{_truncate_text(preview, limit=240)}"] + + if isinstance(value, dict): + lines = [f"{bullet}{label}" if label else f"{bullet}{len(value)} fields"] + shown = 0 + for child_key, child_value in value.items(): + if child_value in (None, "", [], {}): + continue + lines.extend( + _format_structured_value( + str(child_key), + child_value, + indent=indent + 1, + max_depth=max_depth - 1, + max_items=max_items, + ) + ) + shown += 1 + if shown >= max_items: + remaining = max(0, len(value) - shown) + if remaining: + lines.append(f"{' ' * (indent + 1)}- ... {remaining} more fields") + break + return lines + + if isinstance(value, list): + lines = [f"{bullet}{label} {len(value)} item{'s' if len(value) != 1 else ''}" if label else f"{bullet}{len(value)} item{'s' if len(value) != 1 else ''}"] + for idx, item in enumerate(value[:max_items], 1): + if isinstance(item, dict): + headline = str(item.get("content") or item.get("message") or item.get("title") or item.get("name") or item.get("id") or "").strip() + if headline: + lines.append(f"{' ' * (indent + 1)}{idx}. {_truncate_text(headline, limit=220)}") + for child_key in ("id", "status", "type", "scope", "quality_score", "score", "path", "url"): + child_value = item.get(child_key) + if child_value not in (None, "", [], {}): + lines.append(f"{' ' * (indent + 2)}- **{child_key}:** {_truncate_text(str(child_value), limit=180)}") + else: + lines.append(f"{' ' * (indent + 1)}{idx}.") + for child_key, child_value in list(item.items())[:max_items]: + lines.extend( + _format_structured_value( + str(child_key), + child_value, + indent=indent + 2, + max_depth=max_depth - 1, + max_items=max_items, + ) + ) + elif isinstance(item, list): + lines.append(f"{' ' * (indent + 1)}{idx}. {len(item)} items") + for nested in item[:max_items]: + lines.extend( + _format_structured_value( + "", + nested, + indent=indent + 2, + max_depth=max_depth - 1, + max_items=max_items, + ) + ) + else: + lines.append(f"{' ' * (indent + 1)}{idx}. {_truncate_text(str(item), limit=240)}") + if len(value) > max_items: + lines.append(f"{' ' * (indent + 1)}... {len(value) - max_items} more items") + return lines + + return [f"{bullet}{label} {_truncate_text(str(value), limit=500)}" if label else f"{bullet}{_truncate_text(str(value), limit=500)}"] + + +def _format_generic_structured_result( + tool_name: str, + result: Optional[str], + *, + fallback_to_text: bool = True, +) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, (dict, list)): - return result if isinstance(result, str) and result.strip() else None + return result if fallback_to_text and isinstance(result, str) and result.strip() else None if isinstance(data, list): lines = [f"{tool_name}: {len(data)} item{'s' if len(data) != 1 else ''}"] for item in data[:12]: - lines.append(f"- {_truncate_text(str(item), limit=240)}") + if isinstance(item, (dict, list)): + lines.extend(_format_structured_value("", item, indent=0, max_depth=2, max_items=6)) + else: + lines.append(f"- {_truncate_text(str(item), limit=240)}") + if len(data) > 12: + lines.append(f"... {len(data) - 12} more items") return _truncate_text("\n".join(lines), limit=5000) if data.get("success") is False or data.get("error"): @@ -699,12 +819,9 @@ def _format_generic_structured_result(tool_name: str, result: Optional[str]) -> continue if value in (None, "", [], {}): continue - if isinstance(value, (dict, list)): - preview = json.dumps(value, ensure_ascii=False, default=str) - else: - preview = str(value) - lines.append(f"- **{key}:** {_truncate_text(preview, limit=500)}") - if len(lines) >= 14: + lines.extend(_format_structured_value(str(key), value, indent=0, max_depth=3, max_items=8)) + if len(lines) >= 40: + lines.append("- ... more fields truncated") break content = data.get("content") @@ -744,8 +861,9 @@ def _build_polished_completion_content( if formatter is None and tool_name in _POLISHED_TOOLS: formatter = lambda: _format_generic_structured_result(tool_name, result) if formatter is None: - return None - text = formatter() + text = _format_generic_structured_result(tool_name, result, fallback_to_text=False) + else: + text = formatter() if not text: return None return [_text(text)] @@ -1135,6 +1253,11 @@ def build_tool_start( tool_call_id, title, kind=kind, content=content, locations=locations, ) + if not arguments: + return acp.start_tool_call( + tool_call_id, title, kind=kind, content=None, locations=locations, raw_input=None, + ) + # Generic fallback try: args_text = json.dumps(arguments, indent=2, default=str) @@ -1147,6 +1270,10 @@ def build_tool_start( ) +def _is_structured_json_result(result: Optional[str]) -> bool: + return isinstance(_json_loads_maybe(result), (dict, list)) + + def build_tool_complete( tool_call_id: str, tool_name: str, @@ -1171,7 +1298,7 @@ def build_tool_complete( kind=kind, status="completed", content=content, - raw_output=None if tool_name in _POLISHED_TOOLS else result, + raw_output=None if tool_name in _POLISHED_TOOLS or _is_structured_json_result(result) else result, ) diff --git a/tests/acp/test_tools.py b/tests/acp/test_tools.py index 004b1f32f8..a2d1e3b6d3 100644 --- a/tests/acp/test_tools.py +++ b/tests/acp/test_tools.py @@ -462,6 +462,62 @@ class TestBuildToolComplete: assert "timeout" in text assert result.raw_output is None + def test_build_tool_complete_generically_formats_unknown_json_dict_without_raw_output(self): + result = build_tool_complete( + "tc-recall-search", + "memory_archive_search", + '{"results":[{"id":"obs-1","status":"active","content":"Recall should render as a readable summary."}],"trust":"lower-trust archive evidence"}', + ) + text = result.content[0].content.text + assert "memory_archive_search result" in text + assert "lower-trust archive evidence" in text + assert "Recall should render as a readable summary" in text + assert "{\"results\"" not in text + assert result.raw_output is None + + def test_build_tool_complete_generically_formats_unknown_json_list_without_raw_output(self): + result = build_tool_complete( + "tc-plugin-list", + "some_plugin_tool", + '[{"name":"alpha","status":"ok"},{"name":"beta","status":"ok"}]', + ) + text = result.content[0].content.text + assert "some_plugin_tool: 2 items" in text + assert "alpha" in text + assert result.raw_output is None + + def test_build_tool_complete_generically_formats_nested_json_without_inline_blob(self): + result = build_tool_complete( + "tc-recall-stats", + "memory_archive_stats", + '{"observations_by_status":{"active":12,"rejected":83},"capabilities":["sqlite-fts5-archive","hash-chain-audit"],"audit":{"ok":true,"count":208,"head":"abc123"}}', + ) + text = result.content[0].content.text + assert "**observations_by_status:**" in text + assert "**active:** 12" in text + assert "**rejected:** 83" in text + assert "**capabilities:** 2 items" in text + assert "sqlite-fts5-archive" in text + assert "**audit:**" in text + assert "**ok:** True" in text + assert "{\"active\"" not in text + assert "[\"sqlite" not in text + assert result.raw_output is None + + def test_build_tool_complete_for_search_files_files_only_formats_file_list(self): + result = build_tool_complete( + "tc-search-files", + "search_files", + '{"total_count":36,"files":["/home/nour/.hermes/config.yaml","/home/nour/.hermes/profiles/recall-test/config.yaml"],"truncated":true}', + ) + text = result.content[0].content.text + assert "File search results" in text + assert "Found 36 files; showing 2." in text + assert "/home/nour/.hermes/config.yaml" in text + assert "use offset to page" in text + assert "{\"total_count\"" not in text + assert result.raw_output is None + def test_build_tool_complete_truncates_large_output(self): """Very large outputs should be truncated.""" big_output = "x" * 10000 From b38d2d133bbb76fb9d91f08c3b1a838f5ce0a5b9 Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 15 May 2026 21:42:16 +0100 Subject: [PATCH 030/338] fix(acp): mark failed tool completions --- acp_adapter/tools.py | 24 +++++++++++++++++++++++- tests/acp/test_tools.py | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index 4524b8c6f1..22b181650c 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -202,6 +202,28 @@ def _json_loads_maybe(value: Optional[str]) -> Any: return None +def _tool_result_failed(result: Optional[str]) -> bool: + """Return True when a structured Hermes tool result clearly failed. + + Keep this deliberately conservative. Plain text can contain words like + "error" because tests failed or a command printed diagnostics; Zed should + only receive ACP failed status for structured tool-level failures. + """ + data = _json_loads_maybe(result) + if not isinstance(data, dict): + return False + + for key in ("success", "ok"): + if data.get(key) is False: + return True + + exit_code = data.get("exit_code", data.get("returncode")) + if isinstance(exit_code, int) and exit_code != 0: + return True + + return False + + def _truncate_text(text: str, limit: int = 5000) -> str: if len(text) <= limit: return text @@ -1296,7 +1318,7 @@ def build_tool_complete( return acp.update_tool_call( tool_call_id, kind=kind, - status="completed", + status="failed" if _tool_result_failed(result) else "completed", content=content, raw_output=None if tool_name in _POLISHED_TOOLS or _is_structured_json_result(result) else result, ) diff --git a/tests/acp/test_tools.py b/tests/acp/test_tools.py index a2d1e3b6d3..efce0d24bf 100644 --- a/tests/acp/test_tools.py +++ b/tests/acp/test_tools.py @@ -345,6 +345,30 @@ class TestBuildToolComplete: assert "hello" in text assert result.raw_output is None + def test_build_tool_complete_marks_success_false_as_failed(self): + result = build_tool_complete("tc-fail", "skill_manage", '{"success": false, "error": "boom"}') + assert result.status == "failed" + + def test_build_tool_complete_marks_ok_false_as_failed(self): + result = build_tool_complete("tc-fail", "some_tool", '{"ok": false, "error": "boom"}') + assert result.status == "failed" + + def test_build_tool_complete_marks_exit_code_nonzero_as_failed(self): + result = build_tool_complete("tc-fail", "terminal", '{"output": "bad", "exit_code": 2}') + assert result.status == "failed" + + def test_build_tool_complete_marks_returncode_nonzero_as_failed(self): + result = build_tool_complete("tc-fail", "execute_code", '{"output": "bad", "returncode": 2}') + assert result.status == "failed" + + def test_build_tool_complete_keeps_plain_error_text_completed(self): + result = build_tool_complete("tc-ok", "terminal", "tests failed: 1 assertion error") + assert result.status == "completed" + + def test_build_tool_complete_keeps_json_error_without_failure_flag_completed(self): + result = build_tool_complete("tc-ok", "some_tool", '{"error": "timeout while reading optional source"}') + assert result.status == "completed" + def test_build_tool_complete_for_skill_manage_summarizes_without_raw_json(self): result = build_tool_complete( "tc-skill-manage", From 9cf1140caaefa6f275667ed1b11a61aabb6199dd Mon Sep 17 00:00:00 2001 From: HenkDz Date: Sat, 16 May 2026 11:50:50 +0100 Subject: [PATCH 031/338] fix(acp): treat polished tool error payloads as failed --- acp_adapter/tools.py | 11 +++++++++-- tests/acp/test_tools.py | 4 ++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index 22b181650c..8de0b6b1ac 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -202,7 +202,7 @@ def _json_loads_maybe(value: Optional[str]) -> Any: return None -def _tool_result_failed(result: Optional[str]) -> bool: +def _tool_result_failed(result: Optional[str], tool_name: str | None = None) -> bool: """Return True when a structured Hermes tool result clearly failed. Keep this deliberately conservative. Plain text can contain words like @@ -221,6 +221,13 @@ def _tool_result_failed(result: Optional[str]) -> bool: if isinstance(exit_code, int) and exit_code != 0: return True + # Hermes core/polished tools commonly report tool-level failures as a + # structured {"error": "..."} payload without an explicit success flag. + # Keep generic plugin/unknown tool payloads conservative to avoid marking + # optional diagnostic messages as failed. + if tool_name in _POLISHED_TOOLS and data.get("error") and not data.get("content"): + return True + return False @@ -1318,7 +1325,7 @@ def build_tool_complete( return acp.update_tool_call( tool_call_id, kind=kind, - status="failed" if _tool_result_failed(result) else "completed", + status="failed" if _tool_result_failed(result, tool_name) else "completed", content=content, raw_output=None if tool_name in _POLISHED_TOOLS or _is_structured_json_result(result) else result, ) diff --git a/tests/acp/test_tools.py b/tests/acp/test_tools.py index efce0d24bf..a077160b1f 100644 --- a/tests/acp/test_tools.py +++ b/tests/acp/test_tools.py @@ -365,6 +365,10 @@ class TestBuildToolComplete: result = build_tool_complete("tc-ok", "terminal", "tests failed: 1 assertion error") assert result.status == "completed" + def test_build_tool_complete_marks_structured_polished_tool_error_as_failed(self): + result = build_tool_complete("tc-fail", "read_file", '{"error": "File not found"}') + assert result.status == "failed" + def test_build_tool_complete_keeps_json_error_without_failure_flag_completed(self): result = build_tool_complete("tc-ok", "some_tool", '{"error": "timeout while reading optional source"}') assert result.status == "completed" From eda1c97a1ef8c7d8505f40be1839929e130ad4c4 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 18 May 2026 11:43:19 -0700 Subject: [PATCH 032/338] fix(acp): also mark raised-exception tool results as failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends #26573 to also catch the case the original PR deliberately left out: when a tool raises an exception, the agent's tool executor wraps it in a canonical 'Error executing tool '': ...' string prefix (see agent/tool_executor.py around the try/except). That prefix is unique to the wrapper and cannot legitimately appear in well-behaved tool output, so it is a safe signal that the tool blew up. Without this, the canonical 'tool raised' case still rendered as a green 'completed' row in Zed despite being a runtime failure — exactly the class of bug #26573 set out to fix. Adds a positive test (raised-exception prefix -> failed) and a negative test (bare 'Error:' word in legit tool output stays completed) so a future contributor doesn't accidentally widen the rule to false-positive on compiler/linter diagnostics. --- acp_adapter/tools.py | 9 +++++++++ tests/acp/test_tools.py | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index 8de0b6b1ac..be4e49d013 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -209,6 +209,15 @@ def _tool_result_failed(result: Optional[str], tool_name: str | None = None) -> "error" because tests failed or a command printed diagnostics; Zed should only receive ACP failed status for structured tool-level failures. """ + # Raised exceptions from the agent's tool executor get wrapped in a + # canonical "Error executing tool '': ..." prefix (see + # agent/tool_executor.py around the try/except). That prefix is uniquely + # produced by the wrapper itself — it cannot legitimately appear in + # well-behaved tool output. Catch it so a tool that blew up shows as + # failed in Zed instead of misleadingly green. + if isinstance(result, str) and result.startswith("Error executing tool '"): + return True + data = _json_loads_maybe(result) if not isinstance(data, dict): return False diff --git a/tests/acp/test_tools.py b/tests/acp/test_tools.py index a077160b1f..455ee25194 100644 --- a/tests/acp/test_tools.py +++ b/tests/acp/test_tools.py @@ -365,6 +365,31 @@ class TestBuildToolComplete: result = build_tool_complete("tc-ok", "terminal", "tests failed: 1 assertion error") assert result.status == "completed" + def test_build_tool_complete_marks_raised_exception_prefix_as_failed(self): + """The agent's tool executor wraps raised exceptions in a canonical + "Error executing tool '': ..." prefix. That prefix is unique to + the wrapper and means the tool blew up, so it must surface as failed + in Zed regardless of whether the body parses as JSON. + """ + result = build_tool_complete( + "tc-fail-exc", + "patch", + "Error executing tool 'patch': KeyError: 'foo'", + ) + assert result.status == "failed" + + def test_build_tool_complete_does_not_match_error_word_alone(self): + """Bare 'Error: ...' messages (without the unique 'Error executing + tool '':' prefix) must still be reported as completed — they + legitimately appear in compiler/linter/test output. + """ + result = build_tool_complete( + "tc-ok-error-word", + "terminal", + "Error: pytest collected 0 items", + ) + assert result.status == "completed" + def test_build_tool_complete_marks_structured_polished_tool_error_as_failed(self): result = build_tool_complete("tc-fail", "read_file", '{"error": "File not found"}') assert result.status == "failed" From 741a34945810a7cb5142804660f9e6108e2d49ec Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 15 May 2026 20:32:57 +0100 Subject: [PATCH 033/338] fix(acp): refresh session info after auto-title --- acp_adapter/server.py | 38 +++++++++++++++++++++++++++++++++++ tests/acp/test_server.py | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/acp_adapter/server.py b/acp_adapter/server.py index e4fc336b66..1f6064d67f 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -46,6 +46,7 @@ from acp.schema import ( ResourceContentBlock, SessionCapabilities, SessionForkCapabilities, + SessionInfoUpdate, SessionListCapabilities, SessionMode, SessionModeState, @@ -707,6 +708,35 @@ class HermesACPAgent(acp.Agent): exc_info=True, ) + async def _send_session_info_update(self, session_id: str) -> None: + """Send ACP native session metadata after Hermes changes it.""" + if not self._conn: + return + try: + row = self.session_manager._get_db().get_session(session_id) + except Exception: + logger.debug("Could not read ACP session info for %s", session_id, exc_info=True) + return + if not row: + return + + title = row.get("title") + updated_at = row.get("updated_at") + if updated_at is not None and not isinstance(updated_at, str): + updated_at = str(updated_at) + update = SessionInfoUpdate( + session_update="session_info_update", + title=title if isinstance(title, str) and title.strip() else None, + updated_at=updated_at, + ) + try: + await self._conn.session_update( + session_id=session_id, + update=update, + ) + except Exception: + logger.debug("Could not send ACP session info update for %s", session_id, exc_info=True) + def _schedule_usage_update(self, state: SessionState) -> None: """Schedule native context indicator refresh after ACP responses.""" if not self._conn: @@ -1471,12 +1501,20 @@ class HermesACPAgent(acp.Agent): try: from agent.title_generator import maybe_auto_title + def _notify_title_update(_title: str) -> None: + if conn: + loop.call_soon_threadsafe( + asyncio.create_task, + self._send_session_info_update(session_id), + ) + maybe_auto_title( self.session_manager._get_db(), session_id, user_text, final_response, state.history, + title_callback=_notify_title_update, ) except Exception: logger.debug("Failed to auto-title ACP session %s", session_id, exc_info=True) diff --git a/tests/acp/test_server.py b/tests/acp/test_server.py index 79b7e56f2b..a1c46a27f6 100644 --- a/tests/acp/test_server.py +++ b/tests/acp/test_server.py @@ -29,6 +29,7 @@ from acp.schema import ( SetSessionModelResponse, SetSessionModeResponse, SessionInfo, + SessionInfoUpdate, TextContentBlock, ToolCallProgress, ToolCallStart, @@ -1140,6 +1141,48 @@ class TestPrompt: assert mock_title.call_args.args[1] == new_resp.session_id assert mock_title.call_args.args[2] == "fix the broken ACP history" assert mock_title.call_args.args[3] == "Here is the fix." + assert callable(mock_title.call_args.kwargs["title_callback"]) + + @pytest.mark.asyncio + async def test_prompt_sends_session_info_update_after_auto_title(self, agent): + mock_conn = MagicMock(spec=acp.Client) + mock_conn.session_update = AsyncMock() + agent._conn = mock_conn + + resp = await agent.new_session(cwd="/tmp") + state = agent.session_manager.get_session(resp.session_id) + state.agent.run_conversation = MagicMock(return_value={ + "final_response": "Done.", + "messages": [ + {"role": "user", "content": "fix zed titles"}, + {"role": "assistant", "content": "Done."}, + ], + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }) + + def fake_auto_title(db, session_id, user_text, final_response, history, **kwargs): + db.set_session_title(session_id, "Fix Zed titles") + kwargs["title_callback"]("Fix Zed titles") + + with patch("agent.title_generator.maybe_auto_title", side_effect=fake_auto_title): + mock_conn.session_update.reset_mock() + await agent.prompt( + session_id=resp.session_id, + prompt=[TextContentBlock(type="text", text="fix zed titles")], + ) + await asyncio.sleep(0) + await asyncio.sleep(0) + + updates = [ + call.kwargs.get("update") or call.args[1] + for call in mock_conn.session_update.await_args_list + ] + info_updates = [u for u in updates if isinstance(u, SessionInfoUpdate)] + assert len(info_updates) == 1 + assert info_updates[0].session_update == "session_info_update" + assert info_updates[0].title == "Fix Zed titles" @pytest.mark.asyncio async def test_prompt_populates_usage_from_top_level_run_conversation_fields(self, agent): From 2057977102da26cbf0fa9e699fa4432dbf017566 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 18 May 2026 11:45:35 -0700 Subject: [PATCH 034/338] fix(acp): use refresh moment as updated_at on session info push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #26543. The sessions table does not have an updated_at column (see hermes_state.py — only started_at/ended_at), so row.get('updated_at') always returned None and the str() coercion was dead code. Use datetime.now(UTC).isoformat() instead, which reflects exactly what the field means here: 'the title was refreshed at this moment'. Drop the dead coercion. --- acp_adapter/server.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 1f6064d67f..26d5809d1a 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from datetime import datetime, timezone import base64 import contextvars import json @@ -721,9 +722,11 @@ class HermesACPAgent(acp.Agent): return title = row.get("title") - updated_at = row.get("updated_at") - if updated_at is not None and not isinstance(updated_at, str): - updated_at = str(updated_at) + # The `sessions` table does not have an `updated_at` column (see + # hermes_state.py schema — only started_at/ended_at). Use "now" as + # the updated_at since we're emitting this notification precisely + # because the title was just refreshed. + updated_at = datetime.now(timezone.utc).isoformat() update = SessionInfoUpdate( session_update="session_info_update", title=title if isinstance(title, str) and title.strip() else None, From 52e3bfc2f4440186763a7840c0be669a2659c6dd Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 15 May 2026 23:19:20 +0100 Subject: [PATCH 035/338] feat(acp): enrich permission request cards --- acp_adapter/permissions.py | 24 ++++++++++++++++++++++-- tests/acp/test_permissions.py | 28 +++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/acp_adapter/permissions.py b/acp_adapter/permissions.py index 76474e55da..29bd101edd 100644 --- a/acp_adapter/permissions.py +++ b/acp_adapter/permissions.py @@ -23,11 +23,21 @@ _OPTION_ID_TO_HERMES = { "allow_session": "session", "allow_always": "always", "deny": "deny", + "deny_always": "deny", } _PERMISSION_REQUEST_IDS = count(1) +def _permission_option_supports_kind(kind: str) -> bool: + """Return whether the installed ACP SDK accepts a permission option kind.""" + try: + PermissionOption(option_id="__probe__", kind=kind, name="probe") + except Exception: + return False + return True + + def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption]: """Return ACP options that match Hermes approval semantics.""" options = [ @@ -49,6 +59,14 @@ def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption ), ) options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny")) + if _permission_option_supports_kind("reject_always"): + options.append( + PermissionOption( + option_id="deny_always", + kind="reject_always", + name="Deny always", + ), + ) return options @@ -62,12 +80,14 @@ def _build_permission_tool_call(command: str, description: str): import acp as _acp tool_call_id = f"perm-check-{next(_PERMISSION_REQUEST_IDS)}" + title = f"{description}: {command}" if description else command + content_text = f"{description}\n$ {command}" if description else f"$ {command}" return _acp.update_tool_call( tool_call_id, - title=description, + title=title, kind="execute", status="pending", - content=[_acp.tool_content(_acp.text_block(f"$ {command}"))], + content=[_acp.tool_content(_acp.text_block(content_text))], raw_input={"command": command, "description": description}, ) diff --git a/tests/acp/test_permissions.py b/tests/acp/test_permissions.py index b4c121829d..a7248aa717 100644 --- a/tests/acp/test_permissions.py +++ b/tests/acp/test_permissions.py @@ -76,12 +76,22 @@ class TestApprovalBridge: assert tool_call.tool_call_id.startswith("perm-check-") assert tool_call.kind == "execute" assert tool_call.status == "pending" - assert tool_call.title == "dangerous command" + assert "dangerous command" in tool_call.title + assert "rm -rf /" in tool_call.title + content_text = tool_call.content[0].content.text + assert "$ rm -rf /" in content_text + assert "dangerous command" in content_text assert tool_call.raw_input == { "command": "rm -rf /", "description": "dangerous command", } - assert option_ids == ["allow_once", "allow_session", "allow_always", "deny"] + assert option_ids == [ + "allow_once", + "allow_session", + "allow_always", + "deny", + "deny_always", + ] def test_tool_call_ids_are_unique(self): _, first_kwargs, _, _, _ = _invoke_callback( @@ -103,7 +113,19 @@ class TestApprovalBridge: option_ids = [option.option_id for option in kwargs["options"]] assert result == "session" - assert option_ids == ["allow_once", "allow_session", "deny"] + assert option_ids == ["allow_once", "allow_session", "deny", "deny_always"] + + def test_reject_always_outcome_denies_without_changing_policy(self): + result, kwargs, _, _, _ = _invoke_callback( + AllowedOutcome(option_id="deny_always", outcome="selected"), + use_prompt_path=True, + ) + + deny_always = [option for option in kwargs["options"] if option.option_id == "deny_always"] + + assert result == "deny" + assert len(deny_always) == 1 + assert deny_always[0].kind == "reject_always" def test_allow_always_maps_correctly(self): result, _, _, _, _ = _invoke_callback( From 6fa1701bd3d9dd41923adc30fe55ec3f02c693ce Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 18 May 2026 15:20:31 -0400 Subject: [PATCH 036/338] feat(web): mobile dashboard UX polish (#28127) * feat(web): mobile dashboard UX polish Bottom sheets for sidebar theme/language pickers on narrow viewports with enter/exit animation and drag-to-close; inline header badges beside titles; bottom padding on the route outlet for scroll clearance; profiles loading uses a unicode braille spinner; align profile/cron card actions to the top; viewport-fit cover and supporting layout tweaks across dashboard pages. Co-authored-by: Cursor * Fix Nix web npm hash and mobile sheet accessibility. Align fetchNpmDeps in nix/web.nix with web/package-lock.json for CI. Improve BottomPickSheet backdrop labeling, avoid aria-hidden on the dialog during exit animation, and wire theme/language sheets with listbox semantics and localized dismiss labels. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- nix/web.nix | 2 +- web/index.html | 5 +- web/package-lock.json | 33 +++- web/package.json | 1 + web/src/App.tsx | 14 +- web/src/components/BottomPickSheet.tsx | 224 ++++++++++++++++++++++++ web/src/components/LanguageSwitcher.tsx | 161 ++++++++++++----- web/src/components/ThemeSwitcher.tsx | 172 +++++++++++------- web/src/contexts/PageHeaderProvider.tsx | 53 ++++-- web/src/hooks/useBelowBreakpoint.ts | 19 ++ web/src/i18n/context.tsx | 39 +++-- web/src/main.tsx | 1 + web/src/pages/AnalyticsPage.tsx | 2 +- web/src/pages/ConfigPage.tsx | 10 +- web/src/pages/CronPage.tsx | 2 +- web/src/pages/EnvPage.tsx | 7 +- web/src/pages/LogsPage.tsx | 28 ++- web/src/pages/ModelsPage.tsx | 51 +++--- web/src/pages/PluginsPage.tsx | 58 +++--- web/src/pages/ProfilesPage.tsx | 42 ++++- web/src/pages/SessionsPage.tsx | 115 ++++++------ web/src/pages/SkillsPage.tsx | 21 +-- web/src/themes/context.tsx | 12 +- web/src/themes/index.ts | 2 +- 24 files changed, 779 insertions(+), 295 deletions(-) create mode 100644 web/src/components/BottomPickSheet.tsx create mode 100644 web/src/hooks/useBelowBreakpoint.ts diff --git a/nix/web.nix b/nix/web.nix index a5793dff7a..f335bb9fa9 100644 --- a/nix/web.nix +++ b/nix/web.nix @@ -4,7 +4,7 @@ let src = ../web; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-HWB1piIPglTXbzQHXFYHLgVZIbDb60esupXSQGa1+lI="; + hash = "sha256-H98reD4N++WroZOQ9NFrKtC5aiHj6KqaYDzUOiZA2bE="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; }; diff --git a/web/index.html b/web/index.html index e420ce6dba..fe7cda519d 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,10 @@ - + Hermes Agent - Dashboard diff --git a/web/package-lock.json b/web/package-lock.json index 7f987c5a1d..149aa24e42 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -19,6 +19,7 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "flag-icons": "^7.5.0", "gsap": "^3.15.0", "leva": "^0.10.1", "lucide-react": "^0.577.0", @@ -76,6 +77,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1124,6 +1126,7 @@ "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==", "license": "ISC", + "peer": true, "dependencies": { "d3": "^7.9.0", "interval-tree-1d": "^1.0.0", @@ -1776,6 +1779,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz", "integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -2481,6 +2485,7 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2490,6 +2495,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2500,6 +2506,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2564,6 +2571,7 @@ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", @@ -2892,6 +2900,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3044,6 +3053,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3551,6 +3561,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -3864,6 +3875,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4143,6 +4155,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flag-icons": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz", + "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==", + "license": "MIT" + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -4242,7 +4260,8 @@ "version": "3.15.0", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license." + "license": "Standard 'no charge' license: https://gsap.com/standard-license.", + "peer": true }, "node_modules/has-flag": { "version": "4.0.0", @@ -4548,6 +4567,7 @@ "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", @@ -4986,6 +5006,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -5113,6 +5134,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5184,6 +5206,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5203,6 +5226,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5562,7 +5586,8 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tinyglobby": { "version": "0.2.16", @@ -5627,6 +5652,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5725,6 +5751,7 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -5740,6 +5767,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5861,6 +5889,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/package.json b/web/package.json index 50456076b6..04cbe290b3 100644 --- a/web/package.json +++ b/web/package.json @@ -24,6 +24,7 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "flag-icons": "^7.5.0", "gsap": "^3.15.0", "leva": "^0.10.1", "lucide-react": "^0.577.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index 71a97113c2..987252ce0b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -424,8 +424,8 @@ export default function App() {
-
+
@@ -588,8 +588,8 @@ export default function App() { "relative z-2 flex min-w-0 min-h-0 flex-1 flex-col", "px-3 sm:px-6", isChatRoute - ? "pb-3 pt-1 sm:pb-4 sm:pt-2 lg:pt-4" - : "pt-2 sm:pt-4 lg:pt-6 pb-4 sm:pb-8", + ? "pb-0 pt-1 sm:pt-2 lg:pt-4" + : "pt-2 sm:pt-4 lg:pt-6", isDocsRoute && "min-h-0 flex-1", )} > @@ -597,6 +597,8 @@ export default function App() {
| null>(null); + const sheetRef = useRef(null); + const dragTrackingRef = useRef(false); + const dragStartYRef = useRef(0); + const dragOffsetRef = useRef(0); + + const reducedMotion = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + const syncDragPx = (next: number) => { + dragOffsetRef.current = next; + setDragOffsetPx(next); + }; + + useEffect(() => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + + const ms = reducedMotion ? 0 : SHEET_TRANSITION_MS; + + let openRafId = 0; + let exitRafId = 0; + + if (open) { + openRafId = requestAnimationFrame(() => { + dragTrackingRef.current = false; + dragOffsetRef.current = 0; + setDragActive(false); + setDragOffsetPx(0); + setRenderPortal(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => setEntered(true)); + }); + }); + } else { + exitRafId = requestAnimationFrame(() => { + dragTrackingRef.current = false; + setDragActive(false); + setEntered(false); + closeTimerRef.current = window.setTimeout(() => { + dragOffsetRef.current = 0; + setDragOffsetPx(0); + setRenderPortal(false); + closeTimerRef.current = null; + }, ms); + }); + } + + return () => { + cancelAnimationFrame(openRafId); + cancelAnimationFrame(exitRafId); + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }; + }, [open, reducedMotion]); + + useEffect(() => { + if (!renderPortal) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [renderPortal]); + + if (!renderPortal || typeof document === "undefined") return null; + + const durationClass = reducedMotion ? "duration-0" : "duration-[280ms]"; + + const draggingVisual = dragActive || dragOffsetPx > 0; + + const onDragPointerDown = (e: ReactPointerEvent) => { + if (reducedMotion || !entered) return; + if (e.pointerType === "mouse" && e.button !== 0) return; + + dragTrackingRef.current = true; + setDragActive(true); + dragStartYRef.current = e.clientY; + syncDragPx(0); + e.currentTarget.setPointerCapture(e.pointerId); + }; + + const onDragPointerMove = (e: ReactPointerEvent) => { + if (!dragTrackingRef.current) return; + const dy = e.clientY - dragStartYRef.current; + const next = Math.max(0, dy); + const sheetH = sheetRef.current?.offsetHeight ?? 560; + syncDragPx(Math.min(next, sheetH)); + }; + + const endDrag = (e: ReactPointerEvent) => { + if (!dragTrackingRef.current) return; + dragTrackingRef.current = false; + setDragActive(false); + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch { + /* already released */ + } + + const sheetH = sheetRef.current?.offsetHeight ?? 560; + const threshold = Math.max(CLOSE_DRAG_MIN_PX, sheetH * CLOSE_DRAG_RATIO); + const d = dragOffsetRef.current; + + if (d >= threshold) { + onClose(); + return; + } + syncDragPx(0); + }; + + return createPortal( +
+ - {open && ( -
setOpen(false)} + open={open} + title={sheetTitle} > - {allLocales.map(([code, meta]) => { - const selected = code === locale; - return ( - - ); - })} +
+ +
+ + )} + + {open && !useMobileSheet && ( +
+
)}
); } + +function LanguageSwitcherOptions({ + allLocales, + locale, + setLocale, + setOpen, +}: LanguageSwitcherOptionsProps) { + return ( + <> + {allLocales.map(([code, meta]) => { + const selected = code === locale; + + return ( + + ); + })} + + ); +} + +function LocaleFlagIcon({ countryCode }: LocaleFlagIconProps) { + return ( + + ); +} + +interface LanguageSwitcherOptionsProps { + allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>; + locale: Locale; + setLocale: (code: Locale) => void; + setOpen: (open: boolean) => void; +} + +interface LanguageSwitcherProps { + dropUp?: boolean; +} + +interface LocaleFlagIconProps { + countryCode: string; +} diff --git a/web/src/components/ThemeSwitcher.tsx b/web/src/components/ThemeSwitcher.tsx index 90a3d11ebd..17e0ae3d6d 100644 --- a/web/src/components/ThemeSwitcher.tsx +++ b/web/src/components/ThemeSwitcher.tsx @@ -2,9 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Palette, Check } from "lucide-react"; import { Button } from "@nous-research/ui/ui/components/button"; import { ListItem } from "@nous-research/ui/ui/components/list-item"; +import { BottomPickSheet } from "@/components/BottomPickSheet"; import { Typography } from "@/components/NouiTypography"; +import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint"; import { BUILTIN_THEMES, useTheme } from "@/themes"; -import type { DashboardTheme } from "@/themes"; +import type { DashboardTheme, ThemeListEntry } from "@/themes"; import { useI18n } from "@/i18n"; import { cn } from "@/lib/utils"; @@ -17,18 +19,31 @@ import { cn } from "@/lib/utils"; * * When placed at the bottom of a container (e.g. the sidebar rail), pass * `dropUp` so the menu opens above the trigger instead of clipping below - * the viewport. + * the viewport. On viewports below the `sm` breakpoint, `dropUp` uses a + * bottom sheet portaled to `document.body` so the picker is not clipped by + * the sidebar (same idea as a responsive Drawer). */ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) { const { themeName, availableThemes, setTheme } = useTheme(); const { t } = useI18n(); const [open, setOpen] = useState(false); const wrapperRef = useRef(null); + const narrowViewport = useBelowBreakpoint(640); + const useMobileSheet = Boolean(dropUp && narrowViewport); const close = useCallback(() => setOpen(false), []); useEffect(() => { if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, close]); + + useEffect(() => { + if (!open || useMobileSheet) return; const onMouseDown = (e: MouseEvent) => { if ( wrapperRef.current && @@ -37,19 +52,13 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) { close(); } }; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") close(); - }; document.addEventListener("mousedown", onMouseDown); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onMouseDown); - document.removeEventListener("keydown", onKey); - }; - }, [open, close]); + return () => document.removeEventListener("mousedown", onMouseDown); + }, [open, close, useMobileSheet]); const current = availableThemes.find((th) => th.name === themeName); const label = current?.label ?? themeName; + const sheetTitle = t.theme?.title ?? "Theme"; return (
@@ -74,77 +83,113 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) { - {open && ( + {useMobileSheet && ( + +
+ +
+
+ )} + + {open && !useMobileSheet && (
- {t.theme?.title ?? "Theme"} + {sheetTitle}
- {availableThemes.map((th) => { - const isActive = th.name === themeName; - const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition; - - return ( - { - setTheme(th.name); - close(); - }} - className="gap-3" - > - {paletteTheme ? ( - - ) : ( - - )} - -
- - {th.label} - - {th.description && ( - - {th.description} - - )} -
- - -
- ); - })} +
)}
); } +function ThemeSwitcherOptions({ + availableThemes, + close, + setTheme, + themeName, +}: ThemeSwitcherOptionsProps) { + return ( + <> + {availableThemes.map((th) => { + const isActive = th.name === themeName; + const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition; + + return ( + { + setTheme(th.name); + close(); + }} + role="option" + > + {paletteTheme ? ( + + ) : ( + + )} + +
+ + {th.label} + + {th.description && ( + + {th.description} + + )} +
+ + +
+ ); + })} + + ); +} + function ThemeSwatch({ theme }: { theme: DashboardTheme }) { const { background, midground, warmGlow } = theme.palette; return ( @@ -168,6 +213,13 @@ function PlaceholderSwatch() { ); } +interface ThemeSwitcherOptionsProps { + availableThemes: ThemeListEntry[]; + close: () => void; + setTheme: (name: string) => void; + themeName: string; +} + interface ThemeSwitcherProps { dropUp?: boolean; } diff --git a/web/src/contexts/PageHeaderProvider.tsx b/web/src/contexts/PageHeaderProvider.tsx index 4184ecb3d9..9fdd6215e3 100644 --- a/web/src/contexts/PageHeaderProvider.tsx +++ b/web/src/contexts/PageHeaderProvider.tsx @@ -35,6 +35,9 @@ export function PageHeaderProvider({ const displayTitle = titleOverride ?? defaultTitle; const isChatRoute = pathname === "/chat" || pathname === "/chat/"; + /** Env jump-nav is wide — stack below title on small screens so KEYS stays readable. */ + const isEnvRoute = + pathname === "/env" || pathname.startsWith("/env/"); const value = useMemo( () => ({ @@ -51,37 +54,65 @@ export function PageHeaderProvider({
-
+

{displayTitle}

- {afterTitle} + {afterTitle ? ( +
+ {afterTitle} +
+ ) : null}
{end ? (
{end} @@ -93,6 +124,8 @@ export function PageHeaderProvider({
+ typeof window !== "undefined" ? window.matchMedia(query).matches : false, + ); + + useEffect(() => { + const mql = window.matchMedia(query); + const sync = () => setMatches(mql.matches); + sync(); + mql.addEventListener("change", sync); + return () => mql.removeEventListener("change", sync); + }, [query]); + + return matches; +} diff --git a/web/src/i18n/context.tsx b/web/src/i18n/context.tsx index 7d6fecf5c9..e31ffa6505 100644 --- a/web/src/i18n/context.tsx +++ b/web/src/i18n/context.tsx @@ -38,25 +38,26 @@ const TRANSLATIONS: Record = { // Display metadata for the language picker — endonym (native name) so users // recognize their language even if they don't speak the current UI language, -// plus a flag emoji for visual scanning. Exposed as a constant so the -// LanguageSwitcher and any future settings page can share the same list. -export const LOCALE_META: Record = { - en: { name: "English", flag: "đŸ‡Ŧ🇧" }, - zh: { name: "įŽ€äŊ“中文", flag: "đŸ‡¨đŸ‡ŗ" }, - "zh-hant": { name: "įšéĢ”ä¸­æ–‡", flag: "🇹đŸ‡ŧ" }, - ja: { name: "æ—ĨæœŦčĒž", flag: "đŸ‡¯đŸ‡ĩ" }, - de: { name: "Deutsch", flag: "🇩đŸ‡Ē" }, - es: { name: "EspaÃąol", flag: "đŸ‡Ē🇸" }, - fr: { name: "Français", flag: "đŸ‡Ģ🇷" }, - tr: { name: "TÃŧrkçe", flag: "🇹🇷" }, - uk: { name: "ĐŖĐēŅ€Đ°Ņ—ĐŊҁҌĐēа", flag: "đŸ‡ēđŸ‡Ļ" }, - af: { name: "Afrikaans", flag: "đŸ‡ŋđŸ‡Ļ" }, - ko: { name: "한ęĩ­ė–´", flag: "🇰🇷" }, - it: { name: "Italiano", flag: "🇮🇹" }, - ga: { name: "Gaeilge", flag: "🇮đŸ‡Ē" }, - pt: { name: "PortuguÃĒs", flag: "đŸ‡ĩ🇹" }, - ru: { name: "Đ ŅƒŅŅĐēиК", flag: "🇷đŸ‡ē" }, - hu: { name: "Magyar", flag: "🇭đŸ‡ē" }, +// plus a flag-icons sprite (ISO 3166-1 alpha-2) for visual scanning. +// Exposed as a constant so the LanguageSwitcher and any future settings page +// can share the same list. +export const LOCALE_META: Record = { + en: { name: "English", flagCountryCode: "gb" }, + zh: { name: "įŽ€äŊ“中文", flagCountryCode: "cn" }, + "zh-hant": { name: "įšéĢ”ä¸­æ–‡", flagCountryCode: "tw" }, + ja: { name: "æ—ĨæœŦčĒž", flagCountryCode: "jp" }, + de: { name: "Deutsch", flagCountryCode: "de" }, + es: { name: "EspaÃąol", flagCountryCode: "es" }, + fr: { name: "Français", flagCountryCode: "fr" }, + tr: { name: "TÃŧrkçe", flagCountryCode: "tr" }, + uk: { name: "ĐŖĐēŅ€Đ°Ņ—ĐŊҁҌĐēа", flagCountryCode: "ua" }, + af: { name: "Afrikaans", flagCountryCode: "za" }, + ko: { name: "한ęĩ­ė–´", flagCountryCode: "kr" }, + it: { name: "Italiano", flagCountryCode: "it" }, + ga: { name: "Gaeilge", flagCountryCode: "ie" }, + pt: { name: "PortuguÃĒs", flagCountryCode: "pt" }, + ru: { name: "Đ ŅƒŅŅĐēиК", flagCountryCode: "ru" }, + hu: { name: "Magyar", flagCountryCode: "hu" }, }; const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[]; diff --git a/web/src/main.tsx b/web/src/main.tsx index e0d00fdf63..c727f0e3f7 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,5 +1,6 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; +import "flag-icons/css/flag-icons.min.css"; import "./index.css"; import App from "./App"; import { SystemActionsProvider } from "./contexts/SystemActions"; diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index 4896e76063..c97af1deed 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -439,7 +439,7 @@ export default function AnalyticsPage() { ); setEnd( showTokens === false ? null : ( -
+
{PERIODS.map((p) => ( diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx index da9afe9236..bfe1be3ec7 100644 --- a/web/src/pages/LogsPage.tsx +++ b/web/src/pages/LogsPage.tsx @@ -46,6 +46,12 @@ const LINE_COLORS: Record = { const toOptions = (values: readonly T[]) => values.map((v) => ({ value: v, label: v })); +const filterGroupClass = + "flex min-w-0 w-full flex-col items-start gap-1.5 sm:w-auto sm:max-w-full sm:flex-row sm:items-center"; + +const segmentedClass = + "w-fit max-w-full flex-wrap justify-start self-start"; + export default function LogsPage() { const [file, setFile] = useState<(typeof FILES)[number]>("agent"); const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL"); @@ -87,7 +93,7 @@ export default function LogsPage() { , ); setEnd( -
+
+
- + - + - + - + setLineCount(Number(v) as (typeof LINE_COUNTS)[number]) @@ -190,7 +200,7 @@ export default function LogsPage() {
- + @@ -206,7 +216,7 @@ export default function LogsPage() {
{lines.length === 0 && !loading && (

diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index f09104d424..134ff3eab3 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -336,7 +336,9 @@ function ModelCard({ )?.task ?? null; return ( - +

@@ -666,22 +668,20 @@ function ModelSettingsPanel({ ).length ?? 0; return ( - - -
-
- - Model Settings - - applies to new sessions - -
+ + +
+ + Model Settings + + applies to new sessions +
- + {/* Main row */} -
+
@@ -698,14 +698,14 @@ function ModelSettingsPanel({
{/* Auxiliary tasks summary + open modal */} -
+
@@ -723,7 +723,7 @@ function ModelSettingsPanel({ size="sm" outlined onClick={() => setAuxModalOpen(true)} - className="text-xs" + className="shrink-0 self-start text-xs sm:self-center" > Configure @@ -827,7 +827,7 @@ export default function ModelsPage() { , ); setEnd( -
+
{PERIODS.map((p) => ( , +
+ +
, ); return () => setEnd(null); }, [loading, rescanBusy, setEnd, t.pluginsPage.refreshDashboard]); @@ -413,32 +415,20 @@ function PluginRowCard(props: PluginRowCardProps) {
+
-
+ {row.name} -
+ + {t.pluginsPage.sourceBadge}: {row.source} + - {row.name} + v{row.version || "—"} - - {t.pluginsPage.sourceBadge}: {row.source} - + {row.runtime_status} - - v{row.version || "—"} - - {row.runtime_status} - - {row.auth_required ? ( - {t.pluginsPage.authRequired} - ) : null} -
- - {row.description ? ( - -

- {row.description} -

+ {row.auth_required ? ( + {t.pluginsPage.authRequired} ) : null}
@@ -544,6 +534,12 @@ function PluginRowCard(props: PluginRowCardProps) {
+ {row.description ? ( +

+ {row.description} +

+ ) : null} + {dm?.slots?.length ? (

diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx index 933f3f3e1d..af00c96f6d 100644 --- a/web/src/pages/ProfilesPage.tsx +++ b/web/src/pages/ProfilesPage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users, X } from "lucide-react"; +import spinners from "unicode-animations"; import { H2 } from "@/components/NouiTypography"; import { api } from "@/lib/api"; import type { ProfileInfo } from "@/lib/api"; @@ -21,6 +22,35 @@ import { usePageHeader } from "@/contexts/usePageHeader"; // invalid names (uppercase, spaces, â€Ļ) before round-tripping a doomed POST. const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; +/** Braille unicode spinner (`unicode-animations`); static first frame when reduced motion is preferred. */ +function ProfilesLoadingSpinner() { + const { frames, interval } = spinners.braille; + const [frameIndex, setFrameIndex] = useState(0); + + useEffect(() => { + if ( + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + return; + } + const id = window.setInterval( + () => setFrameIndex((i) => (i + 1) % frames.length), + interval, + ); + return () => window.clearInterval(id); + }, [frames.length, interval]); + + return ( + + {frames[frameIndex]} + + ); +} + export default function ProfilesPage() { const [profiles, setProfiles] = useState([]); const [loading, setLoading] = useState(true); @@ -199,8 +229,14 @@ export default function ProfilesPage() { if (loading) { return ( -

-
+
+ {t.common.loading} + +
); } @@ -318,7 +354,7 @@ export default function ProfilesPage() { const isEditingSoul = editingSoulFor === p.name; return ( - +
{isRenaming ? ( diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index dd2ad6b231..f7d24e9d72 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -83,7 +83,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) { parts.push(snippet.slice(last)); } return ( -

+

{parts}

); @@ -296,24 +296,24 @@ function SessionRow({ return (
-
-
- -
-
-
+
+ +
+
+
+
{hasTitle ? session.title @@ -322,71 +322,70 @@ function SessionRow({ : t.sessions.untitledSession} {session.is_active && ( - + {t.common.live} )}
-
- +
+ {(session.model ?? t.common.unknown).split("/").pop()} · - + {session.message_count} {t.common.msgs} {session.tool_call_count > 0 && ( <> · - + {session.tool_call_count} {t.common.tools} )} · - {timeAgo(session.last_active)} + {timeAgo(session.last_active)}
- {snippet && }
-
- -
- - {session.source ?? "local"} - - {resumeInChatEnabled && ( + {snippet && } +
+ + {session.source ?? "local"} + + {resumeInChatEnabled && ( + + )} - )} - +
{isExpanded && ( -
+
{loading && (
@@ -624,7 +623,7 @@ export default function SessionsPage() { } return ( -
+
@@ -732,28 +731,28 @@ export default function SessionsPage() { )} {recentSessions.length > 0 && ( - - -
- - + + +
+ + {t.status.recentSessions}
- + {recentSessions.map((s) => (
-
- +
+ {s.title ?? t.common.untitled} - + {(s.model ?? t.common.unknown).split("/").pop()} {" "} @@ -762,15 +761,15 @@ export default function SessionsPage() { {s.preview && ( - +

{s.preview} - +

)}
{s.source ?? "local"} @@ -795,7 +794,7 @@ export default function SessionsPage() {
) : ( <> -
+
{filtered.map((s) => ( setSearch(e.target.value)} @@ -256,12 +256,7 @@ export default function SkillsPage() {