Files
hermes-agent/tests/hermes_cli/test_config_save_integrity.py
ethernet 97de4b4ef8 Merge origin/main into ethie/pm-clean
Conflict resolutions and semantic fixups:

- utils.py / hermes_yaml.py: main widened ruamel's round-trip emitter so a long
  double-quoted scalar is never folded after an escaped backslash. pm-clean builds
  every rt emitter through hermes_yaml.roundtrip_yaml(), so the width lives there
  (ROUNDTRIP_YAML_WIDTH moves with it); xai_retirement imports it from hermes_yaml.
- hermes_cli/banner.py: keep pm-clean's removal of the banner update check. Main's
  GIT_NO_LAZY_FETCH fix for it applies to its replacement, source_check: every
  read-only probe (source_git_env) now refuses promisor lazy fetches, and the
  partial-clone test targets that probe (red without the flag).
- .github/workflows/tests.yml: keep setup-pm; main's uv pin bump does not apply.
  Main's WAL-capable SQLite gates are kept, run against $HERMES_PYTHON (the
  PM-pinned interpreter, SQLite 3.53.1). The e2e step takes main's
  --include-integration invocation.
- apps/desktop: package.json has no build block here, so main's macOS locale-marker
  restore joins the darwin branch of the existing after-pack.mjs, and its test
  loads the hook from electron-builder.config.cjs and imports PlatformPackager
  from app-builder-lib's root (electron-builder 27 exports no ./out paths). The
  win32 row is dropped: this hook sanitizes and signs PE trees on win32 by design.
- reconciliation.ts: main's rowId hydration (#119326) was merged into the first of
  pm-clean's split helpers only; the resolver is now one helper both halves use.
- en.ts: both sides' keys kept. tests/tools/test_lazy_deps.py stays deleted.
- Tests main added with `import yaml` use hermes_yaml, like the rest of the tree.
2026-09-23 19:51:34 -04:00

230 lines
9.3 KiB
Python

