mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
Merge pull request #52891 from kshitijk4poor/salvage/52623-aux-host
fix(auxiliary): gate Anthropic base_url override on Anthropic-compatible host (#52608)
This commit is contained in:
commit
7d568293f9
2 changed files with 222 additions and 4 deletions
|
|
@ -666,6 +666,28 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str:
|
|||
return str(url or "").strip().rstrip("/")
|
||||
|
||||
|
||||
# Hostnames (lowercase, exact) that the auxiliary Anthropic path is allowed to
|
||||
# be pointed at via config.yaml model.base_url. Anything else falls back to the
|
||||
# Anthropic default — operators routing main-session traffic through a
|
||||
# non-Anthropic host (e.g. OpenRouter, OpenAI) with provider=anthropic in config
|
||||
# must NOT have that foreign host leak into the auxiliary client. See #52608.
|
||||
_ANTHROPIC_COMPATIBLE_HOSTS = frozenset({
|
||||
"api.anthropic.com",
|
||||
})
|
||||
|
||||
|
||||
def _is_anthropic_compatible_host(url: str) -> bool:
|
||||
"""Return True if ``url``'s hostname is an Anthropic endpoint we trust for aux calls."""
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
host = (urlparse(url).hostname or "").strip().lower().rstrip(".")
|
||||
return host in _ANTHROPIC_COMPATIBLE_HOSTS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _nous_min_key_ttl_seconds() -> int:
|
||||
try:
|
||||
return max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800")))
|
||||
|
|
@ -2256,9 +2278,16 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
|
|||
if not token:
|
||||
return None, None
|
||||
|
||||
# Allow base URL override from config.yaml model.base_url, but only
|
||||
# when the configured provider is anthropic — otherwise a non-Anthropic
|
||||
# base_url (e.g. Codex endpoint) would leak into Anthropic requests.
|
||||
# Allow base URL override from config.yaml model.base_url, but only when:
|
||||
# 1. the configured provider is anthropic (otherwise a non-Anthropic
|
||||
# base_url, e.g. Codex endpoint, would leak into Anthropic requests), AND
|
||||
# 2. the override URL actually points at an Anthropic-compatible endpoint.
|
||||
# Without gate (2), operators who route main-session traffic through a
|
||||
# non-Anthropic provider that accepts Anthropic-format requests (e.g.
|
||||
# OpenRouter at openrouter.ai/api/v1, with provider=anthropic in config.yaml)
|
||||
# would have every auxiliary side-channel call (memory extractors,
|
||||
# reflection, vision, title generation) 401 from the foreign host —
|
||||
# see issue #52608.
|
||||
base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
|
@ -2268,7 +2297,7 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
|
|||
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
if cfg_provider == "anthropic":
|
||||
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
|
||||
if cfg_base_url:
|
||||
if cfg_base_url and _is_anthropic_compatible_host(cfg_base_url):
|
||||
base_url = cfg_base_url
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
"""Regression tests for issue #52608.
|
||||
|
||||
auxiliary_client `_try_anthropic()` must NOT apply `cfg["model"]["base_url"]`
|
||||
when the configured base_url host is not an Anthropic-compatible endpoint
|
||||
(e.g. OpenRouter, OpenAI). Operators routing main traffic through a
|
||||
non-Anthropic provider's endpoint while keeping `provider: anthropic` would
|
||||
otherwise have every side-channel call (memory extractors, reflection,
|
||||
vision, title generation) 401 from the foreign host.
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _extract_base_url_passed_to_build(mock_build):
|
||||
"""Pull the base_url that `_try_anthropic()` actually handed to build_anthropic_client."""
|
||||
args, _kwargs = mock_build.call_args
|
||||
# build_anthropic_client(token, base_url) per agent/auxiliary_client.py line 2180
|
||||
assert len(args) >= 2, f"expected (token, base_url), got args={args}"
|
||||
return args[1]
|
||||
|
||||
|
||||
class TestTryAnthropicBaseUrlHostValidation:
|
||||
"""Issue #52608: side-channel calls must not be sent to a non-Anthropic host."""
|
||||
|
||||
def test_openrouter_base_url_does_not_leak_into_auxiliary(self, tmp_path, monkeypatch):
|
||||
"""cfg.model.base_url=https://openrouter.ai/api/v1 must NOT override aux base_url."""
|
||||
import yaml
|
||||
from agent.auxiliary_client import _try_anthropic
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||||
"model": {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
}
|
||||
}))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent.auxiliary_client._select_pool_entry", return_value=(False, None)
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.resolve_anthropic_token",
|
||||
return_value="***",
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.build_anthropic_client"
|
||||
) as mock_build,
|
||||
):
|
||||
mock_build.return_value = MagicMock()
|
||||
client, _model = _try_anthropic()
|
||||
|
||||
assert client is not None, "auxiliary client must still be created"
|
||||
actual = _extract_base_url_passed_to_build(mock_build)
|
||||
assert actual == "https://api.anthropic.com", (
|
||||
f"Auxiliary client must use the Anthropic default base_url, "
|
||||
f"not the operator's main-session override. Got: {actual!r}"
|
||||
)
|
||||
|
||||
def test_anthropic_default_host_is_preserved(self, tmp_path, monkeypatch):
|
||||
"""The common case (operator sets model.base_url to api.anthropic.com) must still apply."""
|
||||
import yaml
|
||||
from agent.auxiliary_client import _try_anthropic
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||||
"model": {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
}
|
||||
}))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent.auxiliary_client._select_pool_entry", return_value=(False, None)
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.resolve_anthropic_token",
|
||||
return_value="***",
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.build_anthropic_client"
|
||||
) as mock_build,
|
||||
):
|
||||
mock_build.return_value = MagicMock()
|
||||
client, _model = _try_anthropic()
|
||||
|
||||
assert client is not None
|
||||
actual = _extract_base_url_passed_to_build(mock_build)
|
||||
assert actual == "https://api.anthropic.com"
|
||||
|
||||
def test_openai_base_url_does_not_leak(self, tmp_path, monkeypatch):
|
||||
"""Generic non-Anthropic host must not be applied as auxiliary base_url."""
|
||||
import yaml
|
||||
from agent.auxiliary_client import _try_anthropic
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||||
"model": {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
}
|
||||
}))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent.auxiliary_client._select_pool_entry", return_value=(False, None)
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.resolve_anthropic_token",
|
||||
return_value="***",
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.build_anthropic_client"
|
||||
) as mock_build,
|
||||
):
|
||||
mock_build.return_value = MagicMock()
|
||||
client, _model = _try_anthropic()
|
||||
|
||||
assert client is not None
|
||||
actual = _extract_base_url_passed_to_build(mock_build)
|
||||
assert actual == "https://api.anthropic.com", (
|
||||
f"Non-Anthropic host must not be applied. Got: {actual!r}"
|
||||
)
|
||||
|
||||
def test_empty_base_url_falls_back_to_default(self, tmp_path, monkeypatch):
|
||||
"""Empty model.base_url must not crash and must fall back to default."""
|
||||
import yaml
|
||||
from agent.auxiliary_client import _try_anthropic
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||||
"model": {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"base_url": "",
|
||||
}
|
||||
}))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent.auxiliary_client._select_pool_entry", return_value=(False, None)
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.resolve_anthropic_token",
|
||||
return_value="***",
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.build_anthropic_client"
|
||||
) as mock_build,
|
||||
):
|
||||
mock_build.return_value = MagicMock()
|
||||
client, _model = _try_anthropic()
|
||||
|
||||
assert client is not None
|
||||
actual = _extract_base_url_passed_to_build(mock_build)
|
||||
assert actual == "https://api.anthropic.com"
|
||||
|
||||
def test_anthropic_host_with_path_is_preserved(self, tmp_path, monkeypatch):
|
||||
"""api.anthropic.com with a path suffix must still pass the host check."""
|
||||
import yaml
|
||||
from agent.auxiliary_client import _try_anthropic
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||||
"model": {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"base_url": "https://api.anthropic.com/v1/messages",
|
||||
}
|
||||
}))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent.auxiliary_client._select_pool_entry", return_value=(False, None)
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.resolve_anthropic_token",
|
||||
return_value="***",
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.build_anthropic_client"
|
||||
) as mock_build,
|
||||
):
|
||||
mock_build.return_value = MagicMock()
|
||||
client, _model = _try_anthropic()
|
||||
|
||||
assert client is not None
|
||||
actual = _extract_base_url_passed_to_build(mock_build)
|
||||
assert actual == "https://api.anthropic.com/v1/messages", (
|
||||
f"Anthropic host with path must be preserved. Got: {actual!r}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue