Four writers each dropped their own uniquely-named copy of config.yaml next to the real file and none of them ever deleted anything: hermes setup (config.yaml.bak.YYYYMMDD_HHMMSS, one per run even with no change), the corrupt-YAML snapshot (config.yaml.corrupt.<ts>.bak), hermes migrate xai (config.yaml.bak-pre-migrate-xai-<ts>) and the Docker boot migration (config.yaml.bak-<ts>, .env.bak-<ts>). A home dir accumulated a dozen variants with no way to tell which mattered. hermes_cli/config_backups.py::backup_config is now the single writer: backups/config/config.yaml.<reason>.<YYYYMMDD-HHMMSS>, skipped when the newest copy for that reason is byte-identical, rotated to the newest five per reason. backups/ is already excluded from full backups so nothing nests. Legacy siblings written by the old schemes are moved into the dir on first use; hand-named copies (config.yaml.bak-my-note) are left alone. Live: three `hermes setup --non-interactive` runs against an unchanged config went from three .bak files in HERMES_HOME to one pre-setup copy under backups/config/; repeated loads of broken YAML produce one corrupt copy instead of one per process (deduped by content).
206 lines
6.1 KiB
Python
206 lines
6.1 KiB
Python
"""Regression coverage for fail-closed non-interactive config startup."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
import types
|
|
from argparse import Namespace
|
|
|
|
import pytest
|
|
|
|
|
|
def _args(**overrides) -> Namespace:
|
|
values = {
|
|
"command": "chat",
|
|
"ignore_user_config": False,
|
|
"oneshot": None,
|
|
"query": "hello",
|
|
"quiet": False,
|
|
"safe_mode": False,
|
|
"yolo": False,
|
|
}
|
|
values.update(overrides)
|
|
return Namespace(**values)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolated_config_env(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.delenv("HERMES_IGNORE_USER_CONFIG", raising=False)
|
|
yield
|
|
os.environ.pop("HERMES_IGNORE_USER_CONFIG", None)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
_args(query="hello"),
|
|
_args(command=None, query=None, oneshot="hello"),
|
|
_args(query="hello", quiet=True),
|
|
],
|
|
ids=["single-query", "oneshot", "quiet-query"],
|
|
)
|
|
def test_noninteractive_guard_rejects_malformed_yaml(args, tmp_path, caplog, capsys):
|
|
from hermes_cli import main as main_mod
|
|
|
|
broken = "model: [unterminated\n"
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text(broken, encoding="utf-8")
|
|
|
|
with caplog.at_level(logging.ERROR, logger="hermes_cli.config"):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
main_mod._guard_noninteractive_user_config(args)
|
|
|
|
assert exc_info.value.code == 2
|
|
assert "Refusing non-interactive startup" in capsys.readouterr().err
|
|
assert any(
|
|
record.levelno == logging.ERROR
|
|
and "Refusing non-interactive startup" in record.getMessage()
|
|
for record in caplog.records
|
|
)
|
|
assert config_path.read_text(encoding="utf-8") == broken
|
|
backups = list((tmp_path / "backups" / "config").glob("config.yaml.corrupt.*"))
|
|
assert len(backups) == 1
|
|
assert backups[0].read_text(encoding="utf-8") == broken
|
|
|
|
|
|
def test_prepare_rejects_bad_config_before_plugin_discovery(monkeypatch, tmp_path):
|
|
from hermes_cli import main as main_mod
|
|
|
|
(tmp_path / "config.yaml").write_text("model: [unterminated\n")
|
|
discovery_calls = []
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"hermes_cli.plugins",
|
|
types.SimpleNamespace(
|
|
discover_plugins=lambda: discovery_calls.append("plugins")
|
|
),
|
|
)
|
|
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
main_mod._prepare_agent_startup(_args())
|
|
|
|
assert exc_info.value.code == 2
|
|
assert discovery_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content", [None, "", "{}\n", "model:\n default: local/test\n"]
|
|
)
|
|
def test_noninteractive_guard_accepts_missing_empty_and_mapping_configs(
|
|
content, tmp_path
|
|
):
|
|
from hermes_cli import main as main_mod
|
|
|
|
if content is not None:
|
|
(tmp_path / "config.yaml").write_text(content, encoding="utf-8")
|
|
args = _args()
|
|
|
|
main_mod._guard_noninteractive_user_config(args)
|
|
|
|
assert args._noninteractive_config_validated is True
|
|
|
|
|
|
def test_noninteractive_guard_rejects_non_mapping_yaml(tmp_path, capsys):
|
|
from hermes_cli import main as main_mod
|
|
|
|
(tmp_path / "config.yaml").write_text("- model\n- provider\n")
|
|
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
main_mod._guard_noninteractive_user_config(_args())
|
|
|
|
assert exc_info.value.code == 2
|
|
assert "top-level YAML value must be a mapping" in capsys.readouterr().err
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
_args(ignore_user_config=True),
|
|
_args(safe_mode=True),
|
|
],
|
|
ids=["ignore-user-config", "safe-mode"],
|
|
)
|
|
def test_explicit_config_bypasses_allow_noninteractive_recovery(args, tmp_path):
|
|
from hermes_cli import main as main_mod
|
|
|
|
(tmp_path / "config.yaml").write_text("model: [unterminated\n")
|
|
|
|
main_mod._guard_noninteractive_user_config(args)
|
|
|
|
assert args._noninteractive_config_validated is True
|
|
assert list(tmp_path.rglob("config.yaml.corrupt.*")) == []
|
|
|
|
|
|
def test_interactive_chat_keeps_existing_repair_behavior(tmp_path):
|
|
from hermes_cli import main as main_mod
|
|
|
|
(tmp_path / "config.yaml").write_text("model: [unterminated\n")
|
|
args = _args(query=None)
|
|
|
|
main_mod._guard_noninteractive_user_config(args)
|
|
|
|
assert not hasattr(args, "_noninteractive_config_validated")
|
|
assert list(tmp_path.rglob("config.yaml.corrupt.*")) == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
_args(query=""),
|
|
_args(query=None, quiet=True),
|
|
_args(query="", quiet=True),
|
|
],
|
|
ids=["empty-query", "quiet", "quiet-empty-query"],
|
|
)
|
|
def test_queryless_chat_keeps_interactive_repair_behavior(args, tmp_path):
|
|
from hermes_cli import main as main_mod
|
|
|
|
(tmp_path / "config.yaml").write_text("model: [unterminated\n")
|
|
|
|
main_mod._guard_noninteractive_user_config(args)
|
|
|
|
assert not hasattr(args, "_noninteractive_config_validated")
|
|
assert list(tmp_path.rglob("config.yaml.corrupt.*")) == []
|
|
|
|
|
|
def test_env_only_config_bypass_allows_noninteractive_recovery(monkeypatch, tmp_path):
|
|
from hermes_cli import main as main_mod
|
|
|
|
(tmp_path / "config.yaml").write_text("model: [unterminated\n")
|
|
monkeypatch.setenv("HERMES_IGNORE_USER_CONFIG", "1")
|
|
args = _args()
|
|
|
|
main_mod._guard_noninteractive_user_config(args)
|
|
|
|
assert args._noninteractive_config_validated is True
|
|
assert list(tmp_path.rglob("config.yaml.corrupt.*")) == []
|
|
|
|
|
|
def test_reused_args_can_retry_after_config_repair(tmp_path):
|
|
from hermes_cli import main as main_mod
|
|
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text("model: [unterminated\n")
|
|
args = _args()
|
|
|
|
with pytest.raises(SystemExit):
|
|
main_mod._guard_noninteractive_user_config(args)
|
|
|
|
config_path.write_text("model:\n default: local/test\n")
|
|
main_mod._guard_noninteractive_user_config(args)
|
|
|
|
assert args._noninteractive_config_validated is True
|
|
|
|
|
|
def test_ignore_user_config_is_applied_before_oneshot_startup(monkeypatch):
|
|
from hermes_cli import main as main_mod
|
|
|
|
monkeypatch.delenv("HERMES_IGNORE_USER_CONFIG", raising=False)
|
|
|
|
main_mod._apply_user_config_bypass(_args(ignore_user_config=True))
|
|
|
|
assert os.environ["HERMES_IGNORE_USER_CONFIG"] == "1"
|