"""config.yaml write integrity: a failed read never reaches disk, and a save keeps what the user wrote."""
import errno
import os
import shutil
import pytest
import hermes_yaml as yaml
import hermes_cli.config as config_mod
from hermes_cli.config import DEFAULT_CONFIG, load_config, migrate_config, read_raw_config, save_config
_CONFIG = """# hand-tuned
model:
default: my-org/custom-model
display:
skin: mono
terminal:
backend: docker
approvals:
deny:
- rm -rf /
"""
@pytest.fixture
def home(tmp_path, monkeypatch):
from tui_gateway import server
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(server, "_hermes_home", tmp_path)
server._cfg_cache = server._cfg_sig = server._cfg_path = None
(tmp_path / "config.yaml").write_text(_CONFIG, encoding="utf-8")
yield tmp_path
server._cfg_cache = server._cfg_sig = server._cfg_path = None
class _ReadFaults:
"""Counts parses of config.yaml and fails the chosen one with a transient EMFILE (intact file)."""
def __init__(self, monkeypatch, path):
self.path, self.count, self.fail_at, real = str(path), 0, 0, config_mod.fast_safe_load
def flaky(stream):
if getattr(stream, "name", None) == self.path:
self.count += 1
if self.count == self.fail_at:
raise OSError(errno.EMFILE, "Too many open files")
return real(stream)
monkeypatch.setattr(config_mod, "fast_safe_load", flaky)
def arm(self, fail_at=0):
self.count, self.fail_at = 0, fail_at
def _fresh_process(home, text, *, keep_last_known_good=False):
"""*text* on a new inode with cold caches (and no last-known-good copy unless asked)."""
from tui_gateway import server
config_mod._LOAD_CONFIG_CACHE.clear()
config_mod._RAW_CONFIG_CACHE.clear()
server._cfg_cache = server._cfg_sig = server._cfg_path = None
if not keep_last_known_good:
config_mod._LAST_EXPANDED_CONFIG_BY_PATH.clear()
shutil.rmtree(home / "backups", ignore_errors=True)
tmp = home / ".config.yaml.new"
tmp.write_text(text, encoding="utf-8")
os.replace(tmp, home / "config.yaml")
def _save_loaded():
cfg = load_config()
cfg["display"]["skin"] = "ares"
save_config(cfg)
def _save_raw(): # the shape every migration step and ``write_platform_config_field(raw=True)`` use
cfg = read_raw_config()
cfg.setdefault("display", {})["skin"] = "ares"
save_config(cfg)
def _tui_config_set():
from tui_gateway import server
reply = server._methods["config.set"](1, {"key": "skin", "value": "ares"})
if "error" in reply:
raise RuntimeError(reply["error"]["message"])
def _dashboard_put():
from starlette.testclient import TestClient
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app
client = TestClient(app, raise_server_exceptions=False)
r = client.put("/api/config", json={"config": {"display": {"skin": "ares"}}},
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN})
if r.status_code != 200:
raise RuntimeError(r.text)
@pytest.mark.parametrize("last_known_good", ["none", "stale"])
@pytest.mark.parametrize("write", [_save_loaded, _save_raw, _tui_config_set, _dashboard_put],
ids=["load_config", "read_raw_config", "tui-config.set", "dashboard-put"])
def test_no_single_transient_read_error_reaches_the_file(write, last_known_good, home, monkeypatch):
"""Fail each config read the write performs, one at a time: the write either lands exactly as
it would have, or is refused with the file byte-identical, and the retry is not poisoned."""
path = home / "config.yaml"
before = _CONFIG + "timezone: Europe/Paris\n"
faults = _ReadFaults(monkeypatch, path)
def run(k):
_fresh_process(home, before)
if last_known_good == "stale": # loaded earlier in this process, edited elsewhere since
_fresh_process(home, _CONFIG)
load_config()
_fresh_process(home, before, keep_last_known_good=True)
faults.arm(k)
try:
write()
except RuntimeError:
return "refused"
return "ok"
assert run(0) == "ok"
good_after, reads = path.read_bytes(), faults.count
assert b"skin: ares" in good_after and reads >= 1
for k in range(1, reads + 1):
if run(k) == "refused":
assert path.read_text(encoding="utf-8") == before, f"read {k}/{reads} failed, file changed"
faults.arm()
write() # the retry is not poisoned by a cached fallback
assert path.read_bytes() == good_after, f"read {k}/{reads} failed, the saved file differs"
@pytest.mark.parametrize("read", [load_config, read_raw_config], ids=["load_config", "read_raw_config"])
def test_transient_read_error_is_not_recorded_as_a_corrupt_config(read, home, monkeypatch, capsys):
"""One EMFILE on an intact file must not leave the process treating config.yaml as corrupt: the
provider auto-resolution refusal (`corrupt_config`) keyed on the file signature would otherwise
fire until the file is next edited, and the good file would be copied away as `.corrupt`."""
from hermes_cli.auth import _refuse_env_adoption_if_config_corrupt
from hermes_cli.config_read_errors import _CONFIG_PARSE_WARNED, get_active_config_parse_failure
path = home / "config.yaml"
_fresh_process(home, _CONFIG)
_CONFIG_PARSE_WARNED.clear()
faults = _ReadFaults(monkeypatch, path)
faults.arm(1)
read()
assert get_active_config_parse_failure() is not None # unreadable right now: the refusal holds
assert read()["display"]["skin"] == "mono"
assert get_active_config_parse_failure() is None
_refuse_env_adoption_if_config_corrupt()
assert not list((home / "backups").glob("**/*.corrupt.*"))
assert "could not be read" in capsys.readouterr().err
def test_unreadable_config_serves_one_cached_fallback_until_it_reads(home, monkeypatch):
"""While config.yaml stays unreadable, loads serve one cached fallback (the backup is parsed
once, not per call: ~250x slower loads before), and the first load once the file opens again
reads the real file even though its signature never changed."""
import builtins
from hermes_cli import config_backups
from hermes_cli.config_read_errors import FailedConfigRead
path = home / "config.yaml"
_fresh_process(home, _CONFIG)
load_config() # leaves the `good` backup a fresh process falls back to
config_mod._LOAD_CONFIG_CACHE.clear()
config_mod._LAST_EXPANDED_CONFIG_BY_PATH.clear()
blocked, rebuilds, real_backup = [True], [], config_backups.load_newest_good_backup
def guarded_open(file, *args, **kwargs):
if blocked[0] and str(file) == str(path):
raise OSError(errno.EMFILE, "Too many open files")
return builtins.open(file, *args, **kwargs)
monkeypatch.setattr(config_mod, "open", guarded_open, raising=False)
monkeypatch.setattr(config_backups, "load_newest_good_backup", lambda p: rebuilds.append(p) or real_backup(p))
for _ in range(5):
cfg = load_config()
assert isinstance(cfg, FailedConfigRead) and cfg["display"]["skin"] == "mono"
assert len(rebuilds) == 1
blocked[0] = False
assert type(load_config()) is dict
def test_save_refusal_for_bad_yaml_asks_for_an_edit_not_a_retry(home):
_fresh_process(home, "model: [unclosed\n")
cfg = load_config()
cfg["display"]["skin"] = "ares"
with pytest.raises(RuntimeError, match="has a formatting error") as refusal:
save_config(cfg)
assert "hermes config edit" in str(refusal.value) and "Try again" not in str(refusal.value)
assert (home / "config.yaml").read_text(encoding="utf-8") == "model: [unclosed\n"
@pytest.mark.parametrize("operation", ["save", "partial_save", "migrate"])
def test_authored_nulls_survive_config_writes(tmp_path, monkeypatch, operation):
from hermes_cli.resource_limits import configured_nofile_soft_limit
from agent.agent_runtime_helpers import prompt_caching_disabled_from_config
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config_path = tmp_path / "config.yaml"
seed = {
"_config_version": DEFAULT_CONFIG["_config_version"] - (operation == "migrate"),
"runtime": {"nofile_soft_limit": None},
"prompt_caching": {"cache_ttl": None},
"x_null_preservation": {"nested": {"optional": None}, "keep": "value"},
}
config_path.write_text(yaml.safe_dump(seed), encoding="utf-8")
if operation == "migrate":
migrate_config(interactive=False, quiet=True)
elif operation == "partial_save":
save_config({"display": {"skin": "mono"}}, merge_existing=True)
else:
config = load_config()
config["display"]["skin"] = "mono"
save_config(config)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["runtime"]["nofile_soft_limit"] is None
assert raw["prompt_caching"]["cache_ttl"] is None
assert raw["x_null_preservation"] == seed["x_null_preservation"]
assert "terminal" not in raw
assert "agent" not in raw # no section the user never wrote, not even an empty one
assert configured_nofile_soft_limit() is None
assert prompt_caching_disabled_from_config() is True
if operation == "migrate":
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
else:
assert raw["display"]["skin"] == "mono"