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 ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -21,6 +22,25 @@ from hermes_constants import OPENROUTER_MODELS_URL
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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.
|
# 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")
|
# 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.
|
# 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
|
return _model_metadata_cache
|
||||||
|
|
||||||
try:
|
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()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -562,6 +582,7 @@ def fetch_endpoint_model_metadata(
|
||||||
server_url.rstrip("/") + "/api/v1/models",
|
server_url.rstrip("/") + "/api/v1/models",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
|
verify=_resolve_requests_verify(),
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
|
|
@ -610,7 +631,7 @@ def fetch_endpoint_model_metadata(
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
url = candidate.rstrip("/") + "/models"
|
url = candidate.rstrip("/") + "/models"
|
||||||
try:
|
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()
|
response.raise_for_status()
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
cache: Dict[str, Dict[str, Any]] = {}
|
cache: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
@ -641,9 +662,10 @@ def fetch_endpoint_model_metadata(
|
||||||
try:
|
try:
|
||||||
# Try /v1/props first (current llama.cpp); fall back to /props for older builds
|
# Try /v1/props first (current llama.cpp); fall back to /props for older builds
|
||||||
base = candidate.rstrip("/").replace("/v1", "")
|
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:
|
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:
|
if props_resp.ok:
|
||||||
props = props_resp.json()
|
props = props_resp.json()
|
||||||
gen_settings = props.get("default_generation_settings", {})
|
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,
|
"x-api-key": api_key,
|
||||||
"anthropic-version": "2023-06-01",
|
"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:
|
if resp.status_code != 200:
|
||||||
return None
|
return None
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import shutil
|
||||||
import shlex
|
import shlex
|
||||||
import ssl
|
import ssl
|
||||||
import stat
|
import stat
|
||||||
|
import sys
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -1708,6 +1709,24 @@ def resolve_codex_runtime_credentials(
|
||||||
# TLS verification helper
|
# 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(
|
def _resolve_verify(
|
||||||
*,
|
*,
|
||||||
insecure: Optional[bool] = None,
|
insecure: Optional[bool] = None,
|
||||||
|
|
@ -1726,6 +1745,7 @@ def _resolve_verify(
|
||||||
or tls_state.get("ca_bundle")
|
or tls_state.get("ca_bundle")
|
||||||
or os.getenv("HERMES_CA_BUNDLE")
|
or os.getenv("HERMES_CA_BUNDLE")
|
||||||
or os.getenv("SSL_CERT_FILE")
|
or os.getenv("SSL_CERT_FILE")
|
||||||
|
or os.getenv("REQUESTS_CA_BUNDLE")
|
||||||
)
|
)
|
||||||
|
|
||||||
if effective_insecure:
|
if effective_insecure:
|
||||||
|
|
@ -1737,9 +1757,9 @@ def _resolve_verify(
|
||||||
"CA bundle path does not exist: %s — falling back to default certificates",
|
"CA bundle path does not exist: %s — falling back to default certificates",
|
||||||
ca_path,
|
ca_path,
|
||||||
)
|
)
|
||||||
return True
|
return _default_verify()
|
||||||
return ssl.create_default_context(cafile=ca_path)
|
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:
|
class TestResolveVerifyFallback:
|
||||||
"""Verify _resolve_verify falls back to True when CA bundle path doesn't exist."""
|
"""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):
|
def test_missing_ca_bundle_in_auth_state_falls_back(self):
|
||||||
from hermes_cli.auth import _resolve_verify
|
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