test: make test env hermetic; enforce CI parity via scripts/run_tests.sh (#11577)
* test: make test env hermetic; enforce CI parity via scripts/run_tests.sh
Fixes the recurring 'works locally, fails in CI' (and vice versa) class
of flakes by making tests hermetic and providing a canonical local runner
that matches CI's environment.
## Layer 1 — hermetic conftest.py (tests/conftest.py)
Autouse fixture now unsets every credential-shaped env var before every
test, so developer-local API keys can't leak into tests that assert
'auto-detect provider when key present'.
Pattern: unset any var ending in _API_KEY, _TOKEN, _SECRET, _PASSWORD,
_CREDENTIALS, _ACCESS_KEY, _PRIVATE_KEY, etc. Plus an explicit list of
credential names that don't fit the suffix pattern (AWS_ACCESS_KEY_ID,
FAL_KEY, GH_TOKEN, etc.) and all the provider BASE_URL overrides that
change auto-detect behavior.
Also unsets HERMES_* behavioral vars (HERMES_YOLO_MODE, HERMES_QUIET,
HERMES_SESSION_*, etc.) that mutate agent behavior.
Also:
- Redirects HOME to a per-test tempdir (not just HERMES_HOME), so
code reading ~/.hermes/* directly can't touch the real dir.
- Pins TZ=UTC, LANG=C.UTF-8, LC_ALL=C.UTF-8, PYTHONHASHSEED=0 to
match CI's deterministic runtime.
The old _isolate_hermes_home fixture name is preserved as an alias so
any test that yields it explicitly still works.
## Layer 2 — scripts/run_tests.sh canonical runner
'Always use scripts/run_tests.sh, never call pytest directly' is the
new rule (documented in AGENTS.md). The script:
- Unsets all credential env vars (belt-and-suspenders for callers
who bypass conftest — e.g. IDE integrations)
- Pins TZ/LANG/PYTHONHASHSEED
- Uses -n 4 xdist workers (matches GHA ubuntu-latest; -n auto on
a 20-core workstation surfaces test-ordering flakes CI will never
see, causing the infamous 'passes in CI, fails locally' drift)
- Finds the venv in .venv, venv, or main checkout's venv
- Passes through arbitrary pytest args
Installs pytest-split on demand so the script can also be used to run
matrix-split subsets locally for debugging.
## Remove 3 module-level dotenv stubs that broke test isolation
tests/hermes_cli/test_{arcee,xiaomi,api_key}_provider.py each had a
module-level:
if 'dotenv' not in sys.modules:
fake_dotenv = types.ModuleType('dotenv')
fake_dotenv.load_dotenv = lambda *a, **kw: None
sys.modules['dotenv'] = fake_dotenv
This patches sys.modules['dotenv'] to a fake at import time with no
teardown. Under pytest-xdist LoadScheduling, whichever worker collected
one of these files first poisoned its sys.modules; subsequent tests in
the same worker that imported load_dotenv transitively (e.g.
test_env_loader.py via hermes_cli.env_loader) got the no-op lambda and
saw their assertions fail.
dotenv is a required dependency (python-dotenv>=1.2.1 in pyproject.toml),
so the defensive stub was never needed. Removed.
## Validation
- tests/hermes_cli/ alone: 2178 passed, 1 skipped, 0 failed (was 4
failures in test_env_loader.py before this fix)
- tests/test_plugin_skills.py, tests/hermes_cli/test_plugins.py,
tests/test_hermes_logging.py combined: 123 passed (the caplog
regression tests from PR #11453 still pass)
- Local full run shows no F/E clusters in the 0-55% range that were
previously present before the conftest hardening
## Background
See AGENTS.md 'Testing' section for the full list of drift sources
this closes. Matrix split (closed as #11566) will be re-attempted
once this foundation lands — cross-test pollution was the root cause
of the shard-3 hang in that PR.
* fix(conftest): don't redirect HOME — it broke CI subprocesses
PR #11577's autouse fixture was setting HOME to a per-test tempdir.
CI started timing out at 97% complete with dozens of E/F markers and
orphan python processes at cleanup — tests (or transitive deps)
spawn subprocesses that expect a stable HOME, and the redirect broke
them in non-obvious ways.
Env-var unsetting and TZ/LANG/hashseed pinning (the actual CI-drift
fixes) are unchanged and still in place. HERMES_HOME redirection is
also unchanged — that's the canonical way to isolate tests from
~/.hermes/, not HOME.
Any code in the codebase reading ~/.hermes/* via `Path.home() / ".hermes"`
instead of `get_hermes_home()` is a bug to fix at the callsite, not
something to paper over in conftest.
2026-04-17 06:09:09 -07:00
|
|
|
"""Shared fixtures for the hermes-agent test suite.
|
|
|
|
|
|
|
|
|
|
Hermetic-test invariants enforced here (see AGENTS.md for rationale):
|
|
|
|
|
|
|
|
|
|
1. **No credential env vars.** All provider/credential-shaped env vars
|
|
|
|
|
(ending in _API_KEY, _TOKEN, _SECRET, _PASSWORD, _CREDENTIALS, etc.)
|
|
|
|
|
are unset before every test. Local developer keys cannot leak in.
|
|
|
|
|
2. **Isolated HERMES_HOME.** HERMES_HOME points to a per-test tempdir so
|
|
|
|
|
code reading ``~/.hermes/*`` via ``get_hermes_home()`` can't see the
|
|
|
|
|
real one. (We do NOT also redirect HOME — that broke subprocesses in
|
|
|
|
|
CI. Code using ``Path.home() / ".hermes"`` instead of the canonical
|
|
|
|
|
``get_hermes_home()`` is a bug to fix at the callsite.)
|
|
|
|
|
3. **Deterministic runtime.** TZ=UTC, LANG=C.UTF-8, PYTHONHASHSEED=0.
|
|
|
|
|
4. **No HERMES_SESSION_* inheritance** — the agent's current gateway
|
|
|
|
|
session must not leak into tests.
|
|
|
|
|
|
|
|
|
|
These invariants make the local test run match CI closely. Gaps that
|
|
|
|
|
remain (CPU count, xdist worker count) are addressed by the canonical
|
|
|
|
|
test runner at ``scripts/run_tests.sh``.
|
|
|
|
|
"""
|
test: reorganize test structure and add missing unit tests
Reorganize flat tests/ directory to mirror source code structure
(tools/, gateway/, hermes_cli/, integration/). Add 11 new test files
covering previously untested modules: registry, patch_parser,
fuzzy_match, todo_tool, approval, file_tools, gateway session/config/
delivery, and hermes_cli config/models. Total: 147 unit tests passing,
9 integration tests gated behind pytest marker.
2026-02-26 03:20:08 +03:00
|
|
|
|
2026-03-14 03:14:34 -07:00
|
|
|
import asyncio
|
test: reorganize test structure and add missing unit tests
Reorganize flat tests/ directory to mirror source code structure
(tools/, gateway/, hermes_cli/, integration/). Add 11 new test files
covering previously untested modules: registry, patch_parser,
fuzzy_match, todo_tool, approval, file_tools, gateway session/config/
delivery, and hermes_cli config/models. Total: 147 unit tests passing,
9 integration tests gated behind pytest marker.
2026-02-26 03:20:08 +03:00
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
# Ensure project root is importable
|
|
|
|
|
PROJECT_ROOT = Path(__file__).parent.parent
|
|
|
|
|
if str(PROJECT_ROOT) not in sys.path:
|
|
|
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
|
|
|
|
|
|
|
test: use subprocesses for each test file (#29016)
* ci(tests): install ripgrep from prebuilt tarball instead of apt
apt-get update + install of ripgrep takes ~4 min on the GHA Ubuntu
runners (the apt-get update against archive.ubuntu.com is the slow
part; ripgrep itself is small). Switching to the upstream musl
binary tarball cuts the step to a few seconds.
- Pinned to ripgrep 15.1.0 with sha256 verification (same hash as
published in the releases sha256 sidecar file).
- Drops the `rg` binary into /usr/local/bin so it is on PATH for
every subsequent step without GITHUB_PATH manipulation.
- Applied to both the test and e2e jobs in tests.yml.
* fix(cli): compile syntax check to tempdir, not source __pycache__
`_validate_critical_files_syntax` runs `py_compile.compile()` on each
critical bootstrap file after a successful `git pull`. The default
`py_compile` writes the resulting `.pyc` next to the source under
`__pycache__/`, which causes two real problems:
1. Parallel test workers walking the same source tree (e.g. running
the suite under per-file process isolation) can race against each
other on the `__pycache__` write — manifests as flaky 'directory
not empty' errors during teardown.
2. In production, the post-pull syntax check leaves a `.pyc` behind
that the next interpreter run might pick up — fine when the
interpreter version matches, sketchy if it doesn't.
Fix: write the compiled output to a `tempfile.TemporaryDirectory()`
that's discarded on function exit. We only care about the compile-or-not
signal, not the artifact.
* test(runner): per-file process isolation, drop manual state reset + xdist
Replace fragile manual _reset_module_state test fixtures with robust
per-file subprocess isolation. Each test file runs in a fresh
`python -m pytest <file>` subprocess via ThreadPoolExecutor. No xdist,
no custom pytest plugin, no shared worker state.
Key changes:
* scripts/run_tests_parallel.py — new runner: discovers test files,
runs N in parallel via ThreadPoolExecutor, captures stdout per file,
treats exit code 5 (no tests collected) as pass, kills all children
on exit. Change from cpu_count to cpu_count*2. The runner is
I/O-bound (waiting on subprocess.communicate() from pytest children)
The parent process does almost no CPU work, so 2x oversubscription
keeps more pipes full. When a file fails, immediately show the last
30 lines of pytest output (stack traces + FAILED summary) plus a
ready-to-copy repro command:
python -m pytest tests/agent/test_auxiliary_client.py
* scripts/run_tests.sh — delegates to run_tests_parallel.py
* .github/workflows/tests.yml — test step: python
scripts/run_tests_parallel.py
* pyproject.toml — drop pytest-xdist, pytest-split; simplify addopts
* tests/conftest.py — remove ~200 lines of manual state-reset fixtures
* AGENTS.md — update Testing section for per-file design
* test(runner): speed gateway test antipattern scan up
* fix(test): web search provider plugin test missing xai
* fix(tests): make 14 test files pass under per-file subprocess isolation
Tests that relied on cross-file state pollution from xdist workers
fail when run in isolation (per-file subprocess model). Root causes
and fixes:
Tool registry not populated:
- test_video_generation_tool_surface_matrix: add discover_builtin_tools()
- test_web_providers_brave_free/ddgs/searxng/general: autouse fixtures
registering all 8 bundled web providers, reset after each test
- test_website_policy: same provider registration pattern
- test_web_tools_tavily: same pattern across 3 dispatch test classes
- Also add is_safe_url/check_website_access mocks where SSRF check
blocks example.com (DNS resolution fails in isolated envs)
Stale check_fn cache:
- test_kanban_tools: invalidate_check_fn_cache() + _clear_tool_defs_cache()
in both kanban guidance tests (prior test cached False for kanban_show)
- test_discord_tool: cache invalidation in setup/teardown
- test_homeassistant_tool: invalidate_check_fn_cache() before registry queries
Module-level state pollution:
- test_auxiliary_client: autouse fixture clearing _aux_unhealthy_until cache
- test_skill_commands: set_session_vars() instead of patch.dict(os.environ)
(ContextVar takes precedence over os.environ)
- test_dm_topics: overwrite sys.modules + separate telegram.constants mock
+ force-reimport of gateway.platforms.telegram
- test_terminal_tool_requirements: removed duplicate class declaration,
autouse _clear_caches fixture
* change(tests): run_tests.sh explicitly includes env vars
instead of manually dropping some vars, now we just only include some
* fix(tests): 5 more isolation/NixOS fixes
- test_approval_plugin_hooks: isolate HERMES_HOME so real user's
command_allowlist doesn't short-circuit the approval path
- test_google_chat: skipif when Platform.GOOGLE_CHAT not in enum
(feature not merged on this branch)
- test_write_deny: test systemd prefix against tmp_path instead of
/etc/systemd which resolves to /nix/store on NixOS
- test_pty_bridge: use shutil.which('cat') instead of /bin/cat
(doesn't exist on NixOS)
- profiles.py: rmtree onexc handler chmod's parent dirs too, fixing
profile deletion when copytree preserved read-only modes from
nix store
* fix(tests): clear unhealthy cache in autouse fixture for auxiliary_client
* fix(tests): skip send_message when telegram not installed; handle missing worker_id in browser_supervisor
* fix: py3.11 rmtree onexc compat + belt-and-suspenders unhealthy cache clear for expired codex test
* fix: address PR #29016 review feedback
- Remove tracked .pytest-cache/ artifact and add to .gitignore
- Fix stale 'xdist worker' comment in conftest.py
- Deduplicate web provider registration into tests/tools/conftest.py
shared helper (register_all_web_providers), replacing 8 copy-pasted
blocks across 6 test files
- Update PR description: remove stale recovered-test-files claim,
fix worker count to match code (cpu_count*2)
* fix: eliminate race in stale-cache achievements test
The background scan thread could complete and overwrite _SNAPSHOT_CACHE
before evaluate_all() returned the stale data — only 10 fake sessions
made the scan finish instantly. Added scan_delay param to _FakeSessionDB
and set it to 2s in the stale-cache test so the background thread can't
win the race.
2026-05-21 07:10:04 -04:00
|
|
|
# ── Per-file process isolation ──────────────────────────────────────────────
|
|
|
|
|
# Tests run via ``scripts/run_tests_parallel.py``, which spawns a fresh
|
|
|
|
|
# ``python -m pytest <file>`` subprocess per test file. Cross-file state
|
|
|
|
|
# leakage (module-level dicts, ContextVars, caches) is impossible: each
|
|
|
|
|
# file gets a clean Python interpreter. Intra-file ordering is the test
|
|
|
|
|
# author's responsibility — if test A in foo.py mutates state that test B
|
|
|
|
|
# in foo.py reads, that's a real bug to fix in the file (it would also
|
|
|
|
|
# bite anyone running ``pytest tests/foo.py`` directly).
|
|
|
|
|
#
|
|
|
|
|
# This replaces the historic _reset_module_state autouse fixture (manual
|
|
|
|
|
# state clearing) and the brief experiment with subprocess-per-test
|
|
|
|
|
# isolation (too slow at ~17k tests).
|
|
|
|
|
#
|
|
|
|
|
# See ``scripts/run_tests_parallel.py`` for the runner.
|
|
|
|
|
|
|
|
|
|
|
test: make test env hermetic; enforce CI parity via scripts/run_tests.sh (#11577)
* test: make test env hermetic; enforce CI parity via scripts/run_tests.sh
Fixes the recurring 'works locally, fails in CI' (and vice versa) class
of flakes by making tests hermetic and providing a canonical local runner
that matches CI's environment.
## Layer 1 — hermetic conftest.py (tests/conftest.py)
Autouse fixture now unsets every credential-shaped env var before every
test, so developer-local API keys can't leak into tests that assert
'auto-detect provider when key present'.
Pattern: unset any var ending in _API_KEY, _TOKEN, _SECRET, _PASSWORD,
_CREDENTIALS, _ACCESS_KEY, _PRIVATE_KEY, etc. Plus an explicit list of
credential names that don't fit the suffix pattern (AWS_ACCESS_KEY_ID,
FAL_KEY, GH_TOKEN, etc.) and all the provider BASE_URL overrides that
change auto-detect behavior.
Also unsets HERMES_* behavioral vars (HERMES_YOLO_MODE, HERMES_QUIET,
HERMES_SESSION_*, etc.) that mutate agent behavior.
Also:
- Redirects HOME to a per-test tempdir (not just HERMES_HOME), so
code reading ~/.hermes/* directly can't touch the real dir.
- Pins TZ=UTC, LANG=C.UTF-8, LC_ALL=C.UTF-8, PYTHONHASHSEED=0 to
match CI's deterministic runtime.
The old _isolate_hermes_home fixture name is preserved as an alias so
any test that yields it explicitly still works.
## Layer 2 — scripts/run_tests.sh canonical runner
'Always use scripts/run_tests.sh, never call pytest directly' is the
new rule (documented in AGENTS.md). The script:
- Unsets all credential env vars (belt-and-suspenders for callers
who bypass conftest — e.g. IDE integrations)
- Pins TZ/LANG/PYTHONHASHSEED
- Uses -n 4 xdist workers (matches GHA ubuntu-latest; -n auto on
a 20-core workstation surfaces test-ordering flakes CI will never
see, causing the infamous 'passes in CI, fails locally' drift)
- Finds the venv in .venv, venv, or main checkout's venv
- Passes through arbitrary pytest args
Installs pytest-split on demand so the script can also be used to run
matrix-split subsets locally for debugging.
## Remove 3 module-level dotenv stubs that broke test isolation
tests/hermes_cli/test_{arcee,xiaomi,api_key}_provider.py each had a
module-level:
if 'dotenv' not in sys.modules:
fake_dotenv = types.ModuleType('dotenv')
fake_dotenv.load_dotenv = lambda *a, **kw: None
sys.modules['dotenv'] = fake_dotenv
This patches sys.modules['dotenv'] to a fake at import time with no
teardown. Under pytest-xdist LoadScheduling, whichever worker collected
one of these files first poisoned its sys.modules; subsequent tests in
the same worker that imported load_dotenv transitively (e.g.
test_env_loader.py via hermes_cli.env_loader) got the no-op lambda and
saw their assertions fail.
dotenv is a required dependency (python-dotenv>=1.2.1 in pyproject.toml),
so the defensive stub was never needed. Removed.
## Validation
- tests/hermes_cli/ alone: 2178 passed, 1 skipped, 0 failed (was 4
failures in test_env_loader.py before this fix)
- tests/test_plugin_skills.py, tests/hermes_cli/test_plugins.py,
tests/test_hermes_logging.py combined: 123 passed (the caplog
regression tests from PR #11453 still pass)
- Local full run shows no F/E clusters in the 0-55% range that were
previously present before the conftest hardening
## Background
See AGENTS.md 'Testing' section for the full list of drift sources
this closes. Matrix split (closed as #11566) will be re-attempted
once this foundation lands — cross-test pollution was the root cause
of the shard-3 hang in that PR.
* fix(conftest): don't redirect HOME — it broke CI subprocesses
PR #11577's autouse fixture was setting HOME to a per-test tempdir.
CI started timing out at 97% complete with dozens of E/F markers and
orphan python processes at cleanup — tests (or transitive deps)
spawn subprocesses that expect a stable HOME, and the redirect broke
them in non-obvious ways.
Env-var unsetting and TZ/LANG/hashseed pinning (the actual CI-drift
fixes) are unchanged and still in place. HERMES_HOME redirection is
also unchanged — that's the canonical way to isolate tests from
~/.hermes/, not HOME.
Any code in the codebase reading ~/.hermes/* via `Path.home() / ".hermes"`
instead of `get_hermes_home()` is a bug to fix at the callsite, not
something to paper over in conftest.
2026-04-17 06:09:09 -07:00
|
|
|
# ── Credential env-var filter ──────────────────────────────────────────────
|
|
|
|
|
#
|
|
|
|
|
# Any env var in the current process matching ONE of these patterns is
|
|
|
|
|
# unset for every test. Developers' local keys cannot leak into assertions
|
|
|
|
|
# about "auto-detect provider when key present".
|
|
|
|
|
|
|
|
|
|
_CREDENTIAL_SUFFIXES = (
|
|
|
|
|
"_API_KEY",
|
|
|
|
|
"_TOKEN",
|
|
|
|
|
"_SECRET",
|
|
|
|
|
"_PASSWORD",
|
|
|
|
|
"_CREDENTIALS",
|
|
|
|
|
"_ACCESS_KEY",
|
|
|
|
|
"_SECRET_ACCESS_KEY",
|
|
|
|
|
"_PRIVATE_KEY",
|
|
|
|
|
"_OAUTH_TOKEN",
|
|
|
|
|
"_WEBHOOK_SECRET",
|
|
|
|
|
"_ENCRYPT_KEY",
|
|
|
|
|
"_APP_SECRET",
|
|
|
|
|
"_CLIENT_SECRET",
|
|
|
|
|
"_CORP_SECRET",
|
|
|
|
|
"_AES_KEY",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Explicit names (for ones that don't fit the suffix pattern)
|
|
|
|
|
_CREDENTIAL_NAMES = frozenset({
|
|
|
|
|
"AWS_ACCESS_KEY_ID",
|
|
|
|
|
"AWS_SECRET_ACCESS_KEY",
|
|
|
|
|
"AWS_SESSION_TOKEN",
|
|
|
|
|
"ANTHROPIC_TOKEN",
|
|
|
|
|
"FAL_KEY",
|
|
|
|
|
"GH_TOKEN",
|
|
|
|
|
"GITHUB_TOKEN",
|
|
|
|
|
"OPENAI_API_KEY",
|
|
|
|
|
"OPENROUTER_API_KEY",
|
|
|
|
|
"NOUS_API_KEY",
|
|
|
|
|
"GEMINI_API_KEY",
|
|
|
|
|
"GOOGLE_API_KEY",
|
|
|
|
|
"GROQ_API_KEY",
|
|
|
|
|
"XAI_API_KEY",
|
|
|
|
|
"MISTRAL_API_KEY",
|
|
|
|
|
"DEEPSEEK_API_KEY",
|
|
|
|
|
"KIMI_API_KEY",
|
|
|
|
|
"MOONSHOT_API_KEY",
|
|
|
|
|
"GLM_API_KEY",
|
|
|
|
|
"ZAI_API_KEY",
|
|
|
|
|
"MINIMAX_API_KEY",
|
|
|
|
|
"OLLAMA_API_KEY",
|
|
|
|
|
"OPENVIKING_API_KEY",
|
|
|
|
|
"COPILOT_API_KEY",
|
|
|
|
|
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
|
|
|
"BROWSERBASE_API_KEY",
|
|
|
|
|
"FIRECRAWL_API_KEY",
|
|
|
|
|
"PARALLEL_API_KEY",
|
|
|
|
|
"EXA_API_KEY",
|
|
|
|
|
"TAVILY_API_KEY",
|
|
|
|
|
"WANDB_API_KEY",
|
|
|
|
|
"ELEVENLABS_API_KEY",
|
|
|
|
|
"HONCHO_API_KEY",
|
|
|
|
|
"MEM0_API_KEY",
|
|
|
|
|
"SUPERMEMORY_API_KEY",
|
|
|
|
|
"RETAINDB_API_KEY",
|
|
|
|
|
"HINDSIGHT_API_KEY",
|
|
|
|
|
"HINDSIGHT_LLM_API_KEY",
|
|
|
|
|
"DAYTONA_API_KEY",
|
|
|
|
|
"TWILIO_AUTH_TOKEN",
|
|
|
|
|
"TELEGRAM_BOT_TOKEN",
|
|
|
|
|
"DISCORD_BOT_TOKEN",
|
|
|
|
|
"SLACK_BOT_TOKEN",
|
|
|
|
|
"SLACK_APP_TOKEN",
|
|
|
|
|
"MATTERMOST_TOKEN",
|
|
|
|
|
"MATRIX_ACCESS_TOKEN",
|
|
|
|
|
"MATRIX_PASSWORD",
|
|
|
|
|
"MATRIX_RECOVERY_KEY",
|
|
|
|
|
"HASS_TOKEN",
|
|
|
|
|
"EMAIL_PASSWORD",
|
|
|
|
|
"BLUEBUBBLES_PASSWORD",
|
|
|
|
|
"FEISHU_APP_SECRET",
|
|
|
|
|
"FEISHU_ENCRYPT_KEY",
|
|
|
|
|
"FEISHU_VERIFICATION_TOKEN",
|
|
|
|
|
"DINGTALK_CLIENT_SECRET",
|
|
|
|
|
"QQ_CLIENT_SECRET",
|
|
|
|
|
"QQ_STT_API_KEY",
|
|
|
|
|
"WECOM_SECRET",
|
|
|
|
|
"WECOM_CALLBACK_CORP_SECRET",
|
|
|
|
|
"WECOM_CALLBACK_TOKEN",
|
|
|
|
|
"WECOM_CALLBACK_ENCODING_AES_KEY",
|
|
|
|
|
"WEIXIN_TOKEN",
|
|
|
|
|
"MODAL_TOKEN_ID",
|
|
|
|
|
"MODAL_TOKEN_SECRET",
|
|
|
|
|
"TERMINAL_SSH_KEY",
|
|
|
|
|
"SUDO_PASSWORD",
|
|
|
|
|
"GATEWAY_PROXY_KEY",
|
|
|
|
|
"API_SERVER_KEY",
|
|
|
|
|
"TOOL_GATEWAY_USER_TOKEN",
|
|
|
|
|
"TELEGRAM_WEBHOOK_SECRET",
|
|
|
|
|
"WEBHOOK_SECRET",
|
|
|
|
|
"VOICE_TOOLS_OPENAI_KEY",
|
|
|
|
|
"BROWSER_USE_API_KEY",
|
|
|
|
|
"CUSTOM_API_KEY",
|
|
|
|
|
"GATEWAY_PROXY_URL",
|
|
|
|
|
"GEMINI_BASE_URL",
|
|
|
|
|
"OPENAI_BASE_URL",
|
|
|
|
|
"OPENROUTER_BASE_URL",
|
|
|
|
|
"OLLAMA_BASE_URL",
|
|
|
|
|
"GROQ_BASE_URL",
|
|
|
|
|
"XAI_BASE_URL",
|
|
|
|
|
"ANTHROPIC_BASE_URL",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _looks_like_credential(name: str) -> bool:
|
|
|
|
|
"""True if env var name matches a credential-shaped pattern."""
|
|
|
|
|
if name in _CREDENTIAL_NAMES:
|
|
|
|
|
return True
|
|
|
|
|
return any(name.endswith(suf) for suf in _CREDENTIAL_SUFFIXES)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# HERMES_* vars that change test behavior by being set. Unset all of these
|
|
|
|
|
# unconditionally — individual tests that need them set do so explicitly.
|
|
|
|
|
_HERMES_BEHAVIORAL_VARS = frozenset({
|
|
|
|
|
"HERMES_YOLO_MODE",
|
|
|
|
|
"HERMES_INTERACTIVE",
|
|
|
|
|
"HERMES_QUIET",
|
|
|
|
|
"HERMES_TOOL_PROGRESS",
|
|
|
|
|
"HERMES_TOOL_PROGRESS_MODE",
|
|
|
|
|
"HERMES_MAX_ITERATIONS",
|
|
|
|
|
"HERMES_SESSION_PLATFORM",
|
|
|
|
|
"HERMES_SESSION_CHAT_ID",
|
|
|
|
|
"HERMES_SESSION_CHAT_NAME",
|
|
|
|
|
"HERMES_SESSION_THREAD_ID",
|
|
|
|
|
"HERMES_SESSION_SOURCE",
|
|
|
|
|
"HERMES_SESSION_KEY",
|
|
|
|
|
"HERMES_GATEWAY_SESSION",
|
2026-06-01 19:19:15 -07:00
|
|
|
"HERMES_CRON_SESSION",
|
2026-05-30 21:02:53 -07:00
|
|
|
"_HERMES_GATEWAY",
|
test: make test env hermetic; enforce CI parity via scripts/run_tests.sh (#11577)
* test: make test env hermetic; enforce CI parity via scripts/run_tests.sh
Fixes the recurring 'works locally, fails in CI' (and vice versa) class
of flakes by making tests hermetic and providing a canonical local runner
that matches CI's environment.
## Layer 1 — hermetic conftest.py (tests/conftest.py)
Autouse fixture now unsets every credential-shaped env var before every
test, so developer-local API keys can't leak into tests that assert
'auto-detect provider when key present'.
Pattern: unset any var ending in _API_KEY, _TOKEN, _SECRET, _PASSWORD,
_CREDENTIALS, _ACCESS_KEY, _PRIVATE_KEY, etc. Plus an explicit list of
credential names that don't fit the suffix pattern (AWS_ACCESS_KEY_ID,
FAL_KEY, GH_TOKEN, etc.) and all the provider BASE_URL overrides that
change auto-detect behavior.
Also unsets HERMES_* behavioral vars (HERMES_YOLO_MODE, HERMES_QUIET,
HERMES_SESSION_*, etc.) that mutate agent behavior.
Also:
- Redirects HOME to a per-test tempdir (not just HERMES_HOME), so
code reading ~/.hermes/* directly can't touch the real dir.
- Pins TZ=UTC, LANG=C.UTF-8, LC_ALL=C.UTF-8, PYTHONHASHSEED=0 to
match CI's deterministic runtime.
The old _isolate_hermes_home fixture name is preserved as an alias so
any test that yields it explicitly still works.
## Layer 2 — scripts/run_tests.sh canonical runner
'Always use scripts/run_tests.sh, never call pytest directly' is the
new rule (documented in AGENTS.md). The script:
- Unsets all credential env vars (belt-and-suspenders for callers
who bypass conftest — e.g. IDE integrations)
- Pins TZ/LANG/PYTHONHASHSEED
- Uses -n 4 xdist workers (matches GHA ubuntu-latest; -n auto on
a 20-core workstation surfaces test-ordering flakes CI will never
see, causing the infamous 'passes in CI, fails locally' drift)
- Finds the venv in .venv, venv, or main checkout's venv
- Passes through arbitrary pytest args
Installs pytest-split on demand so the script can also be used to run
matrix-split subsets locally for debugging.
## Remove 3 module-level dotenv stubs that broke test isolation
tests/hermes_cli/test_{arcee,xiaomi,api_key}_provider.py each had a
module-level:
if 'dotenv' not in sys.modules:
fake_dotenv = types.ModuleType('dotenv')
fake_dotenv.load_dotenv = lambda *a, **kw: None
sys.modules['dotenv'] = fake_dotenv
This patches sys.modules['dotenv'] to a fake at import time with no
teardown. Under pytest-xdist LoadScheduling, whichever worker collected
one of these files first poisoned its sys.modules; subsequent tests in
the same worker that imported load_dotenv transitively (e.g.
test_env_loader.py via hermes_cli.env_loader) got the no-op lambda and
saw their assertions fail.
dotenv is a required dependency (python-dotenv>=1.2.1 in pyproject.toml),
so the defensive stub was never needed. Removed.
## Validation
- tests/hermes_cli/ alone: 2178 passed, 1 skipped, 0 failed (was 4
failures in test_env_loader.py before this fix)
- tests/test_plugin_skills.py, tests/hermes_cli/test_plugins.py,
tests/test_hermes_logging.py combined: 123 passed (the caplog
regression tests from PR #11453 still pass)
- Local full run shows no F/E clusters in the 0-55% range that were
previously present before the conftest hardening
## Background
See AGENTS.md 'Testing' section for the full list of drift sources
this closes. Matrix split (closed as #11566) will be re-attempted
once this foundation lands — cross-test pollution was the root cause
of the shard-3 hang in that PR.
* fix(conftest): don't redirect HOME — it broke CI subprocesses
PR #11577's autouse fixture was setting HOME to a per-test tempdir.
CI started timing out at 97% complete with dozens of E/F markers and
orphan python processes at cleanup — tests (or transitive deps)
spawn subprocesses that expect a stable HOME, and the redirect broke
them in non-obvious ways.
Env-var unsetting and TZ/LANG/hashseed pinning (the actual CI-drift
fixes) are unchanged and still in place. HERMES_HOME redirection is
also unchanged — that's the canonical way to isolate tests from
~/.hermes/, not HOME.
Any code in the codebase reading ~/.hermes/* via `Path.home() / ".hermes"`
instead of `get_hermes_home()` is a bug to fix at the callsite, not
something to paper over in conftest.
2026-04-17 06:09:09 -07:00
|
|
|
"HERMES_PLATFORM",
|
2026-04-29 23:18:55 -07:00
|
|
|
"HERMES_MODEL",
|
|
|
|
|
"HERMES_INFERENCE_MODEL",
|
test: make test env hermetic; enforce CI parity via scripts/run_tests.sh (#11577)
* test: make test env hermetic; enforce CI parity via scripts/run_tests.sh
Fixes the recurring 'works locally, fails in CI' (and vice versa) class
of flakes by making tests hermetic and providing a canonical local runner
that matches CI's environment.
## Layer 1 — hermetic conftest.py (tests/conftest.py)
Autouse fixture now unsets every credential-shaped env var before every
test, so developer-local API keys can't leak into tests that assert
'auto-detect provider when key present'.
Pattern: unset any var ending in _API_KEY, _TOKEN, _SECRET, _PASSWORD,
_CREDENTIALS, _ACCESS_KEY, _PRIVATE_KEY, etc. Plus an explicit list of
credential names that don't fit the suffix pattern (AWS_ACCESS_KEY_ID,
FAL_KEY, GH_TOKEN, etc.) and all the provider BASE_URL overrides that
change auto-detect behavior.
Also unsets HERMES_* behavioral vars (HERMES_YOLO_MODE, HERMES_QUIET,
HERMES_SESSION_*, etc.) that mutate agent behavior.
Also:
- Redirects HOME to a per-test tempdir (not just HERMES_HOME), so
code reading ~/.hermes/* directly can't touch the real dir.
- Pins TZ=UTC, LANG=C.UTF-8, LC_ALL=C.UTF-8, PYTHONHASHSEED=0 to
match CI's deterministic runtime.
The old _isolate_hermes_home fixture name is preserved as an alias so
any test that yields it explicitly still works.
## Layer 2 — scripts/run_tests.sh canonical runner
'Always use scripts/run_tests.sh, never call pytest directly' is the
new rule (documented in AGENTS.md). The script:
- Unsets all credential env vars (belt-and-suspenders for callers
who bypass conftest — e.g. IDE integrations)
- Pins TZ/LANG/PYTHONHASHSEED
- Uses -n 4 xdist workers (matches GHA ubuntu-latest; -n auto on
a 20-core workstation surfaces test-ordering flakes CI will never
see, causing the infamous 'passes in CI, fails locally' drift)
- Finds the venv in .venv, venv, or main checkout's venv
- Passes through arbitrary pytest args
Installs pytest-split on demand so the script can also be used to run
matrix-split subsets locally for debugging.
## Remove 3 module-level dotenv stubs that broke test isolation
tests/hermes_cli/test_{arcee,xiaomi,api_key}_provider.py each had a
module-level:
if 'dotenv' not in sys.modules:
fake_dotenv = types.ModuleType('dotenv')
fake_dotenv.load_dotenv = lambda *a, **kw: None
sys.modules['dotenv'] = fake_dotenv
This patches sys.modules['dotenv'] to a fake at import time with no
teardown. Under pytest-xdist LoadScheduling, whichever worker collected
one of these files first poisoned its sys.modules; subsequent tests in
the same worker that imported load_dotenv transitively (e.g.
test_env_loader.py via hermes_cli.env_loader) got the no-op lambda and
saw their assertions fail.
dotenv is a required dependency (python-dotenv>=1.2.1 in pyproject.toml),
so the defensive stub was never needed. Removed.
## Validation
- tests/hermes_cli/ alone: 2178 passed, 1 skipped, 0 failed (was 4
failures in test_env_loader.py before this fix)
- tests/test_plugin_skills.py, tests/hermes_cli/test_plugins.py,
tests/test_hermes_logging.py combined: 123 passed (the caplog
regression tests from PR #11453 still pass)
- Local full run shows no F/E clusters in the 0-55% range that were
previously present before the conftest hardening
## Background
See AGENTS.md 'Testing' section for the full list of drift sources
this closes. Matrix split (closed as #11566) will be re-attempted
once this foundation lands — cross-test pollution was the root cause
of the shard-3 hang in that PR.
* fix(conftest): don't redirect HOME — it broke CI subprocesses
PR #11577's autouse fixture was setting HOME to a per-test tempdir.
CI started timing out at 97% complete with dozens of E/F markers and
orphan python processes at cleanup — tests (or transitive deps)
spawn subprocesses that expect a stable HOME, and the redirect broke
them in non-obvious ways.
Env-var unsetting and TZ/LANG/hashseed pinning (the actual CI-drift
fixes) are unchanged and still in place. HERMES_HOME redirection is
also unchanged — that's the canonical way to isolate tests from
~/.hermes/, not HOME.
Any code in the codebase reading ~/.hermes/* via `Path.home() / ".hermes"`
instead of `get_hermes_home()` is a bug to fix at the callsite, not
something to paper over in conftest.
2026-04-17 06:09:09 -07:00
|
|
|
"HERMES_INFERENCE_PROVIDER",
|
2026-04-29 23:18:55 -07:00
|
|
|
"HERMES_TUI_PROVIDER",
|
test: make test env hermetic; enforce CI parity via scripts/run_tests.sh (#11577)
* test: make test env hermetic; enforce CI parity via scripts/run_tests.sh
Fixes the recurring 'works locally, fails in CI' (and vice versa) class
of flakes by making tests hermetic and providing a canonical local runner
that matches CI's environment.
## Layer 1 — hermetic conftest.py (tests/conftest.py)
Autouse fixture now unsets every credential-shaped env var before every
test, so developer-local API keys can't leak into tests that assert
'auto-detect provider when key present'.
Pattern: unset any var ending in _API_KEY, _TOKEN, _SECRET, _PASSWORD,
_CREDENTIALS, _ACCESS_KEY, _PRIVATE_KEY, etc. Plus an explicit list of
credential names that don't fit the suffix pattern (AWS_ACCESS_KEY_ID,
FAL_KEY, GH_TOKEN, etc.) and all the provider BASE_URL overrides that
change auto-detect behavior.
Also unsets HERMES_* behavioral vars (HERMES_YOLO_MODE, HERMES_QUIET,
HERMES_SESSION_*, etc.) that mutate agent behavior.
Also:
- Redirects HOME to a per-test tempdir (not just HERMES_HOME), so
code reading ~/.hermes/* directly can't touch the real dir.
- Pins TZ=UTC, LANG=C.UTF-8, LC_ALL=C.UTF-8, PYTHONHASHSEED=0 to
match CI's deterministic runtime.
The old _isolate_hermes_home fixture name is preserved as an alias so
any test that yields it explicitly still works.
## Layer 2 — scripts/run_tests.sh canonical runner
'Always use scripts/run_tests.sh, never call pytest directly' is the
new rule (documented in AGENTS.md). The script:
- Unsets all credential env vars (belt-and-suspenders for callers
who bypass conftest — e.g. IDE integrations)
- Pins TZ/LANG/PYTHONHASHSEED
- Uses -n 4 xdist workers (matches GHA ubuntu-latest; -n auto on
a 20-core workstation surfaces test-ordering flakes CI will never
see, causing the infamous 'passes in CI, fails locally' drift)
- Finds the venv in .venv, venv, or main checkout's venv
- Passes through arbitrary pytest args
Installs pytest-split on demand so the script can also be used to run
matrix-split subsets locally for debugging.
## Remove 3 module-level dotenv stubs that broke test isolation
tests/hermes_cli/test_{arcee,xiaomi,api_key}_provider.py each had a
module-level:
if 'dotenv' not in sys.modules:
fake_dotenv = types.ModuleType('dotenv')
fake_dotenv.load_dotenv = lambda *a, **kw: None
sys.modules['dotenv'] = fake_dotenv
This patches sys.modules['dotenv'] to a fake at import time with no
teardown. Under pytest-xdist LoadScheduling, whichever worker collected
one of these files first poisoned its sys.modules; subsequent tests in
the same worker that imported load_dotenv transitively (e.g.
test_env_loader.py via hermes_cli.env_loader) got the no-op lambda and
saw their assertions fail.
dotenv is a required dependency (python-dotenv>=1.2.1 in pyproject.toml),
so the defensive stub was never needed. Removed.
## Validation
- tests/hermes_cli/ alone: 2178 passed, 1 skipped, 0 failed (was 4
failures in test_env_loader.py before this fix)
- tests/test_plugin_skills.py, tests/hermes_cli/test_plugins.py,
tests/test_hermes_logging.py combined: 123 passed (the caplog
regression tests from PR #11453 still pass)
- Local full run shows no F/E clusters in the 0-55% range that were
previously present before the conftest hardening
## Background
See AGENTS.md 'Testing' section for the full list of drift sources
this closes. Matrix split (closed as #11566) will be re-attempted
once this foundation lands — cross-test pollution was the root cause
of the shard-3 hang in that PR.
* fix(conftest): don't redirect HOME — it broke CI subprocesses
PR #11577's autouse fixture was setting HOME to a per-test tempdir.
CI started timing out at 97% complete with dozens of E/F markers and
orphan python processes at cleanup — tests (or transitive deps)
spawn subprocesses that expect a stable HOME, and the redirect broke
them in non-obvious ways.
Env-var unsetting and TZ/LANG/hashseed pinning (the actual CI-drift
fixes) are unchanged and still in place. HERMES_HOME redirection is
also unchanged — that's the canonical way to isolate tests from
~/.hermes/, not HOME.
Any code in the codebase reading ~/.hermes/* via `Path.home() / ".hermes"`
instead of `get_hermes_home()` is a bug to fix at the callsite, not
something to paper over in conftest.
2026-04-17 06:09:09 -07:00
|
|
|
"HERMES_MANAGED",
|
2026-06-18 14:02:31 +10:00
|
|
|
"HERMES_MANAGED_DIR",
|
test: make test env hermetic; enforce CI parity via scripts/run_tests.sh (#11577)
* test: make test env hermetic; enforce CI parity via scripts/run_tests.sh
Fixes the recurring 'works locally, fails in CI' (and vice versa) class
of flakes by making tests hermetic and providing a canonical local runner
that matches CI's environment.
## Layer 1 — hermetic conftest.py (tests/conftest.py)
Autouse fixture now unsets every credential-shaped env var before every
test, so developer-local API keys can't leak into tests that assert
'auto-detect provider when key present'.
Pattern: unset any var ending in _API_KEY, _TOKEN, _SECRET, _PASSWORD,
_CREDENTIALS, _ACCESS_KEY, _PRIVATE_KEY, etc. Plus an explicit list of
credential names that don't fit the suffix pattern (AWS_ACCESS_KEY_ID,
FAL_KEY, GH_TOKEN, etc.) and all the provider BASE_URL overrides that
change auto-detect behavior.
Also unsets HERMES_* behavioral vars (HERMES_YOLO_MODE, HERMES_QUIET,
HERMES_SESSION_*, etc.) that mutate agent behavior.
Also:
- Redirects HOME to a per-test tempdir (not just HERMES_HOME), so
code reading ~/.hermes/* directly can't touch the real dir.
- Pins TZ=UTC, LANG=C.UTF-8, LC_ALL=C.UTF-8, PYTHONHASHSEED=0 to
match CI's deterministic runtime.
The old _isolate_hermes_home fixture name is preserved as an alias so
any test that yields it explicitly still works.
## Layer 2 — scripts/run_tests.sh canonical runner
'Always use scripts/run_tests.sh, never call pytest directly' is the
new rule (documented in AGENTS.md). The script:
- Unsets all credential env vars (belt-and-suspenders for callers
who bypass conftest — e.g. IDE integrations)
- Pins TZ/LANG/PYTHONHASHSEED
- Uses -n 4 xdist workers (matches GHA ubuntu-latest; -n auto on
a 20-core workstation surfaces test-ordering flakes CI will never
see, causing the infamous 'passes in CI, fails locally' drift)
- Finds the venv in .venv, venv, or main checkout's venv
- Passes through arbitrary pytest args
Installs pytest-split on demand so the script can also be used to run
matrix-split subsets locally for debugging.
## Remove 3 module-level dotenv stubs that broke test isolation
tests/hermes_cli/test_{arcee,xiaomi,api_key}_provider.py each had a
module-level:
if 'dotenv' not in sys.modules:
fake_dotenv = types.ModuleType('dotenv')
fake_dotenv.load_dotenv = lambda *a, **kw: None
sys.modules['dotenv'] = fake_dotenv
This patches sys.modules['dotenv'] to a fake at import time with no
teardown. Under pytest-xdist LoadScheduling, whichever worker collected
one of these files first poisoned its sys.modules; subsequent tests in
the same worker that imported load_dotenv transitively (e.g.
test_env_loader.py via hermes_cli.env_loader) got the no-op lambda and
saw their assertions fail.
dotenv is a required dependency (python-dotenv>=1.2.1 in pyproject.toml),
so the defensive stub was never needed. Removed.
## Validation
- tests/hermes_cli/ alone: 2178 passed, 1 skipped, 0 failed (was 4
failures in test_env_loader.py before this fix)
- tests/test_plugin_skills.py, tests/hermes_cli/test_plugins.py,
tests/test_hermes_logging.py combined: 123 passed (the caplog
regression tests from PR #11453 still pass)
- Local full run shows no F/E clusters in the 0-55% range that were
previously present before the conftest hardening
## Background
See AGENTS.md 'Testing' section for the full list of drift sources
this closes. Matrix split (closed as #11566) will be re-attempted
once this foundation lands — cross-test pollution was the root cause
of the shard-3 hang in that PR.
* fix(conftest): don't redirect HOME — it broke CI subprocesses
PR #11577's autouse fixture was setting HOME to a per-test tempdir.
CI started timing out at 97% complete with dozens of E/F markers and
orphan python processes at cleanup — tests (or transitive deps)
spawn subprocesses that expect a stable HOME, and the redirect broke
them in non-obvious ways.
Env-var unsetting and TZ/LANG/hashseed pinning (the actual CI-drift
fixes) are unchanged and still in place. HERMES_HOME redirection is
also unchanged — that's the canonical way to isolate tests from
~/.hermes/, not HOME.
Any code in the codebase reading ~/.hermes/* via `Path.home() / ".hermes"`
instead of `get_hermes_home()` is a bug to fix at the callsite, not
something to paper over in conftest.
2026-04-17 06:09:09 -07:00
|
|
|
"HERMES_DEV",
|
|
|
|
|
"HERMES_CONTAINER",
|
|
|
|
|
"HERMES_EPHEMERAL_SYSTEM_PROMPT",
|
|
|
|
|
"HERMES_TIMEZONE",
|
|
|
|
|
"HERMES_REDACT_SECRETS",
|
|
|
|
|
"HERMES_BACKGROUND_NOTIFICATIONS",
|
|
|
|
|
"HERMES_EXEC_ASK",
|
|
|
|
|
"HERMES_HOME_MODE",
|
2026-05-17 19:34:44 +10:00
|
|
|
"HERMES_AGENT_USE_LEGACY_SESSION_KEYS",
|
2026-05-09 12:41:31 +00:00
|
|
|
# Kanban path/board pins must never leak from a developer shell or
|
|
|
|
|
# dispatched worker into tests; otherwise tests can write fake tasks to
|
|
|
|
|
# the real ~/.hermes/kanban.db instead of the per-test HERMES_HOME.
|
|
|
|
|
"HERMES_KANBAN_DB",
|
|
|
|
|
"HERMES_KANBAN_BOARD",
|
2026-05-18 20:47:45 -07:00
|
|
|
"HERMES_KANBAN_HOME",
|
2026-05-09 12:41:31 +00:00
|
|
|
"HERMES_KANBAN_WORKSPACES_ROOT",
|
|
|
|
|
"HERMES_KANBAN_LOGS_ROOT",
|
|
|
|
|
"HERMES_KANBAN_TASK",
|
|
|
|
|
"HERMES_KANBAN_WORKSPACE",
|
2026-05-18 20:47:45 -07:00
|
|
|
"HERMES_KANBAN_RUN_ID",
|
|
|
|
|
"HERMES_KANBAN_CLAIM_LOCK",
|
|
|
|
|
"HERMES_KANBAN_DISPATCH_IN_GATEWAY",
|
2026-05-09 12:41:31 +00:00
|
|
|
"HERMES_TENANT",
|
2026-05-25 10:45:53 +10:00
|
|
|
# Dashboard OAuth auth gate (PR #30156). When set, the bundled
|
|
|
|
|
# dashboard-auth `nous` plugin auto-registers itself on plugin discovery,
|
|
|
|
|
# which is triggered by any `/api/status` call. That leaks a provider
|
|
|
|
|
# into the dashboard_auth registry across tests in the same worker and
|
|
|
|
|
# makes assertions like `auth_providers == []` flaky. CI never sets
|
|
|
|
|
# these, so production tests must not see them either.
|
|
|
|
|
"HERMES_DASHBOARD_OAUTH_CLIENT_ID",
|
|
|
|
|
"HERMES_DASHBOARD_PORTAL_URL",
|
2026-04-29 23:18:55 -07:00
|
|
|
"TERMINAL_CWD",
|
|
|
|
|
"TERMINAL_ENV",
|
|
|
|
|
"TERMINAL_CONTAINER_CPU",
|
|
|
|
|
"TERMINAL_CONTAINER_DISK",
|
|
|
|
|
"TERMINAL_CONTAINER_MEMORY",
|
|
|
|
|
"TERMINAL_CONTAINER_PERSISTENT",
|
fix(docker): reuse containers across processes + fix cleanup leaks
The Docker backend docs claim "Single persistent container — ONE long-
lived container shared across sessions, /new, /reset, and delegate_task
subagents. Stopped/removed on shutdown." In practice the code only
honored that contract within a single Python process via the in-memory
\`_active_environments[task_id]\` cache. Every \`hermes chat\` invocation
spawned a fresh \`hermes-<hex>\` container; older containers piled up in
\`Exited\` state and accumulated until manual \`docker rm\` (issue #20561).
Three root causes, all addressed by this commit:
1. No cross-process container discovery.
2. \`cleanup()\` used fire-and-forget \`subprocess.Popen("... &", shell=True)\`
which raced with parent-process exit — when Python exited promptly the
detached shell child got killed mid-\`docker stop\`, leaving stopped
containers behind.
3. The \`docker rm\` step in cleanup was gated on \`not self._persistent\`
(the bind-mount-persistence flag). Default config sets
\`container_persistent: true\`, so the default happy path skipped \`rm\`
entirely — even when the user explicitly didn't want cross-process
reuse, containers leaked.
Fix:
* Add \`DockerEnvironment.__init__(persist_across_processes=True)\`. When
true, init probes
\`docker ps -a --filter label=hermes-agent=1
--filter label=hermes-task-id=<task>
--filter label=hermes-profile=<profile>\`
and reuses a matching container (running → attach; stopped →
\`docker start\` → attach; \`docker start\` failure → fall through to a
fresh \`docker run\`). Multiple matches prefer the running one, with the
stragglers left for the orphan reaper (next commit) to clean up.
* Rewrite \`cleanup()\`. Uses \`subprocess.run(..., timeout=30)\` on a
daemon \`threading.Thread\`, not the racy \`Popen(... &)\`. The
\`_persistent\` guard is dropped on the \`rm\` step — \`rm\` now runs
whenever \`persist_across_processes\` is false, regardless of the
bind-mount-persistence setting. The leak class is gone in all
combinations.
* Add \`wait_for_cleanup(timeout)\`. \`tools/terminal_tool.py\`'s atexit
hook calls this on every active env, blocking up to 15s for the
cleanup thread before interpreter exit. Without this, \`hermes /quit\`
raced the daemon-thread teardown and dropped the stop/rm work.
* New config \`terminal.docker_persist_across_processes\` (default
\`true\` — restores the documented contract). Set \`false\` for hard
per-process isolation. Wired through all four config-bridge sites
(cli.py env_mappings, gateway/run.py _terminal_env_map,
hermes_cli/config.py _config_to_env_sync, tests/conftest.py env-strip
list); regression-pinned by
\`test_docker_persist_across_processes_is_bridged_everywhere\` matching
the existing pattern for docker_run_as_host_user / docker_env.
Reuse intentionally does NOT compare image / mounts / resources — only
the labels. Operators changing those settings should set
\`docker_persist_across_processes: false\` (or \`docker rm -f\` the
labeled container) to force a fresh start. This keeps the probe cheap
and the failure mode obvious.
Coverage: 12 new unit tests in tests/tools/test_docker_environment.py
covering reuse paths (running, stopped, fallback, opt-out, duplicate
preference) and cleanup behavior (persist-mode no-rm, opt-out always-rm,
no-Popen, wait_for_cleanup semantics, partial-init safety). Plus one
config-bridge regression pin.
Refs #20561
2026-05-28 14:00:26 +10:00
|
|
|
"TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
|
fix(docker): startup orphan reaper for crashed-process containers
The cleanup-fix in the previous commit handles the graceful-exit leak: a
Hermes process that runs ``atexit`` will now actually wait on the docker
stop/rm worker thread, so containers either survive (persist mode) or are
fully removed (opt-out mode) by the time the interpreter exits.
But ``atexit`` doesn't fire on SIGKILL, OOM-kill, or terminal-window
close. Containers from those exits stay parked with no surviving Python
process to reuse or remove them, so they accumulate until the operator
intervenes with ``docker rm -f``. The cleanup-fix doesn't help this class
— there's no live cleanup() to fix.
This commit adds the safety net: a startup orphan reaper that runs once
per Hermes process and removes long-Exited hermes-labeled containers
that the prior commit couldn't reach.
Implementation:
* New ``reap_orphan_containers()`` in ``tools/environments/docker.py``.
Filters: ``label=hermes-agent=1`` + ``status=exited`` + (optional)
``label=hermes-profile=<current>``. Per-container ``docker inspect``
parses ``State.FinishedAt`` (with nanosecond-precision trimming for
Python's microsecond-bound ``fromisoformat``); containers older than
the threshold get ``docker rm -f``'d. The ``status=exited`` filter is
load-bearing — a running container may belong to a sibling Hermes
process whose reuse path will pick it up; killing it would crash the
sibling mid-command. Single-container failures are logged and the
sweep continues to the next candidate.
* New ``_maybe_reap_docker_orphans()`` helper in
``tools/terminal_tool.py``. Wired into ``_create_environment()`` for
``env_type == "docker"``. Gated by:
- ``terminal.docker_orphan_reaper: true`` (default; opt-out for
operators running multiple Hermes processes in the same profile
who don't trust the conservative defaults)
- ``_docker_orphan_reaper_ran`` module flag with double-checked
locking — parallel subagents and RL rollouts don't trigger N
concurrent docker ps storms
- Age threshold = ``2 × TERMINAL_LIFETIME_SECONDS`` with a 60s floor
(so ``TERMINAL_LIFETIME_SECONDS=0`` doesn't race the user's own
setup)
- Profile scoping — a research profile NEVER reaps the default
profile's stragglers
- Exception swallow — a janitor failure must never block container
creation
* New config ``terminal.docker_orphan_reaper`` wired through all four
config-bridge sites (cli.py, gateway/run.py, hermes_cli/config.py,
tests/conftest.py) and pinned by
``test_docker_orphan_reaper_is_bridged_everywhere``.
Coverage:
* 9 new unit tests in test_docker_environment.py — happy path, recent-
container sparing, profile scoping, unparseable-timestamp safety,
docker-ps-failure handling, partial-failure continuation, nanosecond
timestamp parsing, zero-value FinishedAt rejection.
* 6 new integration tests in test_docker_orphan_reaper_integration.py
— once-per-process gate, disable-flag respected, lifetime doubling
with 60s floor, current-profile filter wiring, exception swallow.
* 1 new bridge-invariant regression test.
Closes #20561 (combined with the two prior commits on this branch).
2026-05-28 14:09:13 +10:00
|
|
|
"TERMINAL_DOCKER_ORPHAN_REAPER",
|
2026-04-29 23:18:55 -07:00
|
|
|
"TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
2026-04-17 15:03:31 -06:00
|
|
|
"BROWSER_CDP_URL",
|
|
|
|
|
"CAMOFOX_URL",
|
test(conftest): reset module-level state + unset platform allowlists (#13400)
Three fixes that close the remaining structural sources of CI flakes
after PR #13363.
## 1. Per-test reset of module-level singletons and ContextVars
Python modules are singletons per process, and pytest-xdist workers are
long-lived. Module-level dicts/sets and ContextVars persist across tests
on the same worker. A test that sets state in `tools.approval._session_approved`
and doesn't explicitly clear it leaks that state to every subsequent test
on the same worker.
New `_reset_module_state` autouse fixture in `tests/conftest.py` clears:
- tools.approval: _session_approved, _session_yolo, _permanent_approved,
_pending, _gateway_queues, _gateway_notify_cbs, _approval_session_key
- tools.interrupt: _interrupted_threads
- gateway.session_context: 10 session/cron ContextVars (reset to _UNSET)
- tools.env_passthrough: _allowed_env_vars_var (reset to empty set)
- tools.credential_files: _registered_files_var (reset to empty dict)
- tools.file_tools: _read_tracker, _file_ops_cache
This was the single biggest remaining class of CI flakes.
`test_command_guards::test_warn_session_approved` and
`test_combined_cli_session_approves_both` were failing 12/15 recent main
runs specifically because `_session_approved` carried approvals from a
prior test's session into these tests' `"default"` session lookup.
## 2. Unset platform allowlist env vars in hermetic fixture
`TELEGRAM_ALLOWED_USERS`, `DISCORD_ALLOWED_USERS`, and 20 other
`*_ALLOWED_USERS` / `*_ALLOW_ALL_USERS` vars are now unset per-test in
the same place credential env vars already are. These aren't credentials
but they change gateway auth behavior; if set from any source (user
shell, leaky test, CI env) they flake button-authorization tests.
Fixes three `test_telegram_approval_buttons` tests that were failing
across recent runs of the full gateway directory.
## 3. Two specific tests with module-level captured state
- `test_signal::TestSignalPhoneRedaction`: `agent.redact._REDACT_ENABLED`
is captured at module import from `HERMES_REDACT_SECRETS`, not read
per-call. `monkeypatch.delenv` at test time is too late. Added
`monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)` per
skill xdist-cross-test-pollution Pattern 5.
- `test_internal_event_bypass_pairing::test_non_internal_event_without_user_triggers_pairing`:
`gateway.pairing.PAIRING_DIR` is captured at module import from
HERMES_HOME, so per-test HERMES_HOME redirection in conftest doesn't
retroactively move it. Test now monkeypatches PAIRING_DIR directly to
its tmp_path, preventing rate-limit state from prior xdist workers
from letting the pairing send-call be suppressed.
## Validation
- tests/tools/: 3494 pass (0 fail) including test_command_guards
- tests/gateway/: 3504 pass (0 fail) across repeat runs
- tests/agent/ + tests/hermes_cli/ + tests/run_agent/ + tests/tools/:
8371 pass, 37 skipped, 0 fail — full suite across directories
No production code changed.
2026-04-21 01:33:10 -07:00
|
|
|
# Platform allowlists — not credentials, but if set from any source
|
|
|
|
|
# (user shell, earlier leaky test, CI env), they change gateway auth
|
|
|
|
|
# behavior and flake button-authorization tests.
|
|
|
|
|
"TELEGRAM_ALLOWED_USERS",
|
|
|
|
|
"DISCORD_ALLOWED_USERS",
|
|
|
|
|
"WHATSAPP_ALLOWED_USERS",
|
|
|
|
|
"SLACK_ALLOWED_USERS",
|
|
|
|
|
"SIGNAL_ALLOWED_USERS",
|
|
|
|
|
"SIGNAL_GROUP_ALLOWED_USERS",
|
|
|
|
|
"EMAIL_ALLOWED_USERS",
|
|
|
|
|
"SMS_ALLOWED_USERS",
|
|
|
|
|
"MATTERMOST_ALLOWED_USERS",
|
|
|
|
|
"MATRIX_ALLOWED_USERS",
|
|
|
|
|
"DINGTALK_ALLOWED_USERS",
|
|
|
|
|
"FEISHU_ALLOWED_USERS",
|
|
|
|
|
"WECOM_ALLOWED_USERS",
|
|
|
|
|
"GATEWAY_ALLOWED_USERS",
|
|
|
|
|
"GATEWAY_ALLOW_ALL_USERS",
|
|
|
|
|
"TELEGRAM_ALLOW_ALL_USERS",
|
|
|
|
|
"DISCORD_ALLOW_ALL_USERS",
|
|
|
|
|
"WHATSAPP_ALLOW_ALL_USERS",
|
|
|
|
|
"SLACK_ALLOW_ALL_USERS",
|
|
|
|
|
"SIGNAL_ALLOW_ALL_USERS",
|
|
|
|
|
"EMAIL_ALLOW_ALL_USERS",
|
|
|
|
|
"SMS_ALLOW_ALL_USERS",
|
2026-05-09 12:41:31 +00:00
|
|
|
# Gateway home channels are set by /sethome in real profiles. Tests that
|
|
|
|
|
# exercise dashboard notification toggles must opt in explicitly or they
|
|
|
|
|
# can accidentally subscribe against a developer's real home channel.
|
|
|
|
|
"TELEGRAM_HOME_CHANNEL",
|
|
|
|
|
"TELEGRAM_HOME_CHANNEL_THREAD_ID",
|
|
|
|
|
"TELEGRAM_HOME_CHANNEL_NAME",
|
2026-05-13 06:13:35 +02:00
|
|
|
"TELEGRAM_CRON_THREAD_ID",
|
2026-05-09 12:41:31 +00:00
|
|
|
"DISCORD_HOME_CHANNEL",
|
|
|
|
|
"DISCORD_HOME_CHANNEL_THREAD_ID",
|
|
|
|
|
"DISCORD_HOME_CHANNEL_NAME",
|
|
|
|
|
"SLACK_HOME_CHANNEL",
|
|
|
|
|
"SLACK_HOME_CHANNEL_THREAD_ID",
|
|
|
|
|
"SLACK_HOME_CHANNEL_NAME",
|
|
|
|
|
"WHATSAPP_HOME_CHANNEL",
|
|
|
|
|
"WHATSAPP_HOME_CHANNEL_THREAD_ID",
|
|
|
|
|
"WHATSAPP_HOME_CHANNEL_NAME",
|
|
|
|
|
"SIGNAL_HOME_CHANNEL",
|
|
|
|
|
"SIGNAL_HOME_CHANNEL_THREAD_ID",
|
|
|
|
|
"SIGNAL_HOME_CHANNEL_NAME",
|
|
|
|
|
"EMAIL_HOME_CHANNEL",
|
|
|
|
|
"EMAIL_HOME_CHANNEL_THREAD_ID",
|
|
|
|
|
"EMAIL_HOME_CHANNEL_NAME",
|
|
|
|
|
"SMS_HOME_CHANNEL",
|
|
|
|
|
"SMS_HOME_CHANNEL_THREAD_ID",
|
|
|
|
|
"SMS_HOME_CHANNEL_NAME",
|
|
|
|
|
"MATTERMOST_HOME_CHANNEL",
|
|
|
|
|
"MATTERMOST_HOME_CHANNEL_THREAD_ID",
|
|
|
|
|
"MATTERMOST_HOME_CHANNEL_NAME",
|
|
|
|
|
"MATRIX_HOME_CHANNEL",
|
|
|
|
|
"MATRIX_HOME_CHANNEL_THREAD_ID",
|
|
|
|
|
"MATRIX_HOME_CHANNEL_NAME",
|
|
|
|
|
"DINGTALK_HOME_CHANNEL",
|
|
|
|
|
"DINGTALK_HOME_CHANNEL_THREAD_ID",
|
|
|
|
|
"DINGTALK_HOME_CHANNEL_NAME",
|
|
|
|
|
"FEISHU_HOME_CHANNEL",
|
|
|
|
|
"FEISHU_HOME_CHANNEL_THREAD_ID",
|
|
|
|
|
"FEISHU_HOME_CHANNEL_NAME",
|
|
|
|
|
"WECOM_HOME_CHANNEL",
|
|
|
|
|
"WECOM_HOME_CHANNEL_THREAD_ID",
|
|
|
|
|
"WECOM_HOME_CHANNEL_NAME",
|
2026-05-16 18:37:12 +00:00
|
|
|
# API server bind/auth settings are common in local gateway profiles and
|
|
|
|
|
# change adapter defaults plus load_gateway_config() enablement. Tests that
|
|
|
|
|
# need them set opt in explicitly with monkeypatch.
|
|
|
|
|
"API_SERVER_ENABLED",
|
|
|
|
|
"API_SERVER_HOST",
|
|
|
|
|
"API_SERVER_PORT",
|
|
|
|
|
"API_SERVER_KEY",
|
|
|
|
|
"API_SERVER_CORS_ORIGINS",
|
|
|
|
|
"API_SERVER_MODEL_NAME",
|
2026-04-26 12:23:05 -07:00
|
|
|
# Platform gating — set by load_gateway_config() as a side effect when
|
|
|
|
|
# a config.yaml is present, so individual test bodies that call the
|
test: use subprocesses for each test file (#29016)
* ci(tests): install ripgrep from prebuilt tarball instead of apt
apt-get update + install of ripgrep takes ~4 min on the GHA Ubuntu
runners (the apt-get update against archive.ubuntu.com is the slow
part; ripgrep itself is small). Switching to the upstream musl
binary tarball cuts the step to a few seconds.
- Pinned to ripgrep 15.1.0 with sha256 verification (same hash as
published in the releases sha256 sidecar file).
- Drops the `rg` binary into /usr/local/bin so it is on PATH for
every subsequent step without GITHUB_PATH manipulation.
- Applied to both the test and e2e jobs in tests.yml.
* fix(cli): compile syntax check to tempdir, not source __pycache__
`_validate_critical_files_syntax` runs `py_compile.compile()` on each
critical bootstrap file after a successful `git pull`. The default
`py_compile` writes the resulting `.pyc` next to the source under
`__pycache__/`, which causes two real problems:
1. Parallel test workers walking the same source tree (e.g. running
the suite under per-file process isolation) can race against each
other on the `__pycache__` write — manifests as flaky 'directory
not empty' errors during teardown.
2. In production, the post-pull syntax check leaves a `.pyc` behind
that the next interpreter run might pick up — fine when the
interpreter version matches, sketchy if it doesn't.
Fix: write the compiled output to a `tempfile.TemporaryDirectory()`
that's discarded on function exit. We only care about the compile-or-not
signal, not the artifact.
* test(runner): per-file process isolation, drop manual state reset + xdist
Replace fragile manual _reset_module_state test fixtures with robust
per-file subprocess isolation. Each test file runs in a fresh
`python -m pytest <file>` subprocess via ThreadPoolExecutor. No xdist,
no custom pytest plugin, no shared worker state.
Key changes:
* scripts/run_tests_parallel.py — new runner: discovers test files,
runs N in parallel via ThreadPoolExecutor, captures stdout per file,
treats exit code 5 (no tests collected) as pass, kills all children
on exit. Change from cpu_count to cpu_count*2. The runner is
I/O-bound (waiting on subprocess.communicate() from pytest children)
The parent process does almost no CPU work, so 2x oversubscription
keeps more pipes full. When a file fails, immediately show the last
30 lines of pytest output (stack traces + FAILED summary) plus a
ready-to-copy repro command:
python -m pytest tests/agent/test_auxiliary_client.py
* scripts/run_tests.sh — delegates to run_tests_parallel.py
* .github/workflows/tests.yml — test step: python
scripts/run_tests_parallel.py
* pyproject.toml — drop pytest-xdist, pytest-split; simplify addopts
* tests/conftest.py — remove ~200 lines of manual state-reset fixtures
* AGENTS.md — update Testing section for per-file design
* test(runner): speed gateway test antipattern scan up
* fix(test): web search provider plugin test missing xai
* fix(tests): make 14 test files pass under per-file subprocess isolation
Tests that relied on cross-file state pollution from xdist workers
fail when run in isolation (per-file subprocess model). Root causes
and fixes:
Tool registry not populated:
- test_video_generation_tool_surface_matrix: add discover_builtin_tools()
- test_web_providers_brave_free/ddgs/searxng/general: autouse fixtures
registering all 8 bundled web providers, reset after each test
- test_website_policy: same provider registration pattern
- test_web_tools_tavily: same pattern across 3 dispatch test classes
- Also add is_safe_url/check_website_access mocks where SSRF check
blocks example.com (DNS resolution fails in isolated envs)
Stale check_fn cache:
- test_kanban_tools: invalidate_check_fn_cache() + _clear_tool_defs_cache()
in both kanban guidance tests (prior test cached False for kanban_show)
- test_discord_tool: cache invalidation in setup/teardown
- test_homeassistant_tool: invalidate_check_fn_cache() before registry queries
Module-level state pollution:
- test_auxiliary_client: autouse fixture clearing _aux_unhealthy_until cache
- test_skill_commands: set_session_vars() instead of patch.dict(os.environ)
(ContextVar takes precedence over os.environ)
- test_dm_topics: overwrite sys.modules + separate telegram.constants mock
+ force-reimport of gateway.platforms.telegram
- test_terminal_tool_requirements: removed duplicate class declaration,
autouse _clear_caches fixture
* change(tests): run_tests.sh explicitly includes env vars
instead of manually dropping some vars, now we just only include some
* fix(tests): 5 more isolation/NixOS fixes
- test_approval_plugin_hooks: isolate HERMES_HOME so real user's
command_allowlist doesn't short-circuit the approval path
- test_google_chat: skipif when Platform.GOOGLE_CHAT not in enum
(feature not merged on this branch)
- test_write_deny: test systemd prefix against tmp_path instead of
/etc/systemd which resolves to /nix/store on NixOS
- test_pty_bridge: use shutil.which('cat') instead of /bin/cat
(doesn't exist on NixOS)
- profiles.py: rmtree onexc handler chmod's parent dirs too, fixing
profile deletion when copytree preserved read-only modes from
nix store
* fix(tests): clear unhealthy cache in autouse fixture for auxiliary_client
* fix(tests): skip send_message when telegram not installed; handle missing worker_id in browser_supervisor
* fix: py3.11 rmtree onexc compat + belt-and-suspenders unhealthy cache clear for expired codex test
* fix: address PR #29016 review feedback
- Remove tracked .pytest-cache/ artifact and add to .gitignore
- Fix stale 'xdist worker' comment in conftest.py
- Deduplicate web provider registration into tests/tools/conftest.py
shared helper (register_all_web_providers), replacing 8 copy-pasted
blocks across 6 test files
- Update PR description: remove stale recovered-test-files claim,
fix worker count to match code (cpu_count*2)
* fix: eliminate race in stale-cache achievements test
The background scan thread could complete and overwrite _SNAPSHOT_CACHE
before evaluate_all() returned the stale data — only 10 fake sessions
made the scan finish instantly. Added scan_delay param to _FakeSessionDB
and set it to 2s in the stale-cache test so the background thread can't
win the race.
2026-05-21 07:10:04 -04:00
|
|
|
# loader leak these values into later tests in the same process.
|
2026-04-26 12:23:05 -07:00
|
|
|
# Force-clear on every test setup so the leak can't happen.
|
|
|
|
|
"SLACK_REQUIRE_MENTION",
|
|
|
|
|
"SLACK_STRICT_MENTION",
|
|
|
|
|
"SLACK_FREE_RESPONSE_CHANNELS",
|
|
|
|
|
"SLACK_ALLOW_BOTS",
|
|
|
|
|
"SLACK_REACTIONS",
|
|
|
|
|
"DISCORD_REQUIRE_MENTION",
|
|
|
|
|
"DISCORD_FREE_RESPONSE_CHANNELS",
|
|
|
|
|
"TELEGRAM_REQUIRE_MENTION",
|
|
|
|
|
"WHATSAPP_REQUIRE_MENTION",
|
|
|
|
|
"DINGTALK_REQUIRE_MENTION",
|
|
|
|
|
"MATRIX_REQUIRE_MENTION",
|
test: make test env hermetic; enforce CI parity via scripts/run_tests.sh (#11577)
* test: make test env hermetic; enforce CI parity via scripts/run_tests.sh
Fixes the recurring 'works locally, fails in CI' (and vice versa) class
of flakes by making tests hermetic and providing a canonical local runner
that matches CI's environment.
## Layer 1 — hermetic conftest.py (tests/conftest.py)
Autouse fixture now unsets every credential-shaped env var before every
test, so developer-local API keys can't leak into tests that assert
'auto-detect provider when key present'.
Pattern: unset any var ending in _API_KEY, _TOKEN, _SECRET, _PASSWORD,
_CREDENTIALS, _ACCESS_KEY, _PRIVATE_KEY, etc. Plus an explicit list of
credential names that don't fit the suffix pattern (AWS_ACCESS_KEY_ID,
FAL_KEY, GH_TOKEN, etc.) and all the provider BASE_URL overrides that
change auto-detect behavior.
Also unsets HERMES_* behavioral vars (HERMES_YOLO_MODE, HERMES_QUIET,
HERMES_SESSION_*, etc.) that mutate agent behavior.
Also:
- Redirects HOME to a per-test tempdir (not just HERMES_HOME), so
code reading ~/.hermes/* directly can't touch the real dir.
- Pins TZ=UTC, LANG=C.UTF-8, LC_ALL=C.UTF-8, PYTHONHASHSEED=0 to
match CI's deterministic runtime.
The old _isolate_hermes_home fixture name is preserved as an alias so
any test that yields it explicitly still works.
## Layer 2 — scripts/run_tests.sh canonical runner
'Always use scripts/run_tests.sh, never call pytest directly' is the
new rule (documented in AGENTS.md). The script:
- Unsets all credential env vars (belt-and-suspenders for callers
who bypass conftest — e.g. IDE integrations)
- Pins TZ/LANG/PYTHONHASHSEED
- Uses -n 4 xdist workers (matches GHA ubuntu-latest; -n auto on
a 20-core workstation surfaces test-ordering flakes CI will never
see, causing the infamous 'passes in CI, fails locally' drift)
- Finds the venv in .venv, venv, or main checkout's venv
- Passes through arbitrary pytest args
Installs pytest-split on demand so the script can also be used to run
matrix-split subsets locally for debugging.
## Remove 3 module-level dotenv stubs that broke test isolation
tests/hermes_cli/test_{arcee,xiaomi,api_key}_provider.py each had a
module-level:
if 'dotenv' not in sys.modules:
fake_dotenv = types.ModuleType('dotenv')
fake_dotenv.load_dotenv = lambda *a, **kw: None
sys.modules['dotenv'] = fake_dotenv
This patches sys.modules['dotenv'] to a fake at import time with no
teardown. Under pytest-xdist LoadScheduling, whichever worker collected
one of these files first poisoned its sys.modules; subsequent tests in
the same worker that imported load_dotenv transitively (e.g.
test_env_loader.py via hermes_cli.env_loader) got the no-op lambda and
saw their assertions fail.
dotenv is a required dependency (python-dotenv>=1.2.1 in pyproject.toml),
so the defensive stub was never needed. Removed.
## Validation
- tests/hermes_cli/ alone: 2178 passed, 1 skipped, 0 failed (was 4
failures in test_env_loader.py before this fix)
- tests/test_plugin_skills.py, tests/hermes_cli/test_plugins.py,
tests/test_hermes_logging.py combined: 123 passed (the caplog
regression tests from PR #11453 still pass)
- Local full run shows no F/E clusters in the 0-55% range that were
previously present before the conftest hardening
## Background
See AGENTS.md 'Testing' section for the full list of drift sources
this closes. Matrix split (closed as #11566) will be re-attempted
once this foundation lands — cross-test pollution was the root cause
of the shard-3 hang in that PR.
* fix(conftest): don't redirect HOME — it broke CI subprocesses
PR #11577's autouse fixture was setting HOME to a per-test tempdir.
CI started timing out at 97% complete with dozens of E/F markers and
orphan python processes at cleanup — tests (or transitive deps)
spawn subprocesses that expect a stable HOME, and the redirect broke
them in non-obvious ways.
Env-var unsetting and TZ/LANG/hashseed pinning (the actual CI-drift
fixes) are unchanged and still in place. HERMES_HOME redirection is
also unchanged — that's the canonical way to isolate tests from
~/.hermes/, not HOME.
Any code in the codebase reading ~/.hermes/* via `Path.home() / ".hermes"`
instead of `get_hermes_home()` is a bug to fix at the callsite, not
something to paper over in conftest.
2026-04-17 06:09:09 -07:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
2026-03-02 04:34:21 -08:00
|
|
|
@pytest.fixture(autouse=True)
|
test: make test env hermetic; enforce CI parity via scripts/run_tests.sh (#11577)
* test: make test env hermetic; enforce CI parity via scripts/run_tests.sh
Fixes the recurring 'works locally, fails in CI' (and vice versa) class
of flakes by making tests hermetic and providing a canonical local runner
that matches CI's environment.
## Layer 1 — hermetic conftest.py (tests/conftest.py)
Autouse fixture now unsets every credential-shaped env var before every
test, so developer-local API keys can't leak into tests that assert
'auto-detect provider when key present'.
Pattern: unset any var ending in _API_KEY, _TOKEN, _SECRET, _PASSWORD,
_CREDENTIALS, _ACCESS_KEY, _PRIVATE_KEY, etc. Plus an explicit list of
credential names that don't fit the suffix pattern (AWS_ACCESS_KEY_ID,
FAL_KEY, GH_TOKEN, etc.) and all the provider BASE_URL overrides that
change auto-detect behavior.
Also unsets HERMES_* behavioral vars (HERMES_YOLO_MODE, HERMES_QUIET,
HERMES_SESSION_*, etc.) that mutate agent behavior.
Also:
- Redirects HOME to a per-test tempdir (not just HERMES_HOME), so
code reading ~/.hermes/* directly can't touch the real dir.
- Pins TZ=UTC, LANG=C.UTF-8, LC_ALL=C.UTF-8, PYTHONHASHSEED=0 to
match CI's deterministic runtime.
The old _isolate_hermes_home fixture name is preserved as an alias so
any test that yields it explicitly still works.
## Layer 2 — scripts/run_tests.sh canonical runner
'Always use scripts/run_tests.sh, never call pytest directly' is the
new rule (documented in AGENTS.md). The script:
- Unsets all credential env vars (belt-and-suspenders for callers
who bypass conftest — e.g. IDE integrations)
- Pins TZ/LANG/PYTHONHASHSEED
- Uses -n 4 xdist workers (matches GHA ubuntu-latest; -n auto on
a 20-core workstation surfaces test-ordering flakes CI will never
see, causing the infamous 'passes in CI, fails locally' drift)
- Finds the venv in .venv, venv, or main checkout's venv
- Passes through arbitrary pytest args
Installs pytest-split on demand so the script can also be used to run
matrix-split subsets locally for debugging.
## Remove 3 module-level dotenv stubs that broke test isolation
tests/hermes_cli/test_{arcee,xiaomi,api_key}_provider.py each had a
module-level:
if 'dotenv' not in sys.modules:
fake_dotenv = types.ModuleType('dotenv')
fake_dotenv.load_dotenv = lambda *a, **kw: None
sys.modules['dotenv'] = fake_dotenv
This patches sys.modules['dotenv'] to a fake at import time with no
teardown. Under pytest-xdist LoadScheduling, whichever worker collected
one of these files first poisoned its sys.modules; subsequent tests in
the same worker that imported load_dotenv transitively (e.g.
test_env_loader.py via hermes_cli.env_loader) got the no-op lambda and
saw their assertions fail.
dotenv is a required dependency (python-dotenv>=1.2.1 in pyproject.toml),
so the defensive stub was never needed. Removed.
## Validation
- tests/hermes_cli/ alone: 2178 passed, 1 skipped, 0 failed (was 4
failures in test_env_loader.py before this fix)
- tests/test_plugin_skills.py, tests/hermes_cli/test_plugins.py,
tests/test_hermes_logging.py combined: 123 passed (the caplog
regression tests from PR #11453 still pass)
- Local full run shows no F/E clusters in the 0-55% range that were
previously present before the conftest hardening
## Background
See AGENTS.md 'Testing' section for the full list of drift sources
this closes. Matrix split (closed as #11566) will be re-attempted
once this foundation lands — cross-test pollution was the root cause
of the shard-3 hang in that PR.
* fix(conftest): don't redirect HOME — it broke CI subprocesses
PR #11577's autouse fixture was setting HOME to a per-test tempdir.
CI started timing out at 97% complete with dozens of E/F markers and
orphan python processes at cleanup — tests (or transitive deps)
spawn subprocesses that expect a stable HOME, and the redirect broke
them in non-obvious ways.
Env-var unsetting and TZ/LANG/hashseed pinning (the actual CI-drift
fixes) are unchanged and still in place. HERMES_HOME redirection is
also unchanged — that's the canonical way to isolate tests from
~/.hermes/, not HOME.
Any code in the codebase reading ~/.hermes/* via `Path.home() / ".hermes"`
instead of `get_hermes_home()` is a bug to fix at the callsite, not
something to paper over in conftest.
2026-04-17 06:09:09 -07:00
|
|
|
def _hermetic_environment(tmp_path, monkeypatch):
|
|
|
|
|
"""Blank out all credential/behavioral env vars so local and CI match.
|
|
|
|
|
|
|
|
|
|
Also redirects HOME and HERMES_HOME to per-test tempdirs so code that
|
|
|
|
|
reads ``~/.hermes/*`` can't touch the real one, and pins TZ/LANG so
|
|
|
|
|
datetime/locale-sensitive tests are deterministic.
|
|
|
|
|
"""
|
|
|
|
|
# 1. Blank every credential-shaped env var that's currently set.
|
|
|
|
|
for name in list(os.environ.keys()):
|
|
|
|
|
if _looks_like_credential(name):
|
|
|
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
|
|
|
|
|
|
# 2. Blank behavioral HERMES_* vars that could change test semantics.
|
|
|
|
|
for name in _HERMES_BEHAVIORAL_VARS:
|
|
|
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
|
|
|
|
|
|
# 3. Redirect HERMES_HOME to a per-test tempdir. Code that reads
|
|
|
|
|
# ``~/.hermes/*`` via ``get_hermes_home()`` now gets the tempdir.
|
|
|
|
|
#
|
|
|
|
|
# NOTE: We do NOT also redirect HOME. Doing so broke CI because
|
|
|
|
|
# some tests (and their transitive deps) spawn subprocesses that
|
|
|
|
|
# inherit HOME and expect it to be stable. If a test genuinely
|
|
|
|
|
# needs HOME isolated, it should set it explicitly in its own
|
|
|
|
|
# fixture. Any code in the codebase reading ``~/.hermes/*`` via
|
|
|
|
|
# ``Path.home() / ".hermes"`` instead of ``get_hermes_home()``
|
|
|
|
|
# is a bug to fix at the callsite.
|
|
|
|
|
fake_hermes_home = tmp_path / "hermes_test"
|
|
|
|
|
fake_hermes_home.mkdir()
|
|
|
|
|
(fake_hermes_home / "sessions").mkdir()
|
|
|
|
|
(fake_hermes_home / "cron").mkdir()
|
|
|
|
|
(fake_hermes_home / "memories").mkdir()
|
|
|
|
|
(fake_hermes_home / "skills").mkdir()
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(fake_hermes_home))
|
|
|
|
|
|
|
|
|
|
# 4. Deterministic locale / timezone / hashseed. CI runs in UTC with
|
|
|
|
|
# C.UTF-8 locale; local dev often doesn't. Pin everything.
|
|
|
|
|
monkeypatch.setenv("TZ", "UTC")
|
|
|
|
|
monkeypatch.setenv("LANG", "C.UTF-8")
|
|
|
|
|
monkeypatch.setenv("LC_ALL", "C.UTF-8")
|
|
|
|
|
monkeypatch.setenv("PYTHONHASHSEED", "0")
|
|
|
|
|
|
2026-04-17 14:21:22 -07:00
|
|
|
# 4b. Disable AWS IMDS lookups. Without this, any test that ends up
|
|
|
|
|
# calling has_aws_credentials() / resolve_aws_auth_env_var()
|
|
|
|
|
# (e.g. provider auto-detect, status command, cron run_job) burns
|
|
|
|
|
# ~2s waiting for the metadata service at 169.254.169.254 to time
|
|
|
|
|
# out. Tests don't run on EC2 — IMDS is always unreachable here.
|
|
|
|
|
monkeypatch.setenv("AWS_EC2_METADATA_DISABLED", "true")
|
|
|
|
|
monkeypatch.setenv("AWS_METADATA_SERVICE_TIMEOUT", "1")
|
|
|
|
|
monkeypatch.setenv("AWS_METADATA_SERVICE_NUM_ATTEMPTS", "1")
|
2026-05-22 17:05:23 -06:00
|
|
|
# Tirith auto-installs from GitHub when enabled and missing. Unit tests
|
|
|
|
|
# should never perform that implicit network/bootstrap path; Tirith-specific
|
|
|
|
|
# tests opt back in by patching the security config directly.
|
|
|
|
|
monkeypatch.setenv("TIRITH_ENABLED", "false")
|
2026-04-17 14:21:22 -07:00
|
|
|
|
test: make test env hermetic; enforce CI parity via scripts/run_tests.sh (#11577)
* test: make test env hermetic; enforce CI parity via scripts/run_tests.sh
Fixes the recurring 'works locally, fails in CI' (and vice versa) class
of flakes by making tests hermetic and providing a canonical local runner
that matches CI's environment.
## Layer 1 — hermetic conftest.py (tests/conftest.py)
Autouse fixture now unsets every credential-shaped env var before every
test, so developer-local API keys can't leak into tests that assert
'auto-detect provider when key present'.
Pattern: unset any var ending in _API_KEY, _TOKEN, _SECRET, _PASSWORD,
_CREDENTIALS, _ACCESS_KEY, _PRIVATE_KEY, etc. Plus an explicit list of
credential names that don't fit the suffix pattern (AWS_ACCESS_KEY_ID,
FAL_KEY, GH_TOKEN, etc.) and all the provider BASE_URL overrides that
change auto-detect behavior.
Also unsets HERMES_* behavioral vars (HERMES_YOLO_MODE, HERMES_QUIET,
HERMES_SESSION_*, etc.) that mutate agent behavior.
Also:
- Redirects HOME to a per-test tempdir (not just HERMES_HOME), so
code reading ~/.hermes/* directly can't touch the real dir.
- Pins TZ=UTC, LANG=C.UTF-8, LC_ALL=C.UTF-8, PYTHONHASHSEED=0 to
match CI's deterministic runtime.
The old _isolate_hermes_home fixture name is preserved as an alias so
any test that yields it explicitly still works.
## Layer 2 — scripts/run_tests.sh canonical runner
'Always use scripts/run_tests.sh, never call pytest directly' is the
new rule (documented in AGENTS.md). The script:
- Unsets all credential env vars (belt-and-suspenders for callers
who bypass conftest — e.g. IDE integrations)
- Pins TZ/LANG/PYTHONHASHSEED
- Uses -n 4 xdist workers (matches GHA ubuntu-latest; -n auto on
a 20-core workstation surfaces test-ordering flakes CI will never
see, causing the infamous 'passes in CI, fails locally' drift)
- Finds the venv in .venv, venv, or main checkout's venv
- Passes through arbitrary pytest args
Installs pytest-split on demand so the script can also be used to run
matrix-split subsets locally for debugging.
## Remove 3 module-level dotenv stubs that broke test isolation
tests/hermes_cli/test_{arcee,xiaomi,api_key}_provider.py each had a
module-level:
if 'dotenv' not in sys.modules:
fake_dotenv = types.ModuleType('dotenv')
fake_dotenv.load_dotenv = lambda *a, **kw: None
sys.modules['dotenv'] = fake_dotenv
This patches sys.modules['dotenv'] to a fake at import time with no
teardown. Under pytest-xdist LoadScheduling, whichever worker collected
one of these files first poisoned its sys.modules; subsequent tests in
the same worker that imported load_dotenv transitively (e.g.
test_env_loader.py via hermes_cli.env_loader) got the no-op lambda and
saw their assertions fail.
dotenv is a required dependency (python-dotenv>=1.2.1 in pyproject.toml),
so the defensive stub was never needed. Removed.
## Validation
- tests/hermes_cli/ alone: 2178 passed, 1 skipped, 0 failed (was 4
failures in test_env_loader.py before this fix)
- tests/test_plugin_skills.py, tests/hermes_cli/test_plugins.py,
tests/test_hermes_logging.py combined: 123 passed (the caplog
regression tests from PR #11453 still pass)
- Local full run shows no F/E clusters in the 0-55% range that were
previously present before the conftest hardening
## Background
See AGENTS.md 'Testing' section for the full list of drift sources
this closes. Matrix split (closed as #11566) will be re-attempted
once this foundation lands — cross-test pollution was the root cause
of the shard-3 hang in that PR.
* fix(conftest): don't redirect HOME — it broke CI subprocesses
PR #11577's autouse fixture was setting HOME to a per-test tempdir.
CI started timing out at 97% complete with dozens of E/F markers and
orphan python processes at cleanup — tests (or transitive deps)
spawn subprocesses that expect a stable HOME, and the redirect broke
them in non-obvious ways.
Env-var unsetting and TZ/LANG/hashseed pinning (the actual CI-drift
fixes) are unchanged and still in place. HERMES_HOME redirection is
also unchanged — that's the canonical way to isolate tests from
~/.hermes/, not HOME.
Any code in the codebase reading ~/.hermes/* via `Path.home() / ".hermes"`
instead of `get_hermes_home()` is a bug to fix at the callsite, not
something to paper over in conftest.
2026-04-17 06:09:09 -07:00
|
|
|
# 5. Reset plugin singleton so tests don't leak plugins from
|
|
|
|
|
# ~/.hermes/plugins/ (which, per step 3, is now empty — but the
|
|
|
|
|
# singleton might still be cached from a previous test).
|
feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
2026-03-16 07:17:36 -07:00
|
|
|
try:
|
|
|
|
|
import hermes_cli.plugins as _plugins_mod
|
|
|
|
|
monkeypatch.setattr(_plugins_mod, "_plugin_manager", None)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
feat(providers): add GMI Cloud as a first-class API-key provider (#11955)
Add GMI Cloud (api.gmi-serving.com) as a full first-class API-key provider
with built-in auth, aliases, model catalog, CLI entry points, auxiliary client
routing, context length resolution, doctor checks, env var tracking, and docs.
- auth.py: ProviderConfig for 'gmi' (api_key, GMI_API_KEY / GMI_BASE_URL)
- providers.py: HermesOverlay with extra_env_vars for models.dev detection
- models.py: curated slash-form model catalog; live /v1/models fetch
- main.py: 'gmi' in _named_custom_provider_map and --provider choices
- model_metadata.py: _URL_TO_PROVIDER, _PROVIDER_PREFIXES, dedicated
context-length probe block (GMI's /models has authoritative data)
- auxiliary_client.py: alias entries; _compat_model fix for slash-form
models on cached aggregator-style clients; gmi aux default model
- doctor.py: GMI in provider connectivity checks
- config.py: GMI_API_KEY / GMI_BASE_URL in OPTIONAL_ENV_VARS
- conftest.py: explicit GMI_BASE_URL clearing (not caught by _API_KEY suffix)
- docs: providers.md, environment-variables.md, fallback-providers.md,
configuration.md, quickstart.md (expands provider table)
Co-authored-by: Isaac Huang <isaachuang@Isaacs-MacBook-Pro.local>
2026-04-17 11:19:56 -07:00
|
|
|
# Explicitly clear provider-specific base URL overrides that don't match
|
|
|
|
|
# the generic credential-shaped env-var filter above.
|
|
|
|
|
monkeypatch.delenv("GMI_API_KEY", raising=False)
|
|
|
|
|
monkeypatch.delenv("GMI_BASE_URL", raising=False)
|
test: make test env hermetic; enforce CI parity via scripts/run_tests.sh (#11577)
* test: make test env hermetic; enforce CI parity via scripts/run_tests.sh
Fixes the recurring 'works locally, fails in CI' (and vice versa) class
of flakes by making tests hermetic and providing a canonical local runner
that matches CI's environment.
## Layer 1 — hermetic conftest.py (tests/conftest.py)
Autouse fixture now unsets every credential-shaped env var before every
test, so developer-local API keys can't leak into tests that assert
'auto-detect provider when key present'.
Pattern: unset any var ending in _API_KEY, _TOKEN, _SECRET, _PASSWORD,
_CREDENTIALS, _ACCESS_KEY, _PRIVATE_KEY, etc. Plus an explicit list of
credential names that don't fit the suffix pattern (AWS_ACCESS_KEY_ID,
FAL_KEY, GH_TOKEN, etc.) and all the provider BASE_URL overrides that
change auto-detect behavior.
Also unsets HERMES_* behavioral vars (HERMES_YOLO_MODE, HERMES_QUIET,
HERMES_SESSION_*, etc.) that mutate agent behavior.
Also:
- Redirects HOME to a per-test tempdir (not just HERMES_HOME), so
code reading ~/.hermes/* directly can't touch the real dir.
- Pins TZ=UTC, LANG=C.UTF-8, LC_ALL=C.UTF-8, PYTHONHASHSEED=0 to
match CI's deterministic runtime.
The old _isolate_hermes_home fixture name is preserved as an alias so
any test that yields it explicitly still works.
## Layer 2 — scripts/run_tests.sh canonical runner
'Always use scripts/run_tests.sh, never call pytest directly' is the
new rule (documented in AGENTS.md). The script:
- Unsets all credential env vars (belt-and-suspenders for callers
who bypass conftest — e.g. IDE integrations)
- Pins TZ/LANG/PYTHONHASHSEED
- Uses -n 4 xdist workers (matches GHA ubuntu-latest; -n auto on
a 20-core workstation surfaces test-ordering flakes CI will never
see, causing the infamous 'passes in CI, fails locally' drift)
- Finds the venv in .venv, venv, or main checkout's venv
- Passes through arbitrary pytest args
Installs pytest-split on demand so the script can also be used to run
matrix-split subsets locally for debugging.
## Remove 3 module-level dotenv stubs that broke test isolation
tests/hermes_cli/test_{arcee,xiaomi,api_key}_provider.py each had a
module-level:
if 'dotenv' not in sys.modules:
fake_dotenv = types.ModuleType('dotenv')
fake_dotenv.load_dotenv = lambda *a, **kw: None
sys.modules['dotenv'] = fake_dotenv
This patches sys.modules['dotenv'] to a fake at import time with no
teardown. Under pytest-xdist LoadScheduling, whichever worker collected
one of these files first poisoned its sys.modules; subsequent tests in
the same worker that imported load_dotenv transitively (e.g.
test_env_loader.py via hermes_cli.env_loader) got the no-op lambda and
saw their assertions fail.
dotenv is a required dependency (python-dotenv>=1.2.1 in pyproject.toml),
so the defensive stub was never needed. Removed.
## Validation
- tests/hermes_cli/ alone: 2178 passed, 1 skipped, 0 failed (was 4
failures in test_env_loader.py before this fix)
- tests/test_plugin_skills.py, tests/hermes_cli/test_plugins.py,
tests/test_hermes_logging.py combined: 123 passed (the caplog
regression tests from PR #11453 still pass)
- Local full run shows no F/E clusters in the 0-55% range that were
previously present before the conftest hardening
## Background
See AGENTS.md 'Testing' section for the full list of drift sources
this closes. Matrix split (closed as #11566) will be re-attempted
once this foundation lands — cross-test pollution was the root cause
of the shard-3 hang in that PR.
* fix(conftest): don't redirect HOME — it broke CI subprocesses
PR #11577's autouse fixture was setting HOME to a per-test tempdir.
CI started timing out at 97% complete with dozens of E/F markers and
orphan python processes at cleanup — tests (or transitive deps)
spawn subprocesses that expect a stable HOME, and the redirect broke
them in non-obvious ways.
Env-var unsetting and TZ/LANG/hashseed pinning (the actual CI-drift
fixes) are unchanged and still in place. HERMES_HOME redirection is
also unchanged — that's the canonical way to isolate tests from
~/.hermes/, not HOME.
Any code in the codebase reading ~/.hermes/* via `Path.home() / ".hermes"`
instead of `get_hermes_home()` is a bug to fix at the callsite, not
something to paper over in conftest.
2026-04-17 06:09:09 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# Backward-compat alias — old tests reference this fixture name. Keep it
|
|
|
|
|
# as a no-op wrapper so imports don't break.
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _isolate_hermes_home(_hermetic_environment):
|
|
|
|
|
"""Alias preserved for any test that yields this name explicitly."""
|
|
|
|
|
return None
|
2026-03-02 04:34:21 -08:00
|
|
|
|
|
|
|
|
|
test: use subprocesses for each test file (#29016)
* ci(tests): install ripgrep from prebuilt tarball instead of apt
apt-get update + install of ripgrep takes ~4 min on the GHA Ubuntu
runners (the apt-get update against archive.ubuntu.com is the slow
part; ripgrep itself is small). Switching to the upstream musl
binary tarball cuts the step to a few seconds.
- Pinned to ripgrep 15.1.0 with sha256 verification (same hash as
published in the releases sha256 sidecar file).
- Drops the `rg` binary into /usr/local/bin so it is on PATH for
every subsequent step without GITHUB_PATH manipulation.
- Applied to both the test and e2e jobs in tests.yml.
* fix(cli): compile syntax check to tempdir, not source __pycache__
`_validate_critical_files_syntax` runs `py_compile.compile()` on each
critical bootstrap file after a successful `git pull`. The default
`py_compile` writes the resulting `.pyc` next to the source under
`__pycache__/`, which causes two real problems:
1. Parallel test workers walking the same source tree (e.g. running
the suite under per-file process isolation) can race against each
other on the `__pycache__` write — manifests as flaky 'directory
not empty' errors during teardown.
2. In production, the post-pull syntax check leaves a `.pyc` behind
that the next interpreter run might pick up — fine when the
interpreter version matches, sketchy if it doesn't.
Fix: write the compiled output to a `tempfile.TemporaryDirectory()`
that's discarded on function exit. We only care about the compile-or-not
signal, not the artifact.
* test(runner): per-file process isolation, drop manual state reset + xdist
Replace fragile manual _reset_module_state test fixtures with robust
per-file subprocess isolation. Each test file runs in a fresh
`python -m pytest <file>` subprocess via ThreadPoolExecutor. No xdist,
no custom pytest plugin, no shared worker state.
Key changes:
* scripts/run_tests_parallel.py — new runner: discovers test files,
runs N in parallel via ThreadPoolExecutor, captures stdout per file,
treats exit code 5 (no tests collected) as pass, kills all children
on exit. Change from cpu_count to cpu_count*2. The runner is
I/O-bound (waiting on subprocess.communicate() from pytest children)
The parent process does almost no CPU work, so 2x oversubscription
keeps more pipes full. When a file fails, immediately show the last
30 lines of pytest output (stack traces + FAILED summary) plus a
ready-to-copy repro command:
python -m pytest tests/agent/test_auxiliary_client.py
* scripts/run_tests.sh — delegates to run_tests_parallel.py
* .github/workflows/tests.yml — test step: python
scripts/run_tests_parallel.py
* pyproject.toml — drop pytest-xdist, pytest-split; simplify addopts
* tests/conftest.py — remove ~200 lines of manual state-reset fixtures
* AGENTS.md — update Testing section for per-file design
* test(runner): speed gateway test antipattern scan up
* fix(test): web search provider plugin test missing xai
* fix(tests): make 14 test files pass under per-file subprocess isolation
Tests that relied on cross-file state pollution from xdist workers
fail when run in isolation (per-file subprocess model). Root causes
and fixes:
Tool registry not populated:
- test_video_generation_tool_surface_matrix: add discover_builtin_tools()
- test_web_providers_brave_free/ddgs/searxng/general: autouse fixtures
registering all 8 bundled web providers, reset after each test
- test_website_policy: same provider registration pattern
- test_web_tools_tavily: same pattern across 3 dispatch test classes
- Also add is_safe_url/check_website_access mocks where SSRF check
blocks example.com (DNS resolution fails in isolated envs)
Stale check_fn cache:
- test_kanban_tools: invalidate_check_fn_cache() + _clear_tool_defs_cache()
in both kanban guidance tests (prior test cached False for kanban_show)
- test_discord_tool: cache invalidation in setup/teardown
- test_homeassistant_tool: invalidate_check_fn_cache() before registry queries
Module-level state pollution:
- test_auxiliary_client: autouse fixture clearing _aux_unhealthy_until cache
- test_skill_commands: set_session_vars() instead of patch.dict(os.environ)
(ContextVar takes precedence over os.environ)
- test_dm_topics: overwrite sys.modules + separate telegram.constants mock
+ force-reimport of gateway.platforms.telegram
- test_terminal_tool_requirements: removed duplicate class declaration,
autouse _clear_caches fixture
* change(tests): run_tests.sh explicitly includes env vars
instead of manually dropping some vars, now we just only include some
* fix(tests): 5 more isolation/NixOS fixes
- test_approval_plugin_hooks: isolate HERMES_HOME so real user's
command_allowlist doesn't short-circuit the approval path
- test_google_chat: skipif when Platform.GOOGLE_CHAT not in enum
(feature not merged on this branch)
- test_write_deny: test systemd prefix against tmp_path instead of
/etc/systemd which resolves to /nix/store on NixOS
- test_pty_bridge: use shutil.which('cat') instead of /bin/cat
(doesn't exist on NixOS)
- profiles.py: rmtree onexc handler chmod's parent dirs too, fixing
profile deletion when copytree preserved read-only modes from
nix store
* fix(tests): clear unhealthy cache in autouse fixture for auxiliary_client
* fix(tests): skip send_message when telegram not installed; handle missing worker_id in browser_supervisor
* fix: py3.11 rmtree onexc compat + belt-and-suspenders unhealthy cache clear for expired codex test
* fix: address PR #29016 review feedback
- Remove tracked .pytest-cache/ artifact and add to .gitignore
- Fix stale 'xdist worker' comment in conftest.py
- Deduplicate web provider registration into tests/tools/conftest.py
shared helper (register_all_web_providers), replacing 8 copy-pasted
blocks across 6 test files
- Update PR description: remove stale recovered-test-files claim,
fix worker count to match code (cpu_count*2)
* fix: eliminate race in stale-cache achievements test
The background scan thread could complete and overwrite _SNAPSHOT_CACHE
before evaluate_all() returned the stale data — only 10 fake sessions
made the scan finish instantly. Added scan_delay param to _FakeSessionDB
and set it to 2s in the stale-cache test so the background thread can't
win the race.
2026-05-21 07:10:04 -04:00
|
|
|
# ── Module-level state reset — replaced by per-file process isolation ──────
|
test(conftest): reset module-level state + unset platform allowlists (#13400)
Three fixes that close the remaining structural sources of CI flakes
after PR #13363.
## 1. Per-test reset of module-level singletons and ContextVars
Python modules are singletons per process, and pytest-xdist workers are
long-lived. Module-level dicts/sets and ContextVars persist across tests
on the same worker. A test that sets state in `tools.approval._session_approved`
and doesn't explicitly clear it leaks that state to every subsequent test
on the same worker.
New `_reset_module_state` autouse fixture in `tests/conftest.py` clears:
- tools.approval: _session_approved, _session_yolo, _permanent_approved,
_pending, _gateway_queues, _gateway_notify_cbs, _approval_session_key
- tools.interrupt: _interrupted_threads
- gateway.session_context: 10 session/cron ContextVars (reset to _UNSET)
- tools.env_passthrough: _allowed_env_vars_var (reset to empty set)
- tools.credential_files: _registered_files_var (reset to empty dict)
- tools.file_tools: _read_tracker, _file_ops_cache
This was the single biggest remaining class of CI flakes.
`test_command_guards::test_warn_session_approved` and
`test_combined_cli_session_approves_both` were failing 12/15 recent main
runs specifically because `_session_approved` carried approvals from a
prior test's session into these tests' `"default"` session lookup.
## 2. Unset platform allowlist env vars in hermetic fixture
`TELEGRAM_ALLOWED_USERS`, `DISCORD_ALLOWED_USERS`, and 20 other
`*_ALLOWED_USERS` / `*_ALLOW_ALL_USERS` vars are now unset per-test in
the same place credential env vars already are. These aren't credentials
but they change gateway auth behavior; if set from any source (user
shell, leaky test, CI env) they flake button-authorization tests.
Fixes three `test_telegram_approval_buttons` tests that were failing
across recent runs of the full gateway directory.
## 3. Two specific tests with module-level captured state
- `test_signal::TestSignalPhoneRedaction`: `agent.redact._REDACT_ENABLED`
is captured at module import from `HERMES_REDACT_SECRETS`, not read
per-call. `monkeypatch.delenv` at test time is too late. Added
`monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)` per
skill xdist-cross-test-pollution Pattern 5.
- `test_internal_event_bypass_pairing::test_non_internal_event_without_user_triggers_pairing`:
`gateway.pairing.PAIRING_DIR` is captured at module import from
HERMES_HOME, so per-test HERMES_HOME redirection in conftest doesn't
retroactively move it. Test now monkeypatches PAIRING_DIR directly to
its tmp_path, preventing rate-limit state from prior xdist workers
from letting the pairing send-call be suppressed.
## Validation
- tests/tools/: 3494 pass (0 fail) including test_command_guards
- tests/gateway/: 3504 pass (0 fail) across repeat runs
- tests/agent/ + tests/hermes_cli/ + tests/run_agent/ + tests/tools/:
8371 pass, 37 skipped, 0 fail — full suite across directories
No production code changed.
2026-04-21 01:33:10 -07:00
|
|
|
#
|
test: use subprocesses for each test file (#29016)
* ci(tests): install ripgrep from prebuilt tarball instead of apt
apt-get update + install of ripgrep takes ~4 min on the GHA Ubuntu
runners (the apt-get update against archive.ubuntu.com is the slow
part; ripgrep itself is small). Switching to the upstream musl
binary tarball cuts the step to a few seconds.
- Pinned to ripgrep 15.1.0 with sha256 verification (same hash as
published in the releases sha256 sidecar file).
- Drops the `rg` binary into /usr/local/bin so it is on PATH for
every subsequent step without GITHUB_PATH manipulation.
- Applied to both the test and e2e jobs in tests.yml.
* fix(cli): compile syntax check to tempdir, not source __pycache__
`_validate_critical_files_syntax` runs `py_compile.compile()` on each
critical bootstrap file after a successful `git pull`. The default
`py_compile` writes the resulting `.pyc` next to the source under
`__pycache__/`, which causes two real problems:
1. Parallel test workers walking the same source tree (e.g. running
the suite under per-file process isolation) can race against each
other on the `__pycache__` write — manifests as flaky 'directory
not empty' errors during teardown.
2. In production, the post-pull syntax check leaves a `.pyc` behind
that the next interpreter run might pick up — fine when the
interpreter version matches, sketchy if it doesn't.
Fix: write the compiled output to a `tempfile.TemporaryDirectory()`
that's discarded on function exit. We only care about the compile-or-not
signal, not the artifact.
* test(runner): per-file process isolation, drop manual state reset + xdist
Replace fragile manual _reset_module_state test fixtures with robust
per-file subprocess isolation. Each test file runs in a fresh
`python -m pytest <file>` subprocess via ThreadPoolExecutor. No xdist,
no custom pytest plugin, no shared worker state.
Key changes:
* scripts/run_tests_parallel.py — new runner: discovers test files,
runs N in parallel via ThreadPoolExecutor, captures stdout per file,
treats exit code 5 (no tests collected) as pass, kills all children
on exit. Change from cpu_count to cpu_count*2. The runner is
I/O-bound (waiting on subprocess.communicate() from pytest children)
The parent process does almost no CPU work, so 2x oversubscription
keeps more pipes full. When a file fails, immediately show the last
30 lines of pytest output (stack traces + FAILED summary) plus a
ready-to-copy repro command:
python -m pytest tests/agent/test_auxiliary_client.py
* scripts/run_tests.sh — delegates to run_tests_parallel.py
* .github/workflows/tests.yml — test step: python
scripts/run_tests_parallel.py
* pyproject.toml — drop pytest-xdist, pytest-split; simplify addopts
* tests/conftest.py — remove ~200 lines of manual state-reset fixtures
* AGENTS.md — update Testing section for per-file design
* test(runner): speed gateway test antipattern scan up
* fix(test): web search provider plugin test missing xai
* fix(tests): make 14 test files pass under per-file subprocess isolation
Tests that relied on cross-file state pollution from xdist workers
fail when run in isolation (per-file subprocess model). Root causes
and fixes:
Tool registry not populated:
- test_video_generation_tool_surface_matrix: add discover_builtin_tools()
- test_web_providers_brave_free/ddgs/searxng/general: autouse fixtures
registering all 8 bundled web providers, reset after each test
- test_website_policy: same provider registration pattern
- test_web_tools_tavily: same pattern across 3 dispatch test classes
- Also add is_safe_url/check_website_access mocks where SSRF check
blocks example.com (DNS resolution fails in isolated envs)
Stale check_fn cache:
- test_kanban_tools: invalidate_check_fn_cache() + _clear_tool_defs_cache()
in both kanban guidance tests (prior test cached False for kanban_show)
- test_discord_tool: cache invalidation in setup/teardown
- test_homeassistant_tool: invalidate_check_fn_cache() before registry queries
Module-level state pollution:
- test_auxiliary_client: autouse fixture clearing _aux_unhealthy_until cache
- test_skill_commands: set_session_vars() instead of patch.dict(os.environ)
(ContextVar takes precedence over os.environ)
- test_dm_topics: overwrite sys.modules + separate telegram.constants mock
+ force-reimport of gateway.platforms.telegram
- test_terminal_tool_requirements: removed duplicate class declaration,
autouse _clear_caches fixture
* change(tests): run_tests.sh explicitly includes env vars
instead of manually dropping some vars, now we just only include some
* fix(tests): 5 more isolation/NixOS fixes
- test_approval_plugin_hooks: isolate HERMES_HOME so real user's
command_allowlist doesn't short-circuit the approval path
- test_google_chat: skipif when Platform.GOOGLE_CHAT not in enum
(feature not merged on this branch)
- test_write_deny: test systemd prefix against tmp_path instead of
/etc/systemd which resolves to /nix/store on NixOS
- test_pty_bridge: use shutil.which('cat') instead of /bin/cat
(doesn't exist on NixOS)
- profiles.py: rmtree onexc handler chmod's parent dirs too, fixing
profile deletion when copytree preserved read-only modes from
nix store
* fix(tests): clear unhealthy cache in autouse fixture for auxiliary_client
* fix(tests): skip send_message when telegram not installed; handle missing worker_id in browser_supervisor
* fix: py3.11 rmtree onexc compat + belt-and-suspenders unhealthy cache clear for expired codex test
* fix: address PR #29016 review feedback
- Remove tracked .pytest-cache/ artifact and add to .gitignore
- Fix stale 'xdist worker' comment in conftest.py
- Deduplicate web provider registration into tests/tools/conftest.py
shared helper (register_all_web_providers), replacing 8 copy-pasted
blocks across 6 test files
- Update PR description: remove stale recovered-test-files claim,
fix worker count to match code (cpu_count*2)
* fix: eliminate race in stale-cache achievements test
The background scan thread could complete and overwrite _SNAPSHOT_CACHE
before evaluate_all() returned the stale data — only 10 fake sessions
made the scan finish instantly. Added scan_delay param to _FakeSessionDB
and set it to 2s in the stale-cache test so the background thread can't
win the race.
2026-05-21 07:10:04 -04:00
|
|
|
# Each test FILE runs in a freshly-spawned ``python -m pytest <file>``
|
|
|
|
|
# subprocess via ``scripts/run_tests_parallel.py``, so module-level dicts /
|
|
|
|
|
# sets / ContextVars from tests in one file cannot leak into tests in
|
|
|
|
|
# another file. No manual per-module clearing needed.
|
test(conftest): reset module-level state + unset platform allowlists (#13400)
Three fixes that close the remaining structural sources of CI flakes
after PR #13363.
## 1. Per-test reset of module-level singletons and ContextVars
Python modules are singletons per process, and pytest-xdist workers are
long-lived. Module-level dicts/sets and ContextVars persist across tests
on the same worker. A test that sets state in `tools.approval._session_approved`
and doesn't explicitly clear it leaks that state to every subsequent test
on the same worker.
New `_reset_module_state` autouse fixture in `tests/conftest.py` clears:
- tools.approval: _session_approved, _session_yolo, _permanent_approved,
_pending, _gateway_queues, _gateway_notify_cbs, _approval_session_key
- tools.interrupt: _interrupted_threads
- gateway.session_context: 10 session/cron ContextVars (reset to _UNSET)
- tools.env_passthrough: _allowed_env_vars_var (reset to empty set)
- tools.credential_files: _registered_files_var (reset to empty dict)
- tools.file_tools: _read_tracker, _file_ops_cache
This was the single biggest remaining class of CI flakes.
`test_command_guards::test_warn_session_approved` and
`test_combined_cli_session_approves_both` were failing 12/15 recent main
runs specifically because `_session_approved` carried approvals from a
prior test's session into these tests' `"default"` session lookup.
## 2. Unset platform allowlist env vars in hermetic fixture
`TELEGRAM_ALLOWED_USERS`, `DISCORD_ALLOWED_USERS`, and 20 other
`*_ALLOWED_USERS` / `*_ALLOW_ALL_USERS` vars are now unset per-test in
the same place credential env vars already are. These aren't credentials
but they change gateway auth behavior; if set from any source (user
shell, leaky test, CI env) they flake button-authorization tests.
Fixes three `test_telegram_approval_buttons` tests that were failing
across recent runs of the full gateway directory.
## 3. Two specific tests with module-level captured state
- `test_signal::TestSignalPhoneRedaction`: `agent.redact._REDACT_ENABLED`
is captured at module import from `HERMES_REDACT_SECRETS`, not read
per-call. `monkeypatch.delenv` at test time is too late. Added
`monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)` per
skill xdist-cross-test-pollution Pattern 5.
- `test_internal_event_bypass_pairing::test_non_internal_event_without_user_triggers_pairing`:
`gateway.pairing.PAIRING_DIR` is captured at module import from
HERMES_HOME, so per-test HERMES_HOME redirection in conftest doesn't
retroactively move it. Test now monkeypatches PAIRING_DIR directly to
its tmp_path, preventing rate-limit state from prior xdist workers
from letting the pairing send-call be suppressed.
## Validation
- tests/tools/: 3494 pass (0 fail) including test_command_guards
- tests/gateway/: 3504 pass (0 fail) across repeat runs
- tests/agent/ + tests/hermes_cli/ + tests/run_agent/ + tests/tools/:
8371 pass, 37 skipped, 0 fail — full suite across directories
No production code changed.
2026-04-21 01:33:10 -07:00
|
|
|
#
|
test: use subprocesses for each test file (#29016)
* ci(tests): install ripgrep from prebuilt tarball instead of apt
apt-get update + install of ripgrep takes ~4 min on the GHA Ubuntu
runners (the apt-get update against archive.ubuntu.com is the slow
part; ripgrep itself is small). Switching to the upstream musl
binary tarball cuts the step to a few seconds.
- Pinned to ripgrep 15.1.0 with sha256 verification (same hash as
published in the releases sha256 sidecar file).
- Drops the `rg` binary into /usr/local/bin so it is on PATH for
every subsequent step without GITHUB_PATH manipulation.
- Applied to both the test and e2e jobs in tests.yml.
* fix(cli): compile syntax check to tempdir, not source __pycache__
`_validate_critical_files_syntax` runs `py_compile.compile()` on each
critical bootstrap file after a successful `git pull`. The default
`py_compile` writes the resulting `.pyc` next to the source under
`__pycache__/`, which causes two real problems:
1. Parallel test workers walking the same source tree (e.g. running
the suite under per-file process isolation) can race against each
other on the `__pycache__` write — manifests as flaky 'directory
not empty' errors during teardown.
2. In production, the post-pull syntax check leaves a `.pyc` behind
that the next interpreter run might pick up — fine when the
interpreter version matches, sketchy if it doesn't.
Fix: write the compiled output to a `tempfile.TemporaryDirectory()`
that's discarded on function exit. We only care about the compile-or-not
signal, not the artifact.
* test(runner): per-file process isolation, drop manual state reset + xdist
Replace fragile manual _reset_module_state test fixtures with robust
per-file subprocess isolation. Each test file runs in a fresh
`python -m pytest <file>` subprocess via ThreadPoolExecutor. No xdist,
no custom pytest plugin, no shared worker state.
Key changes:
* scripts/run_tests_parallel.py — new runner: discovers test files,
runs N in parallel via ThreadPoolExecutor, captures stdout per file,
treats exit code 5 (no tests collected) as pass, kills all children
on exit. Change from cpu_count to cpu_count*2. The runner is
I/O-bound (waiting on subprocess.communicate() from pytest children)
The parent process does almost no CPU work, so 2x oversubscription
keeps more pipes full. When a file fails, immediately show the last
30 lines of pytest output (stack traces + FAILED summary) plus a
ready-to-copy repro command:
python -m pytest tests/agent/test_auxiliary_client.py
* scripts/run_tests.sh — delegates to run_tests_parallel.py
* .github/workflows/tests.yml — test step: python
scripts/run_tests_parallel.py
* pyproject.toml — drop pytest-xdist, pytest-split; simplify addopts
* tests/conftest.py — remove ~200 lines of manual state-reset fixtures
* AGENTS.md — update Testing section for per-file design
* test(runner): speed gateway test antipattern scan up
* fix(test): web search provider plugin test missing xai
* fix(tests): make 14 test files pass under per-file subprocess isolation
Tests that relied on cross-file state pollution from xdist workers
fail when run in isolation (per-file subprocess model). Root causes
and fixes:
Tool registry not populated:
- test_video_generation_tool_surface_matrix: add discover_builtin_tools()
- test_web_providers_brave_free/ddgs/searxng/general: autouse fixtures
registering all 8 bundled web providers, reset after each test
- test_website_policy: same provider registration pattern
- test_web_tools_tavily: same pattern across 3 dispatch test classes
- Also add is_safe_url/check_website_access mocks where SSRF check
blocks example.com (DNS resolution fails in isolated envs)
Stale check_fn cache:
- test_kanban_tools: invalidate_check_fn_cache() + _clear_tool_defs_cache()
in both kanban guidance tests (prior test cached False for kanban_show)
- test_discord_tool: cache invalidation in setup/teardown
- test_homeassistant_tool: invalidate_check_fn_cache() before registry queries
Module-level state pollution:
- test_auxiliary_client: autouse fixture clearing _aux_unhealthy_until cache
- test_skill_commands: set_session_vars() instead of patch.dict(os.environ)
(ContextVar takes precedence over os.environ)
- test_dm_topics: overwrite sys.modules + separate telegram.constants mock
+ force-reimport of gateway.platforms.telegram
- test_terminal_tool_requirements: removed duplicate class declaration,
autouse _clear_caches fixture
* change(tests): run_tests.sh explicitly includes env vars
instead of manually dropping some vars, now we just only include some
* fix(tests): 5 more isolation/NixOS fixes
- test_approval_plugin_hooks: isolate HERMES_HOME so real user's
command_allowlist doesn't short-circuit the approval path
- test_google_chat: skipif when Platform.GOOGLE_CHAT not in enum
(feature not merged on this branch)
- test_write_deny: test systemd prefix against tmp_path instead of
/etc/systemd which resolves to /nix/store on NixOS
- test_pty_bridge: use shutil.which('cat') instead of /bin/cat
(doesn't exist on NixOS)
- profiles.py: rmtree onexc handler chmod's parent dirs too, fixing
profile deletion when copytree preserved read-only modes from
nix store
* fix(tests): clear unhealthy cache in autouse fixture for auxiliary_client
* fix(tests): skip send_message when telegram not installed; handle missing worker_id in browser_supervisor
* fix: py3.11 rmtree onexc compat + belt-and-suspenders unhealthy cache clear for expired codex test
* fix: address PR #29016 review feedback
- Remove tracked .pytest-cache/ artifact and add to .gitignore
- Fix stale 'xdist worker' comment in conftest.py
- Deduplicate web provider registration into tests/tools/conftest.py
shared helper (register_all_web_providers), replacing 8 copy-pasted
blocks across 6 test files
- Update PR description: remove stale recovered-test-files claim,
fix worker count to match code (cpu_count*2)
* fix: eliminate race in stale-cache achievements test
The background scan thread could complete and overwrite _SNAPSHOT_CACHE
before evaluate_all() returned the stale data — only 10 fake sessions
made the scan finish instantly. Added scan_delay param to _FakeSessionDB
and set it to 2s in the stale-cache test so the background thread can't
win the race.
2026-05-21 07:10:04 -04:00
|
|
|
# Within a single file, ordering is the author's responsibility. If your
|
|
|
|
|
# tests in the same file share mutable state, either reset it explicitly
|
|
|
|
|
# in a fixture or split them across files.
|
test(conftest): reset module-level state + unset platform allowlists (#13400)
Three fixes that close the remaining structural sources of CI flakes
after PR #13363.
## 1. Per-test reset of module-level singletons and ContextVars
Python modules are singletons per process, and pytest-xdist workers are
long-lived. Module-level dicts/sets and ContextVars persist across tests
on the same worker. A test that sets state in `tools.approval._session_approved`
and doesn't explicitly clear it leaks that state to every subsequent test
on the same worker.
New `_reset_module_state` autouse fixture in `tests/conftest.py` clears:
- tools.approval: _session_approved, _session_yolo, _permanent_approved,
_pending, _gateway_queues, _gateway_notify_cbs, _approval_session_key
- tools.interrupt: _interrupted_threads
- gateway.session_context: 10 session/cron ContextVars (reset to _UNSET)
- tools.env_passthrough: _allowed_env_vars_var (reset to empty set)
- tools.credential_files: _registered_files_var (reset to empty dict)
- tools.file_tools: _read_tracker, _file_ops_cache
This was the single biggest remaining class of CI flakes.
`test_command_guards::test_warn_session_approved` and
`test_combined_cli_session_approves_both` were failing 12/15 recent main
runs specifically because `_session_approved` carried approvals from a
prior test's session into these tests' `"default"` session lookup.
## 2. Unset platform allowlist env vars in hermetic fixture
`TELEGRAM_ALLOWED_USERS`, `DISCORD_ALLOWED_USERS`, and 20 other
`*_ALLOWED_USERS` / `*_ALLOW_ALL_USERS` vars are now unset per-test in
the same place credential env vars already are. These aren't credentials
but they change gateway auth behavior; if set from any source (user
shell, leaky test, CI env) they flake button-authorization tests.
Fixes three `test_telegram_approval_buttons` tests that were failing
across recent runs of the full gateway directory.
## 3. Two specific tests with module-level captured state
- `test_signal::TestSignalPhoneRedaction`: `agent.redact._REDACT_ENABLED`
is captured at module import from `HERMES_REDACT_SECRETS`, not read
per-call. `monkeypatch.delenv` at test time is too late. Added
`monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)` per
skill xdist-cross-test-pollution Pattern 5.
- `test_internal_event_bypass_pairing::test_non_internal_event_without_user_triggers_pairing`:
`gateway.pairing.PAIRING_DIR` is captured at module import from
HERMES_HOME, so per-test HERMES_HOME redirection in conftest doesn't
retroactively move it. Test now monkeypatches PAIRING_DIR directly to
its tmp_path, preventing rate-limit state from prior xdist workers
from letting the pairing send-call be suppressed.
## Validation
- tests/tools/: 3494 pass (0 fail) including test_command_guards
- tests/gateway/: 3504 pass (0 fail) across repeat runs
- tests/agent/ + tests/hermes_cli/ + tests/run_agent/ + tests/tools/:
8371 pass, 37 skipped, 0 fail — full suite across directories
No production code changed.
2026-04-21 01:33:10 -07:00
|
|
|
#
|
test: use subprocesses for each test file (#29016)
* ci(tests): install ripgrep from prebuilt tarball instead of apt
apt-get update + install of ripgrep takes ~4 min on the GHA Ubuntu
runners (the apt-get update against archive.ubuntu.com is the slow
part; ripgrep itself is small). Switching to the upstream musl
binary tarball cuts the step to a few seconds.
- Pinned to ripgrep 15.1.0 with sha256 verification (same hash as
published in the releases sha256 sidecar file).
- Drops the `rg` binary into /usr/local/bin so it is on PATH for
every subsequent step without GITHUB_PATH manipulation.
- Applied to both the test and e2e jobs in tests.yml.
* fix(cli): compile syntax check to tempdir, not source __pycache__
`_validate_critical_files_syntax` runs `py_compile.compile()` on each
critical bootstrap file after a successful `git pull`. The default
`py_compile` writes the resulting `.pyc` next to the source under
`__pycache__/`, which causes two real problems:
1. Parallel test workers walking the same source tree (e.g. running
the suite under per-file process isolation) can race against each
other on the `__pycache__` write — manifests as flaky 'directory
not empty' errors during teardown.
2. In production, the post-pull syntax check leaves a `.pyc` behind
that the next interpreter run might pick up — fine when the
interpreter version matches, sketchy if it doesn't.
Fix: write the compiled output to a `tempfile.TemporaryDirectory()`
that's discarded on function exit. We only care about the compile-or-not
signal, not the artifact.
* test(runner): per-file process isolation, drop manual state reset + xdist
Replace fragile manual _reset_module_state test fixtures with robust
per-file subprocess isolation. Each test file runs in a fresh
`python -m pytest <file>` subprocess via ThreadPoolExecutor. No xdist,
no custom pytest plugin, no shared worker state.
Key changes:
* scripts/run_tests_parallel.py — new runner: discovers test files,
runs N in parallel via ThreadPoolExecutor, captures stdout per file,
treats exit code 5 (no tests collected) as pass, kills all children
on exit. Change from cpu_count to cpu_count*2. The runner is
I/O-bound (waiting on subprocess.communicate() from pytest children)
The parent process does almost no CPU work, so 2x oversubscription
keeps more pipes full. When a file fails, immediately show the last
30 lines of pytest output (stack traces + FAILED summary) plus a
ready-to-copy repro command:
python -m pytest tests/agent/test_auxiliary_client.py
* scripts/run_tests.sh — delegates to run_tests_parallel.py
* .github/workflows/tests.yml — test step: python
scripts/run_tests_parallel.py
* pyproject.toml — drop pytest-xdist, pytest-split; simplify addopts
* tests/conftest.py — remove ~200 lines of manual state-reset fixtures
* AGENTS.md — update Testing section for per-file design
* test(runner): speed gateway test antipattern scan up
* fix(test): web search provider plugin test missing xai
* fix(tests): make 14 test files pass under per-file subprocess isolation
Tests that relied on cross-file state pollution from xdist workers
fail when run in isolation (per-file subprocess model). Root causes
and fixes:
Tool registry not populated:
- test_video_generation_tool_surface_matrix: add discover_builtin_tools()
- test_web_providers_brave_free/ddgs/searxng/general: autouse fixtures
registering all 8 bundled web providers, reset after each test
- test_website_policy: same provider registration pattern
- test_web_tools_tavily: same pattern across 3 dispatch test classes
- Also add is_safe_url/check_website_access mocks where SSRF check
blocks example.com (DNS resolution fails in isolated envs)
Stale check_fn cache:
- test_kanban_tools: invalidate_check_fn_cache() + _clear_tool_defs_cache()
in both kanban guidance tests (prior test cached False for kanban_show)
- test_discord_tool: cache invalidation in setup/teardown
- test_homeassistant_tool: invalidate_check_fn_cache() before registry queries
Module-level state pollution:
- test_auxiliary_client: autouse fixture clearing _aux_unhealthy_until cache
- test_skill_commands: set_session_vars() instead of patch.dict(os.environ)
(ContextVar takes precedence over os.environ)
- test_dm_topics: overwrite sys.modules + separate telegram.constants mock
+ force-reimport of gateway.platforms.telegram
- test_terminal_tool_requirements: removed duplicate class declaration,
autouse _clear_caches fixture
* change(tests): run_tests.sh explicitly includes env vars
instead of manually dropping some vars, now we just only include some
* fix(tests): 5 more isolation/NixOS fixes
- test_approval_plugin_hooks: isolate HERMES_HOME so real user's
command_allowlist doesn't short-circuit the approval path
- test_google_chat: skipif when Platform.GOOGLE_CHAT not in enum
(feature not merged on this branch)
- test_write_deny: test systemd prefix against tmp_path instead of
/etc/systemd which resolves to /nix/store on NixOS
- test_pty_bridge: use shutil.which('cat') instead of /bin/cat
(doesn't exist on NixOS)
- profiles.py: rmtree onexc handler chmod's parent dirs too, fixing
profile deletion when copytree preserved read-only modes from
nix store
* fix(tests): clear unhealthy cache in autouse fixture for auxiliary_client
* fix(tests): skip send_message when telegram not installed; handle missing worker_id in browser_supervisor
* fix: py3.11 rmtree onexc compat + belt-and-suspenders unhealthy cache clear for expired codex test
* fix: address PR #29016 review feedback
- Remove tracked .pytest-cache/ artifact and add to .gitignore
- Fix stale 'xdist worker' comment in conftest.py
- Deduplicate web provider registration into tests/tools/conftest.py
shared helper (register_all_web_providers), replacing 8 copy-pasted
blocks across 6 test files
- Update PR description: remove stale recovered-test-files claim,
fix worker count to match code (cpu_count*2)
* fix: eliminate race in stale-cache achievements test
The background scan thread could complete and overwrite _SNAPSHOT_CACHE
before evaluate_all() returned the stale data — only 10 fake sessions
made the scan finish instantly. Added scan_delay param to _FakeSessionDB
and set it to 2s in the stale-cache test so the background thread can't
win the race.
2026-05-21 07:10:04 -04:00
|
|
|
# The skill ``test-suite-cascade-diagnosis`` documents the cascade patterns
|
|
|
|
|
# this replaces; the running example was ``test_command_guards`` failing
|
|
|
|
|
# 12/15 CI runs because ``tools.approval._session_approved`` carried
|
|
|
|
|
# approvals from one test's session into another's.
|
test(conftest): reset module-level state + unset platform allowlists (#13400)
Three fixes that close the remaining structural sources of CI flakes
after PR #13363.
## 1. Per-test reset of module-level singletons and ContextVars
Python modules are singletons per process, and pytest-xdist workers are
long-lived. Module-level dicts/sets and ContextVars persist across tests
on the same worker. A test that sets state in `tools.approval._session_approved`
and doesn't explicitly clear it leaks that state to every subsequent test
on the same worker.
New `_reset_module_state` autouse fixture in `tests/conftest.py` clears:
- tools.approval: _session_approved, _session_yolo, _permanent_approved,
_pending, _gateway_queues, _gateway_notify_cbs, _approval_session_key
- tools.interrupt: _interrupted_threads
- gateway.session_context: 10 session/cron ContextVars (reset to _UNSET)
- tools.env_passthrough: _allowed_env_vars_var (reset to empty set)
- tools.credential_files: _registered_files_var (reset to empty dict)
- tools.file_tools: _read_tracker, _file_ops_cache
This was the single biggest remaining class of CI flakes.
`test_command_guards::test_warn_session_approved` and
`test_combined_cli_session_approves_both` were failing 12/15 recent main
runs specifically because `_session_approved` carried approvals from a
prior test's session into these tests' `"default"` session lookup.
## 2. Unset platform allowlist env vars in hermetic fixture
`TELEGRAM_ALLOWED_USERS`, `DISCORD_ALLOWED_USERS`, and 20 other
`*_ALLOWED_USERS` / `*_ALLOW_ALL_USERS` vars are now unset per-test in
the same place credential env vars already are. These aren't credentials
but they change gateway auth behavior; if set from any source (user
shell, leaky test, CI env) they flake button-authorization tests.
Fixes three `test_telegram_approval_buttons` tests that were failing
across recent runs of the full gateway directory.
## 3. Two specific tests with module-level captured state
- `test_signal::TestSignalPhoneRedaction`: `agent.redact._REDACT_ENABLED`
is captured at module import from `HERMES_REDACT_SECRETS`, not read
per-call. `monkeypatch.delenv` at test time is too late. Added
`monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)` per
skill xdist-cross-test-pollution Pattern 5.
- `test_internal_event_bypass_pairing::test_non_internal_event_without_user_triggers_pairing`:
`gateway.pairing.PAIRING_DIR` is captured at module import from
HERMES_HOME, so per-test HERMES_HOME redirection in conftest doesn't
retroactively move it. Test now monkeypatches PAIRING_DIR directly to
its tmp_path, preventing rate-limit state from prior xdist workers
from letting the pairing send-call be suppressed.
## Validation
- tests/tools/: 3494 pass (0 fail) including test_command_guards
- tests/gateway/: 3504 pass (0 fail) across repeat runs
- tests/agent/ + tests/hermes_cli/ + tests/run_agent/ + tests/tools/:
8371 pass, 37 skipped, 0 fail — full suite across directories
No production code changed.
2026-04-21 01:33:10 -07:00
|
|
|
|
|
|
|
|
|
test: reorganize test structure and add missing unit tests
Reorganize flat tests/ directory to mirror source code structure
(tools/, gateway/, hermes_cli/, integration/). Add 11 new test files
covering previously untested modules: registry, patch_parser,
fuzzy_match, todo_tool, approval, file_tools, gateway session/config/
delivery, and hermes_cli config/models. Total: 147 unit tests passing,
9 integration tests gated behind pytest marker.
2026-02-26 03:20:08 +03:00
|
|
|
@pytest.fixture()
|
|
|
|
|
def tmp_dir(tmp_path):
|
|
|
|
|
"""Provide a temporary directory that is cleaned up automatically."""
|
|
|
|
|
return tmp_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
def mock_config():
|
|
|
|
|
"""Return a minimal hermes config dict suitable for unit tests."""
|
|
|
|
|
return {
|
|
|
|
|
"model": "test/mock-model",
|
|
|
|
|
"toolsets": ["terminal", "file"],
|
|
|
|
|
"max_turns": 10,
|
|
|
|
|
"terminal": {
|
|
|
|
|
"backend": "local",
|
|
|
|
|
"cwd": "/tmp",
|
|
|
|
|
"timeout": 30,
|
|
|
|
|
},
|
|
|
|
|
"compression": {"enabled": False},
|
|
|
|
|
"memory": {"memory_enabled": False, "user_profile_enabled": False},
|
|
|
|
|
"command_allowlist": [],
|
|
|
|
|
}
|
2026-03-12 01:23:28 -07:00
|
|
|
|
|
|
|
|
|
test: use subprocesses for each test file (#29016)
* ci(tests): install ripgrep from prebuilt tarball instead of apt
apt-get update + install of ripgrep takes ~4 min on the GHA Ubuntu
runners (the apt-get update against archive.ubuntu.com is the slow
part; ripgrep itself is small). Switching to the upstream musl
binary tarball cuts the step to a few seconds.
- Pinned to ripgrep 15.1.0 with sha256 verification (same hash as
published in the releases sha256 sidecar file).
- Drops the `rg` binary into /usr/local/bin so it is on PATH for
every subsequent step without GITHUB_PATH manipulation.
- Applied to both the test and e2e jobs in tests.yml.
* fix(cli): compile syntax check to tempdir, not source __pycache__
`_validate_critical_files_syntax` runs `py_compile.compile()` on each
critical bootstrap file after a successful `git pull`. The default
`py_compile` writes the resulting `.pyc` next to the source under
`__pycache__/`, which causes two real problems:
1. Parallel test workers walking the same source tree (e.g. running
the suite under per-file process isolation) can race against each
other on the `__pycache__` write — manifests as flaky 'directory
not empty' errors during teardown.
2. In production, the post-pull syntax check leaves a `.pyc` behind
that the next interpreter run might pick up — fine when the
interpreter version matches, sketchy if it doesn't.
Fix: write the compiled output to a `tempfile.TemporaryDirectory()`
that's discarded on function exit. We only care about the compile-or-not
signal, not the artifact.
* test(runner): per-file process isolation, drop manual state reset + xdist
Replace fragile manual _reset_module_state test fixtures with robust
per-file subprocess isolation. Each test file runs in a fresh
`python -m pytest <file>` subprocess via ThreadPoolExecutor. No xdist,
no custom pytest plugin, no shared worker state.
Key changes:
* scripts/run_tests_parallel.py — new runner: discovers test files,
runs N in parallel via ThreadPoolExecutor, captures stdout per file,
treats exit code 5 (no tests collected) as pass, kills all children
on exit. Change from cpu_count to cpu_count*2. The runner is
I/O-bound (waiting on subprocess.communicate() from pytest children)
The parent process does almost no CPU work, so 2x oversubscription
keeps more pipes full. When a file fails, immediately show the last
30 lines of pytest output (stack traces + FAILED summary) plus a
ready-to-copy repro command:
python -m pytest tests/agent/test_auxiliary_client.py
* scripts/run_tests.sh — delegates to run_tests_parallel.py
* .github/workflows/tests.yml — test step: python
scripts/run_tests_parallel.py
* pyproject.toml — drop pytest-xdist, pytest-split; simplify addopts
* tests/conftest.py — remove ~200 lines of manual state-reset fixtures
* AGENTS.md — update Testing section for per-file design
* test(runner): speed gateway test antipattern scan up
* fix(test): web search provider plugin test missing xai
* fix(tests): make 14 test files pass under per-file subprocess isolation
Tests that relied on cross-file state pollution from xdist workers
fail when run in isolation (per-file subprocess model). Root causes
and fixes:
Tool registry not populated:
- test_video_generation_tool_surface_matrix: add discover_builtin_tools()
- test_web_providers_brave_free/ddgs/searxng/general: autouse fixtures
registering all 8 bundled web providers, reset after each test
- test_website_policy: same provider registration pattern
- test_web_tools_tavily: same pattern across 3 dispatch test classes
- Also add is_safe_url/check_website_access mocks where SSRF check
blocks example.com (DNS resolution fails in isolated envs)
Stale check_fn cache:
- test_kanban_tools: invalidate_check_fn_cache() + _clear_tool_defs_cache()
in both kanban guidance tests (prior test cached False for kanban_show)
- test_discord_tool: cache invalidation in setup/teardown
- test_homeassistant_tool: invalidate_check_fn_cache() before registry queries
Module-level state pollution:
- test_auxiliary_client: autouse fixture clearing _aux_unhealthy_until cache
- test_skill_commands: set_session_vars() instead of patch.dict(os.environ)
(ContextVar takes precedence over os.environ)
- test_dm_topics: overwrite sys.modules + separate telegram.constants mock
+ force-reimport of gateway.platforms.telegram
- test_terminal_tool_requirements: removed duplicate class declaration,
autouse _clear_caches fixture
* change(tests): run_tests.sh explicitly includes env vars
instead of manually dropping some vars, now we just only include some
* fix(tests): 5 more isolation/NixOS fixes
- test_approval_plugin_hooks: isolate HERMES_HOME so real user's
command_allowlist doesn't short-circuit the approval path
- test_google_chat: skipif when Platform.GOOGLE_CHAT not in enum
(feature not merged on this branch)
- test_write_deny: test systemd prefix against tmp_path instead of
/etc/systemd which resolves to /nix/store on NixOS
- test_pty_bridge: use shutil.which('cat') instead of /bin/cat
(doesn't exist on NixOS)
- profiles.py: rmtree onexc handler chmod's parent dirs too, fixing
profile deletion when copytree preserved read-only modes from
nix store
* fix(tests): clear unhealthy cache in autouse fixture for auxiliary_client
* fix(tests): skip send_message when telegram not installed; handle missing worker_id in browser_supervisor
* fix: py3.11 rmtree onexc compat + belt-and-suspenders unhealthy cache clear for expired codex test
* fix: address PR #29016 review feedback
- Remove tracked .pytest-cache/ artifact and add to .gitignore
- Fix stale 'xdist worker' comment in conftest.py
- Deduplicate web provider registration into tests/tools/conftest.py
shared helper (register_all_web_providers), replacing 8 copy-pasted
blocks across 6 test files
- Update PR description: remove stale recovered-test-files claim,
fix worker count to match code (cpu_count*2)
* fix: eliminate race in stale-cache achievements test
The background scan thread could complete and overwrite _SNAPSHOT_CACHE
before evaluate_all() returned the stale data — only 10 fake sessions
made the scan finish instantly. Added scan_delay param to _FakeSessionDB
and set it to 2s in the stale-cache test so the background thread can't
win the race.
2026-05-21 07:10:04 -04:00
|
|
|
# ── Per-test timeout — handled by the isolation plugin ─────────────────────
|
|
|
|
|
#
|
|
|
|
|
# The subprocess-per-test plugin enforces the configured ``isolate_timeout``
|
|
|
|
|
# ini key by terminating the child if it overruns. The old SIGALRM-based
|
|
|
|
|
# fixture (POSIX-only, didn't work on Windows) is gone.
|
2026-03-12 01:23:28 -07:00
|
|
|
|
|
|
|
|
|
2026-03-14 03:14:34 -07:00
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _ensure_current_event_loop(request):
|
|
|
|
|
"""Provide a default event loop for sync tests that call get_event_loop().
|
|
|
|
|
|
|
|
|
|
Python 3.11+ no longer guarantees a current loop for plain synchronous tests.
|
|
|
|
|
A number of gateway tests still use asyncio.get_event_loop().run_until_complete(...).
|
|
|
|
|
Ensure they always have a usable loop without interfering with pytest-asyncio's
|
|
|
|
|
own loop management for @pytest.mark.asyncio tests.
|
2026-05-07 20:55:59 +07:00
|
|
|
|
|
|
|
|
On Python 3.12+, ``asyncio.get_event_loop_policy().get_event_loop()`` with no
|
|
|
|
|
*running* loop emits DeprecationWarning; skip that path and install a fresh
|
|
|
|
|
loop via ``new_event_loop()`` instead.
|
2026-03-14 03:14:34 -07:00
|
|
|
"""
|
|
|
|
|
if request.node.get_closest_marker("asyncio") is not None:
|
|
|
|
|
yield
|
|
|
|
|
return
|
|
|
|
|
|
2026-05-07 20:55:59 +07:00
|
|
|
loop = None
|
2026-03-14 03:14:34 -07:00
|
|
|
try:
|
2026-05-07 20:55:59 +07:00
|
|
|
loop = asyncio.get_running_loop()
|
2026-03-14 03:14:34 -07:00
|
|
|
except RuntimeError:
|
2026-05-07 20:55:59 +07:00
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
if loop is None and sys.version_info < (3, 12):
|
|
|
|
|
try:
|
|
|
|
|
loop = asyncio.get_event_loop_policy().get_event_loop()
|
|
|
|
|
except RuntimeError:
|
|
|
|
|
loop = None
|
2026-03-14 03:14:34 -07:00
|
|
|
|
|
|
|
|
created = loop is None or loop.is_closed()
|
|
|
|
|
if created:
|
|
|
|
|
loop = asyncio.new_event_loop()
|
|
|
|
|
asyncio.set_event_loop(loop)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
yield
|
|
|
|
|
finally:
|
|
|
|
|
if created and loop is not None:
|
|
|
|
|
try:
|
|
|
|
|
loop.close()
|
|
|
|
|
finally:
|
|
|
|
|
asyncio.set_event_loop(None)
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 13:20:27 -07:00
|
|
|
# ── Live-system guard ──────────────────────────────────────────────────────
|
|
|
|
|
#
|
|
|
|
|
# Several test files exercise the gateway-restart / kill code paths
|
|
|
|
|
# (``cmd_update``, ``kill_gateway_processes``, ``stop_profile_gateway``).
|
|
|
|
|
# When a single test forgets to mock either ``os.kill`` or the global
|
|
|
|
|
# ``find_gateway_pids`` helper, the real call leaks out of the hermetic
|
|
|
|
|
# environment and finds the developer's live ``hermes-gateway`` process
|
|
|
|
|
# via ``psutil`` — sending it SIGTERM mid-test. The shutdown forensics in
|
|
|
|
|
# PR #23285 caught this happening 5+ times in 3 days, every time
|
|
|
|
|
# correlated with a ``tests/hermes_cli/`` pytest run starting up.
|
|
|
|
|
#
|
|
|
|
|
# This fixture makes the leak impossible by intercepting the two
|
|
|
|
|
# primitives that actually do damage:
|
|
|
|
|
#
|
|
|
|
|
# • ``os.kill`` rejects any PID outside the test process subtree with
|
|
|
|
|
# a hard ``RuntimeError`` so the offending test gets a stack trace
|
|
|
|
|
# instead of silently murdering the real gateway.
|
|
|
|
|
# • ``subprocess.run`` / ``subprocess.Popen`` / ``call`` / ``check_call`` /
|
|
|
|
|
# ``check_output`` reject any ``systemctl ... <verb> hermes-gateway``
|
|
|
|
|
# invocation that would mutate the live unit. Read-only systemctl
|
|
|
|
|
# calls (``status``, ``show``, ``list-units``) still pass through.
|
|
|
|
|
#
|
|
|
|
|
# We intentionally do NOT stub ``find_gateway_pids`` / ``_scan_gateway_pids``
|
|
|
|
|
# here — tests of those functions themselves need the real implementation.
|
|
|
|
|
# Even if a test gets the live gateway PID back from a real scan, the
|
|
|
|
|
# ``os.kill`` guard above catches the actual signal call, and the
|
|
|
|
|
# ``systemctl`` guard catches the systemd path. Discovery without
|
|
|
|
|
# delivery is harmless.
|
|
|
|
|
|
|
|
|
|
_LIVE_SYSTEM_GUARD_BYPASS_MARK = "live_system_guard_bypass"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pytest_configure(config): # noqa: D401 — pytest hook
|
|
|
|
|
"""Register markers used by hermetic conftest."""
|
|
|
|
|
config.addinivalue_line(
|
|
|
|
|
"markers",
|
|
|
|
|
f"{_LIVE_SYSTEM_GUARD_BYPASS_MARK}: bypass the live-system guard "
|
|
|
|
|
"(only for tests that genuinely need real os.kill / subprocess "
|
|
|
|
|
"behaviour — e.g. PTY tests that signal their own child).",
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-07 21:44:46 -07:00
|
|
|
# The pyproject addopts pin ``--timeout-method=signal`` relies on
|
|
|
|
|
# ``signal.SIGALRM``, which does not exist on Windows — pytest-timeout
|
|
|
|
|
# raises AttributeError at timer setup and the whole run aborts before any
|
|
|
|
|
# test executes. Fall back to the thread-based timer on Windows so the
|
|
|
|
|
# suite runs natively there (POSIX keeps the more reliable signal method).
|
|
|
|
|
if sys.platform == "win32" and getattr(config.option, "timeout_method", None) == "signal":
|
|
|
|
|
config.option.timeout_method = "thread"
|
|
|
|
|
|
2026-05-10 13:20:27 -07:00
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _live_system_guard(request, monkeypatch):
|
|
|
|
|
"""Block real os.kill / systemctl / gateway-pid scans during tests.
|
|
|
|
|
|
|
|
|
|
See block comment above for the why. Tests that genuinely need
|
|
|
|
|
real signal delivery (e.g. PTY tests that SIGINT their own child)
|
|
|
|
|
can opt out with ``@pytest.mark.live_system_guard_bypass``.
|
2026-05-10 18:55:28 -07:00
|
|
|
|
|
|
|
|
Coverage (every primitive that can deliver a signal to or otherwise
|
|
|
|
|
terminate a foreign process):
|
|
|
|
|
• os.kill, os.killpg (POSIX)
|
|
|
|
|
• subprocess.run / Popen / call / check_call / check_output
|
|
|
|
|
• subprocess.getoutput / getstatusoutput
|
|
|
|
|
• os.system / os.popen
|
|
|
|
|
• pty.spawn
|
|
|
|
|
• asyncio.create_subprocess_exec / create_subprocess_shell
|
|
|
|
|
Subprocess inspection looks at the WHOLE command string (not just
|
|
|
|
|
tokens[0]), so ``bash -c "systemctl restart hermes-gateway"``,
|
|
|
|
|
``sudo systemctl ...``, ``env systemctl ...``, ``setsid systemctl ...``
|
|
|
|
|
are all caught. ``pkill``/``killall``/``taskkill`` invocations
|
|
|
|
|
targeting hermes/python patterns are also blocked.
|
2026-05-10 13:20:27 -07:00
|
|
|
"""
|
|
|
|
|
if request.node.get_closest_marker(_LIVE_SYSTEM_GUARD_BYPASS_MARK):
|
|
|
|
|
yield
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
import os as _os
|
2026-05-10 18:55:28 -07:00
|
|
|
import shlex as _shlex
|
2026-05-10 13:20:27 -07:00
|
|
|
import subprocess as _subprocess
|
|
|
|
|
|
|
|
|
|
test_pid = _os.getpid()
|
|
|
|
|
# Capture the test process's existing children at fixture start —
|
|
|
|
|
# any *new* children spawned by the test are also allowlisted via
|
|
|
|
|
# the live psutil walk below. Static set keeps the fast path cheap.
|
|
|
|
|
try:
|
|
|
|
|
import psutil as _psutil
|
|
|
|
|
_initial_children = {
|
|
|
|
|
c.pid for c in _psutil.Process(test_pid).children(recursive=True)
|
|
|
|
|
}
|
|
|
|
|
except Exception:
|
|
|
|
|
_psutil = None
|
|
|
|
|
_initial_children = set()
|
|
|
|
|
|
|
|
|
|
def _is_own_subtree(pid: int) -> bool:
|
2026-05-10 18:55:28 -07:00
|
|
|
# PID 0 means "our own process group"; -1 means "every process we
|
|
|
|
|
# can signal". Both are dangerous when paired with SIGTERM/SIGKILL,
|
|
|
|
|
# but pid 0 is technically scoped to our group so allow it; pid -1
|
|
|
|
|
# is treated as foreign (refuse).
|
|
|
|
|
if pid == 0:
|
|
|
|
|
return True
|
|
|
|
|
if pid < 0:
|
|
|
|
|
return False
|
2026-05-10 13:20:27 -07:00
|
|
|
if pid == test_pid or pid in _initial_children:
|
|
|
|
|
return True
|
|
|
|
|
if _psutil is None:
|
|
|
|
|
return False
|
|
|
|
|
try:
|
|
|
|
|
walker = _psutil.Process(pid)
|
|
|
|
|
except Exception:
|
|
|
|
|
# Stale PID — kill would be a no-op anyway, allow it.
|
|
|
|
|
return True
|
|
|
|
|
try:
|
|
|
|
|
for parent in walker.parents():
|
|
|
|
|
if parent.pid == test_pid:
|
|
|
|
|
return True
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
real_kill = _os.kill
|
|
|
|
|
|
|
|
|
|
def _guarded_kill(pid, sig, *args, **kwargs):
|
|
|
|
|
if _is_own_subtree(int(pid)):
|
|
|
|
|
return real_kill(pid, sig, *args, **kwargs)
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"tests/conftest.py live-system guard: blocked os.kill("
|
|
|
|
|
f"{pid}, {sig}) — PID is outside the test process subtree. "
|
|
|
|
|
"If this fired in CI it means the test reached a real "
|
|
|
|
|
"kill_gateway_processes / stop_profile_gateway / cmd_update "
|
|
|
|
|
"code path without mocking find_gateway_pids and os.kill. "
|
|
|
|
|
"Mock both, or mark the test with "
|
|
|
|
|
"@pytest.mark.live_system_guard_bypass if real signal "
|
|
|
|
|
"delivery is genuinely required."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(_os, "kill", _guarded_kill)
|
|
|
|
|
|
|
|
|
|
# ``os.killpg`` is the same risk class — sends a signal to every
|
|
|
|
|
# process in a group. The gateway is a session leader (its own
|
|
|
|
|
# PGID == its PID), so killpg(gateway_pid, SIGTERM) is a one-shot
|
|
|
|
|
# kill of the live process. Allow it only when the target PGID is
|
|
|
|
|
# the test process's own group.
|
|
|
|
|
if hasattr(_os, "killpg"):
|
|
|
|
|
real_killpg = _os.killpg
|
|
|
|
|
own_pgid = _os.getpgrp()
|
|
|
|
|
|
|
|
|
|
def _guarded_killpg(pgid, sig, *args, **kwargs):
|
|
|
|
|
if int(pgid) == own_pgid or _is_own_subtree(int(pgid)):
|
|
|
|
|
return real_killpg(pgid, sig, *args, **kwargs)
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"tests/conftest.py live-system guard: blocked "
|
|
|
|
|
f"os.killpg({pgid}, {sig}) — PGID is outside the test "
|
|
|
|
|
"process group. See _live_system_guard for the why."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(_os, "killpg", _guarded_killpg)
|
|
|
|
|
|
2026-05-10 18:55:28 -07:00
|
|
|
# ── Subprocess command-string inspection (whole-line) ──────────
|
|
|
|
|
_HERMES_TOKENS = (
|
|
|
|
|
"hermes-gateway",
|
|
|
|
|
"hermes.service",
|
|
|
|
|
"hermes_cli.main gateway",
|
|
|
|
|
"hermes_cli/main.py gateway",
|
|
|
|
|
"gateway/run.py",
|
|
|
|
|
"hermes gateway",
|
|
|
|
|
)
|
|
|
|
|
_MUTATING_VERBS = (
|
|
|
|
|
"restart", "start", "stop", "kill", "reload",
|
|
|
|
|
"reset-failed", "enable", "disable", "mask", "unmask",
|
|
|
|
|
"daemon-reload", "try-restart", "reload-or-restart",
|
|
|
|
|
)
|
|
|
|
|
_PROCESS_KILLERS = ("pkill", "killall", "taskkill", "skill", "fuser")
|
2026-05-10 13:20:27 -07:00
|
|
|
|
2026-05-10 18:55:28 -07:00
|
|
|
def _cmd_to_string(cmd) -> str:
|
|
|
|
|
if cmd is None:
|
|
|
|
|
return ""
|
|
|
|
|
if isinstance(cmd, (bytes, bytearray)):
|
|
|
|
|
try:
|
|
|
|
|
return bytes(cmd).decode(errors="replace")
|
|
|
|
|
except Exception:
|
|
|
|
|
return ""
|
|
|
|
|
if isinstance(cmd, str):
|
|
|
|
|
return cmd
|
2026-05-10 13:20:27 -07:00
|
|
|
if isinstance(cmd, (list, tuple)):
|
2026-05-10 18:55:28 -07:00
|
|
|
try:
|
|
|
|
|
return " ".join(str(t) for t in cmd)
|
|
|
|
|
except Exception:
|
|
|
|
|
return ""
|
|
|
|
|
return str(cmd)
|
|
|
|
|
|
|
|
|
|
def _matches_hermes_gateway(cmd_str: str) -> bool:
|
|
|
|
|
low = cmd_str.lower()
|
|
|
|
|
return any(tok in low for tok in _HERMES_TOKENS)
|
|
|
|
|
|
|
|
|
|
def _is_blocked_systemctl(cmd) -> bool:
|
|
|
|
|
cmd_str = _cmd_to_string(cmd)
|
|
|
|
|
if "systemctl" not in cmd_str:
|
2026-05-10 13:20:27 -07:00
|
|
|
return False
|
2026-05-10 18:55:28 -07:00
|
|
|
if not _matches_hermes_gateway(cmd_str):
|
2026-05-10 13:20:27 -07:00
|
|
|
return False
|
2026-05-10 18:55:28 -07:00
|
|
|
try:
|
|
|
|
|
tokens = _shlex.split(cmd_str)
|
|
|
|
|
except ValueError:
|
|
|
|
|
tokens = cmd_str.split()
|
|
|
|
|
return any(verb in tokens for verb in _MUTATING_VERBS)
|
|
|
|
|
|
|
|
|
|
def _is_process_killer(cmd) -> bool:
|
|
|
|
|
cmd_str = _cmd_to_string(cmd)
|
|
|
|
|
try:
|
|
|
|
|
tokens = _shlex.split(cmd_str)
|
|
|
|
|
except ValueError:
|
|
|
|
|
tokens = cmd_str.split()
|
|
|
|
|
if not tokens:
|
2026-05-10 13:20:27 -07:00
|
|
|
return False
|
2026-05-10 18:55:28 -07:00
|
|
|
for tok in tokens:
|
|
|
|
|
head = tok.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
|
|
|
|
if head in _PROCESS_KILLERS:
|
|
|
|
|
low = cmd_str.lower()
|
|
|
|
|
# pkill -f pattern: catch hermes-themed patterns + a
|
|
|
|
|
# plain "python" -f which would catch the live gateway
|
|
|
|
|
# whose cmdline contains "python -m hermes_cli.main".
|
|
|
|
|
if (
|
|
|
|
|
"hermes" in low
|
|
|
|
|
or "gateway" in low
|
|
|
|
|
or ("python" in low and "-f" in tokens)
|
|
|
|
|
):
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def _check_subprocess_cmd(name, cmd):
|
|
|
|
|
if _is_blocked_systemctl(cmd):
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"tests/conftest.py live-system guard: blocked "
|
|
|
|
|
f"subprocess.{name}({cmd!r}) — would mutate the "
|
|
|
|
|
"live hermes-gateway systemd unit. Mock "
|
|
|
|
|
"subprocess.run / _run_systemctl in the test, or "
|
|
|
|
|
"mark with @pytest.mark.live_system_guard_bypass."
|
|
|
|
|
)
|
|
|
|
|
if _is_process_killer(cmd):
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"tests/conftest.py live-system guard: blocked "
|
|
|
|
|
f"subprocess.{name}({cmd!r}) — process-killer command "
|
|
|
|
|
"targeting hermes/python could hit the live gateway. "
|
|
|
|
|
"Mark with @pytest.mark.live_system_guard_bypass if "
|
|
|
|
|
"intentional."
|
|
|
|
|
)
|
2026-06-12 01:19:36 -04:00
|
|
|
# Block any subprocess that would run `hermes update` (or the
|
|
|
|
|
# equivalent `python -m hermes_cli.main update`). These commands
|
|
|
|
|
# run `git fetch origin + git pull` against the REAL checkout,
|
|
|
|
|
# overwriting files like pyproject.toml mid-test-run and corrupting
|
|
|
|
|
# every subsequent subprocess that reads them. The corruption is
|
|
|
|
|
# especially insidious because the spawned process uses setsid/
|
|
|
|
|
# start_new_session=True, making it invisible to pytest's process
|
|
|
|
|
# tree (PPid=1) and nearly impossible to trace without explicit
|
|
|
|
|
# inotify/SHA watchdogs. Any test that legitimately needs to exercise
|
|
|
|
|
# the update-spawn path must mock subprocess.Popen explicitly.
|
|
|
|
|
cmd_str = _cmd_to_string(cmd)
|
|
|
|
|
low = cmd_str.lower()
|
|
|
|
|
if "update" in low and (
|
|
|
|
|
# hermes update / hermes update --gateway / setsid bash -c ... hermes update
|
|
|
|
|
("hermes" in low and "update" in low.split())
|
|
|
|
|
or
|
|
|
|
|
# python -m hermes_cli.main update --gateway
|
|
|
|
|
("hermes_cli" in low and "update" in low.split())
|
|
|
|
|
or
|
|
|
|
|
# venv/bin/hermes update (absolute path variant used in tests)
|
|
|
|
|
(".venv/bin/hermes" in low and "update" in low)
|
|
|
|
|
):
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"tests/conftest.py live-system guard: blocked "
|
|
|
|
|
f"subprocess.{name}({cmd!r}) — this command would run "
|
|
|
|
|
"`hermes update` against the real checkout, fetching "
|
|
|
|
|
"from origin and overwriting repo files (e.g. "
|
|
|
|
|
"pyproject.toml) mid-test-run. This corrupts every "
|
|
|
|
|
"subsequent subprocess in the same runner. "
|
|
|
|
|
"Mock subprocess.Popen (and subprocess.run if used) "
|
|
|
|
|
"in the test instead, or mark with "
|
|
|
|
|
"@pytest.mark.live_system_guard_bypass if genuinely "
|
|
|
|
|
"needed (e.g. an integration test testing the update "
|
|
|
|
|
"flow against a dedicated throwaway repo)."
|
|
|
|
|
)
|
2026-05-10 13:20:27 -07:00
|
|
|
|
|
|
|
|
def _wrap_subprocess(name, real):
|
|
|
|
|
def _guarded(cmd, *args, **kwargs):
|
2026-05-10 18:55:28 -07:00
|
|
|
_check_subprocess_cmd(name, cmd)
|
2026-05-10 13:20:27 -07:00
|
|
|
return real(cmd, *args, **kwargs)
|
|
|
|
|
_guarded.__name__ = f"_guarded_{name}"
|
2026-05-10 18:55:28 -07:00
|
|
|
# Make the wrapper subscriptable like the wrapped callable when
|
|
|
|
|
# the wrapped object is. ``subprocess.Popen[bytes]`` is used as
|
|
|
|
|
# a type annotation in third-party packages (mcp, etc.); replacing
|
|
|
|
|
# ``Popen`` with a plain function breaks ``Popen[bytes]`` at
|
|
|
|
|
# import time. Defer ``__class_getitem__`` to the original.
|
|
|
|
|
if hasattr(real, "__class_getitem__"):
|
|
|
|
|
_guarded.__class_getitem__ = real.__class_getitem__
|
2026-05-10 13:20:27 -07:00
|
|
|
return _guarded
|
|
|
|
|
|
2026-05-10 18:55:28 -07:00
|
|
|
def _wrap_popen():
|
|
|
|
|
"""Subclass Popen so isinstance checks AND Popen[bytes] still work."""
|
|
|
|
|
real = _subprocess.Popen
|
|
|
|
|
|
|
|
|
|
class _GuardedPopen(real): # type: ignore[misc, valid-type]
|
|
|
|
|
def __init__(self, cmd, *args, **kwargs):
|
|
|
|
|
_check_subprocess_cmd("Popen", cmd)
|
|
|
|
|
super().__init__(cmd, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
_GuardedPopen.__name__ = "Popen"
|
|
|
|
|
_GuardedPopen.__qualname__ = "Popen"
|
|
|
|
|
return _GuardedPopen
|
|
|
|
|
|
|
|
|
|
real_run = _subprocess.run
|
|
|
|
|
real_popen = _subprocess.Popen
|
|
|
|
|
real_call = _subprocess.call
|
|
|
|
|
real_check_call = _subprocess.check_call
|
|
|
|
|
real_check_output = _subprocess.check_output
|
|
|
|
|
real_getoutput = _subprocess.getoutput
|
|
|
|
|
real_getstatusoutput = _subprocess.getstatusoutput
|
|
|
|
|
|
2026-05-10 13:20:27 -07:00
|
|
|
monkeypatch.setattr(_subprocess, "run", _wrap_subprocess("run", real_run))
|
2026-05-10 18:55:28 -07:00
|
|
|
monkeypatch.setattr(_subprocess, "Popen", _wrap_popen())
|
2026-05-10 13:20:27 -07:00
|
|
|
monkeypatch.setattr(_subprocess, "call", _wrap_subprocess("call", real_call))
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
_subprocess, "check_call", _wrap_subprocess("check_call", real_check_call)
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
_subprocess,
|
|
|
|
|
"check_output",
|
|
|
|
|
_wrap_subprocess("check_output", real_check_output),
|
|
|
|
|
)
|
2026-05-10 18:55:28 -07:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
_subprocess, "getoutput", _wrap_subprocess("getoutput", real_getoutput)
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
_subprocess,
|
|
|
|
|
"getstatusoutput",
|
|
|
|
|
_wrap_subprocess("getstatusoutput", real_getstatusoutput),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# os.system / os.popen — same risk class, completely unwrapped before.
|
|
|
|
|
real_os_system = _os.system
|
|
|
|
|
real_os_popen = _os.popen
|
|
|
|
|
|
|
|
|
|
def _guarded_os_system(command):
|
|
|
|
|
_check_subprocess_cmd("os.system", command)
|
|
|
|
|
return real_os_system(command)
|
|
|
|
|
|
|
|
|
|
def _guarded_os_popen(cmd, *args, **kwargs):
|
|
|
|
|
_check_subprocess_cmd("os.popen", cmd)
|
|
|
|
|
return real_os_popen(cmd, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(_os, "system", _guarded_os_system)
|
|
|
|
|
monkeypatch.setattr(_os, "popen", _guarded_os_popen)
|
|
|
|
|
|
|
|
|
|
# pty.spawn — POSIX-only.
|
|
|
|
|
try:
|
|
|
|
|
import pty as _pty
|
|
|
|
|
if hasattr(_pty, "spawn"):
|
|
|
|
|
real_pty_spawn = _pty.spawn
|
|
|
|
|
|
|
|
|
|
def _guarded_pty_spawn(argv, *args, **kwargs):
|
|
|
|
|
_check_subprocess_cmd("pty.spawn", argv)
|
|
|
|
|
return real_pty_spawn(argv, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(_pty, "spawn", _guarded_pty_spawn)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# asyncio.create_subprocess_* — bypasses subprocess module entirely.
|
|
|
|
|
try:
|
|
|
|
|
import asyncio as _asyncio
|
|
|
|
|
real_async_exec = _asyncio.create_subprocess_exec
|
|
|
|
|
real_async_shell = _asyncio.create_subprocess_shell
|
|
|
|
|
|
|
|
|
|
async def _guarded_async_exec(program, *args, **kwargs):
|
|
|
|
|
_check_subprocess_cmd(
|
|
|
|
|
"asyncio.create_subprocess_exec", [program, *args]
|
|
|
|
|
)
|
|
|
|
|
return await real_async_exec(program, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
async def _guarded_async_shell(cmd, *args, **kwargs):
|
|
|
|
|
_check_subprocess_cmd("asyncio.create_subprocess_shell", cmd)
|
|
|
|
|
return await real_async_shell(cmd, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(_asyncio, "create_subprocess_exec", _guarded_async_exec)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
_asyncio, "create_subprocess_shell", _guarded_async_shell
|
|
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2026-05-10 13:20:27 -07:00
|
|
|
|
perf(tools): memoize get_tool_definitions + TTL-cache check_fn results (#17098)
Two amplifying optimizations to per-turn overhead in the gateway:
1. get_tool_definitions() memoization (model_tools.py)
Keyed on (frozenset(enabled), frozenset(disabled),
registry._generation, config.yaml mtime+size). Only active when
quiet_mode=True (which is every hot-path caller — gateway,
AIAgent.__init__); quiet_mode=False keeps the existing print side
effects. Cached path returns a shallow-copy list sharing read-only
schema dicts.
Measured: 7.5 ms → 0.01 ms per call (~750× speedup). Gateway
constructs fresh AIAgent per message, so this saves ~7 ms/turn before
any LLM work.
2. check_fn() TTL cache (tools/registry.py)
check_fn callables like check_terminal_requirements probe external
state (Docker daemon, Modal SDK, playwright binary). For a long-lived
process, hitting them on every get_definitions() pass was pure waste
— external state changes on human timescales. 30 s TTL so env-var
flips (hermes tools enable X) propagate within a turn or two without
explicit invalidation.
Measured: first call 7.5ms → 1.6ms (check_fn probes now dominate);
subsequent calls ~0.01ms via the upstream memoization.
Invalidation surface:
- registry._generation bumps on register/deregister/register_toolset_alias,
invalidating the memoized definitions automatically.
- config.yaml mtime in the cache key captures user-visible config edits
affecting dynamic schemas (execute_code mode, discord allowlist).
- invalidate_check_fn_cache() exposed for explicit flushes (e.g. after
hermes tools enable/disable).
- tests/conftest.py autouse fixture clears both caches before every test
so env-var monkeypatches don't see stale results.
Also fixes a regression from PR #17046 that I missed:
- tools/web_tools.py — Firecrawl was removed from module scope by the
lazy import, breaking 8 tests that patch 'tools.web_tools.Firecrawl'.
Applied the same _FirecrawlProxy pattern used in auxiliary_client/
run_agent for OpenAI (module-level proxy that looks like the class
but imports the SDK on first call/isinstance; patch() replaces the
attribute as usual).
Verified:
- 49/49 tests/tools/test_web_tools_config.py pass (was 8 failing on main)
- 68/68 tests/tools/test_homeassistant_tool.py pass (was 1 failing in
the full suite due to check_fn TTL cross-test pollution; fixed by
the autouse fixture)
- 3887/3895 tests/tools/ (8 pre-existing fails: 2 delegate, 1 mcp
dynamic discovery, 5 mcp structured content — all confirmed on main)
- 2973/2976 tests/agent/ + tests/run_agent/ (3 pre-existing fails)
- 868/868 tests/run_agent/ (excluding test_run_agent.py which has
pre-existing suite-level issues)
- Live smoke: 2 turns + /model switch + tool calls, zero errors in
agent.log session window.
Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 18:20:17 -07:00
|
|
|
yield
|