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:
0xbyt4
2026-04-23 14:59:26 +03:00
committed by Teknium
parent b0cb81a089
commit 8aa37a0cf9
5 changed files with 260 additions and 7 deletions

View 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

View 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

View 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