diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 14c26ecb6..1108f8c9b 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -6,6 +6,7 @@ and run_agent.py for pre-flight context checks. import ipaddress import logging +import os import re import time from pathlib import Path @@ -21,6 +22,25 @@ from hermes_constants import OPENROUTER_MODELS_URL logger = logging.getLogger(__name__) + +def _resolve_requests_verify() -> bool | str: + """Resolve SSL verify setting for `requests` calls from env vars. + + The `requests` library only honours REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE + by default. Hermes also honours HERMES_CA_BUNDLE (its own convention) + and SSL_CERT_FILE (used by the stdlib `ssl` module and by httpx), so + that a single env var can cover both `requests` and `httpx` callsites + inside the same process. + + Returns either a filesystem path to a CA bundle, or True to defer to + the requests default (certifi). + """ + for env_var in ("HERMES_CA_BUNDLE", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"): + val = os.getenv(env_var) + if val and os.path.isfile(val): + return val + return True + # Provider names that can appear as a "provider:" prefix before a model ID. # Only these are stripped — Ollama-style "model:tag" colons (e.g. "qwen3.5:27b") # are preserved so the full model name reaches cache lookups and server queries. @@ -495,7 +515,7 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any return _model_metadata_cache try: - response = requests.get(OPENROUTER_MODELS_URL, timeout=10) + response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify()) response.raise_for_status() data = response.json() @@ -562,6 +582,7 @@ def fetch_endpoint_model_metadata( server_url.rstrip("/") + "/api/v1/models", headers=headers, timeout=10, + verify=_resolve_requests_verify(), ) response.raise_for_status() payload = response.json() @@ -610,7 +631,7 @@ def fetch_endpoint_model_metadata( for candidate in candidates: url = candidate.rstrip("/") + "/models" try: - response = requests.get(url, headers=headers, timeout=10) + response = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify()) response.raise_for_status() payload = response.json() cache: Dict[str, Dict[str, Any]] = {} @@ -641,9 +662,10 @@ def fetch_endpoint_model_metadata( try: # Try /v1/props first (current llama.cpp); fall back to /props for older builds base = candidate.rstrip("/").replace("/v1", "") - props_resp = requests.get(base + "/v1/props", headers=headers, timeout=5) + _verify = _resolve_requests_verify() + props_resp = requests.get(base + "/v1/props", headers=headers, timeout=5, verify=_verify) if not props_resp.ok: - props_resp = requests.get(base + "/props", headers=headers, timeout=5) + props_resp = requests.get(base + "/props", headers=headers, timeout=5, verify=_verify) if props_resp.ok: props = props_resp.json() gen_settings = props.get("default_generation_settings", {}) @@ -992,7 +1014,7 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> "x-api-key": api_key, "anthropic-version": "2023-06-01", } - resp = requests.get(url, headers=headers, timeout=10) + resp = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify()) if resp.status_code != 200: return None data = resp.json() diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 847513efd..273d832f5 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -22,6 +22,7 @@ import shutil import shlex import ssl import stat +import sys import base64 import hashlib import subprocess @@ -1708,6 +1709,24 @@ def resolve_codex_runtime_credentials( # TLS verification helper # ============================================================================= +def _default_verify() -> bool | ssl.SSLContext: + """Platform-aware default SSL verify for httpx clients. + + On macOS with Homebrew Python, the system OpenSSL cannot locate the + system trust store and valid public certs fail verification. When + certifi is importable we pin its bundle explicitly; elsewhere we + defer to httpx's built-in default (certifi via its own dependency). + Mirrors the weixin fix in 3a0ec1d93. + """ + if sys.platform == "darwin": + try: + import certifi + return ssl.create_default_context(cafile=certifi.where()) + except ImportError: + pass + return True + + def _resolve_verify( *, insecure: Optional[bool] = None, @@ -1726,6 +1745,7 @@ def _resolve_verify( or tls_state.get("ca_bundle") or os.getenv("HERMES_CA_BUNDLE") or os.getenv("SSL_CERT_FILE") + or os.getenv("REQUESTS_CA_BUNDLE") ) if effective_insecure: @@ -1737,9 +1757,9 @@ def _resolve_verify( "CA bundle path does not exist: %s — falling back to default certificates", ca_path, ) - return True + return _default_verify() return ssl.create_default_context(cafile=ca_path) - return True + return _default_verify() # ============================================================================= diff --git a/tests/agent/test_model_metadata_ssl.py b/tests/agent/test_model_metadata_ssl.py new file mode 100644 index 000000000..6859fd309 --- /dev/null +++ b/tests/agent/test_model_metadata_ssl.py @@ -0,0 +1,90 @@ +"""Tests for _resolve_requests_verify() env var precedence. + +Verifies that custom provider `/models` fetches honour the three supported +CA bundle env vars (HERMES_CA_BUNDLE, REQUESTS_CA_BUNDLE, SSL_CERT_FILE) +in the documented priority order, and that non-existent paths are +skipped gracefully rather than breaking the request. + +No filesystem or network I/O required — we use tmp_path to create real +CA bundle stand-in files and monkeypatch env vars. +""" + +import os +import sys +from pathlib import Path + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest + +from agent.model_metadata import _resolve_requests_verify + + +_CA_ENV_VARS = ("HERMES_CA_BUNDLE", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE") + + +@pytest.fixture +def clean_env(monkeypatch): + """Clear all three SSL env vars so each test starts from a known state.""" + for var in _CA_ENV_VARS: + monkeypatch.delenv(var, raising=False) + return monkeypatch + + +@pytest.fixture +def bundle_file(tmp_path: Path) -> str: + """Create a placeholder CA bundle file and return its absolute path.""" + path = tmp_path / "ca.pem" + path.write_text("-----BEGIN CERTIFICATE-----\nstub\n-----END CERTIFICATE-----\n") + return str(path) + + +class TestResolveRequestsVerify: + def test_no_env_returns_true(self, clean_env): + assert _resolve_requests_verify() is True + + def test_hermes_ca_bundle_returns_path(self, clean_env, bundle_file): + clean_env.setenv("HERMES_CA_BUNDLE", bundle_file) + assert _resolve_requests_verify() == bundle_file + + def test_requests_ca_bundle_returns_path(self, clean_env, bundle_file): + clean_env.setenv("REQUESTS_CA_BUNDLE", bundle_file) + assert _resolve_requests_verify() == bundle_file + + def test_ssl_cert_file_returns_path(self, clean_env, bundle_file): + clean_env.setenv("SSL_CERT_FILE", bundle_file) + assert _resolve_requests_verify() == bundle_file + + def test_priority_hermes_over_requests(self, clean_env, tmp_path, bundle_file): + other = tmp_path / "other.pem" + other.write_text("stub") + clean_env.setenv("HERMES_CA_BUNDLE", bundle_file) + clean_env.setenv("REQUESTS_CA_BUNDLE", str(other)) + assert _resolve_requests_verify() == bundle_file + + def test_priority_requests_over_ssl_cert_file(self, clean_env, tmp_path, bundle_file): + other = tmp_path / "other.pem" + other.write_text("stub") + clean_env.setenv("REQUESTS_CA_BUNDLE", bundle_file) + clean_env.setenv("SSL_CERT_FILE", str(other)) + assert _resolve_requests_verify() == bundle_file + + def test_nonexistent_path_falls_through(self, clean_env, tmp_path, bundle_file): + missing = tmp_path / "does_not_exist.pem" + clean_env.setenv("HERMES_CA_BUNDLE", str(missing)) + clean_env.setenv("REQUESTS_CA_BUNDLE", bundle_file) + assert _resolve_requests_verify() == bundle_file + + def test_all_nonexistent_returns_true(self, clean_env, tmp_path): + missing1 = tmp_path / "a.pem" + missing2 = tmp_path / "b.pem" + missing3 = tmp_path / "c.pem" + clean_env.setenv("HERMES_CA_BUNDLE", str(missing1)) + clean_env.setenv("REQUESTS_CA_BUNDLE", str(missing2)) + clean_env.setenv("SSL_CERT_FILE", str(missing3)) + assert _resolve_requests_verify() is True + + def test_empty_string_env_var_ignored(self, clean_env, bundle_file): + clean_env.setenv("HERMES_CA_BUNDLE", "") + clean_env.setenv("REQUESTS_CA_BUNDLE", bundle_file) + assert _resolve_requests_verify() == bundle_file diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index b6d70a26f..b94c3c50c 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -19,6 +19,12 @@ from hermes_cli.auth import AuthError, get_provider_auth_state, resolve_nous_run class TestResolveVerifyFallback: """Verify _resolve_verify falls back to True when CA bundle path doesn't exist.""" + @pytest.fixture(autouse=True) + def _pin_platform_to_linux(self, monkeypatch): + """Pin sys.platform so the macOS certifi fallback doesn't alter the + generic "default trust" return value asserted by these tests.""" + monkeypatch.setattr("sys.platform", "linux") + def test_missing_ca_bundle_in_auth_state_falls_back(self): from hermes_cli.auth import _resolve_verify diff --git a/tests/hermes_cli/test_auth_ssl_macos.py b/tests/hermes_cli/test_auth_ssl_macos.py new file mode 100644 index 000000000..a6ebb3168 --- /dev/null +++ b/tests/hermes_cli/test_auth_ssl_macos.py @@ -0,0 +1,115 @@ +"""Tests for hermes_cli.auth._default_verify platform-aware fallback. + +On macOS with Homebrew Python, the system OpenSSL cannot locate the +system trust store, so we explicitly load certifi's bundle. On other +platforms we defer to httpx's own default (which itself uses certifi). + +Most tests use monkeypatching — no real SSL handshakes. A handful use +an openssl-generated self-signed cert via the `real_bundle_file` +fixture because `ssl.create_default_context(cafile=...)` parses the +bundle and refuses stubs. +""" + +import os +import shutil +import ssl +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from hermes_cli.auth import _default_verify, _resolve_verify + + +@pytest.fixture +def real_bundle_file(tmp_path: Path) -> str: + """Return a path to a real openssl-generated self-signed cert. + + Skips the test when the `openssl` binary isn't on PATH, so CI images + without it degrade gracefully instead of erroring out. + """ + if shutil.which("openssl") is None: + pytest.skip("openssl binary not available") + cert = tmp_path / "ca.pem" + key = tmp_path / "key.pem" + result = subprocess.run( + [ + "openssl", "req", "-x509", "-newkey", "rsa:2048", + "-keyout", str(key), "-out", str(cert), + "-sha256", "-days", "1", "-nodes", + "-subj", "/CN=test", + ], + capture_output=True, + timeout=10, + ) + if result.returncode != 0: + pytest.skip(f"openssl failed: {result.stderr.decode('utf-8', 'ignore')[:200]}") + return str(cert) + + +class TestDefaultVerify: + def test_returns_ssl_context_on_darwin(self, monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + result = _default_verify() + assert isinstance(result, ssl.SSLContext) + + def test_returns_true_on_linux(self, monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + assert _default_verify() is True + + def test_returns_true_on_windows(self, monkeypatch): + monkeypatch.setattr(sys, "platform", "win32") + assert _default_verify() is True + + def test_darwin_falls_back_to_true_when_certifi_missing(self, monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + + real_import = __import__ + + def fake_import(name, *args, **kwargs): + if name == "certifi": + raise ImportError("simulated missing certifi") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", fake_import) + assert _default_verify() is True + + +class TestResolveVerifyIntegration: + """_resolve_verify should defer to _default_verify in the no-CA path.""" + + def test_no_ca_uses_default_verify_on_darwin(self, monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + for var in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"): + monkeypatch.delenv(var, raising=False) + result = _resolve_verify() + assert isinstance(result, ssl.SSLContext) + + def test_no_ca_uses_default_verify_on_linux(self, monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + for var in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"): + monkeypatch.delenv(var, raising=False) + assert _resolve_verify() is True + + def test_requests_ca_bundle_respected(self, monkeypatch, real_bundle_file): + for var in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("REQUESTS_CA_BUNDLE", real_bundle_file) + result = _resolve_verify() + assert isinstance(result, ssl.SSLContext) + + def test_missing_ca_path_falls_back_to_default_verify(self, monkeypatch, tmp_path): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setenv("HERMES_CA_BUNDLE", str(tmp_path / "missing.pem")) + for var in ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"): + monkeypatch.delenv(var, raising=False) + assert _resolve_verify() is True + + def test_insecure_wins_over_everything(self, monkeypatch, tmp_path): + bundle = tmp_path / "ca.pem" + bundle.write_text("stub") + monkeypatch.setenv("HERMES_CA_BUNDLE", str(bundle)) + assert _resolve_verify(insecure=True) is False