Files
hermes-agent/tests/hermes_cli/test_update_zip_fallback_guards.py
Teknium c57e829d0b refactor: ZIP swap journal and recovery live in _early_recovery_zip
Pure move, behaviour-identical. The ZIP swap journal, owner lock and
journal-driven recovery (~250 lines) are unrelated to the git-marker
restore, yet had grown _early_recovery.py from 684 to 1455 lines. They now
live in the topical sibling hermes_cli/_early_recovery_zip.py.

restore_interrupted_pull imports it only when the journal exists, so the
git-marker repair and its published closure (RECOVERY_CLOSURE) still need
only _early_recovery.py; ZIP_SWAP_JOURNAL stays there for that fast-path
stat. write_durable_text stays too (the marker and fleet writers use it).
Importers and the one monkeypatch seam (_lock_fd, read by the lock) now
point at the module that reads them. (review K132361 shape gate)
2026-10-06 06:22:29 -07:00

381 lines
16 KiB
Python

"""ZIP fallback must not fire on dependency failures or clobber a dirty tree.
Issue #87304: on Windows the update ``try`` spans git pull *and* ``uv pip
install``. A locked ``hermes.exe`` makes the install exit 2, the handler
prints ``Git update failed``, and ``_update_via_zip`` replaces every
top-level entry except ``venv`` / ``node_modules`` / ``.git`` / ``.env`` —
permanently deleting uncommitted edits and untracked files. The git pull
has already succeeded by then, so the ZIP cannot fix the actual failure.
"""
from __future__ import annotations
import shutil
import subprocess
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from hermes_cli import main as hermes_main
import hermes_cli.main_install_repair as main_install_repair
from hermes_cli import update_cmd
def _cpe(cmd, returncode=2, stderr="", stdout="") -> subprocess.CalledProcessError:
exc = subprocess.CalledProcessError(returncode, cmd)
exc.stderr = stderr
exc.stdout = stdout
return exc
# ---------------------------------------------------------------------------
# Stage classification + ZIP gating
# ---------------------------------------------------------------------------
def test_uv_pip_install_is_a_dependency_failure_not_git():
exc = _cpe([r"C:\venv\Scripts\uv.exe", "pip", "install", "-e", "."])
assert update_cmd._called_process_error_is_git(exc) is False
assert update_cmd._called_process_error_is_python_dep_install(exc) is True
assert update_cmd._format_update_failure_stage(exc) == (
"Python dependency install failed"
)
def test_venv_pip_install_is_a_dependency_failure():
exc = _cpe([r"C:\venv\Scripts\python.exe", "-m", "pip", "install", "-e", "."])
assert update_cmd._called_process_error_is_python_dep_install(exc) is True
assert update_cmd._called_process_error_is_git(exc) is False
def test_ensurepip_is_a_dependency_failure():
exc = _cpe([r"C:\venv\Scripts\python.exe", "-m", "ensurepip", "--upgrade"])
assert update_cmd._called_process_error_is_python_dep_install(exc) is True
assert update_cmd._format_update_failure_stage(exc) == (
"Python dependency install failed"
)
def test_git_pull_is_classified_as_git():
exc = _cpe(["git", "-c", "windows.appendAtomically=false", "pull"], returncode=1)
assert update_cmd._called_process_error_is_git(exc) is True
assert update_cmd._called_process_error_is_python_dep_install(exc) is False
assert update_cmd._format_update_failure_stage(exc) == "Git update failed"
def test_git_exe_path_is_still_git():
exc = _cpe([r"C:\Program Files\Git\cmd\git.exe", "fetch", "origin", "main"])
assert update_cmd._called_process_error_is_git(exc) is True
def test_unknown_command_gets_generic_stage():
exc = _cpe(["npm", "install"], returncode=1)
assert update_cmd._format_update_failure_stage(exc) == "Update step failed"
def test_posix_git_failure_does_not_zip(monkeypatch):
monkeypatch.setattr(hermes_main, "_is_windows", lambda: False)
monkeypatch.setattr(main_install_repair, "_is_windows", lambda: False)
exc = _cpe(["git", "pull"], returncode=1)
assert update_cmd._should_zip_fallback_on_update_error(exc) is False
# ---------------------------------------------------------------------------
# Dirty-tree overlay guard
# ---------------------------------------------------------------------------
def _porcelain_run(stdout: str, returncode: int = 0):
def fake_run(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
if "status" in joined and "--porcelain" in joined:
return subprocess.CompletedProcess(cmd, returncode, stdout=stdout, stderr="")
if "ls-tree" in joined: # tracked root entries = what the ZIP ships
return subprocess.CompletedProcess(cmd, 0, stdout="hermes_cli\nscratch\n", stderr="")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return fake_run
def test_zip_overlay_allowed_without_git(tmp_path):
assert update_cmd._zip_overlay_block_reason(tmp_path) is None
def test_zip_overlay_blocked_on_modified_file(tmp_path, monkeypatch):
(tmp_path / ".git").mkdir()
monkeypatch.setattr(
update_cmd.subprocess, "run", _porcelain_run(" M hermes_cli/update_cmd.py\n")
)
reason = update_cmd._zip_overlay_block_reason(tmp_path)
assert reason is not None
assert "uncommitted" in reason
def test_zip_overlay_blocked_on_untracked_file(tmp_path, monkeypatch):
(tmp_path / ".git").mkdir()
monkeypatch.setattr(update_cmd.subprocess, "run", _porcelain_run("?? notes.md\n"))
reason = update_cmd._zip_overlay_block_reason(tmp_path)
assert reason is not None
assert "untracked" in reason
def test_zip_overlay_blocked_when_git_status_fails(tmp_path, monkeypatch):
(tmp_path / ".git").mkdir()
monkeypatch.setattr(
update_cmd.subprocess,
"run",
_porcelain_run("", returncode=128),
)
reason = update_cmd._zip_overlay_block_reason(tmp_path)
assert reason is not None
assert "could not check" in reason
def test_zip_overlay_allowed_on_clean_git_checkout(tmp_path, monkeypatch):
(tmp_path / ".git").mkdir()
monkeypatch.setattr(update_cmd.subprocess, "run", _porcelain_run(""))
assert update_cmd._zip_overlay_block_reason(tmp_path) is None
def test_update_via_zip_aborts_before_download_when_dirty(
tmp_path, monkeypatch, capsys
):
"""The live tree must not be touched, and the ZIP must not be fetched."""
fake_root = tmp_path / "install"
fake_root.mkdir()
(fake_root / ".git").mkdir()
local = fake_root / "keep-me.txt"
local.write_text("local work\n", encoding="utf-8")
untracked_dir = fake_root / "agent" / "scratch"
untracked_dir.mkdir(parents=True)
(untracked_dir / "wip.py").write_text("print('wip')\n", encoding="utf-8")
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", fake_root)
monkeypatch.setattr(
update_cmd.subprocess,
"run",
_porcelain_run(" M keep-me.txt\n?? agent/scratch/wip.py\n"),
)
with patch("urllib.request.urlretrieve") as download:
with pytest.raises(SystemExit) as exc_info:
update_cmd._update_via_zip(SimpleNamespace(branch=None))
assert exc_info.value.code == 1
download.assert_not_called()
assert local.read_text(encoding="utf-8") == "local work\n"
assert (untracked_dir / "wip.py").read_text(encoding="utf-8") == "print('wip')\n"
# ---------------------------------------------------------------------------
# Pre-swap TOCTOU re-check
# ---------------------------------------------------------------------------
def test_status_uses_untracked_files_all(tmp_path, monkeypatch):
"""A user git config hiding untracked files must not blind the guard."""
(tmp_path / ".git").mkdir()
seen = []
def fake_run(cmd, **kwargs):
seen.append(cmd)
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
monkeypatch.setattr(update_cmd.subprocess, "run", fake_run)
update_cmd._zip_overlay_block_reason(tmp_path)
assert seen and "--untracked-files=all" in seen[0]
def test_staging_artifact_lines_are_recognized():
def is_artifact(line):
return update_cmd._is_zip_staging_artifact_status_line(line, ["agent", "cli.py", "tools"])
assert is_artifact("?? agent.hermes-update-staging/")
assert is_artifact("?? cli.py.hermes-update-staging")
# Staging already removed every backup: one at re-check time is not this run's (review F15).
assert not is_artifact("?? tools.hermes-update-old/")
# Nested user files under a staging-lookalike directory don't match the
# top-level test only when the TOP level itself is not an artifact.
assert not is_artifact("?? agent/scratch/wip.py")
assert not is_artifact(" M hermes_cli/update_cmd.py")
assert not is_artifact("?? notes.hermes-update-staging.txt")
def test_recheck_ignores_own_staging_artifacts(tmp_path, monkeypatch):
(tmp_path / ".git").mkdir()
monkeypatch.setattr(
update_cmd.subprocess,
"run",
_porcelain_run("?? agent.hermes-update-staging/\n?? cli.py.hermes-update-staging\n"),
)
assert (
update_cmd._zip_overlay_block_reason(tmp_path, staged=["agent", "cli.py"])
is None
)
# Without the flag the same output still refuses (pre-download check).
assert update_cmd._zip_overlay_block_reason(tmp_path) is not None
def test_recheck_still_blocks_user_files_amid_staging_artifacts(tmp_path, monkeypatch):
(tmp_path / ".git").mkdir()
monkeypatch.setattr(
update_cmd.subprocess,
"run",
_porcelain_run("?? agent.hermes-update-staging/\n?? my-notes.md\n"),
)
reason = update_cmd._zip_overlay_block_reason(
tmp_path, staged=["agent"]
)
assert reason is not None
def test_zip_overlay_blocked_on_ignored_user_file(tmp_path, monkeypatch):
"""#87392 follow-up: a gitignored file outside the preserved entries is
still user data the overlay would delete — it must block."""
(tmp_path / ".git").mkdir()
monkeypatch.setattr(
update_cmd.subprocess, "run", _porcelain_run("!! scratch/notes.local\n")
)
reason = update_cmd._zip_overlay_block_reason(tmp_path)
assert reason is not None
assert "uncommitted" in reason or "untracked" in reason
def test_zip_overlay_flag_is_valid_against_real_git(tmp_path):
"""The ignored-mode flag must be one real git accepts — run REAL git.
Review of the first draft caught ``--ignored=all`` (not a valid mode:
git exits 128 'Invalid ignored mode'), which the mocked siblings could
not see; with an invalid flag every ZIP update would be refused as
'could not check the working tree'. This test creates a real repo with
a real .gitignore and asserts the guard both runs clean AND still sees
ignored user files.
"""
subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
(tmp_path / ".gitignore").write_text("*.local\nvenv/\n.venv/\n", encoding="utf-8")
subprocess.run(
["git", "-C", str(tmp_path), "add", ".gitignore"], check=True
)
subprocess.run(
[
"git", "-C", str(tmp_path),
"-c", "user.email=t@t", "-c", "user.name=t",
"commit", "-qm", "init",
],
check=True,
)
# Clean tree: guard must pass (flag valid, no false refusal).
assert update_cmd._zip_overlay_block_reason(tmp_path) is None
# Ignored user file under a shipped (tracked) dir: the swap would delete it, guard must block.
(tmp_path / "pkg").mkdir()
(tmp_path / "pkg" / "data.local").write_text("x", encoding="utf-8")
reason = update_cmd._zip_overlay_block_reason(tmp_path, shipped={"pkg"})
assert reason is not None
# Ignored preserved entry: still no refusal.
shutil.rmtree(tmp_path / "pkg")
(tmp_path / "venv").mkdir()
(tmp_path / "venv" / "lib.py").write_text("x", encoding="utf-8")
assert update_cmd._zip_overlay_block_reason(tmp_path) is None
# uv-default ``.venv`` is a supported layout (#112958): the ignored dir is the live runtime,
# not user data the overlay would destroy — refusing here made ZIP fallback impossible.
(tmp_path / ".venv").mkdir()
(tmp_path / ".venv" / "lib.py").write_text("x", encoding="utf-8")
status = subprocess.run(
["git", "-C", str(tmp_path), "status", "--porcelain", "--untracked-files=all", "--ignored=matching"],
capture_output=True, text=True,
).stdout
assert update_cmd._zip_overlay_block_reason(tmp_path) is None, status
def _git_install(root):
subprocess.run(["git", "init", "-q", str(root)], check=True)
(root / "alpha").mkdir()
(root / "alpha" / "x.py").write_text("x = 1\n", encoding="utf-8")
(root / "cli.py").write_text("print('cli')\n", encoding="utf-8")
subprocess.run(["git", "-C", str(root), "add", "-A"], check=True)
subprocess.run(["git", "-C", str(root), "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "init"],
check=True)
@pytest.mark.parametrize("name", ["alpha.hermes-update-staging", "cli.py.hermes-update-old"])
def test_zip_gate_refuses_and_keeps_a_user_file_named_like_an_update_artifact(tmp_path, monkeypatch, name):
"""Staging deletes ``<entry>.hermes-update-staging``/``-old``: a user's own file by that name (no
journal claims it) is user work the gate must refuse on, leaving it untouched."""
_git_install(tmp_path)
(tmp_path / name).write_text("PRECIOUS USER BYTES", encoding="utf-8")
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", tmp_path)
with pytest.raises(SystemExit) as exc_info:
update_cmd._abort_zip_update_if_dirty_tree()
assert exc_info.value.code == 1
assert (tmp_path / name).read_text(encoding="utf-8-sig") == "PRECIOUS USER BYTES"
def test_zip_gate_refuses_on_a_user_file_at_the_old_fixed_journal_temp_name(tmp_path, monkeypatch):
"""The gate exempted ``.hermes-update-zip-swap.tmp`` as Hermes' own before any journal existed, and
the journal writer then deleted it (review Z5). Only the writer's unpredictable temp is exempt now."""
_git_install(tmp_path)
user = tmp_path / ".hermes-update-zip-swap.tmp"
user.write_text("USER NOTES", encoding="utf-8")
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", tmp_path)
with pytest.raises(SystemExit) as exc_info:
update_cmd._abort_zip_update_if_dirty_tree()
assert exc_info.value.code == 1 and user.read_text(encoding="utf-8-sig") == "USER NOTES"
assert update_cmd._is_zip_staging_artifact_status_line("?? .hermes-update-zip-swap.0123456789ab.tmp")
def test_zip_gate_admits_the_retry_after_an_interrupted_swap(tmp_path, monkeypatch):
"""The siblings an interrupted swap's journal owns never refuse the retry: they are settled first."""
from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, write_zip_swap_journal, zip_entry_identity
_git_install(tmp_path)
leftover = tmp_path / "alpha.hermes-update-staging"
leftover.mkdir()
(leftover / "x.py").write_text("x = 2\n", encoding="utf-8")
write_zip_swap_journal(tmp_path, "staging", [["alpha", True, zip_entry_identity(leftover), ""],
["cli.py", True, "", ""]], "0123456789ab")
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", tmp_path)
update_cmd._abort_zip_update_if_dirty_tree()
assert not leftover.exists() and not (tmp_path / ZIP_SWAP_JOURNAL).exists()
assert (tmp_path / "alpha" / "x.py").read_text(encoding="utf-8-sig") == "x = 1\n"
def test_preserved_filter_does_not_split_non_rename_lines():
"""A plain ignored FILE literally named 'venv -> node_modules' is ONE
path (porcelain v1 doesn't quote spaces) — splitting it would filter it
as two preserved tops and fail-open into the destructive swap."""
assert not update_cmd._is_zip_preserved_entry_status_line(
"!! venv -> node_modules"
)
assert not update_cmd._is_zip_preserved_entry_status_line(
"?? venv -> node_modules"
)
# Real rename crossing out of a preserved dir still blocks…
assert not update_cmd._is_zip_preserved_entry_status_line(
"R venv/x -> src/x"
)
# …and a rename fully inside preserved entries is still filtered.
assert update_cmd._is_zip_preserved_entry_status_line(
"R venv/a -> node_modules/b"
)
def test_zip_overlay_allows_ignored_preserved_entries(tmp_path, monkeypatch):
"""venv/node_modules are gitignored on every normal install and the swap
preserves them — the ignored probe must not turn them into a false
refusal."""
(tmp_path / ".git").mkdir()
monkeypatch.setattr(
update_cmd.subprocess,
"run",
_porcelain_run("!! venv/\n!! node_modules/\n!! .env\n"),
)
assert update_cmd._zip_overlay_block_reason(tmp_path) is None