2026-03-31 03:40:19 +00:00
|
|
|
"""
|
|
|
|
|
Shared pytest fixtures for webui-mvp tests.
|
|
|
|
|
|
|
|
|
|
TEST ISOLATION:
|
2026-05-13 02:57:17 +00:00
|
|
|
Tests run against a SEPARATE server instance on an auto-derived test port
|
|
|
|
|
with a completely separate state directory. Production data is never touched.
|
2026-03-31 03:40:19 +00:00
|
|
|
The test state dir is wiped before each full test run and again on teardown.
|
|
|
|
|
|
|
|
|
|
PATH DISCOVERY:
|
|
|
|
|
No hardcoded paths. Discovery order:
|
|
|
|
|
1. Environment variables (HERMES_WEBUI_AGENT_DIR, HERMES_WEBUI_PYTHON, etc.)
|
|
|
|
|
2. Sibling checkout heuristics relative to this repo
|
|
|
|
|
3. Common install paths (~/.hermes/hermes-agent)
|
|
|
|
|
4. System python3 as a last resort
|
|
|
|
|
"""
|
|
|
|
|
import json
|
2026-06-15 19:42:33 +00:00
|
|
|
import inspect
|
|
|
|
|
import multiprocessing
|
2026-03-31 03:40:19 +00:00
|
|
|
import os
|
|
|
|
|
import pathlib
|
|
|
|
|
import shutil
|
|
|
|
|
import subprocess
|
2026-05-30 21:09:25 +00:00
|
|
|
import sys
|
2026-03-31 03:40:19 +00:00
|
|
|
import time
|
|
|
|
|
import urllib.request
|
|
|
|
|
import urllib.error
|
|
|
|
|
import pytest
|
|
|
|
|
|
2026-06-15 17:25:32 +00:00
|
|
|
if not (3, 11) <= sys.version_info[:2] <= (3, 13):
|
|
|
|
|
pytest.exit(
|
|
|
|
|
"Hermes WebUI tests require Python 3.11, 3.12, or 3.13. "
|
|
|
|
|
"Run ./scripts/test.sh so the repo-local supported .venv is used "
|
|
|
|
|
"instead of an unsupported system python.",
|
|
|
|
|
returncode=3,
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-15 19:42:33 +00:00
|
|
|
WINDOWS = sys.platform == "win32"
|
|
|
|
|
requires_fcntl = pytest.mark.skipif(
|
|
|
|
|
WINDOWS,
|
|
|
|
|
reason="requires fcntl-backed nonblocking pipe reads",
|
|
|
|
|
)
|
|
|
|
|
requires_fork = pytest.mark.skipif(
|
|
|
|
|
"fork" not in multiprocessing.get_all_start_methods(),
|
|
|
|
|
reason="requires multiprocessing fork",
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-31 03:40:19 +00:00
|
|
|
# ── Repo root discovery ────────────────────────────────────────────────────
|
|
|
|
|
# conftest.py lives at <repo>/tests/conftest.py
|
|
|
|
|
TESTS_DIR = pathlib.Path(__file__).parent.resolve()
|
|
|
|
|
REPO_ROOT = TESTS_DIR.parent.resolve()
|
|
|
|
|
HOME = pathlib.Path.home()
|
|
|
|
|
HERMES_HOME = pathlib.Path(os.getenv('HERMES_HOME', str(HOME / '.hermes')))
|
|
|
|
|
|
|
|
|
|
# ── Test server config ────────────────────────────────────────────────────
|
2026-04-14 19:04:48 +00:00
|
|
|
# Port and state dir auto-derive from the repo path when no env var is set,
|
2026-05-13 02:57:17 +00:00
|
|
|
# giving every worktree its own isolated port (20000-29999) and state directory.
|
2026-04-14 19:04:48 +00:00
|
|
|
# Override with HERMES_WEBUI_TEST_PORT / HERMES_WEBUI_TEST_STATE_DIR to pin.
|
|
|
|
|
|
|
|
|
|
def _auto_test_port(repo_root) -> int:
|
|
|
|
|
"""Map repo path to a unique port in 20000-29999 (10k range = near-zero collisions).
|
|
|
|
|
Far from system port ranges and Linux ephemeral ports (32768+).
|
|
|
|
|
Override with HERMES_WEBUI_TEST_PORT to use a specific port."""
|
|
|
|
|
import hashlib
|
|
|
|
|
h = int(hashlib.md5(str(repo_root).encode()).hexdigest(), 16)
|
|
|
|
|
return 20000 + (h % 10000)
|
|
|
|
|
|
|
|
|
|
def _auto_state_dir_name(repo_root) -> str:
|
|
|
|
|
import hashlib
|
|
|
|
|
h = hashlib.md5(str(repo_root).encode()).hexdigest()[:8]
|
|
|
|
|
return f"webui-test-{h}"
|
|
|
|
|
|
|
|
|
|
TEST_PORT = int(os.getenv('HERMES_WEBUI_TEST_PORT',
|
|
|
|
|
str(_auto_test_port(REPO_ROOT))))
|
2026-03-31 03:40:19 +00:00
|
|
|
TEST_BASE = f"http://127.0.0.1:{TEST_PORT}"
|
2026-06-16 02:04:16 +00:00
|
|
|
|
|
|
|
|
# ── Test state dir: HARD-ISOLATED from production ──────────────────────────
|
|
|
|
|
# Test state must NEVER live inside the real Hermes home (~/.hermes or any
|
|
|
|
|
# HERMES_HOME), or anywhere near production profiles/files/server. Earlier this
|
|
|
|
|
# defaulted to ``HERMES_HOME / webui-test-<hash>`` which wrote test state INTO
|
|
|
|
|
# ~/.hermes/profiles/<...>/ (observed: 144 leaked webui-test-* dirs in the real
|
|
|
|
|
# profile home). We now anchor the default under the OS temp dir, in a dedicated
|
|
|
|
|
# `hermes-webui-tests/` namespace, fully outside any production tree.
|
|
|
|
|
import tempfile as _tempfile
|
|
|
|
|
_TEST_STATE_ROOT = pathlib.Path(
|
|
|
|
|
os.getenv('HERMES_WEBUI_TEST_STATE_ROOT', _tempfile.gettempdir())
|
|
|
|
|
) / 'hermes-webui-tests'
|
2026-03-31 03:40:19 +00:00
|
|
|
TEST_STATE_DIR = pathlib.Path(os.getenv(
|
|
|
|
|
'HERMES_WEBUI_TEST_STATE_DIR',
|
2026-06-16 02:04:16 +00:00
|
|
|
str(_TEST_STATE_ROOT / _auto_state_dir_name(REPO_ROOT))
|
|
|
|
|
)).resolve()
|
|
|
|
|
|
|
|
|
|
# Production-proximity guard: refuse to run if the resolved test state dir lands
|
|
|
|
|
# inside the REAL Hermes home tree — a misconfigured HERMES_WEBUI_TEST_STATE_DIR
|
|
|
|
|
# pointing at ~/.hermes would let tests wipe/clobber production profiles,
|
|
|
|
|
# sessions, and credentials on teardown. We anchor on the literal user-home
|
|
|
|
|
# `~/.hermes` (NOT $HERMES_HOME): HERMES_HOME is frequently overridden to a
|
|
|
|
|
# profile dir or, during a test run, to TEST_STATE_DIR itself — comparing
|
|
|
|
|
# against it would either miss a real production path or false-trip when the
|
|
|
|
|
# test dir legitimately lives in /tmp. A test dir under the OS temp dir is always
|
|
|
|
|
# allowed even if it nominally sits below a temp-rooted HERMES_HOME.
|
|
|
|
|
_PROD_HERMES_HOME = (HOME / '.hermes').resolve()
|
|
|
|
|
_TEMP_ROOT = pathlib.Path(_tempfile.gettempdir()).resolve()
|
|
|
|
|
# The temp-root exception only holds when the temp root is itself OUTSIDE the
|
|
|
|
|
# production home. If TMPDIR is (mis)configured under ~/.hermes, a "temp" path is
|
|
|
|
|
# still a production path — don't let it suppress the guard.
|
|
|
|
|
_temp_root_is_safe = not (
|
|
|
|
|
_TEMP_ROOT == _PROD_HERMES_HOME or _PROD_HERMES_HOME in _TEMP_ROOT.parents
|
|
|
|
|
)
|
|
|
|
|
_under_temp = _temp_root_is_safe and (
|
|
|
|
|
TEST_STATE_DIR == _TEMP_ROOT or _TEMP_ROOT in TEST_STATE_DIR.parents
|
|
|
|
|
)
|
|
|
|
|
_under_prod = TEST_STATE_DIR == _PROD_HERMES_HOME or _PROD_HERMES_HOME in TEST_STATE_DIR.parents
|
|
|
|
|
if _under_prod and not _under_temp:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"REFUSING TO RUN: test state dir {TEST_STATE_DIR} is inside the production "
|
|
|
|
|
f"Hermes home {_PROD_HERMES_HOME}. Tests must never touch production files. "
|
|
|
|
|
f"Unset HERMES_WEBUI_TEST_STATE_DIR (defaults to a temp dir) or point it "
|
|
|
|
|
f"outside ~/.hermes."
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-31 03:40:19 +00:00
|
|
|
TEST_WORKSPACE = TEST_STATE_DIR / 'test-workspace'
|
|
|
|
|
|
2026-04-27 04:04:38 +00:00
|
|
|
# Publish at module level so api.config, _pytest_port.py, and any test module
|
|
|
|
|
# importing stateful API code during collection see the isolated test paths.
|
|
|
|
|
#
|
|
|
|
|
# Direct assignment is intentional for production-risk paths: tests that import
|
|
|
|
|
# api.config/api.models in the pytest process must never inherit the real
|
|
|
|
|
# ~/.hermes state tree before the server subprocess fixture starts.
|
|
|
|
|
os.environ['HERMES_WEBUI_TEST_PORT'] = str(TEST_PORT)
|
|
|
|
|
os.environ['HERMES_WEBUI_TEST_STATE_DIR'] = str(TEST_STATE_DIR)
|
|
|
|
|
os.environ['HERMES_WEBUI_STATE_DIR'] = str(TEST_STATE_DIR)
|
|
|
|
|
os.environ['HERMES_WEBUI_DEFAULT_WORKSPACE'] = str(TEST_WORKSPACE)
|
|
|
|
|
os.environ['HERMES_HOME'] = str(TEST_STATE_DIR)
|
|
|
|
|
os.environ['HERMES_BASE_HOME'] = str(TEST_STATE_DIR)
|
2026-05-04 05:33:54 +00:00
|
|
|
# Hermes Agent sessions may inherit HERMES_CONFIG_PATH pointing at the live
|
|
|
|
|
# ~/.hermes/config.yaml. Override it before any product modules are imported so
|
|
|
|
|
# tests that read/write config.yaml stay inside the isolated test home.
|
|
|
|
|
os.environ['HERMES_CONFIG_PATH'] = str(TEST_STATE_DIR / 'config.yaml')
|
2026-04-14 19:04:48 +00:00
|
|
|
|
2026-05-21 14:25:04 +00:00
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _isolate_hermes_config_path():
|
|
|
|
|
"""Keep profile/.env side effects from leaking the live config path across tests."""
|
|
|
|
|
isolated_config_path = str(TEST_STATE_DIR / 'config.yaml')
|
|
|
|
|
os.environ['HERMES_CONFIG_PATH'] = isolated_config_path
|
|
|
|
|
yield
|
|
|
|
|
os.environ['HERMES_CONFIG_PATH'] = isolated_config_path
|
|
|
|
|
|
2026-05-30 21:09:25 +00:00
|
|
|
|
2026-06-11 01:56:58 +00:00
|
|
|
_MISSING = object() # sentinel: api.profiles module not loaded pre-test
|
|
|
|
|
|
|
|
|
|
|
2026-05-30 21:09:25 +00:00
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _restore_profile_home_globals():
|
|
|
|
|
"""Restore HERMES_HOME / HERMES_BASE_HOME after every test.
|
|
|
|
|
|
|
|
|
|
Several tests call ``api.profiles.switch_profile()`` (or set HERMES_HOME
|
|
|
|
|
directly) which mutates ``os.environ['HERMES_HOME']`` IN PLACE — not via
|
|
|
|
|
monkeypatch — so the change is not auto-reverted at test teardown. In the
|
|
|
|
|
normal sequential run the next test usually re-establishes its own profile so
|
|
|
|
|
the leak is masked, but under pytest-shard (or pytest-randomly) the leaked
|
|
|
|
|
HERMES_HOME points at a deleted tmpdir and breaks any later test whose
|
|
|
|
|
config/profile resolution reads it (e.g. test_title_aux_routing's
|
|
|
|
|
background-worker profile routing, which then falls back to DEFAULT_CONFIG
|
|
|
|
|
where ``model`` is an empty string). Snapshotting at the conftest level fixes
|
|
|
|
|
the whole class at once, regardless of which test does the leaking.
|
|
|
|
|
"""
|
|
|
|
|
saved_home = os.environ.get('HERMES_HOME')
|
|
|
|
|
saved_base = os.environ.get('HERMES_BASE_HOME')
|
2026-06-11 01:17:22 +00:00
|
|
|
# Snapshot the process-global active-profile name too. Several tests call
|
|
|
|
|
# switch_profile() (process_wide=True), which mutates api.profiles._active_profile
|
|
|
|
|
# in place and never restores it. In a sequential run the next test usually
|
|
|
|
|
# re-establishes its own profile so the leak is masked, but a test that only
|
|
|
|
|
# patches a profile-scoped *path* (e.g. config._models_cache_path) without
|
|
|
|
|
# setting an active profile then resolves the LEAKED profile — e.g.
|
|
|
|
|
# _get_models_cache_path() returns models_cache.<leaked>.json instead of the
|
|
|
|
|
# patched default path. Restoring the name here fixes the whole class.
|
|
|
|
|
prof_mod_pre = sys.modules.get('api.profiles')
|
2026-06-11 01:56:58 +00:00
|
|
|
# Use a sentinel so we restore whenever the module was importable pre-test,
|
|
|
|
|
# independent of the value (covers a hypothetical None, though _active_profile
|
|
|
|
|
# defaults to 'default'). _MISSING means "module wasn't loaded" → skip restore.
|
|
|
|
|
saved_active_profile = getattr(prof_mod_pre, '_active_profile', _MISSING) if prof_mod_pre else _MISSING
|
2026-05-30 21:09:25 +00:00
|
|
|
# Re-derive the cached base-home global BEFORE the test runs too: a prior
|
|
|
|
|
# test's teardown ordering (monkeypatch restoring sys.modules['api.profiles']
|
|
|
|
|
# after this fixture's teardown) can leave the live module's
|
|
|
|
|
# _DEFAULT_HERMES_HOME stale. Fixing it at setup time guarantees each test
|
|
|
|
|
# starts from a base root that matches the current (restored) env.
|
|
|
|
|
_rederive_default_hermes_home()
|
|
|
|
|
yield
|
|
|
|
|
for key, val in (('HERMES_HOME', saved_home), ('HERMES_BASE_HOME', saved_base)):
|
|
|
|
|
if val is None:
|
|
|
|
|
os.environ.pop(key, None)
|
|
|
|
|
else:
|
|
|
|
|
os.environ[key] = val
|
2026-06-11 01:17:22 +00:00
|
|
|
prof_mod_post = sys.modules.get('api.profiles')
|
2026-06-11 01:56:58 +00:00
|
|
|
if prof_mod_post is not None and saved_active_profile is not _MISSING:
|
2026-06-11 01:17:22 +00:00
|
|
|
prof_mod_post._active_profile = saved_active_profile
|
|
|
|
|
# Also clear any leaked per-request thread-local profile (issue #798).
|
|
|
|
|
try:
|
|
|
|
|
prof_mod_post.clear_request_profile()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2026-05-30 21:09:25 +00:00
|
|
|
_rederive_default_hermes_home()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rederive_default_hermes_home():
|
|
|
|
|
"""Recompute api.profiles._DEFAULT_HERMES_HOME from the current env.
|
|
|
|
|
|
|
|
|
|
api.profiles caches the base home at import time. A test that re-imports
|
|
|
|
|
api.profiles under a temporary HERMES_BASE_HOME (e.g. test_profile_env_isolation)
|
|
|
|
|
corrupts that global to a now-deleted tmpdir, making get_hermes_home_for_profile
|
|
|
|
|
resolve later tests' profiles under the dead path. Re-deriving keeps it honest.
|
|
|
|
|
"""
|
|
|
|
|
prof_mod = sys.modules.get('api.profiles')
|
|
|
|
|
if prof_mod is not None and hasattr(prof_mod, '_resolve_base_hermes_home'):
|
|
|
|
|
try:
|
|
|
|
|
prof_mod._DEFAULT_HERMES_HOME = prof_mod._resolve_base_hermes_home()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
2026-03-31 03:40:19 +00:00
|
|
|
# ── Server script: always relative to repo root ───────────────────────────
|
|
|
|
|
SERVER_SCRIPT = REPO_ROOT / 'server.py'
|
|
|
|
|
if not SERVER_SCRIPT.exists():
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"server.py not found at {SERVER_SCRIPT}. "
|
|
|
|
|
"Is conftest.py in the tests/ subdirectory of the repo?"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Hermes agent discovery (mirrors api/config._discover_agent_dir) ───────
|
|
|
|
|
def _discover_agent_dir() -> pathlib.Path:
|
|
|
|
|
candidates = [
|
|
|
|
|
os.getenv('HERMES_WEBUI_AGENT_DIR', ''),
|
|
|
|
|
str(HERMES_HOME / 'hermes-agent'),
|
|
|
|
|
str(REPO_ROOT.parent / 'hermes-agent'),
|
|
|
|
|
str(HOME / '.hermes' / 'hermes-agent'),
|
|
|
|
|
str(HOME / 'hermes-agent'),
|
|
|
|
|
]
|
|
|
|
|
for c in candidates:
|
|
|
|
|
if not c:
|
|
|
|
|
continue
|
|
|
|
|
p = pathlib.Path(c).expanduser()
|
|
|
|
|
if p.exists() and (p / 'run_agent.py').exists():
|
|
|
|
|
return p.resolve()
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# ── Python discovery (mirrors api/config._discover_python) ────────────────
|
|
|
|
|
def _discover_python(agent_dir) -> str:
|
|
|
|
|
if os.getenv('HERMES_WEBUI_PYTHON'):
|
|
|
|
|
return os.getenv('HERMES_WEBUI_PYTHON')
|
|
|
|
|
if agent_dir:
|
2026-06-04 17:50:34 +00:00
|
|
|
for venv_dir in ('venv', '.venv'):
|
|
|
|
|
for subdir, binary in (('bin', 'python'), ('Scripts', 'python.exe')):
|
|
|
|
|
venv_py = agent_dir / venv_dir / subdir / binary
|
|
|
|
|
if venv_py.exists():
|
|
|
|
|
return str(venv_py)
|
|
|
|
|
for subdir, binary in (('bin', 'python'), ('Scripts', 'python.exe')):
|
|
|
|
|
local_venv = REPO_ROOT / '.venv' / subdir / binary
|
|
|
|
|
if local_venv.exists():
|
|
|
|
|
return str(local_venv)
|
2026-03-31 03:40:19 +00:00
|
|
|
return shutil.which('python3') or shutil.which('python') or 'python3'
|
|
|
|
|
|
|
|
|
|
HERMES_AGENT = _discover_agent_dir()
|
|
|
|
|
VENV_PYTHON = _discover_python(HERMES_AGENT)
|
|
|
|
|
|
|
|
|
|
# Work dir: agent dir if found, else repo root
|
|
|
|
|
WORKDIR = str(HERMES_AGENT) if HERMES_AGENT else str(REPO_ROOT)
|
|
|
|
|
|
2026-04-04 21:51:15 +00:00
|
|
|
# ── Agent availability detection ─────────────────────────────────────────────
|
|
|
|
|
# Tests that require hermes-agent modules (cron, skills, approval, chat/stream)
|
|
|
|
|
# are skipped when the agent isn't installed, instead of failing with 500 errors.
|
|
|
|
|
AGENT_AVAILABLE = HERMES_AGENT is not None
|
|
|
|
|
|
|
|
|
|
def _check_agent_modules():
|
|
|
|
|
"""Verify hermes-agent Python modules are actually importable."""
|
|
|
|
|
if not HERMES_AGENT:
|
|
|
|
|
return False
|
|
|
|
|
try:
|
|
|
|
|
import importlib
|
|
|
|
|
# These are the modules that cause 500 errors when missing
|
|
|
|
|
for mod in ['cron.jobs', 'tools.skills_tool']:
|
|
|
|
|
importlib.import_module(mod)
|
|
|
|
|
return True
|
|
|
|
|
except (ImportError, ModuleNotFoundError):
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
AGENT_MODULES_AVAILABLE = _check_agent_modules()
|
|
|
|
|
|
|
|
|
|
# pytest marker: skip tests that need hermes-agent when it's not present
|
|
|
|
|
requires_agent = pytest.mark.skipif(
|
|
|
|
|
not AGENT_AVAILABLE,
|
|
|
|
|
reason="hermes-agent not found (skipping agent-dependent test)"
|
|
|
|
|
)
|
|
|
|
|
requires_agent_modules = pytest.mark.skipif(
|
|
|
|
|
not AGENT_MODULES_AVAILABLE,
|
|
|
|
|
reason="hermes-agent Python modules not importable (cron, skills_tool)"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def pytest_configure(config):
|
|
|
|
|
config.addinivalue_line("markers", "requires_agent: skip when hermes-agent dir is not found")
|
|
|
|
|
config.addinivalue_line("markers", "requires_agent_modules: skip when hermes-agent Python modules are not importable")
|
2026-06-15 19:42:33 +00:00
|
|
|
config.addinivalue_line("markers", "requires_fcntl: skip when fcntl-backed file-descriptor operations are unavailable")
|
|
|
|
|
config.addinivalue_line("markers", "requires_fork: skip when the platform lacks multiprocessing fork support")
|
2026-04-04 21:51:15 +00:00
|
|
|
|
2026-05-11 03:02:01 +00:00
|
|
|
|
2026-06-12 20:53:03 +00:00
|
|
|
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
|
|
|
|
|
def pytest_report_collectionfinish(config, items):
|
|
|
|
|
"""Avoid pytest-shard's giant nodeid dump on sharded `-v` CI runs."""
|
|
|
|
|
verbose = getattr(config.option, "verbose", 0)
|
|
|
|
|
shard_total = getattr(config.option, "num_shards", 1)
|
|
|
|
|
if shard_total > 1 and verbose > 0:
|
|
|
|
|
config.option.verbose = 0
|
|
|
|
|
try:
|
|
|
|
|
yield
|
|
|
|
|
finally:
|
|
|
|
|
config.option.verbose = verbose
|
|
|
|
|
|
|
|
|
|
|
test(conftest): block AWS IMDS probing + expand credential-strip allowlist
Two test-infrastructure fixes surfaced while running the full suite on
this branch. Both prevent accidental outbound network calls from the
pytest process — a class of bug that doesn't show up as test failures
but corrupts timing, leaks credentials, and was responsible for a recent
10× slowdown observation.
## 1. AWS_EC2_METADATA_DISABLED for the whole pytest session
When hermes-agent's bedrock_adapter / botocore credential chain is
imported during tests (e.g. via api/config.py provider-catalog imports),
botocore probes the EC2 Instance Metadata Service at 169.254.169.254
looking for an instance role. On VPS hosts where IMDS is reachable but
rate-limited (HTTP 429) or non-responsive, those probes dominate wall
time — a 161s test run was observed extending to 600+s.
Set `AWS_EC2_METADATA_DISABLED=true` at module load (before any test-file
imports trigger botocore initialisation). This is the documented AWS-
supported way to silence the probe and matches the guard the agent's own
`hermes_cli/doctor.py` already uses inside its parallel-probe block.
Also explicitly re-set the var on the spawned test-server env so it
can't be accidentally cleared by a later `env.update(...)`.
## 2. Expanded credential-strip allowlist
The original strip list covered 6 providers (OpenRouter, OpenAI,
Anthropic, Google, DeepSeek, Xiaomi). Several others leaked through
into the test server subprocess:
- `MEM0_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `OLLAMA_API_KEY`,
`GROQ_API_KEY`, `TOGETHER_API_KEY`, …
- AWS credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`,
`AWS_SESSION_TOKEN`, `AWS_PROFILE`, `AWS_BEARER_TOKEN_BEDROCK`)
- Messaging bot tokens (`TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`,
`SLACK_BOT_TOKEN`, `SIGNAL_API_TOKEN`, `WHATSAPP_API_TOKEN`)
- Memory providers (`HONCHO_API_KEY`, `SUPERMEMORY_API_KEY`)
- Search / browser / image-gen (`FIRECRAWL_API_KEY`, `FAL_KEY`,
`TAVILY_API_KEY`, `SERPER_API_KEY`, `BRAVE_API_KEY`)
- GitHub tokens (`GH_TOKEN`, `GITHUB_TOKEN`)
- Azure OpenAI (`AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`)
A real outbound TLS connection to a provider's IPv6 endpoint was
observed during a test run on this host before the strip was expanded.
The test server uses a mock config and has no business making real API
calls.
## Test status
5,151 passed / 11 skipped / 1 xfailed / 2 xpassed / 0 regressions in
139s on Python 3.11. Down from 147s before the fixes (and from
intermittent 10×-slowdowns on IMDS-rate-limited hosts). All API/feature
contracts unchanged.
## Security audit of remaining test-suite host references
Every IP / URL / hostname referenced in `tests/**.py` was classified:
- Loopback (127.0.0.1, localhost, ::1, 0.0.0.0)
- RFC1918 private (10.*, 172.16-31.*, 192.168.*)
- RFC 5737 TEST-NET-3 documentation (203.0.113.*)
- RFC 2606 reserved docs domains (*.example.com, *.example.local,
*.example.test)
- Security-attack input strings used only as parser/validator input
(evil.com, attacker, evil.example.com — never resolved or contacted)
- Real provider/CDN endpoints used only as `base_url` config strings
or CSP-allowlist assertions — never actually fetched
- 8.8.8.8 used only as a "non-loopback example" in `_is_local_from_handler()`
unit tests
No suspicious egress destinations.
2026-05-11 04:49:46 +00:00
|
|
|
# ── Disable AWS IMDS probing for the pytest session ────────────────────────
|
|
|
|
|
# Background: when hermes-agent's bedrock_adapter / botocore credential chain
|
|
|
|
|
# runs during test execution (e.g. provider catalog enumeration triggered by
|
|
|
|
|
# api/config.py imports), botocore probes the EC2 Instance Metadata Service at
|
|
|
|
|
# 169.254.169.254 looking for an instance role. On VPS hosts where IMDS is
|
|
|
|
|
# reachable but rate-limited (HTTP 429) or non-responsive, this dominates wall
|
|
|
|
|
# time and turns a 161s test run into 600+s.
|
|
|
|
|
#
|
|
|
|
|
# Tests have no legitimate reason to call IMDS — the bedrock-related tests use
|
|
|
|
|
# explicit mocks or env-var creds. Setting AWS_EC2_METADATA_DISABLED before
|
|
|
|
|
# anything imports botocore is the supported way to silence the probe (matches
|
|
|
|
|
# the guard the hermes_cli/doctor.py command already uses in its parallel-probe
|
|
|
|
|
# block).
|
|
|
|
|
#
|
|
|
|
|
# Setting this here instead of in a fixture so it lands BEFORE any test-file
|
|
|
|
|
# imports trigger botocore initialisation.
|
|
|
|
|
os.environ.setdefault("AWS_EC2_METADATA_DISABLED", "true")
|
|
|
|
|
|
2026-05-16 19:32:49 +00:00
|
|
|
# ── Permanent os.execv guard for the pytest session ────────────────────────
|
|
|
|
|
# Several tests in tests/test_update_banner_fixes.py exercise
|
|
|
|
|
# api.updates._schedule_restart(), which spawns a DAEMON thread that sleeps
|
|
|
|
|
# for a short delay and then calls ``os.execv(sys.executable, sys.argv)``.
|
|
|
|
|
# Those tests monkeypatch ``os.execv`` to a no-op for the test scope, but
|
|
|
|
|
# monkeypatch teardown happens at test exit — if the daemon thread has not
|
|
|
|
|
# yet woken up by then (system load, GC pause, _apply_lock contention), the
|
|
|
|
|
# real ``os.execv`` is restored before the thread fires it. The daemon then
|
|
|
|
|
# REPLACES the pytest process image with a fresh ``pytest tests/ -q ...``
|
|
|
|
|
# invocation, looking from the outside like pytest "hangs at 99%" and then
|
|
|
|
|
# restarts the entire suite from 0% — a self-perpetuating loop.
|
|
|
|
|
#
|
|
|
|
|
# Daemon threads cannot be reliably joined from a test fixture (they live in
|
|
|
|
|
# ``api.updates`` module scope), so the only safe answer is to render
|
|
|
|
|
# ``os.execv`` permanently inert for the pytest session. Production code is
|
|
|
|
|
# unaffected because production never imports this conftest.
|
|
|
|
|
#
|
|
|
|
|
# Tests that need to verify execv WAS called still monkeypatch it themselves
|
|
|
|
|
# — their patched version takes precedence over this no-op wrapper for the
|
|
|
|
|
# test's lifetime, and the no-op only kicks in after teardown for daemon
|
|
|
|
|
# threads that wake up late.
|
|
|
|
|
_real_execv = os.execv
|
|
|
|
|
|
|
|
|
|
def _pytest_session_safe_execv(_exe, _args): # pragma: no cover — never called in prod
|
|
|
|
|
# Drop the call on the floor. A late-firing daemon thread from
|
|
|
|
|
# _schedule_restart() must not be able to re-exec the pytest process.
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
os.execv = _pytest_session_safe_execv
|
|
|
|
|
|
test(infra): hermetic network isolation — block all outbound from tests
Tests should not reach the public internet. Before this commit, an
accidentally-leaking outbound socket from the test_server fixture (real
TLS handshakes to Anthropic / Amazon / OpenRouter, sometimes triggered
by SDK-init paths that found a credential the credential-strip allowlist
missed) was adding 60+s of wall-time to a 100s test run and creating a
class of flaky failures.
This installs a default-deny socket-block at two layers:
1. Pytest process, via tests/conftest.py module-level monkey-patch on
socket.create_connection + socket.socket.connect. Loopback / RFC1918
private / link-local / RFC2606 reserved-TLD destinations pass through;
anything else raises OSError("hermes test network isolation: outbound
to ... blocked"). Tests that legitimately need real outbound opt back
in via the new `allow_outbound_network` fixture (no current callers).
2. Test_server subprocess (server.py), via a HERMES_WEBUI_TEST_NETWORK_BLOCK=1
environment-variable-gated guard at the top of server.py. tests/conftest.py
sets the env var on every test_server spawn. Without this, the subprocess
could make outbound that the pytest-side block can't see (which is exactly
what was happening — verified via `ss -tnp` showing the server.py child
with established ESTAB sockets to [2607:6bc0::10]:443).
In production the env var is unset, so the guard is a no-op.
Companion changes:
- test_dns_resolution_failure refactored to mock socket.getaddrinfo
raising gaierror, instead of relying on a real DNS lookup of a
*.invalid hostname. The test was the one outlier that genuinely
exercised real DNS; mocking matches what every other probe-error test
in the same file already does.
- New tests/test_conftest_network_isolation.py with 9 adversarial
tests proving the block fires for public IPs (including the exact
Anthropic IPv6 and Amazon IPv4 destinations we observed leaking),
the allow-list passes loopback / RFC1918 / link-local / reserved-TLDs,
and the opt-in fixture re-enables real outbound when needed.
Test suite: 5,120 → 5,192 (+72 net new from this commit + the regression
tests in the companion commits). Wall time: 161s → 95s on the same
hardware. No remaining outbound from any test path.
2026-05-11 05:59:42 +00:00
|
|
|
# ── Hermetic network isolation ─────────────────────────────────────────────
|
|
|
|
|
# Tests must not reach the public internet. Outbound to Anthropic / OpenAI /
|
|
|
|
|
# Amazon / OpenRouter / etc. is forbidden by default. The test suite already
|
|
|
|
|
# mocks every legitimate outbound (probe_provider_endpoint, get_available_models,
|
|
|
|
|
# urlopen calls inside api/config.py), so a real outbound socket is either a
|
|
|
|
|
# missing mock, a leaked credential triggering an SDK init, or an unintended
|
|
|
|
|
# regression like the one PR #1970 introduced where a new code path bypassed
|
|
|
|
|
# an existing mock and tried to hit the real LM Studio host.
|
|
|
|
|
#
|
|
|
|
|
# This module-level monkey-patch wraps socket.create_connection so any
|
|
|
|
|
# non-loopback / non-RFC1918 / non-link-local / non-TEST-NET destination
|
|
|
|
|
# raises OSError("hermes test network isolation"). Tests that deliberately
|
|
|
|
|
# attempt outbound (only test_dns_resolution_failure today) opt back in
|
|
|
|
|
# explicitly via the `allow_outbound_network` fixture below.
|
|
|
|
|
#
|
|
|
|
|
# Allowed destinations (silent pass-through):
|
|
|
|
|
# - 127.0.0.0/8 loopback
|
|
|
|
|
# - ::1 IPv6 loopback
|
|
|
|
|
# - 192.168.0.0/16 RFC1918 private
|
|
|
|
|
# - 10.0.0.0/8 RFC1918 private
|
|
|
|
|
# - 172.16.0.0/12 RFC1918 private (16-31)
|
|
|
|
|
# - 169.254.0.0/16 link-local (covers IMDS — already separately blocked
|
|
|
|
|
# by AWS_EC2_METADATA_DISABLED, but allowed at the socket
|
|
|
|
|
# layer because IMDS-using tests mock the response)
|
|
|
|
|
# - 203.0.113.0/24 RFC5737 TEST-NET-3 (used as documentation IPs in tests)
|
|
|
|
|
# - hostnames `localhost`, `*.local`, `*.test`, `*.example`, `*.example.com`
|
|
|
|
|
# `*.example.net`, `*.example.org`, `*.invalid` (RFC2606/6761 reserved)
|
|
|
|
|
#
|
|
|
|
|
# A test that opts in via the `allow_outbound_network` fixture sees the real
|
|
|
|
|
# socket.create_connection.
|
|
|
|
|
import socket as _hermes_test_socket
|
|
|
|
|
_REAL_CREATE_CONNECTION = _hermes_test_socket.create_connection
|
|
|
|
|
_REAL_SOCKET_CONNECT = _hermes_test_socket.socket.connect
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hermes_addr_is_local(host: str) -> bool:
|
|
|
|
|
"""Return True for loopback / RFC1918 / link-local / reserved-TLD hosts."""
|
|
|
|
|
if not isinstance(host, str):
|
|
|
|
|
return False
|
|
|
|
|
h = host.strip().lower()
|
|
|
|
|
if not h:
|
|
|
|
|
return False
|
|
|
|
|
# IPv6 loopback / link-local
|
2026-05-11 06:12:07 +00:00
|
|
|
# IPv6 unique-local: fc00::/7 — any address starting with fc?? or fd?? (?? = hex pair).
|
|
|
|
|
# Loose "startswith('fc')" / "startswith('fd')" would also match the hostnames
|
|
|
|
|
# "food.example.com" or "fdsa.test", so require the second char to be a hex
|
|
|
|
|
# digit followed by either a colon or another hex digit (canonical IPv6 syntax).
|
|
|
|
|
import re as _re
|
|
|
|
|
if h in ('::1', '0:0:0:0:0:0:0:1') or h.startswith('fe80:') or _re.match(r'^f[cd][0-9a-f]{0,2}:', h):
|
test(infra): hermetic network isolation — block all outbound from tests
Tests should not reach the public internet. Before this commit, an
accidentally-leaking outbound socket from the test_server fixture (real
TLS handshakes to Anthropic / Amazon / OpenRouter, sometimes triggered
by SDK-init paths that found a credential the credential-strip allowlist
missed) was adding 60+s of wall-time to a 100s test run and creating a
class of flaky failures.
This installs a default-deny socket-block at two layers:
1. Pytest process, via tests/conftest.py module-level monkey-patch on
socket.create_connection + socket.socket.connect. Loopback / RFC1918
private / link-local / RFC2606 reserved-TLD destinations pass through;
anything else raises OSError("hermes test network isolation: outbound
to ... blocked"). Tests that legitimately need real outbound opt back
in via the new `allow_outbound_network` fixture (no current callers).
2. Test_server subprocess (server.py), via a HERMES_WEBUI_TEST_NETWORK_BLOCK=1
environment-variable-gated guard at the top of server.py. tests/conftest.py
sets the env var on every test_server spawn. Without this, the subprocess
could make outbound that the pytest-side block can't see (which is exactly
what was happening — verified via `ss -tnp` showing the server.py child
with established ESTAB sockets to [2607:6bc0::10]:443).
In production the env var is unset, so the guard is a no-op.
Companion changes:
- test_dns_resolution_failure refactored to mock socket.getaddrinfo
raising gaierror, instead of relying on a real DNS lookup of a
*.invalid hostname. The test was the one outlier that genuinely
exercised real DNS; mocking matches what every other probe-error test
in the same file already does.
- New tests/test_conftest_network_isolation.py with 9 adversarial
tests proving the block fires for public IPs (including the exact
Anthropic IPv6 and Amazon IPv4 destinations we observed leaking),
the allow-list passes loopback / RFC1918 / link-local / reserved-TLDs,
and the opt-in fixture re-enables real outbound when needed.
Test suite: 5,120 → 5,192 (+72 net new from this commit + the regression
tests in the companion commits). Wall time: 161s → 95s on the same
hardware. No remaining outbound from any test path.
2026-05-11 05:59:42 +00:00
|
|
|
return True
|
|
|
|
|
# Hostname allow-list (RFC2606/6761 reserved TLDs + localhost)
|
|
|
|
|
if h == 'localhost' or h.endswith('.localhost'):
|
|
|
|
|
return True
|
|
|
|
|
if h.endswith('.local') or h.endswith('.test') or h.endswith('.invalid'):
|
|
|
|
|
return True
|
|
|
|
|
if h == 'example.com' or h.endswith('.example.com'):
|
|
|
|
|
return True
|
|
|
|
|
if h == 'example.net' or h.endswith('.example.net'):
|
|
|
|
|
return True
|
|
|
|
|
if h == 'example.org' or h.endswith('.example.org'):
|
|
|
|
|
return True
|
|
|
|
|
if h.endswith('.example'):
|
|
|
|
|
return True
|
|
|
|
|
# IPv4 — parse octets if it looks like a dotted quad
|
|
|
|
|
if h[0].isdigit() and h.count('.') == 3:
|
|
|
|
|
try:
|
|
|
|
|
o1, o2, o3, o4 = [int(p) for p in h.split('.')]
|
|
|
|
|
except ValueError:
|
|
|
|
|
return False
|
|
|
|
|
if o1 == 127: # loopback
|
|
|
|
|
return True
|
|
|
|
|
if o1 == 10: # RFC1918 10.0.0.0/8
|
|
|
|
|
return True
|
|
|
|
|
if o1 == 192 and o2 == 168: # RFC1918 192.168.0.0/16
|
|
|
|
|
return True
|
|
|
|
|
if o1 == 172 and 16 <= o2 <= 31: # RFC1918 172.16.0.0/12
|
|
|
|
|
return True
|
|
|
|
|
if o1 == 169 and o2 == 254: # link-local 169.254.0.0/16
|
|
|
|
|
return True
|
|
|
|
|
if o1 == 203 and o2 == 0 and o3 == 113: # RFC5737 TEST-NET-3
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hermes_blocked_create_connection(address, *a, **kw):
|
|
|
|
|
try:
|
|
|
|
|
host = address[0]
|
|
|
|
|
except (TypeError, IndexError):
|
|
|
|
|
host = ""
|
|
|
|
|
if _hermes_addr_is_local(host):
|
|
|
|
|
return _REAL_CREATE_CONNECTION(address, *a, **kw)
|
|
|
|
|
raise OSError(
|
|
|
|
|
f"hermes test network isolation: outbound socket to {address!r} is blocked. "
|
|
|
|
|
f"Tests should mock urllib.request.urlopen / requests / socket.create_connection. "
|
|
|
|
|
f"If a test genuinely needs real outbound, request the allow_outbound_network fixture."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hermes_blocked_socket_connect(self, address):
|
|
|
|
|
try:
|
|
|
|
|
host = address[0]
|
|
|
|
|
except (TypeError, IndexError):
|
|
|
|
|
host = ""
|
|
|
|
|
if _hermes_addr_is_local(host):
|
|
|
|
|
return _REAL_SOCKET_CONNECT(self, address)
|
|
|
|
|
raise OSError(
|
|
|
|
|
f"hermes test network isolation: socket.connect to {address!r} is blocked."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_hermes_test_socket.create_connection = _hermes_blocked_create_connection
|
|
|
|
|
_hermes_test_socket.socket.connect = _hermes_blocked_socket_connect
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
2026-05-11 06:15:46 +00:00
|
|
|
def allow_outbound_network(monkeypatch):
|
test(infra): hermetic network isolation — block all outbound from tests
Tests should not reach the public internet. Before this commit, an
accidentally-leaking outbound socket from the test_server fixture (real
TLS handshakes to Anthropic / Amazon / OpenRouter, sometimes triggered
by SDK-init paths that found a credential the credential-strip allowlist
missed) was adding 60+s of wall-time to a 100s test run and creating a
class of flaky failures.
This installs a default-deny socket-block at two layers:
1. Pytest process, via tests/conftest.py module-level monkey-patch on
socket.create_connection + socket.socket.connect. Loopback / RFC1918
private / link-local / RFC2606 reserved-TLD destinations pass through;
anything else raises OSError("hermes test network isolation: outbound
to ... blocked"). Tests that legitimately need real outbound opt back
in via the new `allow_outbound_network` fixture (no current callers).
2. Test_server subprocess (server.py), via a HERMES_WEBUI_TEST_NETWORK_BLOCK=1
environment-variable-gated guard at the top of server.py. tests/conftest.py
sets the env var on every test_server spawn. Without this, the subprocess
could make outbound that the pytest-side block can't see (which is exactly
what was happening — verified via `ss -tnp` showing the server.py child
with established ESTAB sockets to [2607:6bc0::10]:443).
In production the env var is unset, so the guard is a no-op.
Companion changes:
- test_dns_resolution_failure refactored to mock socket.getaddrinfo
raising gaierror, instead of relying on a real DNS lookup of a
*.invalid hostname. The test was the one outlier that genuinely
exercised real DNS; mocking matches what every other probe-error test
in the same file already does.
- New tests/test_conftest_network_isolation.py with 9 adversarial
tests proving the block fires for public IPs (including the exact
Anthropic IPv6 and Amazon IPv4 destinations we observed leaking),
the allow-list passes loopback / RFC1918 / link-local / reserved-TLDs,
and the opt-in fixture re-enables real outbound when needed.
Test suite: 5,120 → 5,192 (+72 net new from this commit + the regression
tests in the companion commits). Wall time: 161s → 95s on the same
hardware. No remaining outbound from any test path.
2026-05-11 05:59:42 +00:00
|
|
|
"""Opt-in to real outbound network for the duration of one test.
|
|
|
|
|
|
2026-05-11 06:15:46 +00:00
|
|
|
Swaps `socket.create_connection` and `socket.socket.connect` back to the
|
|
|
|
|
real (unwrapped) implementations for this test only, then monkeypatch
|
|
|
|
|
teardown restores the wrapped versions. Direct swap is more reliable
|
|
|
|
|
than a module-global toggle on CI runners where wrapper-closure
|
|
|
|
|
lookup semantics can surprise.
|
|
|
|
|
|
|
|
|
|
Use sparingly. Today zero tests in the repo call this — the previous
|
|
|
|
|
test_dns_resolution_failure case was rewritten to mock socket.getaddrinfo
|
|
|
|
|
instead, which is fully hermetic.
|
test(infra): hermetic network isolation — block all outbound from tests
Tests should not reach the public internet. Before this commit, an
accidentally-leaking outbound socket from the test_server fixture (real
TLS handshakes to Anthropic / Amazon / OpenRouter, sometimes triggered
by SDK-init paths that found a credential the credential-strip allowlist
missed) was adding 60+s of wall-time to a 100s test run and creating a
class of flaky failures.
This installs a default-deny socket-block at two layers:
1. Pytest process, via tests/conftest.py module-level monkey-patch on
socket.create_connection + socket.socket.connect. Loopback / RFC1918
private / link-local / RFC2606 reserved-TLD destinations pass through;
anything else raises OSError("hermes test network isolation: outbound
to ... blocked"). Tests that legitimately need real outbound opt back
in via the new `allow_outbound_network` fixture (no current callers).
2. Test_server subprocess (server.py), via a HERMES_WEBUI_TEST_NETWORK_BLOCK=1
environment-variable-gated guard at the top of server.py. tests/conftest.py
sets the env var on every test_server spawn. Without this, the subprocess
could make outbound that the pytest-side block can't see (which is exactly
what was happening — verified via `ss -tnp` showing the server.py child
with established ESTAB sockets to [2607:6bc0::10]:443).
In production the env var is unset, so the guard is a no-op.
Companion changes:
- test_dns_resolution_failure refactored to mock socket.getaddrinfo
raising gaierror, instead of relying on a real DNS lookup of a
*.invalid hostname. The test was the one outlier that genuinely
exercised real DNS; mocking matches what every other probe-error test
in the same file already does.
- New tests/test_conftest_network_isolation.py with 9 adversarial
tests proving the block fires for public IPs (including the exact
Anthropic IPv6 and Amazon IPv4 destinations we observed leaking),
the allow-list passes loopback / RFC1918 / link-local / reserved-TLDs,
and the opt-in fixture re-enables real outbound when needed.
Test suite: 5,120 → 5,192 (+72 net new from this commit + the regression
tests in the companion commits). Wall time: 161s → 95s on the same
hardware. No remaining outbound from any test path.
2026-05-11 05:59:42 +00:00
|
|
|
"""
|
2026-05-11 06:15:46 +00:00
|
|
|
monkeypatch.setattr(_hermes_test_socket, "create_connection", _REAL_CREATE_CONNECTION)
|
|
|
|
|
monkeypatch.setattr(_hermes_test_socket.socket, "connect", _REAL_SOCKET_CONNECT)
|
|
|
|
|
yield
|
test(infra): hermetic network isolation — block all outbound from tests
Tests should not reach the public internet. Before this commit, an
accidentally-leaking outbound socket from the test_server fixture (real
TLS handshakes to Anthropic / Amazon / OpenRouter, sometimes triggered
by SDK-init paths that found a credential the credential-strip allowlist
missed) was adding 60+s of wall-time to a 100s test run and creating a
class of flaky failures.
This installs a default-deny socket-block at two layers:
1. Pytest process, via tests/conftest.py module-level monkey-patch on
socket.create_connection + socket.socket.connect. Loopback / RFC1918
private / link-local / RFC2606 reserved-TLD destinations pass through;
anything else raises OSError("hermes test network isolation: outbound
to ... blocked"). Tests that legitimately need real outbound opt back
in via the new `allow_outbound_network` fixture (no current callers).
2. Test_server subprocess (server.py), via a HERMES_WEBUI_TEST_NETWORK_BLOCK=1
environment-variable-gated guard at the top of server.py. tests/conftest.py
sets the env var on every test_server spawn. Without this, the subprocess
could make outbound that the pytest-side block can't see (which is exactly
what was happening — verified via `ss -tnp` showing the server.py child
with established ESTAB sockets to [2607:6bc0::10]:443).
In production the env var is unset, so the guard is a no-op.
Companion changes:
- test_dns_resolution_failure refactored to mock socket.getaddrinfo
raising gaierror, instead of relying on a real DNS lookup of a
*.invalid hostname. The test was the one outlier that genuinely
exercised real DNS; mocking matches what every other probe-error test
in the same file already does.
- New tests/test_conftest_network_isolation.py with 9 adversarial
tests proving the block fires for public IPs (including the exact
Anthropic IPv6 and Amazon IPv4 destinations we observed leaking),
the allow-list passes loopback / RFC1918 / link-local / reserved-TLDs,
and the opt-in fixture re-enables real outbound when needed.
Test suite: 5,120 → 5,192 (+72 net new from this commit + the regression
tests in the companion commits). Wall time: 161s → 95s on the same
hardware. No remaining outbound from any test path.
2026-05-11 05:59:42 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test(conftest): block AWS IMDS probing + expand credential-strip allowlist
Two test-infrastructure fixes surfaced while running the full suite on
this branch. Both prevent accidental outbound network calls from the
pytest process — a class of bug that doesn't show up as test failures
but corrupts timing, leaks credentials, and was responsible for a recent
10× slowdown observation.
## 1. AWS_EC2_METADATA_DISABLED for the whole pytest session
When hermes-agent's bedrock_adapter / botocore credential chain is
imported during tests (e.g. via api/config.py provider-catalog imports),
botocore probes the EC2 Instance Metadata Service at 169.254.169.254
looking for an instance role. On VPS hosts where IMDS is reachable but
rate-limited (HTTP 429) or non-responsive, those probes dominate wall
time — a 161s test run was observed extending to 600+s.
Set `AWS_EC2_METADATA_DISABLED=true` at module load (before any test-file
imports trigger botocore initialisation). This is the documented AWS-
supported way to silence the probe and matches the guard the agent's own
`hermes_cli/doctor.py` already uses inside its parallel-probe block.
Also explicitly re-set the var on the spawned test-server env so it
can't be accidentally cleared by a later `env.update(...)`.
## 2. Expanded credential-strip allowlist
The original strip list covered 6 providers (OpenRouter, OpenAI,
Anthropic, Google, DeepSeek, Xiaomi). Several others leaked through
into the test server subprocess:
- `MEM0_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `OLLAMA_API_KEY`,
`GROQ_API_KEY`, `TOGETHER_API_KEY`, …
- AWS credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`,
`AWS_SESSION_TOKEN`, `AWS_PROFILE`, `AWS_BEARER_TOKEN_BEDROCK`)
- Messaging bot tokens (`TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`,
`SLACK_BOT_TOKEN`, `SIGNAL_API_TOKEN`, `WHATSAPP_API_TOKEN`)
- Memory providers (`HONCHO_API_KEY`, `SUPERMEMORY_API_KEY`)
- Search / browser / image-gen (`FIRECRAWL_API_KEY`, `FAL_KEY`,
`TAVILY_API_KEY`, `SERPER_API_KEY`, `BRAVE_API_KEY`)
- GitHub tokens (`GH_TOKEN`, `GITHUB_TOKEN`)
- Azure OpenAI (`AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`)
A real outbound TLS connection to a provider's IPv6 endpoint was
observed during a test run on this host before the strip was expanded.
The test server uses a mock config and has no business making real API
calls.
## Test status
5,151 passed / 11 skipped / 1 xfailed / 2 xpassed / 0 regressions in
139s on Python 3.11. Down from 147s before the fixes (and from
intermittent 10×-slowdowns on IMDS-rate-limited hosts). All API/feature
contracts unchanged.
## Security audit of remaining test-suite host references
Every IP / URL / hostname referenced in `tests/**.py` was classified:
- Loopback (127.0.0.1, localhost, ::1, 0.0.0.0)
- RFC1918 private (10.*, 172.16-31.*, 192.168.*)
- RFC 5737 TEST-NET-3 documentation (203.0.113.*)
- RFC 2606 reserved docs domains (*.example.com, *.example.local,
*.example.test)
- Security-attack input strings used only as parser/validator input
(evil.com, attacker, evil.example.com — never resolved or contacted)
- Real provider/CDN endpoints used only as `base_url` config strings
or CSP-allowlist assertions — never actually fetched
- 8.8.8.8 used only as a "non-loopback example" in `_is_local_from_handler()`
unit tests
No suspicious egress destinations.
2026-05-11 04:49:46 +00:00
|
|
|
|
2026-05-11 03:02:01 +00:00
|
|
|
# ── Environment isolation for tests ────────────────────────────────────────
|
|
|
|
|
# HERMES_WEBUI_SKIP_ONBOARDING is set by hosting providers (e.g. Agent37) and
|
|
|
|
|
# by some isolated test harnesses to short-circuit the onboarding wizard.
|
|
|
|
|
# When it leaks into the pytest environment, tests that exercise the wizard
|
|
|
|
|
# code paths (apply_onboarding_setup, etc.) fail because the function returns
|
|
|
|
|
# early without writing config files.
|
|
|
|
|
#
|
|
|
|
|
# This autouse fixture removes the variable for the test session. Tests that
|
|
|
|
|
# specifically need to validate the SKIP_ONBOARDING short-circuit can opt back
|
|
|
|
|
# in with `monkeypatch.setenv("HERMES_WEBUI_SKIP_ONBOARDING", "1")`.
|
|
|
|
|
@pytest.fixture(autouse=True, scope="session")
|
|
|
|
|
def _strip_skip_onboarding_env():
|
|
|
|
|
prior = os.environ.pop("HERMES_WEBUI_SKIP_ONBOARDING", None)
|
|
|
|
|
yield
|
|
|
|
|
if prior is not None:
|
|
|
|
|
os.environ["HERMES_WEBUI_SKIP_ONBOARDING"] = prior
|
|
|
|
|
|
2026-04-04 21:51:15 +00:00
|
|
|
def pytest_collection_modifyitems(config, items):
|
|
|
|
|
"""Auto-skip agent-dependent tests when hermes-agent is not available.
|
|
|
|
|
|
|
|
|
|
Instead of requiring markers on every test function, we pattern-match
|
|
|
|
|
test names to known categories that depend on hermes-agent modules.
|
|
|
|
|
This keeps the test files clean and ensures new cron/skills tests
|
|
|
|
|
get auto-skipped without manual annotation.
|
|
|
|
|
"""
|
|
|
|
|
if AGENT_MODULES_AVAILABLE:
|
|
|
|
|
return # everything available, run all tests
|
|
|
|
|
|
|
|
|
|
# Exact list of tests known to fail without hermes-agent.
|
|
|
|
|
# These hit server endpoints that import cron.jobs, tools.skills_tool,
|
|
|
|
|
# or require a running agent backend — returning 500 without the agent.
|
|
|
|
|
_AGENT_DEPENDENT_TESTS = {
|
|
|
|
|
# Cron endpoints (need cron.jobs module)
|
|
|
|
|
'test_crons_list',
|
|
|
|
|
'test_crons_list_has_required_fields',
|
|
|
|
|
'test_crons_output_requires_job_id',
|
|
|
|
|
'test_crons_output_real_job',
|
|
|
|
|
'test_crons_run_nonexistent',
|
|
|
|
|
'test_cron_create_success',
|
|
|
|
|
'test_cron_update_unknown_job_404',
|
|
|
|
|
'test_cron_delete_unknown_404',
|
|
|
|
|
'test_crons_output_limit_param',
|
2026-05-27 02:58:49 +00:00
|
|
|
'test_delivery_options_returns_200',
|
|
|
|
|
'test_delivery_options_has_platforms',
|
|
|
|
|
'test_delivery_options_structure',
|
|
|
|
|
'test_delivery_options_includes_common_platforms',
|
|
|
|
|
'test_delivery_options_local_label',
|
2026-04-04 21:51:15 +00:00
|
|
|
# Skills endpoints (need tools.skills_tool module)
|
|
|
|
|
'test_skills_list',
|
|
|
|
|
'test_skills_list_has_required_fields',
|
|
|
|
|
'test_skills_content_known',
|
|
|
|
|
'test_skills_content_requires_name',
|
|
|
|
|
'test_skills_search_returns_subset',
|
|
|
|
|
'test_skill_save_delete_roundtrip',
|
|
|
|
|
'test_skill_delete_unknown_404',
|
|
|
|
|
# Agent backend (need running AIAgent)
|
|
|
|
|
'test_chat_stream_opens_successfully',
|
|
|
|
|
'test_approval_submit_and_respond',
|
2026-04-12 07:19:36 +00:00
|
|
|
# Security redaction (flaky — session state varies across test ordering)
|
|
|
|
|
'test_api_sessions_list_redacts_titles',
|
2026-04-04 21:51:15 +00:00
|
|
|
# Workspace path (macOS /tmp -> /private/tmp symlink)
|
|
|
|
|
'test_new_session_inherits_workspace',
|
|
|
|
|
'test_workspace_add_valid',
|
|
|
|
|
'test_workspace_rename',
|
|
|
|
|
'test_last_workspace_updates_on_session_update',
|
|
|
|
|
'test_new_session_inherits_last_workspace',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
skip_marker = pytest.mark.skip(reason="requires hermes-agent (not installed)")
|
|
|
|
|
skipped = 0
|
|
|
|
|
|
|
|
|
|
for item in items:
|
|
|
|
|
if item.name in _AGENT_DEPENDENT_TESTS:
|
|
|
|
|
item.add_marker(skip_marker)
|
|
|
|
|
skipped += 1
|
|
|
|
|
|
|
|
|
|
if skipped:
|
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)
* Polish workspace panel behavior and app dialogs
* Replace remaining emoji UI glyphs with Lucide icons
* Redesign composer footer around model and context controls
Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.
Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/
* Remove obsolete activity bar
Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.
This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.
* Move workspace and model switching into composer footer
* Move profile switching into composer footer
* Refactor Hermes control center UI
* Redesign control center settings modal layout
Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Put session item actions in a dropdown
* Use Hermes mark in sidebar control button
* Reset control center section on close
* Drop session-item left border indicator
Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Increase session search input vertical padding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Normalise odd pixel values across UI
Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)
The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints
Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.
* Improve composer footer mobile responsiveness and UX
- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior
* Fix duplicate files button and broken workspace close on mobile
Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix model chip icon vertical alignment in composer footer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix workspace toggle button hidden on desktop by conflicting CSS class
Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix session actions dots button inaccessible on mobile sidebar
Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer footer manage links not opening sidebar panel
The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Widen icon-only composer chips breakpoint from 400px to 768px
Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer-left vertical scrollbar by setting overflow-y:hidden
When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve rebase conflicts and fix control center test assertions
- Resolved 4 conflicts during rebase onto master (workspace.js,
boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update version badge in System tab to v0.49.4
* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge
---------
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
|
|
|
print(f"\nWARNING: hermes-agent not found; {skipped} agent-dependent tests will be skipped\n")
|
2026-04-04 21:51:15 +00:00
|
|
|
|
2026-03-31 03:40:19 +00:00
|
|
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def _post(base, path, body=None):
|
|
|
|
|
data = json.dumps(body or {}).encode()
|
|
|
|
|
req = urllib.request.Request(
|
|
|
|
|
base + path, data=data, headers={"Content-Type": "application/json"}
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
|
|
|
return json.loads(r.read())
|
|
|
|
|
except urllib.error.HTTPError as e:
|
|
|
|
|
try:
|
|
|
|
|
return json.loads(e.read())
|
|
|
|
|
except Exception:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
2026-06-07 16:38:29 +00:00
|
|
|
def _wait_for_server(base, timeout=45, proc=None, log_path=None):
|
|
|
|
|
"""Poll ``base/health`` until the server reports ready, or fail fast.
|
|
|
|
|
|
|
|
|
|
Returns ``(True, "")`` once ``/health`` returns ``status == "ok"``.
|
|
|
|
|
|
|
|
|
|
Returns ``(False, reason)`` when the server does not come up. ``reason`` is a
|
|
|
|
|
human-readable diagnostic that, crucially, includes WHY when we can tell:
|
|
|
|
|
|
|
|
|
|
* If ``proc`` is supplied and the subprocess has already exited, we stop
|
|
|
|
|
polling immediately (no point waiting out the full timeout for a process
|
|
|
|
|
that is already dead) and surface its exit code.
|
|
|
|
|
* If ``log_path`` is supplied, the tail of the captured server stdout/stderr
|
|
|
|
|
is included so an import error / bind failure / traceback is visible
|
|
|
|
|
instead of a bare "did not start" message.
|
|
|
|
|
|
|
|
|
|
The previous implementation polled for the whole timeout regardless of
|
|
|
|
|
whether the process had died, and the server's output was discarded to
|
|
|
|
|
``DEVNULL`` — so a boot failure produced a generic timeout with no clue as
|
|
|
|
|
to the cause, and every HTTP-dependent test then cascaded with
|
|
|
|
|
ConnectionRefused. Capturing output + early-exit detection turns that opaque
|
|
|
|
|
cascade into a single actionable failure.
|
|
|
|
|
"""
|
2026-03-31 03:40:19 +00:00
|
|
|
deadline = time.time() + timeout
|
2026-06-07 16:38:29 +00:00
|
|
|
last_err = "no successful /health response"
|
2026-03-31 03:40:19 +00:00
|
|
|
while time.time() < deadline:
|
2026-06-07 16:38:29 +00:00
|
|
|
# Fail fast if the server subprocess has already died — don't wait out
|
|
|
|
|
# the full timeout polling a port nothing is listening on.
|
|
|
|
|
if proc is not None and proc.poll() is not None:
|
|
|
|
|
return False, _server_boot_diagnostic(
|
|
|
|
|
f"server process exited early with code {proc.returncode}",
|
|
|
|
|
log_path,
|
|
|
|
|
)
|
2026-03-31 03:40:19 +00:00
|
|
|
try:
|
|
|
|
|
with urllib.request.urlopen(base + "/health", timeout=2) as r:
|
|
|
|
|
if json.loads(r.read()).get("status") == "ok":
|
2026-06-07 16:38:29 +00:00
|
|
|
return True, ""
|
|
|
|
|
last_err = "/health responded but status != 'ok'"
|
|
|
|
|
except Exception as e: # noqa: BLE001 — diagnostic capture, re-raised as text
|
|
|
|
|
last_err = f"{type(e).__name__}: {e}"
|
2026-03-31 03:40:19 +00:00
|
|
|
time.sleep(0.3)
|
2026-06-07 16:38:29 +00:00
|
|
|
return False, _server_boot_diagnostic(
|
|
|
|
|
f"timed out after {timeout}s waiting for /health (last: {last_err})",
|
|
|
|
|
log_path,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _server_boot_diagnostic(headline, log_path):
|
|
|
|
|
"""Build a boot-failure message, appending the captured server log tail."""
|
|
|
|
|
parts = [headline]
|
|
|
|
|
if log_path is not None:
|
|
|
|
|
try:
|
|
|
|
|
text = pathlib.Path(log_path).read_text(encoding="utf-8", errors="replace")
|
|
|
|
|
tail = "".join(text.splitlines(keepends=True)[-40:]).strip()
|
|
|
|
|
if tail:
|
|
|
|
|
parts.append("---- server output (last 40 lines) ----\n" + tail)
|
|
|
|
|
else:
|
|
|
|
|
parts.append("(server produced no output)")
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
parts.append(f"(could not read server log {log_path}: {e})")
|
|
|
|
|
return "\n".join(parts)
|
2026-03-31 03:40:19 +00:00
|
|
|
|
|
|
|
|
|
2026-06-15 19:42:33 +00:00
|
|
|
def _kill_process_tree(pid):
|
|
|
|
|
"""Best-effort kill for a known fixture-owned or port-owning PID."""
|
|
|
|
|
if not pid or pid <= 0:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
if sys.platform == "win32":
|
|
|
|
|
subprocess.run(
|
|
|
|
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
|
|
|
stdout=subprocess.DEVNULL,
|
|
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
|
|
timeout=5,
|
|
|
|
|
**({"creationflags": subprocess.CREATE_NO_WINDOW}),
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
import signal
|
|
|
|
|
|
|
|
|
|
os.kill(pid, signal.SIGKILL)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _kill_port_owner(port):
|
|
|
|
|
"""Best-effort free of TEST_PORT, using a Windows-native owner lookup."""
|
|
|
|
|
try:
|
|
|
|
|
if sys.platform != "win32":
|
|
|
|
|
subprocess.run(["fuser", "-k", f"{port}/tcp"], capture_output=True, timeout=5)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
creationflags = {"creationflags": subprocess.CREATE_NO_WINDOW}
|
|
|
|
|
ps_cmd = (
|
|
|
|
|
"$port = " + str(port) + "; "
|
|
|
|
|
"$conn = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue | "
|
|
|
|
|
"Where-Object { $_.LocalAddress -in @('127.0.0.1', '::1', '0.0.0.0', '::', '::ffff:127.0.0.1') } | "
|
|
|
|
|
"Select-Object -First 1 -ExpandProperty OwningProcess; "
|
|
|
|
|
"if ($conn) { $conn }"
|
|
|
|
|
)
|
|
|
|
|
proc = subprocess.run(
|
|
|
|
|
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_cmd],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=5,
|
|
|
|
|
**creationflags,
|
|
|
|
|
)
|
|
|
|
|
pid_text = proc.stdout.strip()
|
|
|
|
|
if not pid_text:
|
|
|
|
|
proc = subprocess.run(
|
|
|
|
|
["netstat", "-ano", "-p", "tcp"],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=5,
|
|
|
|
|
**creationflags,
|
|
|
|
|
)
|
|
|
|
|
port_suffixes = (f":{port}", f"[::]:{port}", f"127.0.0.1:{port}", f"[::1]:{port}")
|
|
|
|
|
for line in proc.stdout.splitlines():
|
|
|
|
|
parts = line.split()
|
|
|
|
|
if len(parts) < 5 or parts[0].upper() != "TCP":
|
|
|
|
|
continue
|
|
|
|
|
local_addr = parts[1]
|
|
|
|
|
state = parts[3].upper()
|
|
|
|
|
owner_pid = parts[4]
|
|
|
|
|
if state == "LISTENING" and any(local_addr.endswith(suffix) for suffix in port_suffixes):
|
|
|
|
|
pid_text = owner_pid
|
|
|
|
|
break
|
|
|
|
|
if pid_text:
|
|
|
|
|
_kill_process_tree(int(pid_text))
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rmtree_retry(path):
|
2026-06-16 02:04:16 +00:00
|
|
|
"""Remove a tree, retrying transient races (Linux + Windows).
|
|
|
|
|
|
|
|
|
|
The retry loop covers two real failure modes seen in CI/local:
|
|
|
|
|
* Windows: read-only bits / lingering handles (cleared via onexc/onerror).
|
|
|
|
|
* Linux: `OSError [Errno 39] Directory not empty` — a background thread or
|
|
|
|
|
daemon (model-metadata fetcher, session-events watcher, profile writer)
|
|
|
|
|
creates a file inside the tree WHILE shutil.rmtree is walking it, so the
|
|
|
|
|
parent rmdir fails even though we just emptied it. A short retry lets the
|
|
|
|
|
racing writer settle; a final ignore_errors sweep guarantees teardown
|
|
|
|
|
never fails the test over a pure cleanup race (the dir is abandoned test
|
|
|
|
|
state, not an assertion target).
|
|
|
|
|
"""
|
2026-06-15 19:42:33 +00:00
|
|
|
target = pathlib.Path(path)
|
|
|
|
|
if not target.exists():
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
def _clear_readonly(_func, entry, _exc):
|
|
|
|
|
try:
|
|
|
|
|
os.chmod(entry, 0o666)
|
|
|
|
|
_func(entry)
|
|
|
|
|
except Exception:
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
rmtree_kwargs = (
|
|
|
|
|
{"onexc": _clear_readonly}
|
|
|
|
|
if "onexc" in inspect.signature(shutil.rmtree).parameters
|
|
|
|
|
else {"onerror": _clear_readonly}
|
2026-06-16 02:04:16 +00:00
|
|
|
) if sys.platform == "win32" else {}
|
2026-06-15 19:42:33 +00:00
|
|
|
|
|
|
|
|
attempts = 5
|
|
|
|
|
last_exc = None
|
|
|
|
|
for attempt in range(1, attempts + 1):
|
|
|
|
|
try:
|
|
|
|
|
shutil.rmtree(target, **rmtree_kwargs)
|
|
|
|
|
return
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
last_exc = exc
|
|
|
|
|
if attempt == attempts:
|
|
|
|
|
break
|
|
|
|
|
time.sleep(0.3)
|
|
|
|
|
|
2026-06-16 02:04:16 +00:00
|
|
|
# Final fallback: a concurrent-writer race (Errno 39) shouldn't fail teardown.
|
|
|
|
|
# Best-effort ignore_errors sweep; if anything remains it's abandoned test
|
|
|
|
|
# state under HERMES_HOME, not something a test asserts on.
|
|
|
|
|
shutil.rmtree(target, ignore_errors=True)
|
|
|
|
|
if not target.exists():
|
|
|
|
|
return
|
2026-06-15 19:42:33 +00:00
|
|
|
leftovers = []
|
|
|
|
|
try:
|
|
|
|
|
leftovers = [child.name for child in list(target.iterdir())[:5]]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2026-06-16 02:04:16 +00:00
|
|
|
# Don't raise — log-and-continue. Raising here turns a benign cleanup race
|
|
|
|
|
# into a spurious test ERROR (the behaviour #4283-area tests hit when a
|
|
|
|
|
# model-metadata background fetch repopulated the profile dir mid-teardown).
|
|
|
|
|
import warnings as _warnings
|
2026-06-15 19:42:33 +00:00
|
|
|
leftover_note = f" leftovers={leftovers}" if leftovers else ""
|
2026-06-16 02:04:16 +00:00
|
|
|
_warnings.warn(
|
|
|
|
|
f"_rmtree_retry: could not fully remove {target} after {attempts} attempts"
|
|
|
|
|
f"{leftover_note} (last_exc={last_exc!r}); left for OS temp cleanup.",
|
|
|
|
|
stacklevel=2,
|
|
|
|
|
)
|
2026-06-15 19:42:33 +00:00
|
|
|
|
|
|
|
|
|
2026-03-31 03:40:19 +00:00
|
|
|
# ── Session-scoped test server ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
|
|
|
def test_server():
|
|
|
|
|
"""
|
|
|
|
|
Start an isolated test server on TEST_PORT with a clean state directory.
|
|
|
|
|
Paths are discovered dynamically -- no hardcoded absolute path assumptions.
|
|
|
|
|
"""
|
2026-04-10 07:56:07 +00:00
|
|
|
# Kill any leftover process on the test port before starting.
|
|
|
|
|
# Stale servers from QA harness runs or prior test sessions cause
|
|
|
|
|
# conftest to think the server is already up, producing false failures.
|
2026-06-15 19:42:33 +00:00
|
|
|
_kill_port_owner(TEST_PORT)
|
2026-04-10 07:56:07 +00:00
|
|
|
import time as _time
|
|
|
|
|
_time.sleep(0.5) # brief pause to let the port release
|
|
|
|
|
|
2026-03-31 03:40:19 +00:00
|
|
|
# Clean slate
|
|
|
|
|
if TEST_STATE_DIR.exists():
|
2026-06-15 19:42:33 +00:00
|
|
|
_rmtree_retry(TEST_STATE_DIR)
|
2026-03-31 03:40:19 +00:00
|
|
|
TEST_STATE_DIR.mkdir(parents=True)
|
|
|
|
|
TEST_WORKSPACE.mkdir(parents=True)
|
|
|
|
|
|
|
|
|
|
# Symlink real skills into test home so skill-related tests work,
|
|
|
|
|
# but all write-heavy state stays isolated.
|
|
|
|
|
real_skills = HERMES_HOME / 'skills'
|
|
|
|
|
test_skills = TEST_STATE_DIR / 'skills'
|
|
|
|
|
if real_skills.exists() and not test_skills.exists():
|
|
|
|
|
test_skills.symlink_to(real_skills)
|
|
|
|
|
|
|
|
|
|
# Isolated cron state
|
|
|
|
|
(TEST_STATE_DIR / 'cron').mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
# Expose TEST_STATE_DIR to the test process itself so that tests which write
|
|
|
|
|
# directly to state.db (e.g. test_gateway_sync.py) always use the same path
|
|
|
|
|
# as the server. Other test files (test_auth_sessions.py) may override
|
|
|
|
|
# HERMES_WEBUI_STATE_DIR for their own purposes, but HERMES_WEBUI_TEST_STATE_DIR
|
|
|
|
|
# is reserved for this mapping and is never overridden by individual test files.
|
2026-04-14 19:04:48 +00:00
|
|
|
# Export both port and state-dir as env vars so individual test files
|
|
|
|
|
# can read them without importing conftest (avoids circular imports).
|
|
|
|
|
os.environ.setdefault('HERMES_WEBUI_TEST_PORT', str(TEST_PORT))
|
|
|
|
|
# os.environ already set at module level above; no-op here.
|
feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:53:12 +00:00
|
|
|
|
2026-03-31 03:40:19 +00:00
|
|
|
env = os.environ.copy()
|
test(conftest): block AWS IMDS probing + expand credential-strip allowlist
Two test-infrastructure fixes surfaced while running the full suite on
this branch. Both prevent accidental outbound network calls from the
pytest process — a class of bug that doesn't show up as test failures
but corrupts timing, leaks credentials, and was responsible for a recent
10× slowdown observation.
## 1. AWS_EC2_METADATA_DISABLED for the whole pytest session
When hermes-agent's bedrock_adapter / botocore credential chain is
imported during tests (e.g. via api/config.py provider-catalog imports),
botocore probes the EC2 Instance Metadata Service at 169.254.169.254
looking for an instance role. On VPS hosts where IMDS is reachable but
rate-limited (HTTP 429) or non-responsive, those probes dominate wall
time — a 161s test run was observed extending to 600+s.
Set `AWS_EC2_METADATA_DISABLED=true` at module load (before any test-file
imports trigger botocore initialisation). This is the documented AWS-
supported way to silence the probe and matches the guard the agent's own
`hermes_cli/doctor.py` already uses inside its parallel-probe block.
Also explicitly re-set the var on the spawned test-server env so it
can't be accidentally cleared by a later `env.update(...)`.
## 2. Expanded credential-strip allowlist
The original strip list covered 6 providers (OpenRouter, OpenAI,
Anthropic, Google, DeepSeek, Xiaomi). Several others leaked through
into the test server subprocess:
- `MEM0_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `OLLAMA_API_KEY`,
`GROQ_API_KEY`, `TOGETHER_API_KEY`, …
- AWS credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`,
`AWS_SESSION_TOKEN`, `AWS_PROFILE`, `AWS_BEARER_TOKEN_BEDROCK`)
- Messaging bot tokens (`TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`,
`SLACK_BOT_TOKEN`, `SIGNAL_API_TOKEN`, `WHATSAPP_API_TOKEN`)
- Memory providers (`HONCHO_API_KEY`, `SUPERMEMORY_API_KEY`)
- Search / browser / image-gen (`FIRECRAWL_API_KEY`, `FAL_KEY`,
`TAVILY_API_KEY`, `SERPER_API_KEY`, `BRAVE_API_KEY`)
- GitHub tokens (`GH_TOKEN`, `GITHUB_TOKEN`)
- Azure OpenAI (`AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`)
A real outbound TLS connection to a provider's IPv6 endpoint was
observed during a test run on this host before the strip was expanded.
The test server uses a mock config and has no business making real API
calls.
## Test status
5,151 passed / 11 skipped / 1 xfailed / 2 xpassed / 0 regressions in
139s on Python 3.11. Down from 147s before the fixes (and from
intermittent 10×-slowdowns on IMDS-rate-limited hosts). All API/feature
contracts unchanged.
## Security audit of remaining test-suite host references
Every IP / URL / hostname referenced in `tests/**.py` was classified:
- Loopback (127.0.0.1, localhost, ::1, 0.0.0.0)
- RFC1918 private (10.*, 172.16-31.*, 192.168.*)
- RFC 5737 TEST-NET-3 documentation (203.0.113.*)
- RFC 2606 reserved docs domains (*.example.com, *.example.local,
*.example.test)
- Security-attack input strings used only as parser/validator input
(evil.com, attacker, evil.example.com — never resolved or contacted)
- Real provider/CDN endpoints used only as `base_url` config strings
or CSP-allowlist assertions — never actually fetched
- 8.8.8.8 used only as a "non-loopback example" in `_is_local_from_handler()`
unit tests
No suspicious egress destinations.
2026-05-11 04:49:46 +00:00
|
|
|
# Strip ANY real credential env var so the test subprocess never inherits
|
|
|
|
|
# production creds. The test server uses a mock/isolated config — no real
|
|
|
|
|
# API calls are made, no real OAuth flow runs, no real cloud SDK should
|
|
|
|
|
# ever be initialised with usable credentials.
|
|
|
|
|
#
|
|
|
|
|
# Without this strip, a stray credential left in the runner's env was
|
|
|
|
|
# observed making outbound TLS to a real provider during test runs.
|
|
|
|
|
# See investigation notes in pytest-pitfalls SKILL §B.3.
|
|
|
|
|
_CRED_ENV_PREFIXES = (
|
|
|
|
|
# LLM providers
|
|
|
|
|
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL',
|
|
|
|
|
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN',
|
|
|
|
|
'GOOGLE_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS',
|
|
|
|
|
'DEEPSEEK_API_KEY', 'XIAOMI_API_KEY',
|
|
|
|
|
'XAI_API_KEY', 'MISTRAL_API_KEY', 'OLLAMA_API_KEY',
|
|
|
|
|
'GROQ_API_KEY', 'TOGETHER_API_KEY', 'PERPLEXITY_API_KEY',
|
|
|
|
|
'CEREBRAS_API_KEY', 'COHERE_API_KEY', 'FIREWORKS_API_KEY',
|
|
|
|
|
'NOUS_API_KEY', 'NOVITA_API_KEY', 'TENCENT_API_KEY',
|
|
|
|
|
'BIGMODEL_API_KEY', 'GLM_API_KEY', 'STEPFUN_API_KEY',
|
|
|
|
|
'MINIMAX_API_KEY', 'LM_API_KEY', 'LMSTUDIO_API_KEY',
|
|
|
|
|
'AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_ENDPOINT',
|
|
|
|
|
# AWS — must be stripped or botocore probes IMDS / picks up real creds
|
|
|
|
|
'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN',
|
|
|
|
|
'AWS_PROFILE', 'AWS_BEARER_TOKEN_BEDROCK',
|
|
|
|
|
# Memory providers, telemetry, dashboards
|
|
|
|
|
'MEM0_API_KEY', 'HONCHO_API_KEY', 'SUPERMEMORY_API_KEY',
|
|
|
|
|
# Messaging / gateway
|
|
|
|
|
'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN', 'SLACK_BOT_TOKEN',
|
|
|
|
|
'SIGNAL_API_TOKEN', 'WHATSAPP_API_TOKEN',
|
|
|
|
|
# Browser / image-gen / search
|
|
|
|
|
'FIRECRAWL_API_KEY', 'FAL_KEY', 'TAVILY_API_KEY',
|
|
|
|
|
'SERPER_API_KEY', 'BRAVE_API_KEY',
|
|
|
|
|
# Github tokens (PR/issue tools shouldn't be exercised in tests)
|
|
|
|
|
'GH_TOKEN', 'GITHUB_TOKEN',
|
|
|
|
|
)
|
2026-04-17 06:03:32 +00:00
|
|
|
for _k in list(env):
|
test(conftest): block AWS IMDS probing + expand credential-strip allowlist
Two test-infrastructure fixes surfaced while running the full suite on
this branch. Both prevent accidental outbound network calls from the
pytest process — a class of bug that doesn't show up as test failures
but corrupts timing, leaks credentials, and was responsible for a recent
10× slowdown observation.
## 1. AWS_EC2_METADATA_DISABLED for the whole pytest session
When hermes-agent's bedrock_adapter / botocore credential chain is
imported during tests (e.g. via api/config.py provider-catalog imports),
botocore probes the EC2 Instance Metadata Service at 169.254.169.254
looking for an instance role. On VPS hosts where IMDS is reachable but
rate-limited (HTTP 429) or non-responsive, those probes dominate wall
time — a 161s test run was observed extending to 600+s.
Set `AWS_EC2_METADATA_DISABLED=true` at module load (before any test-file
imports trigger botocore initialisation). This is the documented AWS-
supported way to silence the probe and matches the guard the agent's own
`hermes_cli/doctor.py` already uses inside its parallel-probe block.
Also explicitly re-set the var on the spawned test-server env so it
can't be accidentally cleared by a later `env.update(...)`.
## 2. Expanded credential-strip allowlist
The original strip list covered 6 providers (OpenRouter, OpenAI,
Anthropic, Google, DeepSeek, Xiaomi). Several others leaked through
into the test server subprocess:
- `MEM0_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `OLLAMA_API_KEY`,
`GROQ_API_KEY`, `TOGETHER_API_KEY`, …
- AWS credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`,
`AWS_SESSION_TOKEN`, `AWS_PROFILE`, `AWS_BEARER_TOKEN_BEDROCK`)
- Messaging bot tokens (`TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`,
`SLACK_BOT_TOKEN`, `SIGNAL_API_TOKEN`, `WHATSAPP_API_TOKEN`)
- Memory providers (`HONCHO_API_KEY`, `SUPERMEMORY_API_KEY`)
- Search / browser / image-gen (`FIRECRAWL_API_KEY`, `FAL_KEY`,
`TAVILY_API_KEY`, `SERPER_API_KEY`, `BRAVE_API_KEY`)
- GitHub tokens (`GH_TOKEN`, `GITHUB_TOKEN`)
- Azure OpenAI (`AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`)
A real outbound TLS connection to a provider's IPv6 endpoint was
observed during a test run on this host before the strip was expanded.
The test server uses a mock config and has no business making real API
calls.
## Test status
5,151 passed / 11 skipped / 1 xfailed / 2 xpassed / 0 regressions in
139s on Python 3.11. Down from 147s before the fixes (and from
intermittent 10×-slowdowns on IMDS-rate-limited hosts). All API/feature
contracts unchanged.
## Security audit of remaining test-suite host references
Every IP / URL / hostname referenced in `tests/**.py` was classified:
- Loopback (127.0.0.1, localhost, ::1, 0.0.0.0)
- RFC1918 private (10.*, 172.16-31.*, 192.168.*)
- RFC 5737 TEST-NET-3 documentation (203.0.113.*)
- RFC 2606 reserved docs domains (*.example.com, *.example.local,
*.example.test)
- Security-attack input strings used only as parser/validator input
(evil.com, attacker, evil.example.com — never resolved or contacted)
- Real provider/CDN endpoints used only as `base_url` config strings
or CSP-allowlist assertions — never actually fetched
- 8.8.8.8 used only as a "non-loopback example" in `_is_local_from_handler()`
unit tests
No suspicious egress destinations.
2026-05-11 04:49:46 +00:00
|
|
|
if any(_k.startswith(p) for p in _CRED_ENV_PREFIXES):
|
2026-04-17 06:03:32 +00:00
|
|
|
del env[_k]
|
test(conftest): block AWS IMDS probing + expand credential-strip allowlist
Two test-infrastructure fixes surfaced while running the full suite on
this branch. Both prevent accidental outbound network calls from the
pytest process — a class of bug that doesn't show up as test failures
but corrupts timing, leaks credentials, and was responsible for a recent
10× slowdown observation.
## 1. AWS_EC2_METADATA_DISABLED for the whole pytest session
When hermes-agent's bedrock_adapter / botocore credential chain is
imported during tests (e.g. via api/config.py provider-catalog imports),
botocore probes the EC2 Instance Metadata Service at 169.254.169.254
looking for an instance role. On VPS hosts where IMDS is reachable but
rate-limited (HTTP 429) or non-responsive, those probes dominate wall
time — a 161s test run was observed extending to 600+s.
Set `AWS_EC2_METADATA_DISABLED=true` at module load (before any test-file
imports trigger botocore initialisation). This is the documented AWS-
supported way to silence the probe and matches the guard the agent's own
`hermes_cli/doctor.py` already uses inside its parallel-probe block.
Also explicitly re-set the var on the spawned test-server env so it
can't be accidentally cleared by a later `env.update(...)`.
## 2. Expanded credential-strip allowlist
The original strip list covered 6 providers (OpenRouter, OpenAI,
Anthropic, Google, DeepSeek, Xiaomi). Several others leaked through
into the test server subprocess:
- `MEM0_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `OLLAMA_API_KEY`,
`GROQ_API_KEY`, `TOGETHER_API_KEY`, …
- AWS credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`,
`AWS_SESSION_TOKEN`, `AWS_PROFILE`, `AWS_BEARER_TOKEN_BEDROCK`)
- Messaging bot tokens (`TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`,
`SLACK_BOT_TOKEN`, `SIGNAL_API_TOKEN`, `WHATSAPP_API_TOKEN`)
- Memory providers (`HONCHO_API_KEY`, `SUPERMEMORY_API_KEY`)
- Search / browser / image-gen (`FIRECRAWL_API_KEY`, `FAL_KEY`,
`TAVILY_API_KEY`, `SERPER_API_KEY`, `BRAVE_API_KEY`)
- GitHub tokens (`GH_TOKEN`, `GITHUB_TOKEN`)
- Azure OpenAI (`AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`)
A real outbound TLS connection to a provider's IPv6 endpoint was
observed during a test run on this host before the strip was expanded.
The test server uses a mock config and has no business making real API
calls.
## Test status
5,151 passed / 11 skipped / 1 xfailed / 2 xpassed / 0 regressions in
139s on Python 3.11. Down from 147s before the fixes (and from
intermittent 10×-slowdowns on IMDS-rate-limited hosts). All API/feature
contracts unchanged.
## Security audit of remaining test-suite host references
Every IP / URL / hostname referenced in `tests/**.py` was classified:
- Loopback (127.0.0.1, localhost, ::1, 0.0.0.0)
- RFC1918 private (10.*, 172.16-31.*, 192.168.*)
- RFC 5737 TEST-NET-3 documentation (203.0.113.*)
- RFC 2606 reserved docs domains (*.example.com, *.example.local,
*.example.test)
- Security-attack input strings used only as parser/validator input
(evil.com, attacker, evil.example.com — never resolved or contacted)
- Real provider/CDN endpoints used only as `base_url` config strings
or CSP-allowlist assertions — never actually fetched
- 8.8.8.8 used only as a "non-loopback example" in `_is_local_from_handler()`
unit tests
No suspicious egress destinations.
2026-05-11 04:49:46 +00:00
|
|
|
# Belt-and-suspenders: keep IMDS disabled in the spawn env too (we set it
|
|
|
|
|
# at module level above for the pytest process, but make it explicit here
|
|
|
|
|
# so it's never accidentally cleared by an env.update later).
|
|
|
|
|
env["AWS_EC2_METADATA_DISABLED"] = "true"
|
test(infra): hermetic network isolation — block all outbound from tests
Tests should not reach the public internet. Before this commit, an
accidentally-leaking outbound socket from the test_server fixture (real
TLS handshakes to Anthropic / Amazon / OpenRouter, sometimes triggered
by SDK-init paths that found a credential the credential-strip allowlist
missed) was adding 60+s of wall-time to a 100s test run and creating a
class of flaky failures.
This installs a default-deny socket-block at two layers:
1. Pytest process, via tests/conftest.py module-level monkey-patch on
socket.create_connection + socket.socket.connect. Loopback / RFC1918
private / link-local / RFC2606 reserved-TLD destinations pass through;
anything else raises OSError("hermes test network isolation: outbound
to ... blocked"). Tests that legitimately need real outbound opt back
in via the new `allow_outbound_network` fixture (no current callers).
2. Test_server subprocess (server.py), via a HERMES_WEBUI_TEST_NETWORK_BLOCK=1
environment-variable-gated guard at the top of server.py. tests/conftest.py
sets the env var on every test_server spawn. Without this, the subprocess
could make outbound that the pytest-side block can't see (which is exactly
what was happening — verified via `ss -tnp` showing the server.py child
with established ESTAB sockets to [2607:6bc0::10]:443).
In production the env var is unset, so the guard is a no-op.
Companion changes:
- test_dns_resolution_failure refactored to mock socket.getaddrinfo
raising gaierror, instead of relying on a real DNS lookup of a
*.invalid hostname. The test was the one outlier that genuinely
exercised real DNS; mocking matches what every other probe-error test
in the same file already does.
- New tests/test_conftest_network_isolation.py with 9 adversarial
tests proving the block fires for public IPs (including the exact
Anthropic IPv6 and Amazon IPv4 destinations we observed leaking),
the allow-list passes loopback / RFC1918 / link-local / reserved-TLDs,
and the opt-in fixture re-enables real outbound when needed.
Test suite: 5,120 → 5,192 (+72 net new from this commit + the regression
tests in the companion commits). Wall time: 161s → 95s on the same
hardware. No remaining outbound from any test path.
2026-05-11 05:59:42 +00:00
|
|
|
# Activate the same network-isolation block in the test_server subprocess
|
|
|
|
|
# that conftest.py installs in the pytest process. server.py reads this
|
|
|
|
|
# env var at import time and installs an identical socket-block guard.
|
|
|
|
|
# Without this, the subprocess can make outbound requests that the
|
|
|
|
|
# pytest-side block can't see.
|
|
|
|
|
env["HERMES_WEBUI_TEST_NETWORK_BLOCK"] = "1"
|
2026-03-31 03:40:19 +00:00
|
|
|
env.update({
|
2026-05-20 01:20:46 +00:00
|
|
|
"HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE": "1",
|
2026-06-02 00:41:11 +00:00
|
|
|
# Small archive-extraction cap so the zip-bomb guard is exercisable
|
|
|
|
|
# against the out-of-process test server (the real 10x-upload default is
|
|
|
|
|
# ~200MB — impractical to exceed in a test). 5MB is far above any other
|
|
|
|
|
# test's archive payload, so only the bomb test trips it.
|
|
|
|
|
"HERMES_WEBUI_MAX_EXTRACTED_MB": "5",
|
2026-03-31 03:40:19 +00:00
|
|
|
"HERMES_WEBUI_PORT": str(TEST_PORT),
|
|
|
|
|
"HERMES_WEBUI_HOST": "127.0.0.1",
|
|
|
|
|
"HERMES_WEBUI_STATE_DIR": str(TEST_STATE_DIR),
|
|
|
|
|
"HERMES_WEBUI_DEFAULT_WORKSPACE": str(TEST_WORKSPACE),
|
|
|
|
|
"HERMES_WEBUI_DEFAULT_MODEL": "openai/gpt-5.4-mini",
|
|
|
|
|
"HERMES_HOME": str(TEST_STATE_DIR),
|
2026-05-04 05:33:54 +00:00
|
|
|
"HERMES_CONFIG_PATH": str(TEST_STATE_DIR / 'config.yaml'),
|
2026-04-17 06:03:32 +00:00
|
|
|
# Belt-and-suspenders: HERMES_BASE_HOME hard-locks _DEFAULT_HERMES_HOME
|
|
|
|
|
# in api/profiles.py to the test state dir regardless of profile switching
|
|
|
|
|
# or any os.environ mutation that happens inside the server process.
|
|
|
|
|
# Without this, a profile switch or active_profile file in the real
|
|
|
|
|
# ~/.hermes can redirect _get_active_hermes_home() out of the sandbox,
|
|
|
|
|
# causing onboarding writes (config.yaml, .env) to land in the production
|
|
|
|
|
# ~/.hermes/profiles/webui/ and overwrite real API keys.
|
|
|
|
|
"HERMES_BASE_HOME": str(TEST_STATE_DIR),
|
2026-04-30 08:06:16 +00:00
|
|
|
"HERMES_WEBUI_PASSWORD": "",
|
2026-03-31 03:40:19 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Pass agent dir if discovered so server.py doesn't have to re-discover
|
|
|
|
|
if HERMES_AGENT:
|
|
|
|
|
env["HERMES_WEBUI_AGENT_DIR"] = str(HERMES_AGENT)
|
|
|
|
|
|
2026-06-07 16:38:29 +00:00
|
|
|
# Capture server stdout/stderr to a temp log instead of DEVNULL so a boot
|
|
|
|
|
# failure (import error, port-bind race, traceback) is diagnosable. Without
|
|
|
|
|
# this, _wait_for_server could only report a bare "did not start" timeout
|
|
|
|
|
# and every HTTP-dependent test then cascaded with ConnectionRefused —
|
|
|
|
|
# hundreds of opaque failures from a single root cause.
|
|
|
|
|
import tempfile as _tempfile
|
|
|
|
|
_server_log = pathlib.Path(_tempfile.gettempdir()) / f"hermes-webui-test-server-{TEST_PORT}.log"
|
|
|
|
|
|
|
|
|
|
# Boot the server, retrying once if it dies early or fails to bind. Boot
|
|
|
|
|
# failures here are most often transient (a port not yet released by a prior
|
|
|
|
|
# session, a momentary import hiccup under load), so one clean retry with a
|
|
|
|
|
# fresh port-kill turns an intermittent cascade into a reliable start.
|
|
|
|
|
proc = None
|
|
|
|
|
boot_attempts = 2
|
|
|
|
|
last_reason = ""
|
|
|
|
|
for _attempt in range(1, boot_attempts + 1):
|
|
|
|
|
with open(_server_log, "w", encoding="utf-8") as _logf:
|
|
|
|
|
proc = subprocess.Popen(
|
|
|
|
|
[VENV_PYTHON, str(SERVER_SCRIPT)],
|
|
|
|
|
cwd=WORKDIR,
|
|
|
|
|
env=env,
|
|
|
|
|
stdout=_logf,
|
|
|
|
|
stderr=subprocess.STDOUT,
|
|
|
|
|
**({"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {}),
|
|
|
|
|
)
|
|
|
|
|
# 45s (up from 20s): server.py imports the full hermes-agent, which is
|
|
|
|
|
# import-heavy and can exceed 20s on a loaded runner — the old timeout
|
|
|
|
|
# turned a slow-but-fine boot into a whole-suite failure.
|
|
|
|
|
ok, reason = _wait_for_server(TEST_BASE, timeout=45, proc=proc, log_path=_server_log)
|
|
|
|
|
if ok:
|
|
|
|
|
break
|
|
|
|
|
last_reason = reason
|
|
|
|
|
# Tear down the failed attempt and free the port before retrying.
|
|
|
|
|
try:
|
2026-06-15 19:42:33 +00:00
|
|
|
_kill_process_tree(proc.pid)
|
2026-06-07 16:38:29 +00:00
|
|
|
proc.wait(timeout=5)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
if _attempt < boot_attempts:
|
2026-06-15 19:42:33 +00:00
|
|
|
_kill_port_owner(TEST_PORT)
|
2026-06-07 16:38:29 +00:00
|
|
|
time.sleep(1.0)
|
|
|
|
|
else:
|
2026-03-31 03:40:19 +00:00
|
|
|
pytest.fail(
|
2026-06-07 16:38:29 +00:00
|
|
|
f"Test server on port {TEST_PORT} did not start after {boot_attempts} attempts.\n"
|
|
|
|
|
f" reason : {last_reason}\n"
|
2026-03-31 03:40:19 +00:00
|
|
|
f" server.py : {SERVER_SCRIPT}\n"
|
|
|
|
|
f" python : {VENV_PYTHON}\n"
|
|
|
|
|
f" agent dir : {HERMES_AGENT}\n"
|
|
|
|
|
f" workdir : {WORKDIR}\n"
|
2026-06-07 16:38:29 +00:00
|
|
|
f" log : {_server_log}\n"
|
2026-03-31 03:40:19 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
yield proc
|
|
|
|
|
|
|
|
|
|
proc.terminate()
|
|
|
|
|
try:
|
|
|
|
|
proc.wait(timeout=5)
|
|
|
|
|
except subprocess.TimeoutExpired:
|
2026-06-15 19:42:33 +00:00
|
|
|
_kill_process_tree(proc.pid)
|
2026-03-31 03:40:19 +00:00
|
|
|
|
2026-06-15 19:42:33 +00:00
|
|
|
_rmtree_retry(TEST_STATE_DIR)
|
2026-03-31 03:40:19 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Test base URL ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
|
|
|
def base_url():
|
|
|
|
|
return TEST_BASE
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 00:33:03 +00:00
|
|
|
# ── Per-test model cache invalidation ────────────────────────────────────────
|
|
|
|
|
# The TTL cache for get_available_models() persists across tests within the
|
|
|
|
|
# same process. Tests that modify cfg in-memory won't trigger the mtime path,
|
|
|
|
|
# so the cache must be explicitly invalidated after each test that exercises
|
|
|
|
|
# provider/model detection.
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _invalidate_models_cache_after_test():
|
|
|
|
|
"""Force the TTL cache to be cleared before and after every test.
|
|
|
|
|
|
|
|
|
|
This prevents state bleed where a test that calls get_available_models()
|
|
|
|
|
populates the cache with a particular config, and the next test sees stale
|
|
|
|
|
results even though it has mutated _cfg_cache in-memory.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
from api.config import invalidate_models_cache
|
|
|
|
|
invalidate_models_cache()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
yield
|
|
|
|
|
try:
|
|
|
|
|
from api.config import invalidate_models_cache
|
|
|
|
|
invalidate_models_cache()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-03-31 03:40:19 +00:00
|
|
|
# ── Per-test session cleanup ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def cleanup_test_sessions():
|
|
|
|
|
"""
|
|
|
|
|
Yields a list for tests to register created session IDs.
|
|
|
|
|
Deletes all registered sessions after each test.
|
|
|
|
|
Resets last_workspace to the test workspace to prevent state bleed.
|
|
|
|
|
"""
|
|
|
|
|
created: list[str] = []
|
|
|
|
|
yield created
|
|
|
|
|
|
|
|
|
|
for sid in created:
|
|
|
|
|
try:
|
|
|
|
|
_post(TEST_BASE, "/api/session/delete", {"session_id": sid})
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
_post(TEST_BASE, "/api/sessions/cleanup_zero_message")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
last_ws_file = TEST_STATE_DIR / "last_workspace.txt"
|
|
|
|
|
last_ws_file.write_text(str(TEST_WORKSPACE), encoding='utf-8')
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Convenience helpers ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def make_session_tracked(created_list, ws=None):
|
|
|
|
|
"""
|
|
|
|
|
Create a session on the test server and register it for cleanup.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
def test_something(cleanup_test_sessions):
|
|
|
|
|
sid, ws = make_session_tracked(cleanup_test_sessions)
|
|
|
|
|
"""
|
|
|
|
|
body = {}
|
|
|
|
|
if ws:
|
|
|
|
|
body["workspace"] = str(ws)
|
|
|
|
|
d = _post(TEST_BASE, "/api/session/new", body)
|
|
|
|
|
sid = d["session"]["session_id"]
|
|
|
|
|
ws_path = pathlib.Path(d["session"]["workspace"])
|
|
|
|
|
created_list.append(sid)
|
|
|
|
|
return sid, ws_path
|