Files
hermes-agent/tests/hermes_cli/test_profile_pm_portability.py
ethernet 1c8fae6180 fix(pm): preserve runtime and user state across failure paths
Keep downloads bound to their remote representation and publish through
atomic destination-local staging. Serialize shared partial ownership.

Keep explicit CA trust scoped to provider probes. Preserve checkpoint
history and edited files, validate all profile inputs before dependency
publication, and separate data removal from installed runtime ownership.

Exclude machine-specific PM state from portable transfers. Keep plugin
files and nested skill tools intact. Preserve native test isolation.

Focused native Windows receipts cover the individual repairs and their
integration. This commit does not claim a full-suite or release build.
2026-09-09 15:17:08 -04:00

187 lines
8.4 KiB
Python

"""Portable transfers keep user files, not machine-specific PM state."""
from argparse import Namespace
from pathlib import Path
import tarfile
import zipfile
import pytest
@pytest.fixture
def transfer_home(tmp_path, monkeypatch):
home = tmp_path / "source-home"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
from hermes_cli import backup, gateway, profiles
# Restoring data must not install host services or modify the test user's shell.
monkeypatch.setattr(gateway, "ensure_gateway_service", lambda **kwargs: False)
monkeypatch.setattr(gateway, "_is_service_running", lambda: False)
monkeypatch.setattr(profiles, "check_alias_collision", lambda name: "test has no aliases")
monkeypatch.setattr(backup, "_collect_memory_provider_external_paths", lambda: [])
return home
def _write_files(root, files):
for rel, content in files.items():
target = root / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
@pytest.mark.parametrize("route", ["backup", "default", "named", "distribution", "explicit"])
def test_transfers_preserve_user_files_without_porting_pm_state(
transfer_home, tmp_path, monkeypatch, route
):
from hermes_cli.backup import run_backup, run_import
from hermes_cli.profile_distribution import (
DistributionManifest, install_distribution, write_manifest,
)
from hermes_cli.profiles import export_profile, get_profile_dir, import_profile
portable = {
"config.yaml": "model:\n model: portable\n",
"SOUL.md": "Be kind.\n",
"plugins/example/facts.json": '{"owner": "plugin"}\n',
"skills/example/tools/helper.py": "print('portable tool')\n",
"skills/example/cache/notes.txt": "user-authored data\n",
}
runtime = {
"installs/install-id/facts.json": '{"environment": "C:/other-machine/generation"}\n',
"installs/install-id/environments/generation/pyvenv.cfg": "home = other-machine\n",
"tools/uv/bin/uv": "machine executable\n",
"cache/partials/transfer.partial": "unfinished transfer\n",
}
source = get_profile_dir("porter") if route == "named" else transfer_home
_write_files(source, portable | runtime)
if route == "backup":
portable |= {f"profiles/coder/{rel}": text for rel, text in list(portable.items())}
runtime |= {f"profiles/coder/{rel}": text for rel, text in list(runtime.items())}
_write_files(source, portable | runtime)
archive = tmp_path / "portable.zip"
run_backup(Namespace(output=str(archive)))
with zipfile.ZipFile(archive) as reader:
names = set(reader.namelist())
elif route in {"default", "named"}:
name = "porter" if route == "named" else "default"
archive = export_profile(name, str(tmp_path / "portable.tar.gz"))
with tarfile.open(archive) as reader:
names = {member.name.removeprefix(f"{name}/") for member in reader if member.isfile()}
else:
owned = sorted({Path(rel).parts[0] for rel in portable | runtime}) if route == "explicit" else []
write_manifest(source, DistributionManifest(name="portable", distribution_owned=owned))
names = None
if names is not None:
assert set(portable) <= names
assert not (set(runtime) & names)
target_home = tmp_path / "target-home"
target_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(target_home))
if route == "backup":
run_import(Namespace(zipfile=str(archive), force=True))
restored = target_home
elif route in {"default", "named"}:
restored = import_profile(str(archive), name="restored")
else:
restored = install_distribution(str(source), name="restored").target_dir
for rel, content in portable.items():
assert (restored / rel).read_text(encoding="utf-8") == content, (route, rel)
for rel, content in runtime.items():
assert not (restored / rel).exists(), (route, rel)
assert (source / rel).read_text(encoding="utf-8") == content
@pytest.mark.parametrize("route", ["backup", "profile", "profile-denied", "distribution", "explicit"])
def test_incoming_runtime_state_never_replaces_target_runtime(
transfer_home, tmp_path, monkeypatch, route
):
from hermes_cli.backup import run_import
from hermes_cli.profile_distribution import (
DistributionManifest, install_distribution, write_manifest,
)
from hermes_cli.profiles import get_profile_dir, import_profile
incoming = {
"config.yaml": "model:\n model: restored\n",
"plugins/example/facts.json": '{"owner": "plugin"}\n',
"skills/example/tools/helper.py": "print('user tool')\n",
"skills/example/cache/notes.txt": "keep user files\n",
}
machine = {
"installs/key/facts.json": "foreign generation\n",
"tools/uv/bin/uv": "foreign executable\n",
"cache": "foreign cache file\n",
}
if route == "backup":
incoming |= {f"profiles/coder/{rel}": text for rel, text in list(incoming.items())}
machine |= {f"profiles/coder/{rel}": text for rel, text in list(machine.items())}
staged = tmp_path / "archive-source"
_write_files(staged, incoming | machine)
target = transfer_home if route == "backup" else get_profile_dir("restored")
if not route.startswith("profile"):
_write_files(target, dict.fromkeys(machine, "target-owned runtime\n"))
if route == "backup":
archive = tmp_path / "legacy.zip"
with zipfile.ZipFile(archive, "w") as writer:
for rel, content in (incoming | machine).items():
writer.writestr(f".hermes/{rel}", content)
run_import(Namespace(zipfile=str(archive), force=True))
elif route.startswith("profile"):
archive = tmp_path / "legacy.tar.gz"
with tarfile.open(archive, "w:gz") as writer:
writer.add(staged, arcname="legacy")
if route == "profile-denied":
from hermes_cli import profiles
real_rmtree = profiles.shutil.rmtree
def deny_runtime_removal(path, *args, **kwargs):
if Path(path).name == "installs":
if kwargs.get("ignore_errors"):
return
raise PermissionError("staged runtime is locked")
return real_rmtree(path, *args, **kwargs)
with monkeypatch.context() as scoped:
scoped.setattr(profiles.shutil, "rmtree", deny_runtime_removal)
with pytest.raises(PermissionError, match="staged runtime is locked"):
import_profile(str(archive), name="restored")
assert not target.exists()
return
target = import_profile(str(archive), name="restored")
else:
owned = sorted({Path(rel).parts[0] for rel in incoming | machine}) if route == "explicit" else []
write_manifest(staged, DistributionManifest(name="legacy", distribution_owned=owned))
target = install_distribution(str(staged), name="restored", force=True).target_dir
for rel, content in incoming.items():
assert (target / rel).read_text(encoding="utf-8") == content, (route, rel)
for rel, content in machine.items():
if route == "profile":
assert not (target / rel).exists(), rel
else:
assert (target / rel).read_text(encoding="utf-8") == "target-owned runtime\n", (route, rel)
assert (staged / rel).read_text(encoding="utf-8") == content
if route == "explicit":
# Windows-authored manifests use separators that must not bypass ownership.
write_manifest(staged, DistributionManifest(
name="legacy", distribution_owned=[rel.replace("/", "\\") for rel in incoming | machine],
))
install_distribution(str(staged), name="restored", force=True)
for rel in machine:
assert (target / rel).read_text(encoding="utf-8") == "target-owned runtime\n", rel
elif route == "backup":
# An in-home traversal must not bypass the runtime-root classification.
with zipfile.ZipFile(archive, "w") as writer:
writer.writestr(".hermes/config.yaml", incoming["config.yaml"])
writer.writestr(".hermes/skills/../installs/key/facts.json", "foreign generation\n")
run_import(Namespace(zipfile=str(archive), force=True))
assert (target / "installs/key/facts.json").read_text(encoding="utf-8") == "target-owned runtime\n"