mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
180 lines
No EOL
6.5 KiB
Python
180 lines
No EOL
6.5 KiB
Python
"""Regression tests for the in-container WebSocket client host resolution.
|
|
|
|
Issue #58993: when the dashboard binds to a wildcard (``0.0.0.0`` / ``::``),
|
|
the in-container WS clients built by ``_build_gateway_ws_url`` and
|
|
``_build_sidecar_url`` used the bind host verbatim, so the child TUI
|
|
dialed ``ws://0.0.0.0:9119/api/ws``. Behind a forward proxy whose
|
|
``NO_PROXY`` does not list ``0.0.0.0`` that wildcard dial is routed through
|
|
the proxy and fails the handshake.
|
|
|
|
The contract these tests pin down:
|
|
|
|
* Wildcard bind (``0.0.0.0`` / ``::``) → client dials ``127.0.0.1``.
|
|
* Loopback bind (``127.0.0.1``) → client dials ``127.0.0.1`` (unchanged).
|
|
* LAN / non-wildcard bind (``192.168.1.5``) → client dials that exact
|
|
address (no rewrite to loopback — the bind was deliberate).
|
|
* Explicit ``HERMES_DASHBOARD_WS_HOST`` env var → wins always, regardless
|
|
of the bind host.
|
|
* ``app.state.bound_host`` is left untouched — the bind address used by
|
|
the listener doesn't change.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import web_server
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def saved_app_state():
|
|
"""Snapshot and restore the bits of ``app.state`` the tests mutate.
|
|
|
|
The dashboard's WS URL builders read three things off ``app.state``:
|
|
``bound_host``, ``bound_port``, ``auth_required``. We capture them so
|
|
the suite doesn't leak state into other tests, then yield control.
|
|
"""
|
|
saved = {
|
|
"bound_host": getattr(web_server.app.state, "bound_host", None),
|
|
"bound_port": getattr(web_server.app.state, "bound_port", None),
|
|
"auth_required": getattr(web_server.app.state, "auth_required", None),
|
|
}
|
|
yield saved
|
|
for key, value in saved.items():
|
|
setattr(web_server.app.state, key, value)
|
|
|
|
|
|
@pytest.fixture
|
|
def clear_ws_host_env(monkeypatch):
|
|
"""Ensure no ``HERMES_DASHBOARD_WS_HOST`` leaks in from the test shell."""
|
|
monkeypatch.delenv("HERMES_DASHBOARD_WS_HOST", raising=False)
|
|
yield monkeypatch
|
|
|
|
|
|
def _set_bound(saved_app_state, host: str, port: int = 9119):
|
|
web_server.app.state.bound_host = host
|
|
web_server.app.state.bound_port = port
|
|
web_server.app.state.auth_required = False
|
|
|
|
|
|
def _netloc(ws_url: str) -> str:
|
|
"""Pull the ``host:port`` (or ``[host]:port``) segment out of a ws URL."""
|
|
assert ws_url is not None, "expected a URL, got None"
|
|
# ws://host:port/path?qs — strip the scheme, then take netloc up to "/".
|
|
after_scheme = ws_url.split("://", 1)[1]
|
|
netloc = after_scheme.split("/", 1)[0]
|
|
return netloc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _resolve_client_ws_host — direct unit tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveClientWsHost:
|
|
def test_wildcard_ipv4_uses_loopback(self, saved_app_state, clear_ws_host_env):
|
|
_set_bound(saved_app_state, "0.0.0.0")
|
|
assert web_server._resolve_client_ws_host() == "127.0.0.1"
|
|
|
|
def test_wildcard_ipv6_uses_loopback(self, saved_app_state, clear_ws_host_env):
|
|
_set_bound(saved_app_state, "::")
|
|
assert web_server._resolve_client_ws_host() == "127.0.0.1"
|
|
|
|
def test_loopback_bind_unchanged(self, saved_app_state, clear_ws_host_env):
|
|
_set_bound(saved_app_state, "127.0.0.1")
|
|
assert web_server._resolve_client_ws_host() == "127.0.0.1"
|
|
|
|
|
|
def test_public_dns_bind_preserved(self, saved_app_state, clear_ws_host_env):
|
|
_set_bound(saved_app_state, "fly-app.example.dev")
|
|
assert web_server._resolve_client_ws_host() == "fly-app.example.dev"
|
|
|
|
|
|
|
|
|
|
def test_blank_env_falls_back_to_bind(
|
|
self, saved_app_state, monkeypatch
|
|
):
|
|
"""An explicitly empty override (e.g. ``HERMES_DASHBOARD_WS_HOST=``)
|
|
must NOT silently pin to loopback — it's an unset-by-accident, not
|
|
an intent. Treat whitespace-only as absent and fall through."""
|
|
monkeypatch.setenv("HERMES_DASHBOARD_WS_HOST", " ")
|
|
_set_bound(saved_app_state, "0.0.0.0")
|
|
assert web_server._resolve_client_ws_host() == "127.0.0.1"
|
|
|
|
|
|
def test_bind_host_unchanged_after_wildcard_resolution(
|
|
self, saved_app_state, clear_ws_host_env
|
|
):
|
|
"""Resolution only affects the client netloc — ``bound_host`` on
|
|
``app.state`` (used by the listener and host-header middleware) is
|
|
NOT mutated."""
|
|
_set_bound(saved_app_state, "0.0.0.0")
|
|
web_server._resolve_client_ws_host()
|
|
assert web_server.app.state.bound_host == "0.0.0.0"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _build_gateway_ws_url — end-to-end URL contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGatewayWsUrlHost:
|
|
|
|
def test_ipv6_wildcard_bind_dials_loopback(
|
|
self, saved_app_state, clear_ws_host_env
|
|
):
|
|
_set_bound(saved_app_state, "::", port=9119)
|
|
url = web_server._build_gateway_ws_url()
|
|
assert url is not None
|
|
assert url.startswith("ws://127.0.0.1:9119/api/ws")
|
|
# The ``::`` must not leak into the client URL.
|
|
assert "::" not in url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _build_sidecar_url — end-to-end URL contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSidecarUrlHost:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_no_bound_host_returns_none(
|
|
self, saved_app_state, clear_ws_host_env
|
|
):
|
|
web_server.app.state.bound_host = None
|
|
web_server.app.state.bound_port = None
|
|
assert web_server._build_sidecar_url("ch-1") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _netloc helper is exposed only because it's useful for tests; if the
|
|
# production code ever changes the URL shape the tests catch the regression
|
|
# above without needing to assert on the full string.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_netloc_helper_handles_ipv6_bracket_form():
|
|
"""The IPv6 netloc path is exercised by the production ``[host]:port``
|
|
branch when ``HERMES_DASHBOARD_WS_HOST`` points at an IPv6 address.
|
|
Verify the helper doesn't choke on the bracket form."""
|
|
assert _netloc("ws://[::1]:9119/api/ws?x=1") == "[::1]:9119"
|
|
assert _netloc("ws://127.0.0.1:9119/api/ws?x=1") == "127.0.0.1:9119" |