Salvage of PR #1707 by @kshitijk4poor (cherry-picked with authorship preserved). Adds Tavily as a third web backend alongside Firecrawl and Parallel, using the Tavily REST API via httpx. - Backend selection via hermes tools → saved as web.backend in config.yaml - All three tools supported: search, extract, crawl - TAVILY_API_KEY in config registry, doctor, status, setup wizard - 15 new Tavily tests + 9 backend selection tests + 5 config tests - Backward compatible Closes #1707
380 lines
15 KiB
Python
380 lines
15 KiB
Python
"""Tests for hermes_cli configuration management."""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import yaml
|
|
|
|
from hermes_cli.config import (
|
|
DEFAULT_CONFIG,
|
|
get_hermes_home,
|
|
ensure_hermes_home,
|
|
load_config,
|
|
load_env,
|
|
migrate_config,
|
|
save_config,
|
|
save_env_value,
|
|
save_env_value_secure,
|
|
sanitize_env_file,
|
|
_sanitize_env_lines,
|
|
)
|
|
|
|
|
|
class TestGetHermesHome:
|
|
def test_default_path(self):
|
|
with patch.dict(os.environ, {}, clear=False):
|
|
os.environ.pop("HERMES_HOME", None)
|
|
home = get_hermes_home()
|
|
assert home == Path.home() / ".hermes"
|
|
|
|
def test_env_override(self):
|
|
with patch.dict(os.environ, {"HERMES_HOME": "/custom/path"}):
|
|
home = get_hermes_home()
|
|
assert home == Path("/custom/path")
|
|
|
|
|
|
class TestEnsureHermesHome:
|
|
def test_creates_subdirs(self, tmp_path):
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
ensure_hermes_home()
|
|
assert (tmp_path / "cron").is_dir()
|
|
assert (tmp_path / "sessions").is_dir()
|
|
assert (tmp_path / "logs").is_dir()
|
|
assert (tmp_path / "memories").is_dir()
|
|
|
|
def test_creates_default_soul_md_if_missing(self, tmp_path):
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
ensure_hermes_home()
|
|
soul_path = tmp_path / "SOUL.md"
|
|
assert soul_path.exists()
|
|
assert soul_path.read_text(encoding="utf-8").strip() != ""
|
|
|
|
def test_does_not_overwrite_existing_soul_md(self, tmp_path):
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
soul_path = tmp_path / "SOUL.md"
|
|
soul_path.write_text("custom soul", encoding="utf-8")
|
|
ensure_hermes_home()
|
|
assert soul_path.read_text(encoding="utf-8") == "custom soul"
|
|
|
|
|
|
class TestLoadConfigDefaults:
|
|
def test_returns_defaults_when_no_file(self, tmp_path):
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
config = load_config()
|
|
assert config["model"] == DEFAULT_CONFIG["model"]
|
|
assert config["agent"]["max_turns"] == DEFAULT_CONFIG["agent"]["max_turns"]
|
|
assert "max_turns" not in config
|
|
assert "terminal" in config
|
|
assert config["terminal"]["backend"] == "local"
|
|
|
|
def test_legacy_root_level_max_turns_migrates_to_agent_config(self, tmp_path):
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text("max_turns: 42\n")
|
|
|
|
config = load_config()
|
|
assert config["agent"]["max_turns"] == 42
|
|
assert "max_turns" not in config
|
|
|
|
|
|
class TestSaveAndLoadRoundtrip:
|
|
def test_roundtrip(self, tmp_path):
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
config = load_config()
|
|
config["model"] = "test/custom-model"
|
|
config["agent"]["max_turns"] = 42
|
|
save_config(config)
|
|
|
|
reloaded = load_config()
|
|
assert reloaded["model"] == "test/custom-model"
|
|
assert reloaded["agent"]["max_turns"] == 42
|
|
|
|
saved = yaml.safe_load((tmp_path / "config.yaml").read_text())
|
|
assert saved["agent"]["max_turns"] == 42
|
|
assert "max_turns" not in saved
|
|
|
|
def test_save_config_normalizes_legacy_root_level_max_turns(self, tmp_path):
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
save_config({"model": "test/custom-model", "max_turns": 37})
|
|
|
|
saved = yaml.safe_load((tmp_path / "config.yaml").read_text())
|
|
assert saved["agent"]["max_turns"] == 37
|
|
assert "max_turns" not in saved
|
|
|
|
def test_nested_values_preserved(self, tmp_path):
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
config = load_config()
|
|
config["terminal"]["timeout"] = 999
|
|
save_config(config)
|
|
|
|
reloaded = load_config()
|
|
assert reloaded["terminal"]["timeout"] == 999
|
|
|
|
|
|
class TestSaveEnvValueSecure:
|
|
def test_save_env_value_writes_without_stdout(self, tmp_path, capsys):
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
save_env_value("TENOR_API_KEY", "sk-test-secret")
|
|
captured = capsys.readouterr()
|
|
assert captured.out == ""
|
|
assert captured.err == ""
|
|
|
|
env_values = load_env()
|
|
assert env_values["TENOR_API_KEY"] == "sk-test-secret"
|
|
|
|
def test_secure_save_returns_metadata_only(self, tmp_path):
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
result = save_env_value_secure("GITHUB_TOKEN", "ghp_test_secret")
|
|
assert result == {
|
|
"success": True,
|
|
"stored_as": "GITHUB_TOKEN",
|
|
"validated": False,
|
|
}
|
|
assert "secret" not in str(result).lower()
|
|
|
|
def test_save_env_value_updates_process_environment(self, tmp_path):
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
|
|
os.environ.pop("TENOR_API_KEY", None)
|
|
save_env_value("TENOR_API_KEY", "sk-test-secret")
|
|
assert os.environ["TENOR_API_KEY"] == "sk-test-secret"
|
|
|
|
def test_save_env_value_hardens_file_permissions_on_posix(self, tmp_path):
|
|
if os.name == "nt":
|
|
return
|
|
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
save_env_value("TENOR_API_KEY", "sk-test-secret")
|
|
env_mode = (tmp_path / ".env").stat().st_mode & 0o777
|
|
assert env_mode == 0o600
|
|
|
|
|
|
class TestSaveConfigAtomicity:
|
|
"""Verify save_config uses atomic writes (tempfile + os.replace)."""
|
|
|
|
def test_no_partial_write_on_crash(self, tmp_path):
|
|
"""If save_config crashes mid-write, the previous file stays intact."""
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
# Write an initial config
|
|
config = load_config()
|
|
config["model"] = "original-model"
|
|
save_config(config)
|
|
|
|
config_path = tmp_path / "config.yaml"
|
|
assert config_path.exists()
|
|
|
|
# Simulate a crash during yaml.dump by making atomic_yaml_write's
|
|
# yaml.dump raise after the temp file is created but before replace.
|
|
with patch("utils.yaml.dump", side_effect=OSError("disk full")):
|
|
try:
|
|
config["model"] = "should-not-persist"
|
|
save_config(config)
|
|
except OSError:
|
|
pass
|
|
|
|
# Original file must still be intact
|
|
reloaded = load_config()
|
|
assert reloaded["model"] == "original-model"
|
|
|
|
def test_no_leftover_temp_files(self, tmp_path):
|
|
"""Failed writes must clean up their temp files."""
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
config = load_config()
|
|
save_config(config)
|
|
|
|
with patch("utils.yaml.dump", side_effect=OSError("disk full")):
|
|
try:
|
|
save_config(config)
|
|
except OSError:
|
|
pass
|
|
|
|
# No .tmp files should remain
|
|
tmp_files = list(tmp_path.glob(".*config*.tmp"))
|
|
assert tmp_files == []
|
|
|
|
def test_atomic_write_creates_valid_yaml(self, tmp_path):
|
|
"""The written file must be valid YAML matching the input."""
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
config = load_config()
|
|
config["model"] = "test/atomic-model"
|
|
config["agent"]["max_turns"] = 77
|
|
save_config(config)
|
|
|
|
# Read raw YAML to verify it's valid and correct
|
|
config_path = tmp_path / "config.yaml"
|
|
with open(config_path) as f:
|
|
raw = yaml.safe_load(f)
|
|
assert raw["model"] == "test/atomic-model"
|
|
assert raw["agent"]["max_turns"] == 77
|
|
|
|
|
|
class TestSanitizeEnvLines:
|
|
"""Tests for .env file corruption repair."""
|
|
|
|
def test_splits_concatenated_keys(self):
|
|
"""Two KEY=VALUE pairs jammed on one line get split."""
|
|
lines = ["ANTHROPIC_API_KEY=sk-ant-xxxOPENAI_BASE_URL=https://api.openai.com/v1\n"]
|
|
result = _sanitize_env_lines(lines)
|
|
assert result == [
|
|
"ANTHROPIC_API_KEY=sk-ant-xxx\n",
|
|
"OPENAI_BASE_URL=https://api.openai.com/v1\n",
|
|
]
|
|
|
|
def test_preserves_clean_file(self):
|
|
"""A well-formed .env file passes through unchanged (modulo trailing newlines)."""
|
|
lines = [
|
|
"OPENROUTER_API_KEY=sk-or-xxx\n",
|
|
"FIRECRAWL_API_KEY=fc-xxx\n",
|
|
"# a comment\n",
|
|
"\n",
|
|
]
|
|
result = _sanitize_env_lines(lines)
|
|
assert result == lines
|
|
|
|
def test_preserves_comments_and_blanks(self):
|
|
lines = ["# comment\n", "\n", "KEY=val\n"]
|
|
result = _sanitize_env_lines(lines)
|
|
assert result == lines
|
|
|
|
def test_adds_missing_trailing_newline(self):
|
|
"""Lines missing trailing newline get one added."""
|
|
lines = ["FOO_BAR=baz"]
|
|
result = _sanitize_env_lines(lines)
|
|
assert result == ["FOO_BAR=baz\n"]
|
|
|
|
def test_three_concatenated_keys(self):
|
|
"""Three known keys on one line all get separated."""
|
|
lines = ["FAL_KEY=111FIRECRAWL_API_KEY=222GITHUB_TOKEN=333\n"]
|
|
result = _sanitize_env_lines(lines)
|
|
assert result == [
|
|
"FAL_KEY=111\n",
|
|
"FIRECRAWL_API_KEY=222\n",
|
|
"GITHUB_TOKEN=333\n",
|
|
]
|
|
|
|
def test_value_with_equals_sign_not_split(self):
|
|
"""A value containing '=' shouldn't be falsely split (lowercase in value)."""
|
|
lines = ["OPENAI_BASE_URL=https://api.example.com/v1?key=abc123\n"]
|
|
result = _sanitize_env_lines(lines)
|
|
assert result == lines
|
|
|
|
def test_unknown_keys_not_split(self):
|
|
"""Unknown key names on one line are NOT split (avoids false positives)."""
|
|
lines = ["CUSTOM_VAR=value123OTHER_THING=value456\n"]
|
|
result = _sanitize_env_lines(lines)
|
|
# Unknown keys stay on one line — no false split
|
|
assert len(result) == 1
|
|
|
|
def test_value_ending_with_digits_still_splits(self):
|
|
"""Concatenation is detected even when value ends with digits."""
|
|
lines = ["OPENROUTER_API_KEY=sk-or-v1-abc123OPENAI_BASE_URL=https://api.openai.com/v1\n"]
|
|
result = _sanitize_env_lines(lines)
|
|
assert len(result) == 2
|
|
assert result[0].startswith("OPENROUTER_API_KEY=")
|
|
assert result[1].startswith("OPENAI_BASE_URL=")
|
|
|
|
def test_save_env_value_fixes_corruption_on_write(self, tmp_path):
|
|
"""save_env_value sanitizes corrupted lines when writing a new key."""
|
|
env_file = tmp_path / ".env"
|
|
env_file.write_text(
|
|
"ANTHROPIC_API_KEY=sk-antOPENAI_BASE_URL=https://api.openai.com/v1\n"
|
|
"FAL_KEY=existing\n"
|
|
)
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
save_env_value("MESSAGING_CWD", "/tmp")
|
|
|
|
content = env_file.read_text()
|
|
lines = content.strip().split("\n")
|
|
|
|
# Corrupted line should be split, new key added
|
|
assert "ANTHROPIC_API_KEY=sk-ant" in lines
|
|
assert "OPENAI_BASE_URL=https://api.openai.com/v1" in lines
|
|
assert "MESSAGING_CWD=/tmp" in lines
|
|
|
|
def test_sanitize_env_file_returns_fix_count(self, tmp_path):
|
|
"""sanitize_env_file reports how many entries were fixed."""
|
|
env_file = tmp_path / ".env"
|
|
env_file.write_text(
|
|
"FAL_KEY=good\n"
|
|
"OPENROUTER_API_KEY=valFIRECRAWL_API_KEY=val2\n"
|
|
)
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
fixes = sanitize_env_file()
|
|
assert fixes > 0
|
|
|
|
# Verify file is now clean
|
|
content = env_file.read_text()
|
|
assert "OPENROUTER_API_KEY=val\n" in content
|
|
assert "FIRECRAWL_API_KEY=val2\n" in content
|
|
|
|
def test_sanitize_env_file_noop_on_clean_file(self, tmp_path):
|
|
"""No changes when file is already clean."""
|
|
env_file = tmp_path / ".env"
|
|
env_file.write_text("GOOD_KEY=good\nOTHER_KEY=other\n")
|
|
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
|
fixes = sanitize_env_file()
|
|
assert fixes == 0
|
|
|
|
|
|
class TestOptionalEnvVarsRegistry:
|
|
"""Verify that key env vars are registered in OPTIONAL_ENV_VARS."""
|
|
|
|
def test_tavily_api_key_registered(self):
|
|
"""TAVILY_API_KEY is listed in OPTIONAL_ENV_VARS."""
|
|
from hermes_cli.config import OPTIONAL_ENV_VARS
|
|
assert "TAVILY_API_KEY" in OPTIONAL_ENV_VARS
|
|
|
|
def test_tavily_api_key_is_tool_category(self):
|
|
"""TAVILY_API_KEY is in the 'tool' category."""
|
|
from hermes_cli.config import OPTIONAL_ENV_VARS
|
|
assert OPTIONAL_ENV_VARS["TAVILY_API_KEY"]["category"] == "tool"
|
|
|
|
def test_tavily_api_key_is_password(self):
|
|
"""TAVILY_API_KEY is marked as password."""
|
|
from hermes_cli.config import OPTIONAL_ENV_VARS
|
|
assert OPTIONAL_ENV_VARS["TAVILY_API_KEY"]["password"] is True
|
|
|
|
def test_tavily_api_key_has_url(self):
|
|
"""TAVILY_API_KEY has a URL."""
|
|
from hermes_cli.config import OPTIONAL_ENV_VARS
|
|
assert OPTIONAL_ENV_VARS["TAVILY_API_KEY"]["url"] == "https://app.tavily.com/home"
|
|
|
|
def test_tavily_in_env_vars_by_version(self):
|
|
"""TAVILY_API_KEY is listed in ENV_VARS_BY_VERSION."""
|
|
from hermes_cli.config import ENV_VARS_BY_VERSION
|
|
all_vars = []
|
|
for vars_list in ENV_VARS_BY_VERSION.values():
|
|
all_vars.extend(vars_list)
|
|
assert "TAVILY_API_KEY" in all_vars
|
|
|
|
|
|
class TestAnthropicTokenMigration:
|
|
"""Test that config version 8→9 clears ANTHROPIC_TOKEN."""
|
|
|
|
def _write_config_version(self, tmp_path, version):
|
|
config_path = tmp_path / "config.yaml"
|
|
import yaml
|
|
config_path.write_text(yaml.safe_dump({"_config_version": version}))
|
|
|
|
def test_clears_token_on_upgrade_to_v9(self, tmp_path):
|
|
"""ANTHROPIC_TOKEN is cleared unconditionally when upgrading to v9."""
|
|
self._write_config_version(tmp_path, 8)
|
|
(tmp_path / ".env").write_text("ANTHROPIC_TOKEN=old-token\n")
|
|
with patch.dict(os.environ, {
|
|
"HERMES_HOME": str(tmp_path),
|
|
"ANTHROPIC_TOKEN": "old-token",
|
|
}):
|
|
migrate_config(interactive=False, quiet=True)
|
|
assert load_env().get("ANTHROPIC_TOKEN") == ""
|
|
|
|
def test_skips_on_version_9_or_later(self, tmp_path):
|
|
"""Already at v9 — ANTHROPIC_TOKEN is not touched."""
|
|
self._write_config_version(tmp_path, 9)
|
|
(tmp_path / ".env").write_text("ANTHROPIC_TOKEN=current-token\n")
|
|
with patch.dict(os.environ, {
|
|
"HERMES_HOME": str(tmp_path),
|
|
"ANTHROPIC_TOKEN": "current-token",
|
|
}):
|
|
migrate_config(interactive=False, quiet=True)
|
|
assert load_env().get("ANTHROPIC_TOKEN") == "current-token"
|