d10bb2ab6f
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.
Parallel directories for one source package, folded into the mirror:
tests/acp -> tests/acp_adapter (its __init__/conftest move with it)
tests/cli -> tests/hermes_cli (prompt_toolkit fixture merged into
hermes_cli/conftest.py)
tests/run_agent -> tests/agent (backoff fixture becomes
agent/conftest.py)
tests/relay -> tests/gateway/relay
tests/state -> tests/hermes_state
246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.
Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.
Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).
Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
238 lines
9.4 KiB
Python
238 lines
9.4 KiB
Python
"""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_credentials.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_credentials.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_credentials.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_credentials.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_suffix_gateway_base_url_is_applied(self, tmp_path, monkeypatch):
|
|
"""A gateway exposing the Messages protocol under a ``/anthropic`` suffix
|
|
must be honored — the same convention the primary path already trusts —
|
|
so auxiliary/fallback calls hit the configured endpoint, not the 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": "https://gateway.example.com/anthropic",
|
|
}
|
|
}))
|
|
|
|
with (
|
|
patch(
|
|
"agent.auxiliary_client._select_pool_entry", return_value=(False, None)
|
|
),
|
|
patch(
|
|
"agent.anthropic_credentials.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://gateway.example.com/anthropic", (
|
|
f"/anthropic-suffixed gateway base_url must be applied. Got: {actual!r}"
|
|
)
|
|
|
|
def test_anthropic_suffix_host_check_direct(self):
|
|
"""Unit-level: the host check trusts native hosts and /anthropic gateways,
|
|
and still rejects a bare non-Anthropic host (the #52608 guard)."""
|
|
from agent.auxiliary_client import _is_anthropic_compatible_host as ok
|
|
assert ok("https://api.anthropic.com") is True
|
|
assert ok("https://gateway.example.com/anthropic") is True
|
|
assert ok("http://127.0.0.1:8080/anthropic/v1") is True
|
|
assert ok("https://openrouter.ai/api/v1") is False
|
|
assert ok("https://api.openai.com/v1") is False
|
|
assert ok("") is False
|
|
|
|
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_credentials.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}"
|
|
)
|