Files
aiturk-hermes-ide/tests/hermes_cli/test_backup.py
T

2426 lines
96 KiB
Python

"""Tests for hermes backup and import commands."""
import json
import os
import sqlite3
import stat
import zipfile
from argparse import Namespace
from pathlib import Path
from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _no_real_gateway_service(monkeypatch):
"""run_import() auto-installs the gateway service post-restore; tests must
never touch the host's systemd/launchd. Individual tests re-patch these to
assert the wiring."""
import hermes_cli.gateway as gateway_mod
monkeypatch.setattr(gateway_mod, "ensure_gateway_service", lambda **kw: False)
monkeypatch.setattr(gateway_mod, "_is_service_running", lambda: False)
def _advance_backup_clock(seconds: float = 1.1) -> None:
"""Skew hermes_cli.backup's datetime forward instead of sleeping.
Snapshot ids have 1-second resolution; tests that need two distinct
timestamps previously slept >1s. This installs (once) a datetime shim in
the backup module whose now() adds a cumulative offset, then bumps it.
"""
import datetime as _dt
import hermes_cli.backup as _backup
shim = getattr(_backup.datetime, "_hermes_test_shim", None)
if shim is None:
class _ShimDatetime(_dt.datetime):
_hermes_test_shim = True
_offset = _dt.timedelta(0)
@classmethod
def now(cls, tz=None): # noqa: D102
return _dt.datetime.now(tz) + cls._offset
_backup.datetime = _ShimDatetime
shim = _ShimDatetime
else:
shim = _backup.datetime
shim._offset += _dt.timedelta(seconds=seconds)
def _make_hermes_tree(root: Path) -> None:
"""Create a realistic ~/.hermes directory structure for testing."""
(root / "config.yaml").write_text("model:\n provider: openrouter\n")
(root / ".env").write_text("OPENROUTER_API_KEY=sk-test-123\n")
for db_name in ("memory_store.db", "hermes_state.db"):
with sqlite3.connect(root / db_name) as conn:
conn.execute("CREATE TABLE sample (value TEXT)")
conn.execute("INSERT INTO sample VALUES ('test')")
# Sessions
(root / "sessions").mkdir(exist_ok=True)
(root / "sessions" / "abc123.json").write_text("{}")
# Skills
(root / "skills").mkdir(exist_ok=True)
(root / "skills" / "my-skill").mkdir()
(root / "skills" / "my-skill" / "SKILL.md").write_text("# My Skill\n")
# Skins
(root / "skins").mkdir(exist_ok=True)
(root / "skins" / "cyber.yaml").write_text("name: cyber\n")
# Cron
(root / "cron").mkdir(exist_ok=True)
(root / "cron" / "jobs.json").write_text("[]")
# Memories
(root / "memories").mkdir(exist_ok=True)
(root / "memories" / "notes.json").write_text("{}")
# Profiles
(root / "profiles").mkdir(exist_ok=True)
(root / "profiles" / "coder").mkdir()
(root / "profiles" / "coder" / "config.yaml").write_text("model:\n provider: anthropic\n")
(root / "profiles" / "coder" / ".env").write_text("ANTHROPIC_API_KEY=sk-ant-123\n")
# hermes-agent repo (should be EXCLUDED)
(root / "hermes-agent").mkdir(exist_ok=True)
(root / "hermes-agent" / "run_agent.py").write_text("# big file\n")
(root / "hermes-agent" / ".git").mkdir()
(root / "hermes-agent" / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
# __pycache__ (should be EXCLUDED)
(root / "plugins").mkdir(exist_ok=True)
(root / "plugins" / "__pycache__").mkdir()
(root / "plugins" / "__pycache__" / "mod.cpython-312.pyc").write_bytes(b"\x00")
# PID files (should be EXCLUDED)
(root / "gateway.pid").write_text("12345")
# Logs (should be included)
(root / "logs").mkdir(exist_ok=True)
(root / "logs" / "agent.log").write_text("log line\n")
def _symlink_file_or_skip(link: Path, target: Path) -> None:
try:
link.symlink_to(target)
except OSError as exc:
pytest.skip(f"symlinks unavailable in test environment: {exc}")
# ---------------------------------------------------------------------------
# _should_exclude tests
# ---------------------------------------------------------------------------
class TestShouldExclude:
def test_excludes_hermes_agent(self):
from hermes_cli.backup import _should_exclude
assert _should_exclude(Path("hermes-agent/run_agent.py"))
assert _should_exclude(Path("hermes-agent/.git/HEAD"))
def test_excludes_backups_dir(self):
"""backups/ is excluded so pre-update backups don't nest exponentially."""
from hermes_cli.backup import _should_exclude
assert _should_exclude(Path("backups/pre-update-2026-04-27-063400.zip"))
def test_excludes_state_snapshots_dir(self):
"""state-snapshots/ is excluded for the same reason as backups/: every
quick / pre-update snapshot holds its own copy of state.db, so zipping
the tree would ship the DB once per retained snapshot."""
from hermes_cli.backup import _EXCLUDED_DIRS, _QUICK_SNAPSHOTS_DIR, _should_exclude
assert _QUICK_SNAPSHOTS_DIR in _EXCLUDED_DIRS
assert _should_exclude(Path(_QUICK_SNAPSHOTS_DIR) / "20260814-203829-2026-08-15" / "state.db")
assert _should_exclude(Path(_QUICK_SNAPSHOTS_DIR) / "20260814-203829-2026-08-15" / "manifest.json")
# Named profiles accumulate snapshots too.
assert _should_exclude(Path("profiles/coder") / _QUICK_SNAPSHOTS_DIR / "x" / "state.db")
# The live DB is still backed up.
assert not _should_exclude(Path("state.db"))
def test_excludes_sqlite_sidecars(self):
"""SQLite WAL/SHM/journal sidecars must not ship alongside the
safe-copied .db — pairing a fresh snapshot with stale sidecar state
produces a torn restore."""
from hermes_cli.backup import _should_exclude
assert _should_exclude(Path("state.db-wal"))
assert _should_exclude(Path("state.db-shm"))
assert _should_exclude(Path("state.db-journal"))
assert _should_exclude(Path("memory_store.db-wal"))
# The .db itself is still included (and safe-copied separately)
assert not _should_exclude(Path("state.db"))
def test_excludes_managed_runtime_trees_at_root(self):
"""models/, runtimes/, and node/ at a profile-home root hold
re-downloadable GGUF weights and runtime binaries that reach
hundreds of GB — zipping them is the 20-minute-hang symptom."""
from hermes_cli.backup import _should_exclude
assert _should_exclude(Path("models/Qwen3.6-27B-Q4_K_M.gguf"))
assert _should_exclude(Path("models/assets/mmproj.gguf"))
assert _should_exclude(Path("runtimes/llamacpp/b10362/cuda/ggml-cuda.dll"))
assert _should_exclude(Path("node/node.exe"))
# Named profiles download their own copies.
assert _should_exclude(Path("profiles/clean/models/big.gguf"))
assert _should_exclude(Path("profiles/clean/runtimes/llamacpp/x.dll"))
def test_keeps_nested_dirs_named_like_runtime_trees(self):
"""A deeper directory that happens to be called models/ or node/ is
user data (a skill's assets, project files) and must survive."""
from hermes_cli.backup import _should_exclude
assert not _should_exclude(Path("skills/mlops/models/notes.md"))
assert not _should_exclude(Path("scratch/node/index.js"))
assert not _should_exclude(Path("profiles/clean/skills/x/models/a.txt"))
def test_excludes_desktop_emergency_state_db_baks(self):
"""The desktop updater's pre-flight drops timestamped
state.db.pre-update-emergency-*.bak files at the HERMES_HOME root —
backup artifacts in the same class as backups/, so a full backup
must not re-ship them."""
from hermes_cli.backup import _should_exclude
assert _should_exclude(
Path("state.db.pre-update-emergency-2026-08-15T04-55-33-619Z.bak")
)
assert _should_exclude(
Path("profiles/coder/state.db.pre-update-emergency-2026-08-15T04-55-33-619Z.bak")
)
# Other .bak files are user data and stay.
assert not _should_exclude(Path("config.yaml.bak"))
# ---------------------------------------------------------------------------
# _iter_backup_files tests
# ---------------------------------------------------------------------------
class TestIterBackupFiles:
def test_manual_and_automatic_paths_share_one_walk(self, tmp_path):
"""Both backup entry points must select the identical file set.
Before the walks were unified, the automatic pre-update path pruned
``hermes-agent`` at ANY depth, silently dropping nested skill dirs
like ``skills/autonomous-ai-agents/hermes-agent/`` that the manual
path preserved. One shared iterator makes that drift impossible;
this test pins the contract."""
from hermes_cli.backup import _iter_backup_files
root = tmp_path / ".hermes"
root.mkdir()
_make_hermes_tree(root)
# The case the old automatic walk got wrong: a nested dir named
# hermes-agent holding real skill content.
nested = root / "skills" / "autonomous-ai-agents" / "hermes-agent"
nested.mkdir(parents=True)
(nested / "SKILL.md").write_text("# nested skill\n")
# A root-level managed runtime tree that both paths must prune.
(root / "models").mkdir()
(root / "models" / "big.gguf").write_bytes(b"\x00" * 64)
out_path = tmp_path / "out.zip"
selected = {str(rel) for _, rel in _iter_backup_files(root, out_path)}
rel_nested = str(Path("skills/autonomous-ai-agents/hermes-agent/SKILL.md"))
assert rel_nested in selected
assert str(Path("models/big.gguf")) not in selected
assert not any(s.startswith("hermes-agent") for s in selected)
def test_skipped_dirs_collected_for_summary(self, tmp_path):
from hermes_cli.backup import _iter_backup_files
root = tmp_path / ".hermes"
root.mkdir()
_make_hermes_tree(root)
(root / "models").mkdir()
(root / "models" / "big.gguf").write_bytes(b"\x00")
skipped: set = set()
list(_iter_backup_files(root, tmp_path / "out.zip", skipped))
assert "models" in skipped
assert "hermes-agent" in skipped
# ---------------------------------------------------------------------------
# Backup tests
# ---------------------------------------------------------------------------
class TestBackup:
def test_db_snapshots_staged_beside_output_zip(self, tmp_path, monkeypatch):
"""SQLite staging temp files must be created on the output zip's
filesystem (dir=out_path.parent), NOT the system /tmp default — a
small tmpfs there silently drops large DBs from the backup (#35376)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
_make_hermes_tree(hermes_home)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out_dir = tmp_path / "external-drive"
out_dir.mkdir()
out_zip = out_dir / "backup.zip"
args = Namespace(output=str(out_zip))
import hermes_cli.backup as backup_mod
staged_dirs = []
real_ntf = backup_mod.tempfile.NamedTemporaryFile
def _spy(*a, **kw):
staged_dirs.append(kw.get("dir"))
return real_ntf(*a, **kw)
monkeypatch.setattr(backup_mod.tempfile, "NamedTemporaryFile", _spy)
backup_mod.run_backup(args)
# At least one .db was staged, and every staging call targeted the
# output zip's directory rather than the system temp default.
assert staged_dirs, "no SQLite snapshot was staged"
assert all(d == str(out_dir) for d in staged_dirs), staged_dirs
def test_pre_update_db_snapshots_staged_beside_output_zip(self, tmp_path, monkeypatch):
"""The pre-update/pre-migration zip path (_write_full_zip_backup) must
also stage SQLite snapshots beside its output zip, not in /tmp."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
_make_hermes_tree(hermes_home)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out_zip = hermes_home / "backups" / "pre-update-test.zip"
out_zip.parent.mkdir(parents=True, exist_ok=True)
import hermes_cli.backup as backup_mod
staged_dirs = []
real_ntf = backup_mod.tempfile.NamedTemporaryFile
def _spy(*a, **kw):
staged_dirs.append(kw.get("dir"))
return real_ntf(*a, **kw)
monkeypatch.setattr(backup_mod.tempfile, "NamedTemporaryFile", _spy)
result = backup_mod._write_full_zip_backup(out_zip, hermes_home)
assert result is not None
assert staged_dirs, "no SQLite snapshot was staged"
assert all(d == str(out_zip.parent) for d in staged_dirs), staged_dirs
def test_skips_symlinked_files(self, tmp_path, monkeypatch):
"""Backup must not dereference symlinks and leak files outside HERMES_HOME."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
_make_hermes_tree(hermes_home)
outside = tmp_path / "outside-secret.txt"
outside.write_text("outside secret\n")
_symlink_file_or_skip(hermes_home / "skills" / "outside-link.txt", outside)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out_zip = tmp_path / "backup.zip"
args = Namespace(output=str(out_zip))
from hermes_cli.backup import run_backup
run_backup(args)
with zipfile.ZipFile(out_zip, "r") as zf:
names = zf.namelist()
assert "skills/outside-link.txt" not in names
assert all(zf.read(name) != b"outside secret\n" for name in names)
def test_state_snapshots_not_nested_into_backup(self, tmp_path, monkeypatch):
"""A quick snapshot left under state-snapshots/ must not be re-shipped
by the full backup — each snapshot already holds a copy of state.db, so
nesting them multiplies the archive by (1 + retained snapshots)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
_make_hermes_tree(hermes_home)
with sqlite3.connect(hermes_home / "state.db") as conn:
conn.execute("CREATE TABLE sessions (id TEXT PRIMARY KEY)")
conn.execute("INSERT INTO sessions VALUES ('s1')")
from hermes_cli.backup import _QUICK_SNAPSHOTS_DIR, create_quick_snapshot, run_backup
# Real producer, so the layout under state-snapshots/ is whatever the
# code actually writes (manifest.json + state.db copy + ...).
snap_id = create_quick_snapshot(hermes_home=hermes_home)
assert snap_id and (hermes_home / _QUICK_SNAPSHOTS_DIR / snap_id / "state.db").exists()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out_zip = tmp_path / "backup.zip"
run_backup(Namespace(output=str(out_zip)))
with zipfile.ZipFile(out_zip, "r") as zf:
names = zf.namelist()
assert not any(n.startswith(_QUICK_SNAPSHOTS_DIR + "/") for n in names), names
# Exactly one state.db in the archive: the live one.
assert [n for n in names if n == "state.db" or n.endswith("/state.db")] == ["state.db"]
# ---------------------------------------------------------------------------
# _validate_backup_zip tests
# ---------------------------------------------------------------------------
class TestValidateBackupZip:
def _make_zip(self, zip_path: Path, filenames: list[str]) -> None:
with zipfile.ZipFile(zip_path, "w") as zf:
for name in filenames:
zf.writestr(name, "dummy")
def test_state_db_passes(self, tmp_path):
"""A zip containing state.db is accepted as a valid Hermes backup."""
from hermes_cli.backup import _validate_backup_zip
zip_path = tmp_path / "backup.zip"
self._make_zip(zip_path, ["state.db", "sessions/abc.json"])
with zipfile.ZipFile(zip_path, "r") as zf:
ok, reason = _validate_backup_zip(zf)
assert ok, reason
# ---------------------------------------------------------------------------
# Import tests
# ---------------------------------------------------------------------------
class TestImport:
def _make_backup_zip(self, zip_path: Path, files: dict[str, str | bytes]) -> None:
"""Create a test zip with given files."""
with zipfile.ZipFile(zip_path, "w") as zf:
for name, content in files.items():
if isinstance(content, bytes):
zf.writestr(name, content)
else:
zf.writestr(name, content)
def test_import_auto_installs_gateway_service(self, tmp_path, monkeypatch):
"""After a restore, run_import brings the gateway service up without
prompting — restored cron jobs and bot tokens must not sit dormant
(the install-then-import dead-gateway bug)."""
import hermes_cli.gateway as gateway_mod
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
calls = []
monkeypatch.setattr(
gateway_mod, "ensure_gateway_service",
lambda **kw: calls.append(kw) or True,
)
monkeypatch.setattr(gateway_mod, "_is_service_running", lambda: False)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {"config.yaml": "model: test\n"})
from hermes_cli.backup import run_import
run_import(Namespace(zipfile=str(zip_path), force=True))
assert calls and calls[0].get("context") == "import"
def test_import_skips_service_when_already_running(self, tmp_path, monkeypatch):
"""A live gateway is left alone — no reinstall churn during import."""
import hermes_cli.gateway as gateway_mod
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
calls = []
monkeypatch.setattr(
gateway_mod, "ensure_gateway_service",
lambda **kw: calls.append(kw) or True,
)
monkeypatch.setattr(gateway_mod, "_is_service_running", lambda: True)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {"config.yaml": "model: test\n"})
from hermes_cli.backup import run_import
run_import(Namespace(zipfile=str(zip_path), force=True))
assert not calls
def test_import_survives_service_layer_import_failure(self, tmp_path, monkeypatch, capsys):
"""If the service helpers can't even be reached, import still completes
and prints the manual fallback."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
import hermes_cli.gateway as gateway_mod
def boom():
raise RuntimeError("service layer unavailable")
monkeypatch.setattr(gateway_mod, "_is_service_running", boom)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {"config.yaml": "model: test\n"})
from hermes_cli.backup import run_import
run_import(Namespace(zipfile=str(zip_path), force=True))
out = capsys.readouterr().out
assert "Done. Your Hermes configuration has been restored." in out
assert "hermes gateway install" in out
def test_preserves_per_profile_gateway_state(self, tmp_path, monkeypatch):
"""The skip is matched by basename, so a named profile's
gateway_state.json (profiles/<name>/gateway_state.json) is preserved
the same way the root profile's is."""
hermes_home = tmp_path / ".hermes"
(hermes_home / "profiles" / "coder").mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
live_state = '{"gateway_state": "running"}'
(hermes_home / "profiles" / "coder" / "gateway_state.json").write_text(live_state)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {
"config.yaml": "model: test\n",
"profiles/coder/config.yaml": "model: anthropic\n",
"profiles/coder/gateway_state.json": '{"gateway_state": "stopped"}',
})
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
run_import(args)
# Profile config is restored, but its live gateway state is preserved.
assert (hermes_home / "profiles" / "coder" / "config.yaml").read_text() == "model: anthropic\n"
assert (
hermes_home / "profiles" / "coder" / "gateway_state.json"
).read_text() == live_state
def test_preserves_runtime_pid_and_process_files(self, tmp_path, monkeypatch):
"""gateway.pid / cron.pid / gateway.lock / processes.json from a backup
reference the source machine's process namespace and must never be
written over the target's."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# Live runtime files belonging to the target's own processes.
(hermes_home / "gateway.pid").write_text("4242")
(hermes_home / "processes.json").write_text('{"live": true}')
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {
"config.yaml": "model: test\n",
"gateway.pid": "9999",
"cron.pid": "8888",
"gateway.lock": "7777",
"processes.json": '{"stale": true}',
})
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
run_import(args)
# Live runtime files are untouched; the backup's foreign ones never land.
assert (hermes_home / "gateway.pid").read_text() == "4242"
assert (hermes_home / "processes.json").read_text() == '{"live": true}'
# cron.pid / gateway.lock had no live copy and were not seeded.
assert not (hermes_home / "cron.pid").exists()
assert not (hermes_home / "gateway.lock").exists()
@pytest.mark.skipif(os.name != "posix", reason="POSIX file permissions only")
def test_restores_secret_files_with_0600_perms(self, tmp_path, monkeypatch):
"""Secret files must end up at 0600 after restore (zipfile drops mode bits)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {
"config.yaml": "model: openrouter\n",
".env": "OPENROUTER_API_KEY=sk-secret\n",
"auth.json": '{"providers": {"nous": "token"}}',
"state.db": b"SQLite format 3\x00",
"profiles/coder/.env": "ANTHROPIC_API_KEY=sk-ant-secret\n",
})
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
run_import(args)
for rel in (".env", "auth.json", "state.db", "profiles/coder/.env"):
mode = (hermes_home / rel).stat().st_mode & 0o777
assert mode == 0o600, f"{rel} restored with mode {oct(mode)}, expected 0o600"
# ---------------------------------------------------------------------------
# Round-trip test
# ---------------------------------------------------------------------------
class TestRoundTrip:
def test_backup_then_import(self, tmp_path, monkeypatch):
"""Full round-trip: backup -> import to a new location -> verify."""
# Source
src_home = tmp_path / "source" / ".hermes"
src_home.mkdir(parents=True)
_make_hermes_tree(src_home)
monkeypatch.setenv("HERMES_HOME", str(src_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path / "source")
# Backup
out_zip = tmp_path / "roundtrip.zip"
from hermes_cli.backup import run_backup, run_import
run_backup(Namespace(output=str(out_zip)))
assert out_zip.exists()
# Import into a different location
dst_home = tmp_path / "dest" / ".hermes"
dst_home.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(dst_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path / "dest")
run_import(Namespace(zipfile=str(out_zip), force=True))
# Verify key files
assert (dst_home / "config.yaml").read_text() == "model:\n provider: openrouter\n"
assert (dst_home / ".env").read_text() == "OPENROUTER_API_KEY=sk-test-123\n"
assert (dst_home / "skills" / "my-skill" / "SKILL.md").exists()
assert (dst_home / "profiles" / "coder" / "config.yaml").exists()
assert (dst_home / "sessions" / "abc123.json").exists()
assert (dst_home / "logs" / "agent.log").exists()
# hermes-agent should NOT be present
assert not (dst_home / "hermes-agent").exists()
# __pycache__ should NOT be present
assert not (dst_home / "plugins" / "__pycache__").exists()
# PID files should NOT be present
assert not (dst_home / "gateway.pid").exists()
# ---------------------------------------------------------------------------
# Validate / detect-prefix unit tests
# ---------------------------------------------------------------------------
class TestFormatSize:
def test_bytes(self):
from hermes_cli.backup import _format_size
assert _format_size(512) == "512 B"
def test_kilobytes(self):
from hermes_cli.backup import _format_size
assert "KB" in _format_size(2048)
def test_terabytes(self):
from hermes_cli.backup import _format_size
assert "TB" in _format_size(2 * 1024 ** 4)
class TestValidation:
def test_validate_with_config(self):
"""Zip with config.yaml passes validation."""
import io
from hermes_cli.backup import _validate_backup_zip
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("config.yaml", "test")
buf.seek(0)
with zipfile.ZipFile(buf, "r") as zf:
ok, reason = _validate_backup_zip(zf)
assert ok
def test_detect_prefix_only_dirs(self):
"""Prefix detection returns empty for zip with only directory entries."""
import io
from hermes_cli.backup import _detect_prefix
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
# Only directory entries (trailing slash)
zf.writestr(".hermes/", "")
zf.writestr(".hermes/skills/", "")
buf.seek(0)
with zipfile.ZipFile(buf, "r") as zf:
assert _detect_prefix(zf) == ""
# ---------------------------------------------------------------------------
# Edge case tests for uncovered paths
# ---------------------------------------------------------------------------
class TestBackupEdgeCases:
def test_empty_hermes_home(self, tmp_path, monkeypatch):
"""Backup handles empty hermes home (no files to back up)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
# Only excluded dirs, no actual files
(hermes_home / "__pycache__").mkdir()
(hermes_home / "__pycache__" / "foo.pyc").write_bytes(b"\x00")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
args = Namespace(output=str(tmp_path / "out.zip"))
from hermes_cli.backup import run_backup
run_backup(args)
# No zip should be created
assert not (tmp_path / "out.zip").exists()
def test_pre1980_timestamp_skipped(self, tmp_path, monkeypatch):
"""Backup skips files with pre-1980 timestamps (ZIP limitation)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text("model: test\n")
# Create a file with epoch timestamp (1970-01-01)
old_file = hermes_home / "ancient.txt"
old_file.write_text("old data")
os.utime(old_file, (0, 0))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out_zip = tmp_path / "out.zip"
args = Namespace(output=str(out_zip))
from hermes_cli.backup import run_backup
run_backup(args)
# Zip should still be created with the valid files
assert out_zip.exists()
with zipfile.ZipFile(out_zip, "r") as zf:
names = zf.namelist()
assert "config.yaml" in names
# The pre-1980 file should be skipped, not crash the backup
assert "ancient.txt" not in names
class TestImportEdgeCases:
def _make_backup_zip(self, zip_path: Path, files: dict[str, str | bytes]) -> None:
with zipfile.ZipFile(zip_path, "w") as zf:
for name, content in files.items():
zf.writestr(name, content)
def test_eof_during_confirmation(self, tmp_path, monkeypatch):
"""Import handles EOFError during confirmation prompt."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text("existing\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {"config.yaml": "new\n"})
args = Namespace(zipfile=str(zip_path), force=False)
from hermes_cli.backup import run_import
with patch("builtins.input", side_effect=EOFError):
with pytest.raises(SystemExit):
run_import(args)
def test_progress_with_many_files(self, tmp_path, monkeypatch):
"""Import shows progress with 500+ files."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "big.zip"
files = {"config.yaml": "model: test\n"}
for i in range(600):
files[f"sessions/s{i:04d}.json"] = "{}"
self._make_backup_zip(zip_path, files)
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
run_import(args)
assert (hermes_home / "config.yaml").exists()
assert (hermes_home / "sessions" / "s0599.json").exists()
class _ExplodingMember:
"""Zip member whose stream dies mid-restore (ENOSPC / corrupt member).
Both the pre-fix ``dst.write(src.read())`` and the atomic
``shutil.copyfileobj`` path pull bytes through ``read()``, so injecting
here exercises whichever implementation is in the tree.
"""
def __enter__(self):
return self
def __exit__(self, *exc_info):
return False
def read(self, *args):
raise OSError(28, "No space left on device")
def close(self):
pass
def _break_member(monkeypatch, failing_member: str) -> None:
"""Make ``ZipFile.open`` hand back a dying stream for one member only."""
real_open = zipfile.ZipFile.open
def _patched(self, name, *args, **kwargs):
filename = name.filename if isinstance(name, zipfile.ZipInfo) else name
if filename == failing_member:
return _ExplodingMember()
return real_open(self, name, *args, **kwargs)
monkeypatch.setattr(zipfile.ZipFile, "open", _patched)
class TestImportAtomicWrites:
"""`hermes import` must never leave a user's file truncated.
The pre-fix code did ``open(target, "wb")`` then ``dst.write(src.read())``,
which zeroes the existing file *before* any replacement bytes exist. These
tests pin the invariant for both restore branches: the HERMES_HOME branch
and the ``_external/`` branch that writes into third-party configs under
the user's home.
"""
def _zip(self, zip_path: Path, files: dict) -> None:
with zipfile.ZipFile(zip_path, "w") as zf:
for name, content in files.items():
zf.writestr(name, content)
def test_failed_member_leaves_existing_file_intact(self, tmp_path, monkeypatch):
"""A dying member must not destroy the file it was replacing."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
original = "model: original\napi_key: keep-me\n"
(hermes_home / "config.yaml").write_text(original)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._zip(zip_path, {"config.yaml": "model: replacement\n", "state.db": ""})
_break_member(monkeypatch, "config.yaml")
from hermes_cli.backup import run_import
run_import(Namespace(zipfile=str(zip_path), force=True))
# Pre-fix this file is 0 bytes: the truncate landed, the write did not.
assert (hermes_home / "config.yaml").read_text() == original
# And the aborted write must not litter the directory it staged in.
assert list(hermes_home.glob(".config.yaml.*")) == []
def test_failed_external_member_leaves_existing_file_intact(self, tmp_path, monkeypatch):
"""Same invariant on the `_external/` branch, which writes outside HERMES_HOME."""
dst_home = tmp_path / "dst"
dst_home.mkdir()
hermes_home = dst_home / ".hermes"
hermes_home.mkdir()
honcho = dst_home / ".honcho"
honcho.mkdir()
original = '{"peer":"original"}'
(honcho / "config.json").write_text(original)
zip_path = tmp_path / "backup.zip"
self._zip(zip_path, {
"config.yaml": "model: {}\n",
"_external/.honcho/config.json": '{"peer":"replacement"}',
})
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: dst_home)
_break_member(monkeypatch, "_external/.honcho/config.json")
from hermes_cli.backup import run_import
run_import(Namespace(zipfile=str(zip_path), force=True))
assert (honcho / "config.json").read_text() == original
assert list(honcho.glob(".config.json.*")) == []
@pytest.mark.skipif(os.name != "posix", reason="POSIX symlinks")
def test_symlinked_target_keeps_its_symlink(self, tmp_path, monkeypatch):
"""A symlinked target is written through, not replaced by a regular file.
Guards the atomic rewrite against a naive ``os.replace``, which would
detach dotfiles-managed deployments (GitHub #16743). ``atomic_replace``
resolves the link first.
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
store = hermes_home / "store"
store.mkdir()
real = store / "config.yaml"
real.write_text("model: original\n")
link = hermes_home / "config.yaml"
link.symlink_to(real)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._zip(zip_path, {"config.yaml": "model: restored\n", "state.db": ""})
from hermes_cli.backup import run_import
run_import(Namespace(zipfile=str(zip_path), force=True))
assert link.is_symlink(), "import replaced the symlink with a regular file"
assert real.read_text() == "model: restored\n"
@pytest.mark.skipif(os.name != "posix", reason="POSIX symlinks")
def test_symlinked_external_target_keeps_its_symlink(self, tmp_path, monkeypatch):
"""Same guard on the `_external/` branch — the realistic dotfiles case."""
dst_home = tmp_path / "dst"
dst_home.mkdir()
hermes_home = dst_home / ".hermes"
hermes_home.mkdir()
dotfiles = dst_home / "dotfiles"
dotfiles.mkdir()
real = dotfiles / "honcho.json"
real.write_text('{"peer":"original"}')
honcho = dst_home / ".honcho"
honcho.mkdir()
link = honcho / "config.json"
link.symlink_to(real)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: dst_home)
zip_path = tmp_path / "backup.zip"
self._zip(zip_path, {
"config.yaml": "model: {}\n",
"_external/.honcho/config.json": '{"peer":"restored"}',
})
from hermes_cli.backup import run_import
run_import(Namespace(zipfile=str(zip_path), force=True))
assert link.is_symlink(), "import replaced the symlink with a regular file"
assert real.read_text() == '{"peer":"restored"}'
@pytest.mark.skipif(os.name != "posix", reason="POSIX file modes")
def test_restore_preserves_existing_file_mode(self, tmp_path, monkeypatch):
"""Staging through mkstemp must not silently tighten restored files to 0600.
``tempfile.mkstemp`` creates at 0600; the mode of the file being
replaced has to survive the publish, or Docker/NAS installs that rely
on broader permissions break on restore.
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
target = hermes_home / "config.yaml"
target.write_text("model: original\n")
os.chmod(target, 0o644)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._zip(zip_path, {"config.yaml": "model: restored\n", "state.db": ""})
from hermes_cli.backup import run_import
run_import(Namespace(zipfile=str(zip_path), force=True))
assert target.read_text() == "model: restored\n"
assert (target.stat().st_mode & 0o777) == 0o644
@pytest.mark.skipif(os.name != "posix", reason="POSIX ownership")
def test_restore_preserves_existing_file_owner(self, tmp_path, monkeypatch):
"""A root-run import must not re-own the user's files to root.
``os.replace`` swaps in a temp file owned by the *writing* user, so a
``sudo hermes import`` onto a user-owned (or Docker/NAS volume-owned)
HERMES_HOME would hand every restored file to root. The uid/gid is
forced so the assertion does not require running as root.
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
target = hermes_home / "config.yaml"
target.write_text("model: original\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._zip(zip_path, {"config.yaml": "model: restored\n", "state.db": ""})
chown_calls: list[tuple[Path, int, int]] = []
monkeypatch.setattr(
"hermes_cli.backup._preserve_file_owner",
lambda p: (123, 456) if Path(p).exists() else None,
)
monkeypatch.setattr(
"utils.os.chown",
lambda path, uid, gid: chown_calls.append((Path(path), uid, gid)),
)
from hermes_cli.backup import run_import
run_import(Namespace(zipfile=str(zip_path), force=True))
assert target.read_text() == "model: restored\n"
# config.yaml pre-existed, so its owner is captured and re-applied;
# state.db is newly created, so there is no prior owner to restore.
assert chown_calls == [(target, 123, 456)]
@pytest.mark.skipif(not hasattr(os, "fchmod"), reason="needs fchmod present to remove it")
def test_mode_is_applied_before_the_replace_without_fchmod(self, tmp_path, monkeypatch):
"""Covers the Windows branch: no ``fchmod``, so ``chmod`` the temp path.
Applying the mode only *after* ``atomic_replace`` leaves the published
file at mkstemp's 0600 until that chmod lands (and permanently if the
process dies in between), and ``atomic_replace``'s EXDEV/EBUSY
``shutil.copystat`` fallback would copy 0600 onto the target. Mirrors
the transit-window fix ``atomic_yaml_write`` already carries.
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
target = hermes_home / "config.yaml"
target.write_text("model: original\n")
os.chmod(target, 0o644)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._zip(zip_path, {"config.yaml": "model: restored\n", "state.db": ""})
import hermes_cli.backup as backup_mod
real_replace = backup_mod.atomic_replace
staged_modes: list[int] = []
def spying_replace(tmp, dst):
if Path(dst).name == "config.yaml":
staged_modes.append(os.stat(tmp).st_mode & 0o777)
return real_replace(tmp, dst)
monkeypatch.delattr(os, "fchmod")
monkeypatch.setattr(backup_mod, "atomic_replace", spying_replace)
from hermes_cli.backup import run_import
run_import(Namespace(zipfile=str(zip_path), force=True))
# Without the pre-replace chmod this reads 0o600 (mkstemp's mode).
assert staged_modes == [0o644]
assert (target.stat().st_mode & 0o777) == 0o644
@pytest.mark.skipif(os.name != "posix", reason="POSIX setuid/setgid bits")
def test_restore_does_not_carry_setuid_onto_archive_content(
self, tmp_path, monkeypatch
):
"""An imported member must not inherit a privileged target's identity.
``_preserve_file_mode`` returns ``stat.S_IMODE``, i.e. all twelve bits,
so a target sitting at 0o6755 hands setuid/setgid straight back to a
file whose contents now come from the zip. Whoever produced the
archive would then get whatever that file executes as. The other
``utils`` writers can preserve the full mode safely because they
re-serialize content this process produced; ``hermes import`` is the
one write path where the bytes are untrusted, and it is also the path
that documents ``sudo`` use for owner preservation.
The sibling assertions in this class mask with ``& 0o777``, which
discards exactly the bits at issue, so this failure is invisible to
them.
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
target = hermes_home / "helper.sh"
target.write_text("#!/bin/sh\necho original\n")
os.chmod(target, 0o6755)
if stat.S_IMODE(target.stat().st_mode) != 0o6755:
pytest.skip("filesystem refuses setuid/setgid on a user-owned file")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._zip(
zip_path,
{"helper.sh": "#!/bin/sh\necho attacker\n", "state.db": ""},
)
import hermes_cli.backup as backup_mod
real_replace = backup_mod.atomic_replace
staged_modes: list[int] = []
def spying_replace(tmp, dst):
if Path(dst).name == "helper.sh":
staged_modes.append(stat.S_IMODE(os.stat(tmp).st_mode))
return real_replace(tmp, dst)
monkeypatch.setattr(backup_mod, "atomic_replace", spying_replace)
from hermes_cli.backup import run_import
run_import(Namespace(zipfile=str(zip_path), force=True))
published = stat.S_IMODE(target.stat().st_mode)
assert target.read_text() == "#!/bin/sh\necho attacker\n"
assert not published & stat.S_ISUID, (
f"archive content kept the target's setuid bit (mode 0o{published:o})"
)
assert not published & stat.S_ISGID, (
f"archive content kept the target's setgid bit (mode 0o{published:o})"
)
# The ordinary permission bits are still preserved — this drops the
# elevated bits, it does not fall back to mkstemp's 0600.
assert published == 0o755
# And there must be no transient elevation either: the temp file is
# chmod'd before the replace, so it must never carry the bits.
assert staged_modes == [0o755], (
f"the staged temp file was elevated before publish: {staged_modes}"
)
# ---------------------------------------------------------------------------
# Profile restoration tests
# ---------------------------------------------------------------------------
class TestProfileRestoration:
def _make_backup_zip(self, zip_path: Path, files: dict[str, str | bytes]) -> None:
with zipfile.ZipFile(zip_path, "w") as zf:
for name, content in files.items():
zf.writestr(name, content)
def test_import_skips_profile_dirs_without_config(self, tmp_path, monkeypatch):
"""Import doesn't create wrappers for profile dirs without config."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
wrapper_dir = tmp_path / ".local" / "bin"
wrapper_dir.mkdir(parents=True)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {
"config.yaml": "model: test\n",
"profiles/valid/config.yaml": "model: test\n",
"profiles/empty/readme.txt": "nothing here\n",
})
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
run_import(args)
# Only valid profile should get a wrapper
assert (wrapper_dir / "valid").exists()
assert not (wrapper_dir / "empty").exists()
# ---------------------------------------------------------------------------
# SQLite safe copy tests
# ---------------------------------------------------------------------------
class TestSafeCopyDb:
def test_copies_valid_database(self, tmp_path):
from hermes_cli.backup import _safe_copy_db
src = tmp_path / "test.db"
dst = tmp_path / "copy.db"
conn = sqlite3.connect(str(src))
conn.execute("CREATE TABLE t (x INTEGER)")
conn.execute("INSERT INTO t VALUES (42)")
conn.commit()
conn.close()
result = _safe_copy_db(src, dst)
assert result is True
conn = sqlite3.connect(str(dst))
rows = conn.execute("SELECT x FROM t").fetchall()
conn.close()
assert rows == [(42,)]
def test_aborts_when_source_remains_busy_past_deadline(
self, tmp_path, monkeypatch
):
from hermes_cli import backup as backup_mod
src = tmp_path / "locked.db"
dst = tmp_path / "copy.db"
src.touch()
dst.write_bytes(b"partial")
clock = iter((100.0, 100.5, 101.1))
class FakeSourceConnection:
def backup(self, _destination, *, pages, progress, sleep):
assert pages > 0
assert sleep > 0
progress(sqlite3.SQLITE_BUSY, 0, 1)
progress(sqlite3.SQLITE_BUSY, 0, 1)
def close(self):
pass
destination_closed = []
class FakeDestinationConnection:
def close(self):
destination_closed.append(True)
connections = iter((FakeSourceConnection(), FakeDestinationConnection()))
real_unlink = Path.unlink
def assert_closed_before_unlink(path, *args, **kwargs):
assert destination_closed
return real_unlink(path, *args, **kwargs)
connect_calls = []
def fake_connect(*args, **kwargs):
connect_calls.append((args, kwargs))
return next(connections)
monkeypatch.setattr(backup_mod.sqlite3, "connect", fake_connect)
monkeypatch.setattr(backup_mod.time, "monotonic", lambda: next(clock))
monkeypatch.setattr(Path, "unlink", assert_closed_before_unlink)
assert backup_mod._safe_copy_db(src, dst, timeout_seconds=1.0) is False
assert connect_calls[0][1]["timeout"] == 0.0
assert not dst.exists()
def test_locked_source_fails_fast_not_hang(self, tmp_path):
import subprocess
import sys
import time
from hermes_cli.backup import _safe_copy_db
src = tmp_path / "locked.db"
dst = tmp_path / "copy.db"
conn = sqlite3.connect(str(src))
conn.execute("CREATE TABLE t (x INTEGER)")
conn.commit()
conn.close()
# Hold an EXCLUSIVE transaction in a separate process. POSIX file
# locks only conflict across processes, so an in-process connection
# cannot reproduce the "database is locked" condition.
holder = (
"import sqlite3, time\n"
f"c = sqlite3.connect({str(src)!r})\n"
"c.execute('BEGIN EXCLUSIVE')\n"
"print('LOCKED', flush=True)\n"
"time.sleep(60)\n"
)
proc = subprocess.Popen(
[sys.executable, "-c", holder],
stdout=subprocess.PIPE,
text=True,
)
try:
assert proc.stdout is not None
assert proc.stdout.readline().strip() == "LOCKED"
started = time.monotonic()
result = _safe_copy_db(src, dst)
elapsed = time.monotonic() - started
assert result is False
# The busy timeout is 5s, so a fast failure lands around there.
# The regression this guards against is backup() retrying
# SQLITE_BUSY forever, which would never return at all.
assert elapsed < 30
finally:
proc.kill()
proc.wait()
def test_is_zeroed_sqlite_file_detects_nul_header(self, tmp_path):
from hermes_cli.backup import is_zeroed_sqlite_file
p = tmp_path / "state.db"
p.write_bytes(bytes(4096)) # all NULs
assert is_zeroed_sqlite_file(p) is True
# ---------------------------------------------------------------------------
# Quick state snapshot tests
# ---------------------------------------------------------------------------
class TestQuickSnapshot:
@pytest.fixture
def hermes_home(self, tmp_path):
"""Create a fake HERMES_HOME with critical state files."""
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text("model:\n provider: openrouter\n")
(home / ".env").write_text("OPENROUTER_API_KEY=test-key-123\n")
(home / "auth.json").write_text('{"providers": {}}\n')
(home / "channel_aliases.json").write_text(
'{"whatsapp": {"120363408391911677@g.us": "general"}}\n'
)
(home / "cron").mkdir()
(home / "cron" / "jobs.json").write_text('{"jobs": []}\n')
# Real SQLite database
db_path = home / "state.db"
conn = sqlite3.connect(str(db_path))
conn.execute("CREATE TABLE sessions (id TEXT PRIMARY KEY, data TEXT)")
conn.execute("INSERT INTO sessions VALUES ('s1', 'hello world')")
conn.commit()
conn.close()
return home
def test_state_db_safely_copied(self, hermes_home):
from hermes_cli.backup import create_quick_snapshot
snap_id = create_quick_snapshot(hermes_home=hermes_home)
db_copy = hermes_home / "state-snapshots" / snap_id / "state.db"
assert db_copy.exists()
conn = sqlite3.connect(str(db_copy))
rows = conn.execute("SELECT * FROM sessions").fetchall()
conn.close()
assert len(rows) == 1
assert rows[0] == ("s1", "hello world")
def test_failed_state_db_copy_is_loud(self, hermes_home, monkeypatch, capsys):
"""#68474: unreadable state.db must not look like a silent success."""
from hermes_cli import backup as backup_mod
def boom(src, dst):
return False
monkeypatch.setattr(backup_mod, "_safe_copy_db", boom)
snap_id = backup_mod.create_quick_snapshot(hermes_home=hermes_home)
err = capsys.readouterr().out
assert "SQLite safe copy FAILED" in err or "CRITICAL" in err
assert "state.db" in err
# Other small files may still snapshot
if snap_id:
manifest = (hermes_home / "state-snapshots" / snap_id / "manifest.json")
assert manifest.exists()
data = json.loads(manifest.read_text(encoding="utf-8"))
assert "state.db" not in data.get("files", {})
assert "state.db" in data.get("failed_dbs", [])
def test_restore_refused_db_is_not_counted(self, hermes_home, monkeypatch):
"""A refused live-safe restore (holder detected, backup leg failed) must
not be counted as a restored file — `hermes import` reports it, and
/snapshot restore must not claim success for that file either."""
import hermes_cli.backup as backup_mod
from hermes_cli.backup import create_quick_snapshot, restore_quick_snapshot
snap_id = create_quick_snapshot(hermes_home=hermes_home)
monkeypatch.setattr(backup_mod, "_safe_restore_db", lambda src, dst: False)
restored_log: list[str] = []
real_info = backup_mod.logger.info
monkeypatch.setattr(
backup_mod.logger, "info",
lambda msg, *a, **kw: restored_log.append(msg % a if a else msg) or real_info(msg, *a, **kw),
)
restore_quick_snapshot(snap_id, hermes_home=hermes_home)
manifest = json.loads(
(backup_mod._quick_snapshot_root(hermes_home) / snap_id / "manifest.json").read_text()
)
non_db = [rel for rel in manifest.get("files", {}) if not rel.endswith(".db")]
summary = [line for line in restored_log if line.startswith("Restored ")]
assert summary, restored_log
assert summary[-1].startswith(f"Restored {len(non_db)} files"), summary[-1]
def test_restore_state_db_live_connection(self, hermes_home):
"""Restoring state.db must update data visible through a live connection.
Regression test for #65942: when state.db is open with a live SQLite
connection (as happens with the gateway, dashboard, or another CLI
session), the restore must write pages through the backup API so the
live connection sees the restored data instead of stale cached pages
from a replaced inode.
"""
from hermes_cli.backup import create_quick_snapshot, restore_quick_snapshot
snap_id = create_quick_snapshot(hermes_home=hermes_home)
# Open a live connection (simulating gateway/dashboard).
live_conn = sqlite3.connect(str(hermes_home / "state.db"))
live_conn.execute("PRAGMA journal_mode=wal")
# Insert data AFTER the snapshot — this is what must be reverted.
live_conn.execute("INSERT INTO sessions VALUES ('s2', 'new-data')")
live_conn.commit()
rows_before = live_conn.execute("SELECT * FROM sessions").fetchall()
assert len(rows_before) == 2
# Restore — the live connection stays open during restore.
result = restore_quick_snapshot(snap_id, hermes_home=hermes_home)
assert result is True
# The live connection must see the restored (single-row) state.
# A fresh connection would trivially work; the live one is the test.
rows_after = live_conn.execute("SELECT * FROM sessions").fetchall()
live_conn.close()
assert len(rows_after) == 1, (
f"Live connection still sees {len(rows_after)} rows after restore "
f"(expected 1); the extra row 's2' should have been reverted."
)
def test_snapshot_includes_pairing_directories(self, hermes_home):
"""Pairing JSONs live outside state.db — snapshot must capture them
recursively (generic + per-platform) so approved-user lists survive
disasters like #15733."""
from hermes_cli.backup import create_quick_snapshot
# Generic pairing store (new location)
(hermes_home / "platforms" / "pairing").mkdir(parents=True)
(hermes_home / "platforms" / "pairing" / "telegram-approved.json").write_text(
'{"12345": {"user_name": "alice"}}'
)
(hermes_home / "platforms" / "pairing" / "discord-approved.json").write_text(
'{"67890": {"user_name": "bob"}}'
)
# Legacy pairing store (old location)
(hermes_home / "pairing").mkdir()
(hermes_home / "pairing" / "matrix-approved.json").write_text(
'{"@charlie:server": {"user_name": "charlie"}}'
)
# Feishu's separate JSON
(hermes_home / "feishu_comment_pairing.json").write_text(
'{"doc_abc": {"allow_from": ["user_xyz"]}}'
)
snap_id = create_quick_snapshot(hermes_home=hermes_home)
assert snap_id is not None
snap_dir = hermes_home / "state-snapshots" / snap_id
assert (snap_dir / "platforms" / "pairing" / "telegram-approved.json").exists()
assert (snap_dir / "platforms" / "pairing" / "discord-approved.json").exists()
assert (snap_dir / "pairing" / "matrix-approved.json").exists()
assert (snap_dir / "feishu_comment_pairing.json").exists()
with open(snap_dir / "manifest.json") as f:
meta = json.load(f)
files = meta["files"]
assert "platforms/pairing/telegram-approved.json" in files
assert "platforms/pairing/discord-approved.json" in files
assert "pairing/matrix-approved.json" in files
assert "feishu_comment_pairing.json" in files
# ---------------------------------------------------------------------------
# Pre-update backup (hermes update safety net)
# ---------------------------------------------------------------------------
# -- security: path traversal regression coverage -----------------------
# Per @egilewski audit on PR #9217: restore_quick_snapshot must reject
# malicious snapshot_id values (the directory selector) AND malicious
# rel paths inside the manifest (the per-file selector). Both surfaces
# need explicit regression tests because they validate independent
# traversal vectors.
def test_oversized_db_suppresses_pruning(self, hermes_home, capsys):
"""#68805: an oversized state.db skipped for size must suppress
pruning so the older complete snapshot (containing the only
recoverable database) is preserved.
Reproduces the reviewer's scenario: keep=1 + a state.db exceeding
the size cap → the new snapshot omits state.db, failed_dbs stays
empty (the file wasn't unreadable, just too large), and without
tracking oversized_skipped the older complete snapshot would be
pruned — losing the only recovery copy.
"""
import json
from hermes_cli.backup import create_quick_snapshot, list_quick_snapshots
# First snapshot: complete (state.db is small, under any cap)
first_id = create_quick_snapshot(label="complete", hermes_home=hermes_home)
assert first_id is not None
first_dir = hermes_home / "state-snapshots" / first_id
assert (first_dir / "state.db").exists()
_advance_backup_clock()
# Second snapshot: state.db exceeds the 1024-byte cap → skipped for
# size, but small config files (32-54 bytes) still land in the manifest.
second_id = create_quick_snapshot(
label="oversized", hermes_home=hermes_home, max_file_size=1024, keep=1
)
assert second_id is not None
second_dir = hermes_home / "state-snapshots" / second_id
assert not (second_dir / "state.db").exists()
# Manifest must record the oversized skip
with open(second_dir / "manifest.json") as f:
meta = json.load(f)
assert "state.db" in meta.get("oversized_skipped", [])
# CRITICAL: the first (complete) snapshot must survive pruning
# because the second snapshot is incomplete (oversized state.db).
all_snaps = list_quick_snapshots(limit=100, hermes_home=hermes_home)
snap_ids = {s["id"] for s in all_snaps}
assert first_id in snap_ids, (
f"Complete snapshot {first_id} was pruned by an incomplete "
f"(oversized) snapshot — the recovery copy was lost!"
)
assert second_id in snap_ids
out = capsys.readouterr().out
assert "skipping state.db" in out.lower() or "skipping snapshot prune" in out.lower()
class TestQuickSnapshotProjectsKanban:
"""Regression for #52889: projects.db / kanban.db must survive an upgrade.
Both are per-profile user-created stores outside the git checkout. If they
are not in the pre-update snapshot, the post-update ``CREATE TABLE IF NOT
EXISTS`` runs against a missing file and every project / board row is lost.
"""
@pytest.fixture
def hermes_home(self, tmp_path):
home = tmp_path / ".hermes"
home.mkdir()
# Minimal critical file so the snapshot is non-empty.
(home / "config.yaml").write_text("model:\n provider: openrouter\n")
for name, table, row in (
("projects.db", "projects", ("p1", "demo")),
("kanban.db", "tasks", ("t1", "todo")),
):
conn = sqlite3.connect(str(home / name))
conn.execute(f"CREATE TABLE {table} (id TEXT PRIMARY KEY, data TEXT)")
conn.execute(f"INSERT INTO {table} VALUES (?, ?)", row)
conn.commit()
conn.close()
return home
def test_non_default_kanban_board_snapshotted(self, hermes_home):
"""#52889 completeness: non-default boards live at
<root>/kanban/boards/<slug>/kanban.db, not <root>/kanban.db. The
``kanban/boards`` dir entry must capture them too, or multi-board
users still lose every board except ``default`` on upgrade."""
from hermes_cli.backup import create_quick_snapshot, restore_quick_snapshot
board_dir = hermes_home / "kanban" / "boards" / "work"
board_dir.mkdir(parents=True)
conn = sqlite3.connect(str(board_dir / "kanban.db"))
conn.execute("CREATE TABLE tasks (id TEXT PRIMARY KEY, data TEXT)")
conn.execute("INSERT INTO tasks VALUES (?, ?)", ("w1", "ship"))
conn.commit()
conn.close()
snap_id = create_quick_snapshot(hermes_home=hermes_home)
copy = (
hermes_home / "state-snapshots" / snap_id
/ "kanban" / "boards" / "work" / "kanban.db"
)
assert copy.exists(), "non-default board kanban.db was not snapshotted"
# Simulate the upgrade wiping the board, then restore it.
conn = sqlite3.connect(str(board_dir / "kanban.db"))
conn.execute("DELETE FROM tasks")
conn.commit()
conn.close()
assert restore_quick_snapshot(snap_id, hermes_home=hermes_home) is True
conn = sqlite3.connect(str(board_dir / "kanban.db"))
rows = conn.execute("SELECT * FROM tasks").fetchall()
conn.close()
assert rows == [("w1", "ship")]
def test_board_db_copied_wal_safely(self, hermes_home, monkeypatch):
"""#52889 W2: a non-default board's .db (dir-branch) must go through the
WAL-safe _safe_copy_db, not a raw shutil.copy2, so an open WAL doesn't
produce an inconsistent copy."""
import hermes_cli.backup as bk
from hermes_cli.backup import create_quick_snapshot
board = hermes_home / "kanban" / "boards" / "work"
board.mkdir(parents=True)
conn = sqlite3.connect(str(board / "kanban.db"))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("CREATE TABLE tasks (id TEXT PRIMARY KEY, data TEXT)")
conn.execute("INSERT INTO tasks VALUES ('w1', 'ship')")
conn.commit()
conn.close()
called = {"db": []}
real = bk._safe_copy_db
def _spy(src, dst):
called["db"].append(str(src))
return real(src, dst)
monkeypatch.setattr(bk, "_safe_copy_db", _spy)
snap_id = create_quick_snapshot(hermes_home=hermes_home)
# The board db was copied via _safe_copy_db (not raw copy).
assert any(s.endswith("boards/work/kanban.db") for s in called["db"]), called["db"]
copy = hermes_home / "state-snapshots" / snap_id / "kanban" / "boards" / "work" / "kanban.db"
rows = sqlite3.connect(str(copy)).execute("SELECT * FROM tasks").fetchall()
assert rows == [("w1", "ship")]
class TestPreUpdateBackup:
"""Tests for create_pre_update_backup — the auto-backup ``hermes update``
runs before touching anything."""
@pytest.fixture
def hermes_home(self, tmp_path):
root = tmp_path / ".hermes"
root.mkdir()
_make_hermes_tree(root)
return root
def test_backup_contents_match_full_backup(self, hermes_home):
"""Pre-update backup should include the same user data that
``hermes backup`` would, and should exclude the same directories."""
from hermes_cli.backup import create_pre_update_backup
out = create_pre_update_backup(hermes_home=hermes_home)
assert out is not None
with zipfile.ZipFile(out) as zf:
names = set(zf.namelist())
# User data present
assert "config.yaml" in names
assert ".env" in names
assert "sessions/abc123.json" in names
assert "skills/my-skill/SKILL.md" in names
assert "profiles/coder/config.yaml" in names
# hermes-agent repo excluded
assert not any(n.startswith("hermes-agent/") for n in names)
# __pycache__ excluded
assert not any("__pycache__" in n for n in names)
# pid files excluded
assert "gateway.pid" not in names
def test_pre_update_zip_does_not_nest_the_pre_update_snapshot(self, hermes_home):
"""``hermes update`` in ``full`` mode takes the quick snapshot *before*
the full zip, so the zip walk sees the snapshot it just made. It must
skip it — otherwise every pre-update zip ships state.db twice."""
from hermes_cli.backup import (
_QUICK_SNAPSHOTS_DIR,
create_pre_update_backup,
create_quick_snapshot,
)
with sqlite3.connect(hermes_home / "state.db") as conn:
conn.execute("CREATE TABLE sessions (id TEXT PRIMARY KEY)")
snap_id = create_quick_snapshot(label="pre-update", hermes_home=hermes_home)
assert snap_id and (hermes_home / _QUICK_SNAPSHOTS_DIR / snap_id / "state.db").exists()
out = create_pre_update_backup(hermes_home=hermes_home)
assert out is not None
with zipfile.ZipFile(out) as zf:
names = zf.namelist()
assert "state.db" in names
assert not any(n.startswith(_QUICK_SNAPSHOTS_DIR + "/") for n in names), names
def test_rotation_keeps_only_n(self, hermes_home):
"""After more than ``keep`` backups are created, older ones are
pruned automatically."""
from hermes_cli.backup import create_pre_update_backup
created = []
for _ in range(5):
out = create_pre_update_backup(hermes_home=hermes_home, keep=3)
created.append(out)
_advance_backup_clock()
remaining = sorted(
p.name for p in (hermes_home / "backups").iterdir()
if p.name.startswith("pre-update-")
)
assert len(remaining) == 3
# Oldest two should have been pruned
assert created[0].name not in remaining
assert created[1].name not in remaining
# Newest three should remain
assert created[4].name in remaining
def test_skips_symlinked_files(self, hermes_home, tmp_path):
"""Pre-update backups must not dereference symlinks outside HERMES_HOME."""
from hermes_cli.backup import create_pre_update_backup
outside = tmp_path / "outside-secret.txt"
outside.write_text("outside secret\n")
_symlink_file_or_skip(hermes_home / "skills" / "outside-link.txt", outside)
out = create_pre_update_backup(hermes_home=hermes_home)
assert out is not None
with zipfile.ZipFile(out) as zf:
names = zf.namelist()
assert "skills/outside-link.txt" not in names
assert all(zf.read(name) != b"outside secret\n" for name in names)
class TestRunPreUpdateBackup:
"""Tests for the ``_run_pre_update_backup`` wrapper in main.py —
covers the consolidated off/quick/full mode gate, CLI flags, and
user-facing output."""
@pytest.fixture
def hermes_home(self, tmp_path, monkeypatch):
root = tmp_path / ".hermes"
root.mkdir()
_make_hermes_tree(root)
# Point HERMES_HOME at the temp dir so config + backup paths resolve here
monkeypatch.setenv("HERMES_HOME", str(root))
# Make Path.home() point at tmp_path for anything that uses it
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# Config reads resolve HERMES_HOME dynamically and their caches are
# keyed by config path. Do not remove shared modules from sys.modules:
# other test modules may retain imports from the existing module object.
return root
@staticmethod
def _set_mode(hermes_home, value):
import yaml
(hermes_home / "config.yaml").write_text(yaml.safe_dump({
"_config_version": 22,
"updates": {"pre_update_backup": value},
}))
@staticmethod
def _zips(hermes_home):
d = hermes_home / "backups"
return list(d.glob("pre-update-*.zip")) if d.exists() else []
@staticmethod
def _snaps(hermes_home):
d = hermes_home / "state-snapshots"
return [p for p in d.iterdir() if p.is_dir()] if d.exists() else []
def test_config_off_disables_everything_silently(self, hermes_home, capsys):
"""pre_update_backup: off — an explicit opt-out disables the quick
snapshot too (it previously ran unconditionally), with no output."""
self._set_mode(hermes_home, "off")
from hermes_cli.main import _run_pre_update_backup
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
out = capsys.readouterr().out
assert snap_id is None
assert out == ""
assert not self._snaps(hermes_home)
assert not self._zips(hermes_home)
def test_config_full_mode(self, hermes_home, capsys):
self._set_mode(hermes_home, "full")
from hermes_cli.main import _run_pre_update_backup
snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False))
out = capsys.readouterr().out
assert snap_id is not None
assert "Pre-update snapshot" in out
assert "Creating pre-update backup" in out
assert len(self._zips(hermes_home)) == 1
# ---------------------------------------------------------------------------
# Pre-migration backup (hermes claw migrate safety net)
# ---------------------------------------------------------------------------
class TestPreMigrationBackup:
"""Tests for create_pre_migration_backup — the auto-backup
``hermes claw migrate`` runs before mutating ~/.hermes/."""
@pytest.fixture
def hermes_home(self, tmp_path):
root = tmp_path / ".hermes"
root.mkdir()
_make_hermes_tree(root)
return root
def test_restorable_with_hermes_import(self, hermes_home, tmp_path):
"""The zip produced by pre-migration backup must be a valid Hermes
backup — `hermes import` should accept it."""
from hermes_cli.backup import create_pre_migration_backup, _validate_backup_zip
out = create_pre_migration_backup(hermes_home=hermes_home)
assert out is not None
with zipfile.ZipFile(out) as zf:
valid, _reason = _validate_backup_zip(zf)
assert valid, "pre-migration zip failed _validate_backup_zip"
def test_does_not_touch_pre_update_backups(self, hermes_home):
"""Pre-migration rotation must only prune pre-migration-*.zip files,
leaving pre-update-*.zip backups untouched."""
from hermes_cli.backup import create_pre_update_backup, create_pre_migration_backup
update_backup = create_pre_update_backup(hermes_home=hermes_home, keep=5)
assert update_backup is not None and update_backup.exists()
# Spin up a lot of migration backups with keep=1
for _ in range(3):
out = create_pre_migration_backup(hermes_home=hermes_home, keep=1)
assert out is not None
_advance_backup_clock()
# Update backup must still be there
assert update_backup.exists(), "pre-migration rotation wrongly pruned the pre-update backup"
# ---------------------------------------------------------------------------
# Cron jobs auto-restore after silent migration loss (issue #34600)
# ---------------------------------------------------------------------------
class TestRestoreCronJobsIfEmptied:
"""`hermes update` config migration can leave cron/jobs.json valid-but-empty,
silently dropping every scheduled job. `restore_cron_jobs_if_emptied` is the
post-migration safety net that restores from the pre-update snapshot."""
@staticmethod
def _seed_jobs(path: Path, jobs):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"jobs": jobs}))
def _make_snapshot(self, hermes_home: Path, label="pre-update"):
from hermes_cli.backup import create_quick_snapshot
return create_quick_snapshot(label=label, hermes_home=hermes_home, keep=5)
def test_restores_when_emptied_after_migration(self, tmp_path):
from hermes_cli.backup import restore_cron_jobs_if_emptied
hermes_home = tmp_path / ".hermes"
jobs_path = hermes_home / "cron" / "jobs.json"
# Pre-update: 3 real jobs.
self._seed_jobs(jobs_path, [{"id": "a"}, {"id": "b"}, {"id": "c"}])
snap_id = self._make_snapshot(hermes_home)
assert snap_id
# Migration silently empties the file (valid JSON, zero jobs).
jobs_path.write_text(json.dumps({"jobs": []}))
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
assert result is not None
assert result["restored"] is True
assert result["job_count"] == 3
assert result["snapshot_id"] == snap_id
# The live file now has the jobs back.
restored = json.loads(jobs_path.read_text())
assert len(restored["jobs"]) == 3
def test_restores_when_partial_job_loss(self, tmp_path):
"""Desktop scheduler overwrites jobs.json with its own small set,
losing tool-created crons while keeping desktop-tracked ones."""
from hermes_cli.backup import restore_cron_jobs_if_emptied
hermes_home = tmp_path / ".hermes"
jobs_path = hermes_home / "cron" / "jobs.json"
# Pre-update: 19 jobs (18 tool-created + 1 desktop watchdog).
self._seed_jobs(
jobs_path,
[{"id": f"job-{i}"} for i in range(19)],
)
snap_id = self._make_snapshot(hermes_home)
assert snap_id
# Desktop scheduler overwrites with only its own 1 job.
jobs_path.write_text(json.dumps({"jobs": [{"id": "desktop-watchdog"}]}))
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
assert result is not None
assert result["restored"] is True
assert result["job_count"] == 19
# The live file now has all 19 jobs back.
restored = json.loads(jobs_path.read_text())
assert len(restored["jobs"]) == 19
# ---------------------------------------------------------------------------
# config.yaml model/provider + MoA auto-restore after silent update rewrite
# (issue #64160)
# ---------------------------------------------------------------------------
class TestRestoreConfigModelSettingsIfRewritten:
"""Desktop update/repair cycles have rewritten user-set model.provider /
model.default and dropped the moa: section (#64160).
`restore_config_model_settings_if_rewritten` is the post-update safety net
that restores only the protected keys from the pre-update snapshot."""
USER_CONFIG = (
"_config_version: 39\n"
"model:\n"
" provider: custom\n"
" default: zyphra/zamba-3-large\n"
" base_url: https://api.zyphra.example/v1\n"
"moa:\n"
" enabled: true\n"
" presets:\n"
" council:\n"
" aggregator: {provider: custom, model: zyphra/zamba-3-large}\n"
"custom_unknown_key:\n"
" hello: world\n"
)
def _make_snapshot(self, hermes_home: Path, label="pre-update"):
from hermes_cli.backup import create_quick_snapshot
return create_quick_snapshot(label=label, hermes_home=hermes_home, keep=5)
def _seed(self, hermes_home: Path) -> Path:
hermes_home.mkdir(parents=True, exist_ok=True)
cfg = hermes_home / "config.yaml"
cfg.write_text(self.USER_CONFIG, encoding="utf-8")
return cfg
def test_restores_rewritten_provider_and_dropped_moa(self, tmp_path):
import yaml
from hermes_cli.backup import restore_config_model_settings_if_rewritten
hermes_home = tmp_path / ".hermes"
cfg = self._seed(hermes_home)
snap_id = self._make_snapshot(hermes_home)
assert snap_id
# The update flow rewrites config.yaml with defaults: provider flips
# to deepseek, MoA section is gone (the #64160 field report).
cfg.write_text(
"_config_version: 39\nmodel:\n provider: deepseek\n default: deepseek-chat\n",
encoding="utf-8",
)
result = restore_config_model_settings_if_rewritten(
snap_id, hermes_home=hermes_home
)
assert result is not None
assert result["restored"] is True
assert result["snapshot_id"] == snap_id
assert "model.provider" in result["keys"]
assert "model.default" in result["keys"]
assert "moa" in result["keys"]
after = yaml.safe_load(cfg.read_text(encoding="utf-8"))
assert after["model"]["provider"] == "custom"
assert after["model"]["default"] == "zyphra/zamba-3-large"
assert after["model"]["base_url"] == "https://api.zyphra.example/v1"
assert after["moa"]["enabled"] is True
assert after["moa"]["presets"]["council"]["aggregator"]["model"] == (
"zyphra/zamba-3-large"
)
def test_noop_when_config_untouched(self, tmp_path):
from hermes_cli.backup import restore_config_model_settings_if_rewritten
hermes_home = tmp_path / ".hermes"
cfg = self._seed(hermes_home)
snap_id = self._make_snapshot(hermes_home)
before = cfg.read_text(encoding="utf-8")
result = restore_config_model_settings_if_rewritten(
snap_id, hermes_home=hermes_home
)
assert result is None
assert cfg.read_text(encoding="utf-8") == before
def test_preserves_legitimate_update_writes(self, tmp_path):
"""Only protected keys are restored — a version bump or a new section
the migration legitimately wrote must survive the restore."""
import yaml
from hermes_cli.backup import restore_config_model_settings_if_rewritten
hermes_home = tmp_path / ".hermes"
cfg = self._seed(hermes_home)
snap_id = self._make_snapshot(hermes_home)
cfg.write_text(
"_config_version: 40\n" # legitimate migration bump
"new_section:\n added: true\n" # legitimate new default
"model:\n provider: deepseek\n", # illegitimate rewrite
encoding="utf-8",
)
result = restore_config_model_settings_if_rewritten(
snap_id, hermes_home=hermes_home
)
assert result is not None
after = yaml.safe_load(cfg.read_text(encoding="utf-8"))
assert after["model"]["provider"] == "custom" # restored
assert after["_config_version"] == 40 # kept
assert after["new_section"] == {"added": True} # kept
def test_noop_when_user_never_set_protected_keys(self, tmp_path):
"""A config that never had model.provider/moa set gets no restore even
if the update writes those keys fresh — nothing of the user's was lost."""
from hermes_cli.backup import restore_config_model_settings_if_rewritten
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir(parents=True)
cfg = hermes_home / "config.yaml"
cfg.write_text("_config_version: 39\nagent: {}\n", encoding="utf-8")
snap_id = self._make_snapshot(hermes_home)
cfg.write_text(
"_config_version: 39\nmodel:\n provider: deepseek\n", encoding="utf-8"
)
result = restore_config_model_settings_if_rewritten(
snap_id, hermes_home=hermes_home
)
assert result is None
def test_noop_on_unreadable_live_config(self, tmp_path):
"""An unparseable live config is a different failure the user must see;
the safety net leaves it alone (mirrors the cron net's posture)."""
from hermes_cli.backup import restore_config_model_settings_if_rewritten
hermes_home = tmp_path / ".hermes"
cfg = self._seed(hermes_home)
snap_id = self._make_snapshot(hermes_home)
cfg.write_text(": not [valid yaml", encoding="utf-8")
result = restore_config_model_settings_if_rewritten(
snap_id, hermes_home=hermes_home
)
assert result is None
assert cfg.read_text(encoding="utf-8") == ": not [valid yaml"
def test_noop_without_snapshot_id(self, tmp_path):
from hermes_cli.backup import restore_config_model_settings_if_rewritten
assert restore_config_model_settings_if_rewritten(
"", hermes_home=tmp_path / ".hermes"
) is None
# ---------------------------------------------------------------------------
# Memory-provider external paths (~/.honcho, ~/.hindsight, ...) — captured via
# MemoryProvider.backup_paths() and restored to their original home-relative
# location, NOT under HERMES_HOME. (backup/import cycle data-loss fix)
# ---------------------------------------------------------------------------
class TestMemoryProviderExternalPaths:
def _make_min_tree(self, hermes_home: Path) -> None:
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "config.yaml").write_text("model:\n provider: openrouter\n")
(hermes_home / ".env").write_text("OPENROUTER_API_KEY=sk-test\n")
(hermes_home / "state.db").write_bytes(b"x")
def test_backup_skips_external_paths_outside_home(self, tmp_path, monkeypatch):
"""A declared path outside the home dir is not portable and must be
skipped, never archived."""
hermes_home = tmp_path / ".hermes"
self._make_min_tree(hermes_home)
outside = tmp_path.parent / "outside-home-secret"
outside.mkdir(exist_ok=True)
(outside / "leak.json").write_text('{"secret":1}')
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
import hermes_cli.backup as backup_mod
monkeypatch.setattr(
backup_mod, "_collect_memory_provider_external_paths", lambda: [outside]
)
out_zip = tmp_path / "backup.zip"
backup_mod.run_backup(Namespace(output=str(out_zip)))
with zipfile.ZipFile(out_zip) as zf:
names = set(zf.namelist())
assert not any(n.startswith("_external/") for n in names)
assert not any("leak.json" in n for n in names)
(outside / "leak.json").unlink()
outside.rmdir()
def test_import_restores_external_to_home_relative_location(self, tmp_path, monkeypatch):
"""_external/ members restore to ~/<relpath>, not under HERMES_HOME,
and credential-shaped files get 0600."""
dst_home = tmp_path / "dst"
dst_home.mkdir()
hermes_home = dst_home / ".hermes"
hermes_home.mkdir()
zip_path = tmp_path / "backup.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("config.yaml", "model: {}\n")
zf.writestr(".env", "X=1\n")
zf.writestr("state.db", "")
zf.writestr("_external/.honcho/config.json", '{"peer":"bob"}')
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: dst_home)
from hermes_cli.backup import run_import
run_import(Namespace(zipfile=str(zip_path), force=True))
restored = dst_home / ".honcho" / "config.json"
assert restored.exists()
assert restored.read_text() == '{"peer":"bob"}'
# Credential-shaped file tightened.
assert (restored.stat().st_mode & 0o777) == 0o600
# External state did NOT leak into HERMES_HOME.
assert not (hermes_home / "_external").exists()
# ---------------------------------------------------------------------------
# run_import: HERMES_HOME override handling (issue #99839)
# ---------------------------------------------------------------------------
class TestImportHonorsHermesHomeOverride:
"""`hermes import` must restore into the home the command runs under.
Resolving the target through get_default_hermes_root() maps a profile
home (<root>/profiles/<name>) back to <root>: the import then overwrites
the live root's config.yaml while the profile directory stays empty —
exactly what "Target:" printed it would NOT do.
"""
def _make_backup_zip(self, tmp_path):
import zipfile
src_root = tmp_path / "src-home"
src_root.mkdir()
(src_root / "config.yaml").write_text("model:\n provider: anthropic\n")
(src_root / ".env").write_text("ANTHROPIC_API_KEY=sk-test\n")
zip_path = tmp_path / "backup.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.write(src_root / "config.yaml", "config.yaml")
zf.write(src_root / ".env", ".env")
return zip_path
def test_import_targets_named_profile_home(self, tmp_path, monkeypatch):
"""HERMES_HOME=<root>/profiles/<name> must restore INTO the profile,
not into <root> (which would clobber the live root config)."""
root = tmp_path / "hermes-root"
profile = root / "profiles" / "coder"
profile.mkdir(parents=True)
# Live root config that must survive untouched.
(root / "config.yaml").write_text("model:\n provider: openai\n")
monkeypatch.setenv("HERMES_HOME", str(profile))
from hermes_constants import get_hermes_home
assert get_hermes_home() == profile
zip_path = self._make_backup_zip(tmp_path)
import argparse
from hermes_cli.backup import run_import
args = argparse.Namespace(zipfile=str(zip_path), force=True)
run_import(args)
assert (profile / "config.yaml").read_text() == (
"model:\n provider: anthropic\n"
)
assert (root / "config.yaml").read_text() == "model:\n provider: openai\n"
def test_import_skips_gateway_install_for_non_default_home(
self, tmp_path, monkeypatch
):
"""A restore into a sandbox must not silently start a second gateway
pointed at it — the profile/sandbox gateway would shadow the default
service installed by the primary install."""
native_default = tmp_path / "native-default"
sandbox = tmp_path / "sandbox-home"
sandbox.mkdir(parents=True)
# Live default install markers.
native_default.mkdir()
(native_default / "config.yaml").write_text("model:\n provider: openai\n")
monkeypatch.setenv("HERMES_HOME", str(sandbox))
import argparse
import hermes_constants
from hermes_cli import backup as backup_mod
monkeypatch.setattr(
backup_mod,
"_get_platform_default_hermes_home",
lambda: native_default,
)
calls = []
monkeypatch.setattr(
"hermes_cli.gateway.ensure_gateway_service",
lambda *a, **kw: calls.append(kw),
)
monkeypatch.setattr(
"hermes_cli.gateway._is_service_running",
lambda: False,
)
zip_path = self._make_backup_zip(tmp_path)
args = argparse.Namespace(zipfile=str(zip_path), force=True)
backup_mod.run_import(args)
assert calls == [], "gateway must not be auto-installed for a sandbox restore"
def test_import_installs_gateway_when_default_home_is_target(
self, tmp_path, monkeypatch
):
"""Restoring into the default home keeps the auto-install behavior."""
native_default = tmp_path / "native-default"
native_default.mkdir()
monkeypatch.setenv("HERMES_HOME", str(native_default))
import argparse
from hermes_cli import backup as backup_mod
monkeypatch.setattr(
backup_mod,
"_get_platform_default_hermes_home",
lambda: native_default,
)
calls = []
monkeypatch.setattr(
"hermes_cli.gateway.ensure_gateway_service",
lambda *a, **kw: calls.append(kw),
)
monkeypatch.setattr(
"hermes_cli.gateway._is_service_running",
lambda: False,
)
zip_path = self._make_backup_zip(tmp_path)
args = argparse.Namespace(zipfile=str(zip_path), force=True)
backup_mod.run_import(args)
assert calls and calls[0].get("context") == "import"
# ---------------------------------------------------------------------------
# Live session database import (issue #100960)
# ---------------------------------------------------------------------------
def _write_session_db(path: Path, sessions: int, messages_per_session: int) -> None:
"""Create a minimal Hermes-shaped session database at *path*."""
conn = sqlite3.connect(str(path))
try:
conn.execute(
"CREATE TABLE IF NOT EXISTS sessions "
"(session_id TEXT PRIMARY KEY, message_count INTEGER)"
)
conn.execute(
"CREATE TABLE IF NOT EXISTS messages "
"(id INTEGER PRIMARY KEY, session_id TEXT, content TEXT)"
)
for s in range(sessions):
sid = f"sess-{s}"
conn.execute(
"INSERT INTO sessions VALUES (?, ?)", (sid, messages_per_session)
)
for m in range(messages_per_session):
conn.execute(
"INSERT INTO messages (session_id, content) VALUES (?, ?)",
(sid, f"{sid}-msg-{m}"),
)
conn.commit()
finally:
conn.close()
class TestImportLiveSessionDatabase:
"""`hermes import` must not swap the inode of a database Hermes holds open.
Publishing state.db with a rename leaves any live gateway/dashboard/WebUI
connection reading and writing the unlinked inode, so its sessions vanish
from the database everyone else opens and nothing is logged (#100960).
"""
def _zip_with_db(self, zip_path: Path, db_path: Path) -> None:
with zipfile.ZipFile(zip_path, "w") as zf:
zf.write(db_path, "state.db")
def _prepare(self, tmp_path, monkeypatch, live=(3, 4), backup=(2, 2)):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
live_db = home / "state.db"
_write_session_db(live_db, *live)
staged = tmp_path / "backup-state.db"
_write_session_db(staged, *backup)
zip_path = tmp_path / "backup.zip"
self._zip_with_db(zip_path, staged)
return home, live_db, zip_path
def test_live_holder_sees_imported_rows(self, tmp_path, monkeypatch):
"""A connection open across the import converges on the imported data."""
from hermes_cli.backup import run_import
home, live_db, zip_path = self._prepare(tmp_path, monkeypatch)
holder = sqlite3.connect(str(live_db))
# Read first so the connection has cached pages of the pre-import file.
assert holder.execute("SELECT COUNT(*) FROM messages").fetchone()[0] == 12
inode_before = os.stat(live_db).st_ino
try:
run_import(Namespace(zipfile=str(zip_path), force=True))
assert holder.execute("SELECT COUNT(*) FROM messages").fetchone()[0] == 4
finally:
holder.close()
assert os.stat(live_db).st_ino == inode_before
assert _count_rows(live_db) == (2, 4)
def test_older_backup_reports_replaced_sessions(self, tmp_path, monkeypatch, capsys):
"""Importing a backup that predates recorded work says what it dropped."""
from hermes_cli.backup import run_import
home, live_db, zip_path = self._prepare(tmp_path, monkeypatch)
run_import(Namespace(zipfile=str(zip_path), force=True))
out = capsys.readouterr().out
assert "Session data replaced by older backup contents" in out
assert "3 session(s) / 12 message(s) -> 2 / 4" in out
def test_newer_backup_reports_nothing(self, tmp_path, monkeypatch, capsys):
"""No warning when the import does not shrink the database."""
from hermes_cli.backup import run_import
home, live_db, zip_path = self._prepare(
tmp_path, monkeypatch, live=(1, 1), backup=(3, 4)
)
run_import(Namespace(zipfile=str(zip_path), force=True))
out = capsys.readouterr().out
assert "Session data replaced by older backup contents" not in out
def test_refused_restore_is_reported_and_leaves_db_intact(
self, tmp_path, monkeypatch, capsys
):
"""A refused live-safe restore is a warning, not a counted success."""
import hermes_cli.backup as backup_mod
home, live_db, zip_path = self._prepare(tmp_path, monkeypatch)
monkeypatch.setattr(backup_mod, "_safe_restore_db", lambda src, dst: False)
backup_mod.run_import(Namespace(zipfile=str(zip_path), force=True))
out = capsys.readouterr().out
assert "files skipped" in out
assert "state.db" in out
# The pre-import database is still the one on disk.
assert _count_rows(live_db) == (3, 12)
def test_sidecar_members_are_not_installed_beside_a_restored_db(
self, tmp_path, monkeypatch
):
"""A `state.db-wal` member from an old/hand-built archive must not be
os.replace'd next to the page-restored database: it describes a
different image and SQLite would replay it on the next open."""
from hermes_cli.backup import run_import
home, live_db, zip_path = self._prepare(tmp_path, monkeypatch)
with zipfile.ZipFile(zip_path, "a") as zf:
zf.writestr("state.db-wal", b"foreign-wal-from-archive")
zf.writestr("state.db-shm", b"foreign-shm")
zf.writestr("state.db-journal", b"foreign-journal")
run_import(Namespace(zipfile=str(zip_path), force=True))
for suffix, payload in (
("-wal", b"foreign-wal-from-archive"),
("-shm", b"foreign-shm"),
("-journal", b"foreign-journal"),
):
sidecar = live_db.with_name("state.db" + suffix)
assert not sidecar.exists() or sidecar.read_bytes() != payload, suffix
assert _count_rows(live_db) == (2, 4)
def test_missing_target_takes_the_plain_publish(self, tmp_path, monkeypatch):
"""A fresh install has no inode to preserve; the member still lands."""
from hermes_cli.backup import run_import
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
staged = tmp_path / "backup-state.db"
_write_session_db(staged, 2, 3)
zip_path = tmp_path / "backup.zip"
self._zip_with_db(zip_path, staged)
run_import(Namespace(zipfile=str(zip_path), force=True))
assert _count_rows(home / "state.db") == (2, 6)
def _count_rows(db_path: Path) -> tuple[int, int]:
conn = sqlite3.connect(str(db_path))
try:
return (
conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0],
conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0],
)
finally:
conn.close()