2026-03-31 03:40:19 +00:00
|
|
|
"""
|
|
|
|
|
Shared pytest fixtures for webui-mvp tests.
|
|
|
|
|
|
|
|
|
|
TEST ISOLATION:
|
|
|
|
|
Tests run against a SEPARATE server instance on port 8788 with a
|
|
|
|
|
completely separate state directory. Production data is never touched.
|
|
|
|
|
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
|
|
|
|
|
import os
|
|
|
|
|
import pathlib
|
|
|
|
|
import shutil
|
|
|
|
|
import subprocess
|
|
|
|
|
import time
|
|
|
|
|
import urllib.request
|
|
|
|
|
import urllib.error
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
# ── 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,
|
|
|
|
|
# giving every worktree its own isolated port (8800-8899) and state directory.
|
|
|
|
|
# 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}"
|
|
|
|
|
TEST_STATE_DIR = pathlib.Path(os.getenv(
|
|
|
|
|
'HERMES_WEBUI_TEST_STATE_DIR',
|
2026-04-14 19:04:48 +00:00
|
|
|
str(HERMES_HOME / _auto_state_dir_name(REPO_ROOT))
|
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-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:
|
|
|
|
|
venv_py = agent_dir / 'venv' / 'bin' / 'python'
|
|
|
|
|
if venv_py.exists():
|
|
|
|
|
return str(venv_py)
|
|
|
|
|
local_venv = REPO_ROOT / '.venv' / 'bin' / 'python'
|
|
|
|
|
if local_venv.exists():
|
|
|
|
|
return str(local_venv)
|
|
|
|
|
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-05-11 03:02:01 +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
|
|
|
# ── 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-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',
|
|
|
|
|
# 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 {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _wait_for_server(base, timeout=20):
|
|
|
|
|
deadline = time.time() + timeout
|
|
|
|
|
while time.time() < deadline:
|
|
|
|
|
try:
|
|
|
|
|
with urllib.request.urlopen(base + "/health", timeout=2) as r:
|
|
|
|
|
if json.loads(r.read()).get("status") == "ok":
|
|
|
|
|
return True
|
|
|
|
|
except Exception:
|
|
|
|
|
time.sleep(0.3)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 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.
|
|
|
|
|
try:
|
|
|
|
|
import subprocess as _sp
|
|
|
|
|
_sp.run(['fuser', '-k', f'{TEST_PORT}/tcp'],
|
|
|
|
|
capture_output=True, timeout=5)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
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():
|
|
|
|
|
shutil.rmtree(TEST_STATE_DIR)
|
|
|
|
|
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"
|
2026-03-31 03:40:19 +00:00
|
|
|
env.update({
|
|
|
|
|
"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)
|
|
|
|
|
|
|
|
|
|
proc = subprocess.Popen(
|
|
|
|
|
[VENV_PYTHON, str(SERVER_SCRIPT)],
|
|
|
|
|
cwd=WORKDIR,
|
|
|
|
|
env=env,
|
|
|
|
|
stdout=subprocess.DEVNULL,
|
|
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not _wait_for_server(TEST_BASE, timeout=20):
|
|
|
|
|
proc.kill()
|
|
|
|
|
pytest.fail(
|
|
|
|
|
f"Test server on port {TEST_PORT} did not start within 20s.\n"
|
|
|
|
|
f" server.py : {SERVER_SCRIPT}\n"
|
|
|
|
|
f" python : {VENV_PYTHON}\n"
|
|
|
|
|
f" agent dir : {HERMES_AGENT}\n"
|
|
|
|
|
f" workdir : {WORKDIR}\n"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
yield proc
|
|
|
|
|
|
|
|
|
|
proc.terminate()
|
|
|
|
|
try:
|
|
|
|
|
proc.wait(timeout=5)
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
proc.kill()
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
shutil.rmtree(TEST_STATE_DIR)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 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
|