| """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``. |
| """ |
|
|
| import asyncio |
| import os |
| import re |
| import signal |
| import sys |
| import tempfile |
| from pathlib import Path |
| from unittest.mock import patch |
|
|
| import pytest |
|
|
| |
| PROJECT_ROOT = Path(__file__).parent.parent |
| if str(PROJECT_ROOT) not in sys.path: |
| sys.path.insert(0, str(PROJECT_ROOT)) |
|
|
|
|
| |
| |
| |
| |
| |
|
|
| _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", |
| ) |
|
|
| |
| _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", |
| "TINKER_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", |
| "AI_GATEWAY_API_KEY", |
| "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", |
| "AI_GATEWAY_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_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", |
| "HERMES_PLATFORM", |
| "HERMES_INFERENCE_PROVIDER", |
| "HERMES_MANAGED", |
| "HERMES_DEV", |
| "HERMES_CONTAINER", |
| "HERMES_EPHEMERAL_SYSTEM_PROMPT", |
| "HERMES_TIMEZONE", |
| "HERMES_REDACT_SECRETS", |
| "HERMES_BACKGROUND_NOTIFICATIONS", |
| "HERMES_EXEC_ASK", |
| "HERMES_HOME_MODE", |
| "BROWSER_CDP_URL", |
| "CAMOFOX_URL", |
| |
| |
| |
| "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", |
| }) |
|
|
|
|
| @pytest.fixture(autouse=True) |
| 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. |
| """ |
| |
| for name in list(os.environ.keys()): |
| if _looks_like_credential(name): |
| monkeypatch.delenv(name, raising=False) |
|
|
| |
| for name in _HERMES_BEHAVIORAL_VARS: |
| monkeypatch.delenv(name, raising=False) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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)) |
|
|
| |
| |
| monkeypatch.setenv("TZ", "UTC") |
| monkeypatch.setenv("LANG", "C.UTF-8") |
| monkeypatch.setenv("LC_ALL", "C.UTF-8") |
| monkeypatch.setenv("PYTHONHASHSEED", "0") |
|
|
| |
| |
| |
| |
| |
| monkeypatch.setenv("AWS_EC2_METADATA_DISABLED", "true") |
| monkeypatch.setenv("AWS_METADATA_SERVICE_TIMEOUT", "1") |
| monkeypatch.setenv("AWS_METADATA_SERVICE_NUM_ATTEMPTS", "1") |
|
|
| |
| |
| |
| try: |
| import hermes_cli.plugins as _plugins_mod |
| monkeypatch.setattr(_plugins_mod, "_plugin_manager", None) |
| except Exception: |
| pass |
|
|
|
|
| |
| |
| @pytest.fixture(autouse=True) |
| def _isolate_hermes_home(_hermetic_environment): |
| """Alias preserved for any test that yields this name explicitly.""" |
| return None |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| @pytest.fixture(autouse=True) |
| def _reset_module_state(): |
| """Clear module-level mutable state and ContextVars between tests. |
| |
| Keeps state from leaking across tests on the same xdist worker. Modules |
| that don't exist yet (test collection before production import) are |
| skipped silently β production import later creates fresh empty state. |
| """ |
| |
| try: |
| from tools import approval as _approval_mod |
| _approval_mod._session_approved.clear() |
| _approval_mod._session_yolo.clear() |
| _approval_mod._permanent_approved.clear() |
| _approval_mod._pending.clear() |
| _approval_mod._gateway_queues.clear() |
| _approval_mod._gateway_notify_cbs.clear() |
| |
| |
| |
| _approval_mod._approval_session_key.set("") |
| except Exception: |
| pass |
|
|
| |
| try: |
| from tools import interrupt as _interrupt_mod |
| with _interrupt_mod._lock: |
| _interrupt_mod._interrupted_threads.clear() |
| except Exception: |
| pass |
|
|
| |
| |
| |
| try: |
| from gateway import session_context as _sc_mod |
| for _cv in ( |
| _sc_mod._SESSION_PLATFORM, |
| _sc_mod._SESSION_CHAT_ID, |
| _sc_mod._SESSION_CHAT_NAME, |
| _sc_mod._SESSION_THREAD_ID, |
| _sc_mod._SESSION_USER_ID, |
| _sc_mod._SESSION_USER_NAME, |
| _sc_mod._SESSION_KEY, |
| _sc_mod._CRON_AUTO_DELIVER_PLATFORM, |
| _sc_mod._CRON_AUTO_DELIVER_CHAT_ID, |
| _sc_mod._CRON_AUTO_DELIVER_THREAD_ID, |
| ): |
| _cv.set(_sc_mod._UNSET) |
| except Exception: |
| pass |
|
|
| |
| |
| |
| try: |
| from tools import env_passthrough as _envp_mod |
| _envp_mod._allowed_env_vars_var.set(set()) |
| except Exception: |
| pass |
|
|
| |
| try: |
| from tools import credential_files as _credf_mod |
| _credf_mod._registered_files_var.set({}) |
| except Exception: |
| pass |
|
|
| |
| |
| |
| |
| try: |
| from tools import file_tools as _ft_mod |
| with _ft_mod._read_tracker_lock: |
| _ft_mod._read_tracker.clear() |
| with _ft_mod._file_ops_lock: |
| _ft_mod._file_ops_cache.clear() |
| except Exception: |
| pass |
|
|
| yield |
|
|
|
|
| @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": [], |
| } |
|
|
|
|
| |
| |
| |
| |
|
|
| def _timeout_handler(signum, frame): |
| raise TimeoutError("Test exceeded 30 second timeout") |
|
|
| @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. |
| """ |
| if request.node.get_closest_marker("asyncio") is not None: |
| yield |
| return |
|
|
| try: |
| loop = asyncio.get_event_loop_policy().get_event_loop() |
| except RuntimeError: |
| loop = None |
|
|
| 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) |
|
|
|
|
| @pytest.fixture(autouse=True) |
| def _enforce_test_timeout(): |
| """Kill any individual test that takes longer than 30 seconds. |
| SIGALRM is Unix-only; skip on Windows.""" |
| if sys.platform == "win32": |
| yield |
| return |
| old = signal.signal(signal.SIGALRM, _timeout_handler) |
| signal.alarm(30) |
| yield |
| signal.alarm(0) |
| signal.signal(signal.SIGALRM, old) |
|
|