mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(auth): honor SSL CA env vars across httpx + requests callsites
- hermes_cli/auth.py: add _default_verify() with macOS Homebrew certifi
fallback (mirrors weixin 3a0ec1d93). Extend env var chain to include
REQUESTS_CA_BUNDLE so one env var works across httpx + requests paths.
- agent/model_metadata.py: add _resolve_requests_verify() reading
HERMES_CA_BUNDLE / REQUESTS_CA_BUNDLE / SSL_CERT_FILE in priority
order. Apply explicit verify= to all 6 requests.get callsites.
- Tests: 18 new unit tests + autouse platform pin on existing
TestResolveVerifyFallback to keep its "returns True" assertions
platform-independent.
Empirically verified against self-signed HTTPS server: requests honors
REQUESTS_CA_BUNDLE only; httpx honors SSL_CERT_FILE only. Hermes now
honors all three everywhere.
Triggered by Discord reports — Nous OAuth SSL failure on macOS
Homebrew Python; custom provider self-signed cert ignored despite
REQUESTS_CA_BUNDLE set in env.
This commit is contained in:
parent
b0cb81a089
commit
8aa37a0cf9
5 changed files with 260 additions and 7 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
90
tests/agent/test_model_metadata_ssl.py
Normal file
90
tests/agent/test_model_metadata_ssl.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
115
tests/hermes_cli/test_auth_ssl_macos.py
Normal file
115
tests/hermes_cli/test_auth_ssl_macos.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue