Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
45 changes: 28 additions & 17 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"):
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
34 changes: 33 additions & 1 deletion agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
14 changes: 12 additions & 2 deletions agent/process_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -154,14 +155,21 @@ 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
import socket

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"):
Expand All @@ -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:
Expand Down
63 changes: 63 additions & 0 deletions agent/ssl_verify.py
Original file line number Diff line number Diff line change
@@ -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
81 changes: 79 additions & 2 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading