No conditional checks — just clear it. The new auth flow doesn't use this env var. Anyone upgrading gets it wiped once, then it's done.
348 lines
14 KiB
Python
348 lines
14 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 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"
|