diff --git a/agent/agent_init.py b/agent/agent_init.py index 251db3e1523f..5bd15222a6ff 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -974,6 +974,21 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: # this mutation is reflected in the client built just below. agent._apply_user_default_headers() + try: + from hermes_cli.config import ( + apply_custom_provider_tls_to_client_kwargs, + get_compatible_custom_providers, + load_config, + ) + + apply_custom_provider_tls_to_client_kwargs( + client_kwargs, + str(client_kwargs.get("base_url") or agent.base_url or ""), + get_compatible_custom_providers(load_config()), + ) + except Exception: + logger.debug("custom-provider TLS resolution skipped", exc_info=True) + agent.api_key = client_kwargs.get("api_key", "") agent.base_url = client_kwargs.get("base_url", agent.base_url) try: diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index ced03c9f01f2..10228d5f1efc 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1443,21 +1443,6 @@ def anthropic_prompt_cache_policy( eff_api_mode = api_mode if api_mode is not None else (agent.api_mode or "") eff_model = (model if model is not None else agent.model) or "" - # Global kill switch: prompt_caching.enabled=false disables cache_control - # markers on every path (init, /model switch, fallback re-derivation). - # Escape hatch for strict Anthropic-compatible proxies that inject their - # own markers server-side — stacking ours on top exceeds Anthropic's - # 4-breakpoint limit and 400s. Gating here (not just at init) keeps the - # switch honored after a model switch or fallback re-evaluates the policy. - try: - from hermes_cli.config import load_config as _load_pc_cfg - - _pc_cfg = _load_pc_cfg().get("prompt_caching", {}) or {} - if isinstance(_pc_cfg, dict) and _pc_cfg.get("enabled") is False: - return False, False - except Exception: - pass - model_lower = eff_model.lower() provider_lower = eff_provider.lower() is_claude = "claude" in model_lower @@ -1528,6 +1513,7 @@ def anthropic_prompt_cache_policy( def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: bool) -> Any: from agent.auxiliary_client import _validate_base_url, _validate_proxy_env_urls + from agent.ssl_verify import resolve_httpx_verify # Treat client_kwargs as read-only. Callers pass agent._client_kwargs (or shallow # copies of it) in; any in-place mutation leaks back into the stored dict and is # reused on subsequent requests. #10933 hit this by injecting an httpx.Client @@ -1537,6 +1523,9 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo # copy locks the contract so future transport/keepalive work can't reintroduce # the same class of bug. client_kwargs = dict(client_kwargs) + ssl_ca_cert = client_kwargs.pop("ssl_ca_cert", None) + ssl_verify_cfg = client_kwargs.pop("ssl_verify", None) + httpx_verify = resolve_httpx_verify(ca_bundle=ssl_ca_cert, ssl_verify=ssl_verify_cfg) _validate_proxy_env_urls() _validate_base_url(client_kwargs.get("base_url")) if agent.provider == "copilot-acp" or str(client_kwargs.get("base_url", "")).startswith("acp://copilot"): @@ -1560,7 +1549,9 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo if k in {"api_key", "base_url", "default_headers", "timeout", "http_client"} } if "http_client" not in safe_kwargs: - keepalive_http = agent._build_keepalive_http_client(base_url) + keepalive_http = agent._build_keepalive_http_client( + base_url, verify=httpx_verify, + ) if keepalive_http is not None: safe_kwargs["http_client"] = keepalive_http client = GeminiNativeClient(**safe_kwargs) @@ -1589,7 +1580,9 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo # Tests in ``tests/run_agent/test_create_openai_client_reuse.py`` and # ``tests/run_agent/test_sequential_chats_live.py`` pin this invariant. if "http_client" not in client_kwargs: - keepalive_http = agent._build_keepalive_http_client(client_kwargs.get("base_url", "")) + keepalive_http = agent._build_keepalive_http_client( + client_kwargs.get("base_url", ""), verify=httpx_verify, + ) if keepalive_http is not None: client_kwargs["http_client"] = keepalive_http # Delegate all rate-limit / 5xx retry to hermes's outer conversation loop, @@ -1793,6 +1786,24 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo "api_key": effective_key, "base_url": effective_base, } + try: + from hermes_cli.config import ( + apply_custom_provider_tls_to_client_kwargs, + get_compatible_custom_providers, + load_config_readonly, + ) + + # Read custom_providers from live config (not the init-time + # snapshot on ``agent._custom_providers``) so ssl_ca_cert / + # ssl_verify edits are honored when switching mid-session, + # matching the context-length reload below (#15779). + apply_custom_provider_tls_to_client_kwargs( + agent._client_kwargs, + str(effective_base or ""), + get_compatible_custom_providers(load_config_readonly()), + ) + except Exception: + logger.debug("custom-provider TLS resolution skipped on switch_model", exc_info=True) _sm_timeout = get_provider_request_timeout(agent.provider, agent.model) if _sm_timeout is not None: agent._client_kwargs["timeout"] = _sm_timeout diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 8ed7b5aab657..1b016899a769 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -128,13 +128,45 @@ def __repr__(self): _LOGGED_UNSUPPORTED_OAUTH_KEYS: set = set() +def _resolve_aux_verify(base_url: Optional[str]) -> Any: + """Resolve httpx ``verify`` for an auxiliary-client base_url. + + Mirrors the main client's TLS resolution so auxiliary calls (compression, + vision, web_extract, title generation, etc.) honor per-provider + ``ssl_ca_cert`` / ``ssl_verify`` config and the ``HERMES_CA_BUNDLE`` / + ``SSL_CERT_FILE`` env conventions. Best-effort: any failure falls back to + the httpx/certifi default (``True``). + """ + try: + from agent.ssl_verify import resolve_httpx_verify + from hermes_cli.config import ( + get_custom_provider_tls_settings, + load_config_readonly, + ) + + tls = get_custom_provider_tls_settings( + str(base_url or ""), config=load_config_readonly() + ) + return resolve_httpx_verify( + ca_bundle=tls.get("ssl_ca_cert"), + ssl_verify=tls.get("ssl_verify"), + base_url=str(base_url or ""), + ) + except Exception: + return True + + def _openai_http_client_kwargs( base_url: Optional[str], *, async_mode: bool = False, ) -> Dict[str, Any]: """Inject keepalive httpx client with env-only proxy (not macOS system proxy).""" - client = build_keepalive_http_client(str(base_url or ""), async_mode=async_mode) + client = build_keepalive_http_client( + str(base_url or ""), + async_mode=async_mode, + verify=_resolve_aux_verify(base_url), + ) if client is None: return {} return {"http_client": client} diff --git a/agent/process_bootstrap.py b/agent/process_bootstrap.py index ce238a9d405e..9790dbca9cf7 100644 --- a/agent/process_bootstrap.py +++ b/agent/process_bootstrap.py @@ -146,6 +146,7 @@ def build_keepalive_http_client( base_url: str = "", *, async_mode: bool = False, + verify: Any = True, ) -> Optional[Any]: """Build an httpx client for OpenAI SDK calls with env-only proxy policy. @@ -154,6 +155,13 @@ def build_keepalive_http_client( ``trust_env`` path, so macOS system proxy settings from ``urllib.request.getproxies()`` (which omit the ExceptionsList) are not applied. Mirrors ``AIAgent._build_keepalive_http_client``. + + ``verify`` is forwarded to httpx so auxiliary-client calls (compression, + vision, web_extract, title generation, etc.) honor the same per-provider + ``ssl_ca_cert`` / ``ssl_verify`` and ``HERMES_CA_BUNDLE`` settings the main + client uses. It is passed on the ``HTTPTransport`` (which owns the SSL + context when a custom transport is supplied) and, for the copilot branch + that has no custom transport, on the client itself. """ try: import httpx @@ -161,7 +169,7 @@ def build_keepalive_http_client( if "api.githubcopilot.com" in str(base_url or "").lower(): client_cls = httpx.AsyncClient if async_mode else httpx.Client - return client_cls() + return client_cls(verify=verify) sock_opts = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)] if hasattr(socket, "TCP_KEEPIDLE"): @@ -174,8 +182,10 @@ def build_keepalive_http_client( proxy = _get_proxy_for_base_url(base_url) transport_cls = httpx.AsyncHTTPTransport if async_mode else httpx.HTTPTransport client_cls = httpx.AsyncClient if async_mode else httpx.Client + # verify lives on the transport: httpx ignores the client-level + # ``verify`` when a custom ``transport=`` is supplied. return client_cls( - transport=transport_cls(socket_options=sock_opts), + transport=transport_cls(socket_options=sock_opts, verify=verify), proxy=proxy, ) except Exception: diff --git a/agent/ssl_verify.py b/agent/ssl_verify.py new file mode 100644 index 000000000000..885702185d7e --- /dev/null +++ b/agent/ssl_verify.py @@ -0,0 +1,63 @@ +"""TLS verify resolution for httpx/OpenAI provider clients.""" + +from __future__ import annotations + +import logging +import os +import ssl +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +def _coerce_insecure(ssl_verify: Any) -> bool: + if ssl_verify is False: + return True + if isinstance(ssl_verify, str) and ssl_verify.strip().lower() in {"false", "0", "no", "off"}: + return True + return False + + +def resolve_httpx_verify( + *, + ca_bundle: Optional[str] = None, + ssl_verify: Any = None, + base_url: str = "", +) -> bool | ssl.SSLContext: + """Resolve httpx ``verify`` for provider HTTP clients. + + Priority: + 1. ``ssl_verify: false`` — disable verification (local dev only) + 2. explicit ``ca_bundle`` (per-provider ``ssl_ca_cert`` config field) + 3. ``HERMES_CA_BUNDLE``, ``SSL_CERT_FILE``, ``REQUESTS_CA_BUNDLE``, + ``CURL_CA_BUNDLE`` env vars + 4. ``True`` (httpx/certifi default) + + ``base_url`` is used only for the insecure-mode warning message. + """ + if _coerce_insecure(ssl_verify): + logger.warning( + "TLS certificate verification DISABLED (ssl_verify: false) for %s — " + "this is intended for local development only and is unsafe on any " + "network you do not fully control.", + base_url or "a custom provider endpoint", + ) + return False + + effective_ca = ( + (ca_bundle or "").strip() + or os.getenv("HERMES_CA_BUNDLE", "").strip() + or os.getenv("SSL_CERT_FILE", "").strip() + or os.getenv("REQUESTS_CA_BUNDLE", "").strip() + or os.getenv("CURL_CA_BUNDLE", "").strip() + ) + if effective_ca: + ca_path = str(Path(effective_ca).expanduser()) + if os.path.isfile(ca_path): + return ssl.create_default_context(cafile=ca_path) + logger.warning( + "CA bundle path does not exist: %s — falling back to default certificates", + effective_ca, + ) + return True diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 90366da9d3b6..dcce55b51cda 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2297,7 +2297,6 @@ def _ensure_hermes_home_managed(home: Path): "allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist) "auto_thread": True, # Auto-create threads on @mention in channels (like Slack) "thread_require_mention": False, # If True, require @mention in threads too (multi-bot threads) - "bots_require_inline_mention": False, # Multi-bot rooms: if True, another bot must type @thisbot in its message to trigger a reply; a Discord reply/quote alone won't. Prevents two bots auto-replying to each other forever. Does not affect humans. "history_backfill": True, # If True, prepend recent channel scrollback when bot is triggered (recovers messages missed while require_mention gated them out) "history_backfill_limit": 50, # Max number of recent messages to scan when assembling the backfill block "reactions": True, # Add 👀/✅/❌ reactions to messages during processing @@ -4479,7 +4478,7 @@ def _normalize_custom_provider_entry( "api_mode", "transport", "model", "default_model", "models", "context_length", "rate_limit_delay", "request_timeout_seconds", "stale_timeout_seconds", - "discover_models", "extra_body", + "discover_models", "extra_body", "ssl_ca_cert", "ssl_verify", } for camel, snake in _CAMEL_ALIASES.items(): if camel in entry and snake not in entry: @@ -4586,6 +4585,16 @@ def _normalize_custom_provider_entry( if isinstance(extra_body, dict): normalized["extra_body"] = dict(extra_body) + ssl_ca_cert = entry.get("ssl_ca_cert") + if isinstance(ssl_ca_cert, str) and ssl_ca_cert.strip(): + normalized["ssl_ca_cert"] = ssl_ca_cert.strip() + + ssl_verify = entry.get("ssl_verify") + if isinstance(ssl_verify, bool): + normalized["ssl_verify"] = ssl_verify + elif isinstance(ssl_verify, str) and ssl_verify.strip(): + normalized["ssl_verify"] = ssl_verify.strip() + return normalized @@ -4613,6 +4622,8 @@ def _custom_provider_entry_to_provider_config( "rate_limit_delay", "discover_models", "extra_body", + "ssl_ca_cert", + "ssl_verify", ): if field in normalized: provider_entry[field] = normalized[field] @@ -4689,6 +4700,71 @@ def _append_if_new(entry: Optional[Dict[str, Any]]) -> None: return compatible +def _coerce_ssl_verify(value: Any) -> Optional[bool]: + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"false", "0", "no", "off"}: + return False + if lowered in {"true", "1", "yes", "on"}: + return True + return None + + +def get_custom_provider_tls_settings( + base_url: str, + custom_providers: Optional[List[Dict[str, Any]]] = None, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Return TLS settings from a matching ``custom_providers`` / ``providers`` entry.""" + if custom_providers is None: + try: + custom_providers = get_compatible_custom_providers(config) + except Exception: + custom_providers = [] + if not base_url or not isinstance(custom_providers, list): + return {} + + # Case-insensitive compare: elsewhere custom_providers are keyed on a + # lowercased base_url (see get_compatible_custom_providers dedup), and + # scheme/host are case-insensitive anyway — so a config entry written as + # https://Ollama.Example.com/v1 must still match a lowercased runtime + # base_url. Exact match after rstrip('/') + lower() (no prefix/substring). + target_url = (base_url or "").rstrip("/").lower() + for entry in custom_providers: + if not isinstance(entry, dict): + continue + entry_url = (entry.get("base_url") or "").rstrip("/").lower() + if not entry_url or entry_url != target_url: + continue + out: Dict[str, Any] = {} + ca = entry.get("ssl_ca_cert") + if isinstance(ca, str) and ca.strip(): + out["ssl_ca_cert"] = ca.strip() + verify = _coerce_ssl_verify(entry.get("ssl_verify")) + if verify is not None: + out["ssl_verify"] = verify + return out + return {} + + +def apply_custom_provider_tls_to_client_kwargs( + client_kwargs: Dict[str, Any], + base_url: str, + custom_providers: Optional[List[Dict[str, Any]]] = None, + config: Optional[Dict[str, Any]] = None, +) -> None: + """Attach per-provider TLS knobs to OpenAI client kwargs when matched.""" + tls = get_custom_provider_tls_settings(base_url, custom_providers, config) + if tls.get("ssl_ca_cert"): + client_kwargs["ssl_ca_cert"] = tls["ssl_ca_cert"] + if "ssl_verify" in tls: + client_kwargs["ssl_verify"] = tls["ssl_verify"] + + def get_custom_provider_context_length( model: str, base_url: str, @@ -4814,6 +4890,7 @@ def check_config_version() -> Tuple[int, int]: _VALID_CUSTOM_PROVIDER_FIELDS = { "name", "base_url", "api_key", "api_mode", "model", "models", "context_length", "rate_limit_delay", "extra_body", + "ssl_ca_cert", "ssl_verify", # key_env is read at runtime by runtime_provider.py and auxiliary_client.py # — include it here so the set accurately describes the supported schema. "key_env", diff --git a/run_agent.py b/run_agent.py index 18f3064ced32..9b6485d52f23 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3884,13 +3884,13 @@ def _is_openai_client_closed(client: Any) -> bool: return False @staticmethod - def _build_keepalive_http_client(base_url: str = "") -> Any: + def _build_keepalive_http_client(base_url: str = "", *, verify: Any = True) -> Any: try: import httpx as _httpx import socket as _socket if "api.githubcopilot.com" in str(base_url or "").lower(): - return _httpx.Client() + return _httpx.Client(verify=verify) _sock_opts = [(_socket.SOL_SOCKET, _socket.SO_KEEPALIVE, 1)] if hasattr(_socket, "TCP_KEEPIDLE"): @@ -3904,8 +3904,10 @@ def _build_keepalive_http_client(base_url: str = "") -> Any: # Explicitly read proxy settings while still honoring NO_PROXY for # loopback / local endpoints such as a locally hosted sub2api. _proxy = _get_proxy_for_base_url(base_url) + # verify lives on the transport: httpx ignores the client-level + # ``verify`` when a custom ``transport=`` is supplied. return _httpx.Client( - transport=_httpx.HTTPTransport(socket_options=_sock_opts), + transport=_httpx.HTTPTransport(socket_options=_sock_opts, verify=verify), proxy=_proxy, ) except Exception: @@ -4214,6 +4216,43 @@ def _try_refresh_nous_client_credentials( return True + def _try_refresh_vertex_client_credentials(self) -> bool: + """Re-mint the Vertex OAuth2 access token and rebuild the OpenAI client. + + Vertex tokens live ~1 hour. On a long-lived agent (gateway session) a + cached client's bearer token will expire mid-session, producing a 401. + This re-resolves credentials via the adapter (which refreshes the + underlying google-auth Credentials object when near expiry), swaps the + new token into the client kwargs, and rebuilds the primary OpenAI + client. Returns True when a usable token+base_url were obtained. + """ + if self.api_mode != "chat_completions" or self.provider != "vertex": + return False + + try: + from agent.vertex_adapter import get_vertex_config + + token, base_url = get_vertex_config() + except Exception as exc: + logger.debug("Vertex credential refresh failed: %s", exc) + return False + + if not isinstance(token, str) or not token.strip(): + return False + if not isinstance(base_url, str) or not base_url.strip(): + return False + + self.api_key = token.strip() + self.base_url = base_url.strip().rstrip("/") + self._client_kwargs["api_key"] = self.api_key + self._client_kwargs["base_url"] = self.base_url + + if not self._replace_primary_openai_client(reason="vertex_credential_refresh"): + return False + + logger.info("Vertex AI OAuth token refreshed") + return True + def _try_refresh_copilot_client_credentials(self) -> bool: """Refresh Copilot credentials and rebuild the shared OpenAI client. @@ -5122,7 +5161,7 @@ def _anthropic_preserve_dots(self) -> bool: "alibaba", "minimax", "minimax-cn", "opencode-go", "opencode-zen", "zai", "bedrock", - "xiaomi", + "xiaomi", "vertex", }: return True base = (getattr(self, "base_url", "") or "").lower() @@ -5133,6 +5172,9 @@ def _anthropic_preserve_dots(self) -> bool: or "opencode.ai/zen/" in base or "bigmodel.cn" in base or "xiaomimimo.com" in base + # Vertex AI OpenAI-compat endpoint — Gemini model ids keep dots + # (e.g. google/gemini-3.5-flash); the hyphenated form is wrong. + or "aiplatform.googleapis.com" in base # AWS Bedrock runtime endpoints — defense-in-depth when # ``provider`` is unset but ``base_url`` still names Bedrock. or "bedrock-runtime." in base diff --git a/tests/agent/test_auxiliary_client_ssl_verify.py b/tests/agent/test_auxiliary_client_ssl_verify.py new file mode 100644 index 000000000000..cc484811cb30 --- /dev/null +++ b/tests/agent/test_auxiliary_client_ssl_verify.py @@ -0,0 +1,79 @@ +"""Regression: auxiliary-client keepalive httpx client must honor custom CA bundles. + +The main OpenAI client resolves per-provider ``ssl_ca_cert`` / ``ssl_verify`` and +``HERMES_CA_BUNDLE`` via ``agent.ssl_verify.resolve_httpx_verify``. Auxiliary calls +(compression, vision, web_extract, title generation, session_search) build their own +keepalive client through ``agent.process_bootstrap.build_keepalive_http_client`` and must +apply the same TLS settings — otherwise an HTTPS custom_providers endpoint signed by a +private CA works for chat but fails ``APIConnectionError`` on every auxiliary task. +""" + +import ssl + +import certifi +import httpx +import pytest + +from agent.process_bootstrap import build_keepalive_http_client + +_CA_ENV_VARS = ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "HTTPS_PROXY") + + +@pytest.fixture +def clean_tls_env(monkeypatch): + for var in _CA_ENV_VARS: + monkeypatch.delenv(var, raising=False) + + +def test_build_keepalive_http_client_forwards_verify_context(clean_tls_env): + ctx = ssl.create_default_context(cafile=certifi.where()) + client = build_keepalive_http_client("https://ollama.example.com/v1", verify=ctx) + assert isinstance(client, httpx.Client) + assert client._transport._pool._ssl_context is ctx + + +def test_build_keepalive_http_client_verify_false_disables_hostname_check(clean_tls_env): + client = build_keepalive_http_client("https://ollama.example.com/v1", verify=False) + assert isinstance(client, httpx.Client) + assert client._transport._pool._ssl_context.check_hostname is False + + +def test_build_keepalive_http_client_default_verify_true(clean_tls_env): + client = build_keepalive_http_client("https://ollama.example.com/v1") + assert isinstance(client, httpx.Client) + + +def test_resolve_aux_verify_uses_per_provider_ssl_ca_cert(clean_tls_env, monkeypatch): + """_resolve_aux_verify should mirror the main-client resolution for a matched base_url.""" + import hermes_cli.config as cfg + from agent import auxiliary_client + + # get_custom_provider_tls_settings is imported inside the function from + # hermes_cli.config, so patch it at the source module. + monkeypatch.setattr( + cfg, + "get_custom_provider_tls_settings", + lambda *a, **k: {"ssl_ca_cert": certifi.where()}, + ) + verify = auxiliary_client._resolve_aux_verify("https://ollama.example.com/v1") + assert isinstance(verify, ssl.SSLContext) + + +def test_resolve_aux_verify_ssl_verify_false(clean_tls_env, monkeypatch): + import hermes_cli.config as cfg + from agent import auxiliary_client + + monkeypatch.setattr( + cfg, + "get_custom_provider_tls_settings", + lambda *a, **k: {"ssl_verify": False}, + ) + assert auxiliary_client._resolve_aux_verify("https://ollama.example.com/v1") is False + + +def test_resolve_aux_verify_no_match_defaults_true(clean_tls_env, monkeypatch): + import hermes_cli.config as cfg + from agent import auxiliary_client + + monkeypatch.setattr(cfg, "get_custom_provider_tls_settings", lambda *a, **k: {}) + assert auxiliary_client._resolve_aux_verify("https://openrouter.ai/api/v1") is True diff --git a/tests/agent/test_ssl_verify.py b/tests/agent/test_ssl_verify.py new file mode 100644 index 000000000000..64f7efb0ec04 --- /dev/null +++ b/tests/agent/test_ssl_verify.py @@ -0,0 +1,40 @@ +"""Tests for agent.ssl_verify.resolve_httpx_verify.""" + +import ssl + +import certifi +import pytest + +from agent.ssl_verify import resolve_httpx_verify + +_CA_ENV_VARS = ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE") + + +@pytest.fixture +def clean_ca_env(monkeypatch): + for var in _CA_ENV_VARS: + monkeypatch.delenv(var, raising=False) + + +def test_ssl_verify_false_disables_verification(clean_ca_env): + assert resolve_httpx_verify(ssl_verify=False) is False + + +def test_hermes_ca_bundle_returns_ssl_context(clean_ca_env, monkeypatch): + monkeypatch.setenv("HERMES_CA_BUNDLE", certifi.where()) + result = resolve_httpx_verify() + assert isinstance(result, ssl.SSLContext) + + +def test_explicit_ca_bundle_param(clean_ca_env): + result = resolve_httpx_verify(ca_bundle=certifi.where()) + assert isinstance(result, ssl.SSLContext) + + +def test_missing_ca_bundle_falls_back_to_true(clean_ca_env, monkeypatch): + monkeypatch.setenv("HERMES_CA_BUNDLE", "/nonexistent/root-ca.pem") + assert resolve_httpx_verify() is True + + +def test_default_without_env_is_true(clean_ca_env): + assert resolve_httpx_verify() is True diff --git a/tests/hermes_cli/test_custom_provider_tls.py b/tests/hermes_cli/test_custom_provider_tls.py new file mode 100644 index 000000000000..1c93164efdee --- /dev/null +++ b/tests/hermes_cli/test_custom_provider_tls.py @@ -0,0 +1,72 @@ +"""Tests for per-provider TLS settings in custom_providers config.""" + +from hermes_cli.config import ( + apply_custom_provider_tls_to_client_kwargs, + get_custom_provider_tls_settings, +) + + +def test_get_custom_provider_tls_settings_matches_base_url(): + providers = [ + { + "name": "Ollama", + "base_url": "https://ollama.example.com/v1", + "ssl_ca_cert": "/etc/ssl/mkcert-root.pem", + } + ] + tls = get_custom_provider_tls_settings( + "https://ollama.example.com/v1/", + custom_providers=providers, + ) + assert tls == {"ssl_ca_cert": "/etc/ssl/mkcert-root.pem"} + + +def test_apply_custom_provider_tls_to_client_kwargs(): + client_kwargs = {"api_key": "x", "base_url": "https://ollama.example.com/v1"} + providers = [ + { + "name": "Ollama", + "base_url": "https://ollama.example.com/v1", + "ssl_ca_cert": "/etc/ssl/mkcert-root.pem", + "ssl_verify": True, + } + ] + apply_custom_provider_tls_to_client_kwargs( + client_kwargs, + "https://ollama.example.com/v1", + custom_providers=providers, + ) + assert client_kwargs["ssl_ca_cert"] == "/etc/ssl/mkcert-root.pem" + assert client_kwargs["ssl_verify"] is True + + +def test_get_custom_provider_tls_settings_matches_case_insensitively(): + """A config base_url with mixed case must still match a lowercased runtime base_url.""" + providers = [ + { + "name": "Ollama", + "base_url": "https://Ollama.Example.com/v1", + "ssl_ca_cert": "/etc/ssl/mkcert-root.pem", + } + ] + tls = get_custom_provider_tls_settings( + "https://ollama.example.com/v1", + custom_providers=providers, + ) + assert tls == {"ssl_ca_cert": "/etc/ssl/mkcert-root.pem"} + + +def test_get_custom_provider_tls_settings_no_substring_bypass(): + """A base_url that is only a prefix of an entry must NOT match.""" + providers = [ + { + "name": "Ollama", + "base_url": "https://ollama.example.com/v1", + "ssl_verify": False, + } + ] + # A different host that shares a prefix must not pick up ssl_verify:false. + assert get_custom_provider_tls_settings( + "https://ollama.example.com.attacker.test/v1", + custom_providers=providers, + ) == {} diff --git a/tests/run_agent/test_create_openai_client_ssl_verify.py b/tests/run_agent/test_create_openai_client_ssl_verify.py new file mode 100644 index 000000000000..43d82f67baf8 --- /dev/null +++ b/tests/run_agent/test_create_openai_client_ssl_verify.py @@ -0,0 +1,46 @@ +"""Regression: keepalive httpx client must honor custom CA bundles for HTTPS providers.""" + +import ssl + +import certifi +import httpx +import pytest + +from agent.ssl_verify import resolve_httpx_verify +from run_agent import AIAgent + +_CA_ENV_VARS = ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "HTTPS_PROXY") + + +@pytest.fixture +def clean_tls_env(monkeypatch): + for var in _CA_ENV_VARS: + monkeypatch.delenv(var, raising=False) + + +def test_build_keepalive_http_client_uses_hermes_ca_bundle(clean_tls_env, monkeypatch): + monkeypatch.setenv("HERMES_CA_BUNDLE", certifi.where()) + verify = resolve_httpx_verify() + client = AIAgent._build_keepalive_http_client( + "https://ollama.example.com/v1", verify=verify, + ) + assert isinstance(client, httpx.Client) + assert isinstance(client._transport._pool._ssl_context, ssl.SSLContext) + + +def test_build_keepalive_http_client_honors_per_provider_ssl_ca_cert(clean_tls_env): + verify = resolve_httpx_verify(ca_bundle=certifi.where()) + client = AIAgent._build_keepalive_http_client( + "https://ollama.example.com/v1", verify=verify, + ) + assert isinstance(client, httpx.Client) + assert isinstance(client._transport._pool._ssl_context, ssl.SSLContext) + + +def test_build_keepalive_http_client_ssl_verify_false(clean_tls_env): + verify = resolve_httpx_verify(ssl_verify=False) + client = AIAgent._build_keepalive_http_client( + "https://ollama.example.com/v1", verify=verify, + ) + assert isinstance(client, httpx.Client) + assert client._transport._pool._ssl_context.check_hostname is False