diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index e7e1e8ed9..0064881c4 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -64,6 +64,39 @@ _EXCLUDED_NAMES = { "cron.pid", } +# File names that ``hermes import`` must never overwrite, matched by basename so +# they're caught for the root profile (``gateway_state.json``) and for named +# profiles alike (``profiles//gateway_state.json``). +# +# These hold *volatile gateway/process runtime state that is namespaced to the +# machine or container the backup was taken on* — PIDs in a dead process +# namespace, a runtime lock, the process registry, and the gateway's last +# recorded run/desired state. Restoring them onto a different host (or a hosted +# container) is at best meaningless and at worst actively harmful: +# +# - ``gateway_state.json`` drives the container-boot reconciler +# (``container_boot._read_desired_state``), which only auto-starts a +# gateway whose recorded state is ``running``. A backup taken from a +# machine where the gateway was stopped (or carrying a stale/foreign +# value) overwrites the container's own state and leaves the gateway +# stuck "starting"/"cooking", disconnecting it from the Nous portal +# (NS-508 / the second half of NS-501). +# - ``gateway.pid`` / ``cron.pid`` / ``gateway.lock`` / ``processes.json`` +# reference PIDs and locks in the *source* machine's process namespace; a +# numerically-equal PID in the new environment is a different process. +# These mirror exactly what ``container_boot._STALE_RUNTIME_FILES`` already +# sweeps on every container boot. +# +# Older backups predate the backup-side exclusions, so we filter on import too +# rather than trusting the archive's contents. +_IMPORT_SKIP_NAMES = { + "gateway_state.json", + "gateway.pid", + "cron.pid", + "gateway.lock", + "processes.json", +} + # zipfile.open() drops Unix mode bits on extract; restore tightens these to 0600. _SECRET_FILE_NAMES = {".env", "auth.json", "state.db"} @@ -385,6 +418,7 @@ def run_import(args) -> None: errors = [] restored = 0 + skipped_runtime: list[str] = [] t0 = time.monotonic() for member in members: @@ -397,6 +431,16 @@ def run_import(args) -> None: if not rel: continue + # Never overwrite volatile gateway/process runtime state. These are + # namespaced to the machine/container the backup was taken on; + # clobbering them (especially gateway_state.json) breaks the gateway + # reconciler on the target and disconnects hosted instances from the + # Nous portal. Matched by basename so both the root profile and + # named profiles (profiles//gateway_state.json) are covered. + if Path(rel).name in _IMPORT_SKIP_NAMES: + skipped_runtime.append(rel) + continue + target = hermes_root / rel # Security: reject absolute paths and traversals @@ -433,6 +477,16 @@ def run_import(args) -> None: if len(errors) > 10: print(f" ... and {len(errors) - 10} more") + if skipped_runtime: + print( + f"\n Preserved {len(skipped_runtime)} runtime state " + f"file(s) (kept this machine's, not the backup's):" + ) + for rel in sorted(skipped_runtime)[:10]: + print(f" {rel}") + if len(skipped_runtime) > 10: + print(f" ... and {len(skipped_runtime) - 10} more") + # Post-import: restore profile wrapper scripts profiles_dir = hermes_root / "profiles" restored_profiles = [] diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index 07a6c5546..cf98655e4 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -543,6 +543,126 @@ class TestImport: # traversal file should NOT exist outside hermes home assert not (tmp_path / "etc" / "passwd").exists() + def test_preserves_live_gateway_state(self, tmp_path, monkeypatch): + """Import must not overwrite the target's gateway_state.json. + + The backup carries the *source* machine's gateway run/desired state. + Restoring it onto a hosted container drives the boot reconciler off + stale/foreign state and leaves the gateway stuck "starting", + disconnecting it from the Nous portal (NS-508). The live file wins. + """ + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + # The target (e.g. hosted container) already has its own live state. + live_state = '{"gateway_state": "running", "desired_state": "running"}' + (hermes_home / "gateway_state.json").write_text(live_state) + + zip_path = tmp_path / "backup.zip" + self._make_backup_zip(zip_path, { + "config.yaml": "model: test\n", + # A backup from a laptop where the gateway was stopped. + "gateway_state.json": '{"gateway_state": "stopped", "desired_state": "stopped"}', + }) + + args = Namespace(zipfile=str(zip_path), force=True) + + from hermes_cli.backup import run_import + run_import(args) + + # config.yaml is restored normally... + assert (hermes_home / "config.yaml").read_text() == "model: test\n" + # ...but the live gateway_state.json is untouched. + assert (hermes_home / "gateway_state.json").read_text() == live_state + + def test_does_not_seed_gateway_state_when_absent(self, tmp_path, monkeypatch): + """A backup's gateway_state.json is dropped, not written, when the + target has none — a foreign state must never seed the reconciler.""" + 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: test\n", + "gateway_state.json": '{"gateway_state": "stopped"}', + }) + + 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 not (hermes_home / "gateway_state.json").exists() + + 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//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() + def test_confirmation_prompt_abort(self, tmp_path, monkeypatch): """Import aborts when user says no to confirmation.""" hermes_home = tmp_path / ".hermes"