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.
205 lines
7.2 KiB
Python
205 lines
7.2 KiB
Python
"""Regression harness for the dashboard auth gate.
|
|
|
|
Phase 0 — establish a baseline pin on the current (pre-OAuth) behavior so
|
|
later phases can prove they didn't break loopback mode.
|
|
"""
|
|
import pytest
|
|
|
|
# Phase 5 / Phase 6: these tests mutate ``web_server.app.state.auth_required``
|
|
# at module level. Run them in the same xdist worker so they don't race
|
|
# against each other (and against any other file that also touches
|
|
# ``app.state``) — the marker name is shared across all dashboard-auth test
|
|
# files that gate the app.
|
|
from fastapi.testclient import TestClient
|
|
|
|
from hermes_cli import web_server
|
|
|
|
|
|
@pytest.fixture
|
|
def client_loopback():
|
|
# Pin the bound-host state for host_header_middleware so requests with
|
|
# default Host: testclient pass the DNS-rebinding check. TestClient
|
|
# sends Host: testserver by default, but our middleware accepts the
|
|
# loopback aliases when bound_host is loopback.
|
|
prev_host = getattr(web_server.app.state, "bound_host", None)
|
|
prev_port = getattr(web_server.app.state, "bound_port", None)
|
|
web_server.app.state.bound_host = "127.0.0.1"
|
|
web_server.app.state.bound_port = 9119
|
|
client = TestClient(web_server.app, base_url="http://127.0.0.1:9119")
|
|
yield client
|
|
web_server.app.state.bound_host = prev_host
|
|
web_server.app.state.bound_port = prev_port
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# should_require_auth predicate (Task 0.2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("host,allow_public,expected", [
|
|
("127.0.0.1", False, False),
|
|
("127.0.0.1", True, False),
|
|
("localhost", False, False),
|
|
("::1", False, False),
|
|
# --insecure (allow_public=True) NO LONGER bypasses the gate on a public
|
|
# bind (June 2026 hermes-0day hardening). Non-loopback always requires auth.
|
|
("0.0.0.0", True, True),
|
|
("0.0.0.0", False, True),
|
|
("192.168.1.5", False, True),
|
|
("10.0.0.1", True, True), # allow_public ignored — LAN IP is public
|
|
("100.64.0.1", False, True), # Tailscale CGNAT — treated as public
|
|
("hermes-agent-prod-abc.fly.dev", False, True),
|
|
])
|
|
def test_should_require_auth_truth_table(host, allow_public, expected):
|
|
from hermes_cli.web_server import should_require_auth
|
|
assert should_require_auth(host, allow_public) is expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# start_server stashes auth_required on app.state (Task 0.3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _stub_uvicorn_run(monkeypatch):
|
|
"""Replace uvicorn.Config/Server with no-op fakes so start_server
|
|
returns immediately (rather than blocking on the event loop). Returns the dict
|
|
that will capture the keyword args.
|
|
"""
|
|
import asyncio
|
|
import contextlib
|
|
import uvicorn
|
|
captured: dict = {"kwargs": {}}
|
|
|
|
class _FakeConfig:
|
|
loaded = True
|
|
host = "127.0.0.1"
|
|
port = 8000
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
captured["kwargs"] = kwargs
|
|
|
|
def load(self):
|
|
pass
|
|
|
|
class lifespan_class:
|
|
should_exit = False
|
|
state: dict = {}
|
|
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
async def startup(self):
|
|
pass
|
|
|
|
async def shutdown(self):
|
|
pass
|
|
|
|
class _FakeServer:
|
|
should_exit = False
|
|
started = True
|
|
servers: list = []
|
|
lifespan = None
|
|
|
|
@staticmethod
|
|
def capture_signals():
|
|
return contextlib.nullcontext()
|
|
|
|
async def startup(self, sockets=None):
|
|
pass
|
|
|
|
async def main_loop(self):
|
|
pass
|
|
|
|
async def shutdown(self, sockets=None):
|
|
pass
|
|
|
|
monkeypatch.setattr(uvicorn, "Config", _FakeConfig)
|
|
monkeypatch.setattr(uvicorn, "Server", lambda config: _FakeServer())
|
|
return captured
|
|
|
|
|
|
def test_start_server_loopback_sets_auth_required_false(monkeypatch):
|
|
"""Loopback bind: app.state.auth_required is False after start_server."""
|
|
_stub_uvicorn_run(monkeypatch)
|
|
# Force a fresh state to detect that start_server actually set it.
|
|
web_server.app.state.auth_required = None
|
|
web_server.start_server(
|
|
host="127.0.0.1", port=9119,
|
|
open_browser=False, allow_public=False,
|
|
)
|
|
assert web_server.app.state.auth_required is False
|
|
|
|
|
|
def test_start_server_insecure_public_no_longer_bypasses_gate(monkeypatch):
|
|
"""``--insecure`` (allow_public=True) on a public host: gate now ENGAGES.
|
|
|
|
June 2026 hardening: --insecure no longer disables auth. With no providers
|
|
registered, the bind fails closed (SystemExit) and auth_required is True.
|
|
"""
|
|
from hermes_cli.dashboard_auth import clear_providers
|
|
clear_providers()
|
|
_stub_uvicorn_run(monkeypatch)
|
|
web_server.app.state.auth_required = None
|
|
with pytest.raises(SystemExit):
|
|
web_server.start_server(
|
|
host="0.0.0.0", port=9119,
|
|
open_browser=False, allow_public=True,
|
|
)
|
|
assert web_server.app.state.auth_required is True
|
|
|
|
|
|
def test_start_server_public_without_insecure_records_auth_required(monkeypatch):
|
|
"""Public bind without --insecure: the gate engages and auth_required=True.
|
|
|
|
With no providers registered, this fails closed with SystemExit. The
|
|
flag-stashing happens BEFORE the exit so the rest of the system can
|
|
branch on it. (See task 3.5 tests below for the with-provider path.)
|
|
"""
|
|
from hermes_cli.dashboard_auth import clear_providers
|
|
clear_providers()
|
|
_stub_uvicorn_run(monkeypatch)
|
|
web_server.app.state.auth_required = None
|
|
with pytest.raises(SystemExit):
|
|
web_server.start_server(
|
|
host="0.0.0.0", port=9119,
|
|
open_browser=False, allow_public=False,
|
|
)
|
|
assert web_server.app.state.auth_required is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Task 3.5: start_server fail-closed + proxy_headers + index-token suppression
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_start_server_gate_with_provider_proceeds_and_sets_proxy_headers(monkeypatch):
|
|
"""With at least one provider, public bind + no --insecure starts the server.
|
|
|
|
The SystemExit-refusing-to-bind guard is REPLACED in gated mode by
|
|
"the gate engages", so as long as a provider is registered the bind
|
|
succeeds. uvicorn is called with proxy_headers=True so X-Forwarded-Proto
|
|
from Fly's TLS terminator is honoured for cookie Secure-flag decisions.
|
|
"""
|
|
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
|
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
|
|
|
clear_providers()
|
|
register_provider(StubAuthProvider())
|
|
captured = _stub_uvicorn_run(monkeypatch)
|
|
try:
|
|
web_server.app.state.auth_required = None
|
|
web_server.start_server(
|
|
host="0.0.0.0", port=9119,
|
|
open_browser=False, allow_public=False,
|
|
)
|
|
assert web_server.app.state.auth_required is True
|
|
assert captured["kwargs"].get("host") == "0.0.0.0"
|
|
assert captured["kwargs"].get("proxy_headers") is True
|
|
finally:
|
|
clear_providers()
|
|
|
|
|