mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
171 lines
5.9 KiB
Python
171 lines
5.9 KiB
Python
import codecs
|
|
import importlib
|
|
import os
|
|
import sys
|
|
|
|
from hermes_cli.env_loader import load_hermes_dotenv
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# UTF-16 / UTF-32 .env sanitizer coverage
|
|
#
|
|
# Scope note: intentionally NO UTF-8-BOM assertions here. UTF-8 BOM handling
|
|
# for _load_dotenv_with_fallback is #65124's un-merged fix; a test here would
|
|
# couple the PRs. This suite covers only the sanitizer rewrite path for
|
|
# UTF-16/32 (and UTF-8 / cp1252 regression guards for that path).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _assert_clean_utf8_env_on_disk(env_file, *, first_key: str) -> None:
|
|
"""On-disk file must be clean UTF-8: no BOM, no U+FFFD, canonical key."""
|
|
after = env_file.read_bytes()
|
|
assert not after.startswith(codecs.BOM_UTF8)
|
|
assert not after.startswith(codecs.BOM_UTF16_LE)
|
|
assert not after.startswith(codecs.BOM_UTF16_BE)
|
|
text = after.decode("utf-8") # strict — raises if not clean UTF-8
|
|
assert "\ufffd" not in text
|
|
assert text.startswith(f"{first_key}=") or f"\n{first_key}=" in text
|
|
assert first_key.encode("ascii") in after
|
|
|
|
|
|
|
|
|
|
def test_utf16_le_bom_preserves_non_ascii_values(tmp_path, monkeypatch):
|
|
"""UTF-16-LE+BOM rewrite must preserve non-ASCII values (not just ASCII keys).
|
|
|
|
Uses non-credential var names so _sanitize_loaded_credentials does not
|
|
strip non-ASCII from values (that path only targets *_KEY/*_TOKEN/etc.).
|
|
"""
|
|
home = tmp_path / "hermes"
|
|
home.mkdir()
|
|
env_file = home / ".env"
|
|
content = "GREETING=café\nCJK_LABEL=日本語\n"
|
|
env_file.write_bytes(codecs.BOM_UTF16_LE + content.encode("utf-16-le"))
|
|
|
|
monkeypatch.delenv("GREETING", raising=False)
|
|
monkeypatch.delenv("CJK_LABEL", raising=False)
|
|
|
|
loaded = load_hermes_dotenv(hermes_home=home)
|
|
|
|
assert loaded == [env_file]
|
|
assert os.getenv("GREETING") == "café"
|
|
assert os.getenv("CJK_LABEL") == "日本語"
|
|
after = env_file.read_bytes()
|
|
assert after.decode("utf-8") # strict
|
|
assert "café".encode("utf-8") in after
|
|
assert "日本語".encode("utf-8") in after
|
|
assert b"\xef\xbf\xbd" not in after
|
|
|
|
|
|
def test_utf32_le_bom_leaves_file_untouched(tmp_path, caplog):
|
|
"""UTF-32-LE BOM: refuse-to-mangle (leave bytes untouched + warning).
|
|
|
|
UTF-32-LE's BOM starts with UTF-16-LE's FF FE; sniff order must check
|
|
UTF-32 first so we never misdetect and corrupt.
|
|
|
|
Exercises ``_sanitize_env_file_if_needed`` only: the dotenv load path
|
|
is out of scope here (#65124's surface) and still cannot ingest UTF-32.
|
|
"""
|
|
import logging
|
|
|
|
from hermes_cli.env_loader import _sanitize_env_file_if_needed
|
|
|
|
env_file = tmp_path / ".env"
|
|
content = "HERMES_TEST_KEY=hello_utf32\nSECOND_KEY=world\n"
|
|
raw = codecs.BOM_UTF32_LE + content.encode("utf-32-le")
|
|
env_file.write_bytes(raw)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="hermes_cli.env_loader"):
|
|
_sanitize_env_file_if_needed(env_file)
|
|
|
|
assert env_file.read_bytes() == raw # untouched
|
|
assert any("UTF-32" in r.message for r in caplog.records)
|
|
|
|
|
|
|
|
|
|
def test_utf32_warning_fires_once_per_path(tmp_path, caplog, monkeypatch):
|
|
"""Three sanitize calls on the same UTF-32 file → exactly one warning.
|
|
|
|
Matches house style for warn-once (module-level seen-set, same class as
|
|
``_WARNED_KEYS``): hot-reload / multi-entry load must not spam logs.
|
|
"""
|
|
import logging
|
|
|
|
import hermes_cli.env_loader as env_loader
|
|
from hermes_cli.env_loader import _sanitize_env_file_if_needed
|
|
|
|
# Isolate process-level seen-set so other tests' paths don't leak in.
|
|
monkeypatch.setattr(env_loader, "_WARNED_UTF32_PATHS", set())
|
|
|
|
env_file = tmp_path / ".env"
|
|
content = "HERMES_TEST_KEY=hello_utf32\nSECOND_KEY=world\n"
|
|
raw = codecs.BOM_UTF32_LE + content.encode("utf-32-le")
|
|
env_file.write_bytes(raw)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="hermes_cli.env_loader"):
|
|
_sanitize_env_file_if_needed(env_file)
|
|
_sanitize_env_file_if_needed(env_file)
|
|
_sanitize_env_file_if_needed(env_file)
|
|
|
|
utf32_warnings = [r for r in caplog.records if "UTF-32" in r.message]
|
|
assert len(utf32_warnings) == 1
|
|
assert env_file.read_bytes() == raw
|
|
|
|
|
|
|
|
|
|
def test_plain_utf8_env_regression(tmp_path, monkeypatch):
|
|
"""Plain UTF-8 .env must keep loading after the UTF-16 sanitize changes."""
|
|
home = tmp_path / "hermes"
|
|
home.mkdir()
|
|
env_file = home / ".env"
|
|
before = b"OPENAI_API_KEY=sk-plain\nSECOND_KEY=ok\n"
|
|
env_file.write_bytes(before)
|
|
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("SECOND_KEY", raising=False)
|
|
|
|
loaded = load_hermes_dotenv(hermes_home=home)
|
|
|
|
assert loaded == [env_file]
|
|
assert os.getenv("OPENAI_API_KEY") == "sk-plain"
|
|
assert os.getenv("SECOND_KEY") == "ok"
|
|
# No spurious rewrite of an already-clean file.
|
|
assert env_file.read_bytes() == before
|
|
|
|
|
|
def test_cp1252_env_regression_does_not_crash(tmp_path, monkeypatch):
|
|
"""cp1252/latin-1 body must not crash sanitize; ASCII keys still usable.
|
|
|
|
0xE9 is 'é' in cp1252 and incomplete as UTF-8. First line does not begin
|
|
with U+FFFD, so the FFFD guard must not refuse the whole file.
|
|
|
|
Sanitize leaves the file bytes alone when the only "change" is
|
|
errors=replace on values (original already replace-decoded equals
|
|
sanitized), so _load_dotenv_with_fallback's latin-1 path recovers café.
|
|
"""
|
|
home = tmp_path / "hermes"
|
|
home.mkdir()
|
|
env_file = home / ".env"
|
|
before = b"ASCII_KEY=ok\nLATIN1_VALUE=caf\xe9\n"
|
|
env_file.write_bytes(before)
|
|
|
|
monkeypatch.delenv("ASCII_KEY", raising=False)
|
|
monkeypatch.delenv("LATIN1_VALUE", raising=False)
|
|
|
|
loaded = load_hermes_dotenv(hermes_home=home)
|
|
|
|
assert loaded == [env_file]
|
|
assert os.getenv("ASCII_KEY") == "ok"
|
|
assert os.getenv("LATIN1_VALUE") == "café"
|
|
# Sanitize must not have rewritten (would have persisted U+FFFD).
|
|
assert env_file.read_bytes() == before
|