mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
The desktop self-update chain (Desktop -> hermes-setup --update -> hermes update -> hermes desktop --build-only -> relaunch) rebuilds Hermes.exe on the user's machine and declared success on bare file EXISTENCE. A truncated PE (corrupt cached Electron zip / interrupted extraction or rcedit rewrite / full disk) or a wrong-architecture unpacked tree therefore shipped as the 'updated' app, which Windows refuses to load with 'This app can't run on your computer' (此应用无法在你的电脑上运行) — and the previous working build had already been wiped by before-pack.mjs, leaving nothing to fall back to. Fix, in three parts: - hermes_cli/main.py: post-build integrity gate on Windows (_ensure_desktop_exe_launchable). Parses the PE header of the freshly built Hermes.exe — MZ/PE magic, section-table completeness vs file size (catches truncation), and COFF machine vs the host arch (catches arm64/x64 mixups). On failure it purges the (likely corrupt) cached Electron zip, invalidates the content-hash build stamp so the updater's retry-once genuinely re-downloads and rebuilds, restores the previous build from the .bak tree when one exists (keeping the corrupt tree as .corrupt for diagnostics), tells the user the update was aborted and their old version kept, and exits nonzero. _desktop_packaged_executable also now prefers a host-loadable PE over pure newest-mtime when multiple win-*-unpacked trees coexist. - apps/desktop/scripts/before-pack.mjs: on win32, the previous unpacked tree is preserved as <appOutDir>.bak (only when it holds the product exe — partial/corrupt trees still get the plain wipe) instead of being destroyed, providing the rollback material for the gate above. Non-Windows behavior is unchanged. - Behavior-contract tests: tests/hermes_cli/test_desktop_exe_integrity.py (23 tests — synthetic PE fixtures for truncation/non-PE/arch-mismatch, rollback semantics, and the build-only exit contract) and 6 new vitest cases in before-pack.test.mjs for the .bak preservation rules. Progresses #69179
376 lines
16 KiB
Python
376 lines
16 KiB
Python
"""Behavior tests for the Windows desktop-exe integrity gate (#69179).
|
|
|
|
The desktop self-update chain (Desktop → hermes-setup --update →
|
|
``hermes update`` → ``hermes desktop --build-only`` → relaunch) rebuilds
|
|
Hermes.exe on the end user's machine. Before this gate, "build succeeded" was
|
|
just "the file exists", so a truncated PE (corrupt cached Electron zip), a
|
|
non-PE file, or a wrong-architecture tree shipped as the new app — Windows
|
|
then refuses to launch it with "This app can't run on your computer"
|
|
(此应用无法在你的电脑上运行) and the user has no working install left.
|
|
|
|
These tests exercise the behavior contract only: synthetic PE files go in,
|
|
verdicts/rollbacks come out.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import main as cli_main
|
|
|
|
PE_AMD64 = 0x8664
|
|
PE_ARM64 = 0xAA64
|
|
PE_I386 = 0x014C
|
|
|
|
|
|
def make_pe(path: Path, machine: int = PE_AMD64, *, truncate_to: int | None = None) -> Path:
|
|
"""Write a minimal, structurally-complete PE file with one section.
|
|
|
|
Layout: DOS header (e_lfanew=0x80) → PE signature + COFF header at 0x80 →
|
|
one 40-byte section entry whose raw data spans [0x200, 0x400). Total file
|
|
size 0x400 unless ``truncate_to`` cuts it short (the corrupt-download
|
|
shape).
|
|
"""
|
|
buf = bytearray(0x400)
|
|
buf[0:2] = b"MZ"
|
|
struct.pack_into("<I", buf, 0x3C, 0x80)
|
|
buf[0x80:0x84] = b"PE\x00\x00"
|
|
# COFF: machine, nsections=1, timestamp, symtab ptr, nsyms, opt-hdr size=0, chars
|
|
struct.pack_into("<HHIIIHH", buf, 0x84, machine, 1, 0, 0, 0, 0, 0x0002)
|
|
# Section table entry at 0x80 + 24 = 0x98; SizeOfRawData @+16, PointerToRawData @+20
|
|
section_off = 0x98
|
|
struct.pack_into("<II", buf, section_off + 16, 0x200, 0x200)
|
|
data = bytes(buf if truncate_to is None else buf[:truncate_to])
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data)
|
|
return path
|
|
|
|
|
|
# ─── _parse_pe_machine ──────────────────────────────────────────────────────
|
|
|
|
|
|
def test_parse_pe_machine_reads_machine_field(tmp_path):
|
|
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64)
|
|
assert cli_main._parse_pe_machine(exe) == PE_AMD64
|
|
|
|
exe_arm = make_pe(tmp_path / "arm.exe", PE_ARM64)
|
|
assert cli_main._parse_pe_machine(exe_arm) == PE_ARM64
|
|
|
|
|
|
def test_parse_pe_machine_rejects_non_pe_file(tmp_path):
|
|
"""An HTML error page saved as .exe — the classic corrupted-download body."""
|
|
fake = tmp_path / "Hermes.exe"
|
|
fake.write_bytes(b"<html><body>404 Not Found</body></html>" + b" " * 600)
|
|
with pytest.raises(ValueError, match="MZ"):
|
|
cli_main._parse_pe_machine(fake)
|
|
|
|
|
|
def test_parse_pe_machine_rejects_tiny_file(tmp_path):
|
|
stub = tmp_path / "Hermes.exe"
|
|
stub.write_bytes(b"MZ")
|
|
with pytest.raises(ValueError, match="too small"):
|
|
cli_main._parse_pe_machine(stub)
|
|
|
|
|
|
def test_parse_pe_machine_rejects_truncated_sections(tmp_path):
|
|
"""File cut mid-download: headers parse but section data extends past EOF."""
|
|
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64, truncate_to=0x300)
|
|
with pytest.raises(ValueError, match="truncated"):
|
|
cli_main._parse_pe_machine(exe)
|
|
|
|
|
|
def test_parse_pe_machine_rejects_bad_pe_signature(tmp_path):
|
|
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64)
|
|
data = bytearray(exe.read_bytes())
|
|
data[0x80:0x84] = b"XX\x00\x00"
|
|
exe.write_bytes(bytes(data))
|
|
with pytest.raises(ValueError, match="PE signature"):
|
|
cli_main._parse_pe_machine(exe)
|
|
|
|
|
|
# ─── _expected_windows_pe_machines ──────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("host", "loadable", "not_loadable"),
|
|
[
|
|
("AMD64", {PE_AMD64, PE_I386}, {PE_ARM64}),
|
|
("ARM64", {PE_ARM64, PE_AMD64}, {PE_I386}),
|
|
("x86", {PE_I386}, {PE_AMD64, PE_ARM64}),
|
|
],
|
|
)
|
|
def test_expected_machines_per_host(host, loadable, not_loadable):
|
|
with patch("platform.machine", return_value=host):
|
|
expected = cli_main._expected_windows_pe_machines()
|
|
assert loadable <= expected
|
|
assert not (not_loadable & expected)
|
|
|
|
|
|
def test_expected_machines_unknown_host_is_permissive():
|
|
"""The gate must never brick launch on hosts we don't recognize."""
|
|
with patch("platform.machine", return_value="RISCV64"):
|
|
expected = cli_main._expected_windows_pe_machines()
|
|
assert {PE_AMD64, PE_ARM64, PE_I386} <= expected
|
|
|
|
|
|
# ─── _desktop_exe_integrity_error ───────────────────────────────────────────
|
|
|
|
|
|
def test_integrity_error_none_for_matching_arch(tmp_path):
|
|
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64)
|
|
with patch("platform.machine", return_value="AMD64"):
|
|
assert cli_main._desktop_exe_integrity_error(exe) is None
|
|
|
|
|
|
def test_integrity_error_reports_arch_mismatch(tmp_path):
|
|
"""ARM64 exe on the reporter's 'Windows 10 AMD64' host — the wrong-arch
|
|
flavor of 'This app can't run on your computer'."""
|
|
exe = make_pe(tmp_path / "Hermes.exe", PE_ARM64)
|
|
with patch("platform.machine", return_value="AMD64"):
|
|
error = cli_main._desktop_exe_integrity_error(exe)
|
|
assert error is not None and "architecture mismatch" in error
|
|
assert "ARM64" in error
|
|
|
|
|
|
def test_integrity_error_reports_corruption_reason(tmp_path):
|
|
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64, truncate_to=0x300)
|
|
error = cli_main._desktop_exe_integrity_error(exe)
|
|
assert error is not None and "truncated" in error
|
|
|
|
|
|
# ─── _desktop_packaged_executable arch preference (win32) ───────────────────
|
|
|
|
|
|
def test_packaged_executable_prefers_host_arch_over_mtime(tmp_path, monkeypatch):
|
|
"""A newer wrong-arch tree must not shadow the loadable one (#69179)."""
|
|
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
|
desktop_dir = tmp_path / "apps" / "desktop"
|
|
good = make_pe(desktop_dir / "release" / "win-unpacked" / "Hermes.exe", PE_AMD64)
|
|
bad = make_pe(desktop_dir / "release" / "win-arm64-unpacked" / "Hermes.exe", PE_ARM64)
|
|
# Make the wrong-arch tree the newest, which the pure-mtime pick would take.
|
|
import os
|
|
|
|
os.utime(bad, (bad.stat().st_atime + 1000, bad.stat().st_mtime + 1000))
|
|
|
|
with patch("platform.machine", return_value="AMD64"):
|
|
assert cli_main._desktop_packaged_executable(desktop_dir) == good
|
|
|
|
|
|
def test_packaged_executable_falls_back_to_mtime_when_unparseable(tmp_path, monkeypatch):
|
|
"""Non-PE stubs (dev trees, tests) keep the historical newest-wins pick."""
|
|
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
|
desktop_dir = tmp_path / "apps" / "desktop"
|
|
a = desktop_dir / "release" / "win-unpacked" / "Hermes.exe"
|
|
b = desktop_dir / "release" / "win-arm64-unpacked" / "Hermes.exe"
|
|
for p in (a, b):
|
|
p.parent.mkdir(parents=True)
|
|
p.write_text("", encoding="utf-8")
|
|
import os
|
|
|
|
os.utime(b, (b.stat().st_atime + 1000, b.stat().st_mtime + 1000))
|
|
with patch("platform.machine", return_value="AMD64"):
|
|
assert cli_main._desktop_packaged_executable(desktop_dir) == b
|
|
|
|
|
|
# ─── rollback ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _win_tree(tmp_path: Path) -> tuple[Path, Path]:
|
|
desktop_dir = tmp_path / "apps" / "desktop"
|
|
exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe"
|
|
return desktop_dir, exe
|
|
|
|
|
|
def test_rollback_restores_backup_and_keeps_corrupt_copy(tmp_path):
|
|
desktop_dir, exe = _win_tree(tmp_path)
|
|
make_pe(exe, PE_AMD64, truncate_to=0x300) # corrupt new build
|
|
backup_exe = desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe"
|
|
make_pe(backup_exe, PE_AMD64) # valid old build
|
|
|
|
with patch("platform.machine", return_value="AMD64"):
|
|
restored = cli_main._rollback_desktop_from_backup(exe)
|
|
|
|
assert restored == exe
|
|
# The restored exe is the old, valid build.
|
|
assert cli_main._parse_pe_machine(exe) == PE_AMD64
|
|
assert exe.stat().st_size == 0x400
|
|
# Corrupt tree preserved for diagnostics; backup consumed.
|
|
assert (desktop_dir / "release" / "win-unpacked.corrupt" / "Hermes.exe").exists()
|
|
assert not backup_exe.exists()
|
|
|
|
|
|
def test_rollback_returns_none_without_backup(tmp_path):
|
|
_, exe = _win_tree(tmp_path)
|
|
make_pe(exe, PE_AMD64, truncate_to=0x300)
|
|
assert cli_main._rollback_desktop_from_backup(exe) is None
|
|
# The corrupt tree is left in place (nothing to restore over it).
|
|
assert exe.exists()
|
|
|
|
|
|
def test_rollback_refuses_corrupt_backup(tmp_path):
|
|
"""Never 'restore' a backup that would also fail to launch."""
|
|
desktop_dir, exe = _win_tree(tmp_path)
|
|
make_pe(exe, PE_AMD64, truncate_to=0x300)
|
|
backup_exe = desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe"
|
|
make_pe(backup_exe, PE_AMD64, truncate_to=0x280) # backup also corrupt
|
|
|
|
assert cli_main._rollback_desktop_from_backup(exe) is None
|
|
assert backup_exe.exists() # untouched
|
|
|
|
|
|
# ─── _ensure_desktop_exe_launchable (the gate) ──────────────────────────────
|
|
|
|
|
|
def test_gate_passes_valid_exe(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
|
desktop_dir, exe = _win_tree(tmp_path)
|
|
make_pe(exe, PE_AMD64)
|
|
with patch("platform.machine", return_value="AMD64"):
|
|
verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe)
|
|
assert verified == exe
|
|
assert rolled_back is False
|
|
|
|
|
|
def test_gate_noop_off_windows(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(cli_main.sys, "platform", "linux")
|
|
desktop_dir, exe = _win_tree(tmp_path)
|
|
exe.parent.mkdir(parents=True)
|
|
exe.write_text("not a pe at all", encoding="utf-8")
|
|
verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe)
|
|
assert verified == exe
|
|
assert rolled_back is False
|
|
|
|
|
|
def test_gate_rolls_back_corrupt_exe_and_purges_cache(tmp_path, monkeypatch, capsys):
|
|
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
|
desktop_dir, exe = _win_tree(tmp_path)
|
|
make_pe(exe, PE_AMD64, truncate_to=0x300)
|
|
make_pe(desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe", PE_AMD64)
|
|
|
|
stamp = tmp_path / "home" / "desktop-build-stamp.json"
|
|
stamp.parent.mkdir(parents=True, exist_ok=True)
|
|
stamp.write_text("{}", encoding="utf-8")
|
|
|
|
with patch("platform.machine", return_value="AMD64"), \
|
|
patch("hermes_cli.main._purge_electron_build_cache", return_value=[]) as mock_purge, \
|
|
patch("hermes_cli.main._desktop_stamp_path", return_value=stamp):
|
|
verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe)
|
|
|
|
assert rolled_back is True
|
|
assert verified == exe
|
|
assert cli_main._parse_pe_machine(exe) == PE_AMD64 # restored old build
|
|
mock_purge.assert_called_once()
|
|
assert not stamp.exists() # stamp invalidated so retry genuinely rebuilds
|
|
out = capsys.readouterr().out
|
|
assert "integrity check" in out
|
|
assert "Update aborted" in out
|
|
assert "existing version was kept" in out
|
|
|
|
|
|
def test_gate_fails_clearly_without_backup(tmp_path, monkeypatch, capsys):
|
|
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
|
desktop_dir, exe = _win_tree(tmp_path)
|
|
fake = exe
|
|
fake.parent.mkdir(parents=True)
|
|
fake.write_bytes(b"<html>proxy error</html>" + b" " * 600)
|
|
|
|
with patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), \
|
|
patch("hermes_cli.main._desktop_stamp_path", return_value=tmp_path / "stamp.json"):
|
|
verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe)
|
|
|
|
assert verified is None
|
|
assert rolled_back is False
|
|
out = capsys.readouterr().out
|
|
assert "integrity check" in out
|
|
assert "No usable backup" in out
|
|
|
|
|
|
# ─── end-to-end: `hermes desktop --build-only` exits nonzero on corrupt exe ─
|
|
|
|
|
|
def _ns(**kw):
|
|
defaults = dict(
|
|
skip_build=False,
|
|
build_only=True,
|
|
force_build=False,
|
|
source=False,
|
|
fake_boot=False,
|
|
ignore_existing=False,
|
|
hermes_root=None,
|
|
cwd=None,
|
|
)
|
|
defaults.update(kw)
|
|
return argparse.Namespace(**defaults)
|
|
|
|
|
|
def test_build_only_fails_when_pack_produces_corrupt_exe(tmp_path, monkeypatch, capsys):
|
|
"""The updater chain's contract: a rebuild whose Hermes.exe cannot launch
|
|
must exit nonzero (so hermes-setup's retry-once kicks in) and must restore
|
|
the previous working build instead of leaving the corrupt one."""
|
|
root = tmp_path / "hermes-agent"
|
|
desktop_dir = root / "apps" / "desktop"
|
|
desktop_dir.mkdir(parents=True)
|
|
(desktop_dir / "package.json").write_text("{}", encoding="utf-8")
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
|
|
|
exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe"
|
|
make_pe(exe, PE_AMD64, truncate_to=0x300) # what the failed pack produced
|
|
make_pe(desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe", PE_AMD64)
|
|
|
|
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
|
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
|
|
|
|
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
|
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
|
|
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
|
|
patch("hermes_cli.main._stop_desktop_processes_locking_build", return_value=[]), \
|
|
patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), \
|
|
patch("hermes_cli.main._desktop_stamp_path", return_value=tmp_path / "stamp.json"), \
|
|
patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \
|
|
patch("platform.machine", return_value="AMD64"), \
|
|
patch("hermes_cli.main.subprocess.run", return_value=pack_ok), \
|
|
pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns())
|
|
|
|
assert exc.value.code == 1
|
|
# The previous working exe was restored...
|
|
assert cli_main._parse_pe_machine(exe) == PE_AMD64
|
|
# ...and the poisoned build was never stamped as good.
|
|
mock_stamp.assert_not_called()
|
|
out = capsys.readouterr().out
|
|
assert "integrity check" in out
|
|
|
|
|
|
def test_build_only_succeeds_with_valid_exe(tmp_path, monkeypatch, capsys):
|
|
root = tmp_path / "hermes-agent"
|
|
desktop_dir = root / "apps" / "desktop"
|
|
desktop_dir.mkdir(parents=True)
|
|
(desktop_dir / "package.json").write_text("{}", encoding="utf-8")
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
|
|
|
make_pe(desktop_dir / "release" / "win-unpacked" / "Hermes.exe", PE_AMD64)
|
|
|
|
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
|
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
|
|
|
|
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
|
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
|
|
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
|
|
patch("hermes_cli.main._stop_desktop_processes_locking_build", return_value=[]), \
|
|
patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \
|
|
patch("platform.machine", return_value="AMD64"), \
|
|
patch("hermes_cli.main.subprocess.run", return_value=pack_ok):
|
|
cli_main.cmd_gui(_ns())
|
|
|
|
mock_stamp.assert_called_once()
|
|
assert "Desktop packaged app ready" in capsys.readouterr().out
|