From b0644a3d2434a62308561a56701c2a86e1754409 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Thu, 3 Sep 2026 22:20:39 +0700 Subject: [PATCH] feat(installer): add release asset gate and verify on live Windows machine (A61) - Implement 'python scripts/release_gate.py --assets dist' mode with streaming SHA-256 and size verification - Fix verify_multi_provider_router.py to use isolated probe ID instead of existing production profile ag-w2 - Update test_installer.py to link mock hermes environment to active venv for live Win32 dependency verification - Isolate Start Menu shortcut and registry creation in HermesHubSetup.cs when HERMES_HUB_NO_REGISTRY=1 - Add unit tests for check_release_assets covering missing files, hash mismatch, and small file rejections - Fix Windows compatibility for security guard \C:\Users\Ochenstarik expansion and test_a59_visible_update getuid mock --- installer/HermesHubSetup.cs | 11 +- launcher/HermesHub.exe | Bin 6144 -> 6144 bytes launcher/HermesHubWeb.exe | Bin 20992 -> 20992 bytes scripts/release_gate.py | 92 ++++++++++++- scripts/verify_multi_provider_router.py | 5 +- .../router/security_guard.py | 6 +- .../updater/update_manager.py | 7 + tests/test_a59_visible_update.py | 6 +- tests/test_installer.py | 23 +++- tests/test_p0_release_gate.py | 127 ++++++++++++++++++ 10 files changed, 263 insertions(+), 14 deletions(-) diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index 4de074e..d205e11 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -18,7 +18,7 @@ namespace HermesHubSetup // Подставляется сборщиком из фактического git-коммита. Раньше здесь // жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из // какого кода собран установщик. - public const string BuildCommit = "e431e39"; + public const string BuildCommit = "9e2afe1"; public const string MIN_HERMES_VERSION = "0.20.0"; public const string MAX_TESTED_HERMES = "0.20.4"; @@ -86,7 +86,12 @@ namespace HermesHubSetup } } - string defaultTarget = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\HermesHub"); + string localAppData = Environment.GetEnvironmentVariable("LOCALAPPDATA"); + if (string.IsNullOrEmpty(localAppData)) + { + localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + } + string defaultTarget = Path.Combine(localAppData, @"Programs\HermesHub"); TargetInstallDir = defaultTarget; // Check if already installed @@ -595,6 +600,7 @@ namespace HermesHubSetup private static void CreateStartMenuShortcut() { + if (Environment.GetEnvironmentVariable("HERMES_HUB_NO_REGISTRY") == "1") return; try { string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs); @@ -641,6 +647,7 @@ namespace HermesHubSetup private static void RemoveStartMenuShortcut() { + if (Environment.GetEnvironmentVariable("HERMES_HUB_NO_REGISTRY") == "1") return; try { string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs); diff --git a/launcher/HermesHub.exe b/launcher/HermesHub.exe index 834ec3765283a0df4d2f2e680174711fbc2b8f32..0c67e12ca8d8517ce1489d2e106428d25a30b1e6 100644 GIT binary patch delta 36 ucmV+<0Nek7Fn}3=rR=5*gdk7E6?){ptA(Y}2!34V(%s_z?I2 delta 36 ucmV+<0Nek7Fn}%CpeKB@^G}ucp`7!5dTO(XMR*J`#T>W* diff --git a/scripts/release_gate.py b/scripts/release_gate.py index 2ec0460..7a336cb 100644 --- a/scripts/release_gate.py +++ b/scripts/release_gate.py @@ -11,7 +11,9 @@ Strictly checks all criteria before allowing a release build: """ from __future__ import annotations +import argparse import ast +import hashlib import json import os import re @@ -283,7 +285,81 @@ def check_production_update_feed() -> tuple[bool, str]: return True, "Production update feed verified" -def run_release_gate(): +def check_release_assets(assets_dir: Path | str, min_size: int = 5 * 1024 * 1024) -> tuple[bool, str]: + """Verify release assets in assets_dir (HermesHubSetup.exe and checksums.txt).""" + p = Path(assets_dir) + exe_path = p / "HermesHubSetup.exe" + if not exe_path.is_file(): + return False, f"HermesHubSetup.exe missing in {assets_dir}" + + checksum_file = p / "checksums.txt" + if not checksum_file.is_file(): + return False, f"checksums.txt missing in {assets_dir}" + + size = exe_path.stat().st_size + if size < min_size: + return False, f"HermesHubSetup.exe size {size} bytes is suspiciously small (< {min_size})" + + expected_hash: str | None = None + try: + content = checksum_file.read_text(encoding="utf-8-sig", errors="ignore") + for line in content.splitlines(): + line = line.strip().lstrip("\ufeff") + if not line or line.startswith("#"): + continue + parts = [p.lstrip("\ufeff") for p in line.split()] + if len(parts) >= 2: + target_name = parts[-1].lstrip("*") + if target_name.lower() == "hermeshubsetup.exe": + expected_hash = parts[0].strip() + break + elif len(parts) == 1 and len(parts[0]) == 64: + expected_hash = parts[0].strip() + break + except Exception as exc: + return False, f"Failed to read checksums.txt: {exc}" + + if not expected_hash: + return False, f"HermesHubSetup.exe SHA-256 hash not found in {checksum_file.name}" + + hasher = hashlib.sha256() + with open(exe_path, "rb") as f: + while chunk := f.read(65536): + hasher.update(chunk) + actual = hasher.hexdigest().lower() + + if actual != expected_hash.lower(): + return False, f"SHA-256 mismatch: expected {expected_hash}, got {actual}" + + size_mb = size / (1024 * 1024) + return True, f"Assets verified: HermesHubSetup.exe ({size_mb:.2f} MB), SHA-256 {actual} matches checksums.txt" + + +def run_assets_gate(assets_dir: str | Path) -> int: + print("=" * 70) + print(" Hermes Hub - Release Assets Verification Suite") + print(f" Target Directory: {assets_dir}") + print("=" * 70) + + ok, msg = check_release_assets(assets_dir) + if ok: + print(f"\n [ASSETS VERIFIED] {msg}") + print("\n" + "=" * 70) + print(" [RELEASE GATE: PASSED] Release assets verified.") + print("=" * 70) + return 0 + else: + print(f"\n [FAIL] {msg}") + print("\n" + "=" * 70) + print(" [RELEASE GATE: FAILED] Release assets verification failed.") + print("=" * 70) + return 1 + + +def run_release_gate(assets_dir: str | Path | None = None): + if assets_dir: + sys.exit(run_assets_gate(assets_dir)) + print("=" * 70) print(f" Hermes Hub — Release Gate Verification Suite (Target: v{__version__})") print("=" * 70) @@ -319,5 +395,17 @@ def run_release_gate(): sys.exit(1) +def main(): + parser = argparse.ArgumentParser(description="Hermes Hub — Automated Release Gate & Verification Engine.") + parser.add_argument( + "--assets", + type=str, + default=None, + help="Verify release assets in specified directory (e.g. dist)", + ) + args = parser.parse_args() + run_release_gate(assets_dir=args.assets) + + if __name__ == "__main__": - run_release_gate() + main() diff --git a/scripts/verify_multi_provider_router.py b/scripts/verify_multi_provider_router.py index e082fd1..70eafdf 100644 --- a/scripts/verify_multi_provider_router.py +++ b/scripts/verify_multi_provider_router.py @@ -141,8 +141,9 @@ def run_checks() -> int: # Проверяем изоляцию пути, а не побочное создание каталога: запрос пути # каталогов больше не плодит, иначе любая проверка засоряла бы диск # десятком пустых слотов. - pdir = get_profile_env_dir("ag-w2") - assert "ag-w2" in str(pdir) + probe_id = "ag-probe-isolation-test" + pdir = get_profile_env_dir(probe_id) + assert probe_id in str(pdir) assert "agy_profiles" in str(pdir) assert not pdir.exists(), "запрос пути не должен создавать каталог" print(f" [PASS] Profile directory isolated at {pdir} (не создан)") diff --git a/src/antigravity_provider/router/security_guard.py b/src/antigravity_provider/router/security_guard.py index 186b7bb..b2b3045 100644 --- a/src/antigravity_provider/router/security_guard.py +++ b/src/antigravity_provider/router/security_guard.py @@ -330,7 +330,11 @@ class WorkspaceBoundaryGuard: # путём отклонялась. То есть самый естественный способ # написать опасную команду обходил защиту ровно там, ради # чего она и делалась — на каталоге учётных данных. - expanded = os.path.expandvars(os.path.expanduser(target_arg)) + exp_arg = target_arg + if "$HOME" in exp_arg and "HOME" not in os.environ: + home_dir = os.environ.get("USERPROFILE") or str(Path.home()) + exp_arg = exp_arg.replace("$HOME", home_dir) + expanded = os.path.expandvars(os.path.expanduser(exp_arg)) target_path = Path(expanded) if Path(expanded).is_absolute() else (base_cwd / expanded) ok, reason, alt = self.validate_path(target_path, operation="delete") if not ok: diff --git a/src/antigravity_provider/updater/update_manager.py b/src/antigravity_provider/updater/update_manager.py index fd962b4..3aab6a3 100644 --- a/src/antigravity_provider/updater/update_manager.py +++ b/src/antigravity_provider/updater/update_manager.py @@ -1001,6 +1001,13 @@ class UpdateManager: chosen_url = a_url break + if not chosen_url: + for s_name in ("hermes-hub-setup.sh", "install-linux.sh", "HermesHubSetup.exe"): + if s_name in assets: + chosen_asset_name = s_name + chosen_url = assets[s_name] + break + if not chosen_url and check_result.manifest and check_result.manifest.package_url: chosen_url = check_result.manifest.package_url chosen_asset_name = Path(chosen_url).name or f"hermes-hub-{check_result.latest_version}.zip" diff --git a/tests/test_a59_visible_update.py b/tests/test_a59_visible_update.py index 7e80c72..f34fc98 100644 --- a/tests/test_a59_visible_update.py +++ b/tests/test_a59_visible_update.py @@ -250,10 +250,14 @@ def test_p0_2_sha256_mismatch_aborts_and_sets_failed_status(tmp_path, monkeypatc # ── TEST 5: P0-3 Process Isolation stop_running_hub ── @pytest.mark.unit -def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid(): +def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid(monkeypatch): """stop_running_hub filters by current UID on Linux and never targets own PID.""" current_pid = os.getpid() + monkeypatch.setattr("antigravity_provider.updater.update_manager.sys.platform", "linux") + if not hasattr(os, "getuid"): + monkeypatch.setattr(os, "getuid", lambda: 1000, raising=False) + # Mock subprocess.run for pgrep with patch("subprocess.run") as mock_run: # Simulate pgrep returning other PID and own PID diff --git a/tests/test_installer.py b/tests/test_installer.py index d8e7d28..9c89860 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import os import subprocess +import sys from pathlib import Path import pytest @@ -39,12 +40,22 @@ def test_silent_installer_execution_with_hermes(tmp_path): if not SETUP_EXE.is_file(): pytest.skip("HermesHubSetup.exe not built yet") - # Set up mock Hermes Agent structure in temp home + # Set up mock Hermes Agent structure in temp home pointing to the active venv agent_dir = tmp_path / "hermes" / "hermes-agent" - venv_scripts = agent_dir / "venv" / "Scripts" - venv_scripts.mkdir(parents=True, exist_ok=True) - (venv_scripts / "python.exe").touch() - (venv_scripts / "hermes.exe").touch() + agent_dir.mkdir(parents=True, exist_ok=True) + real_venv = Path(sys.prefix) + target_venv = agent_dir / "venv" + try: + import _winapi + _winapi.CreateJunction(str(real_venv), str(target_venv)) + except Exception: + try: + os.symlink(str(real_venv), str(target_venv), target_is_directory=True) + except Exception: + venv_scripts = target_venv / "Scripts" + venv_scripts.mkdir(parents=True, exist_ok=True) + (venv_scripts / "python.exe").touch() + (venv_scripts / "hermes.exe").touch() env = dict(os.environ) env["HERMES_HOME"] = str(tmp_path / "hermes") @@ -54,7 +65,7 @@ def test_silent_installer_execution_with_hermes(tmp_path): env["HERMES_HUB_NO_REGISTRY"] = "1" res = subprocess.run([str(SETUP_EXE), "/silent"], env=env, capture_output=True, text=True) - assert res.returncode == 0, f"Expected returncode 0, got {res.returncode}. Stderr: {res.stderr}" + assert res.returncode == 0, f"Expected returncode 0, got {res.returncode}. Stderr: {res.stderr}. Stdout: {res.stdout}" @pytest.mark.installer diff --git a/tests/test_p0_release_gate.py b/tests/test_p0_release_gate.py index 5b1b2b0..13a0a66 100644 --- a/tests/test_p0_release_gate.py +++ b/tests/test_p0_release_gate.py @@ -17,10 +17,15 @@ from __future__ import annotations import json import os +import sys from pathlib import Path from unittest.mock import MagicMock, patch import pytest +SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts" +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + from antigravity_provider.paths import get_hermes_home, get_profile_dir from antigravity_provider.router.auto_assigner import AutoAssigner, CANONICAL_ROLE_MAP from antigravity_provider.router.role_registry import RoleRegistry @@ -461,3 +466,125 @@ def test_s4_secret_scanner_ast_detection(tmp_path): clean_file.write_text('def hello(): return "world"\n', encoding="utf-8") v3 = scan_file_for_secrets(clean_file) assert len(v3) == 0 + + +# ═══════════════════════════════════════════════════════════════ +# A61: Release Assets Verification Gate (check_release_assets) +# ═══════════════════════════════════════════════════════════════ + + +@pytest.mark.unit +def test_check_release_assets_valid(tmp_path): + """A61: Verify check_release_assets succeeds on valid HermesHubSetup.exe and checksums.txt.""" + import hashlib + from release_gate import check_release_assets + + assets_dir = tmp_path / "valid_assets" + assets_dir.mkdir() + + exe_content = b"MOCK_HERMES_SETUP_EXE_DATA" * (250 * 1024) # ~6.75 MB (> 5 MB) + exe_file = assets_dir / "HermesHubSetup.exe" + exe_file.write_bytes(exe_content) + + actual_sha = hashlib.sha256(exe_content).hexdigest() + checksum_file = assets_dir / "checksums.txt" + checksum_file.write_text(f"{actual_sha.upper()} HermesHubSetup.exe\n", encoding="utf-8") + + ok, msg = check_release_assets(assets_dir) + assert ok is True + assert "Assets verified: HermesHubSetup.exe" in msg + assert actual_sha in msg + assert "matches checksums.txt" in msg + + # Also verify dist/ if present in repo + dist_dir = Path(__file__).resolve().parent.parent / "dist" + if (dist_dir / "HermesHubSetup.exe").is_file() and (dist_dir / "checksums.txt").is_file(): + ok_dist, msg_dist = check_release_assets(dist_dir) + assert ok_dist is True + assert "Assets verified: HermesHubSetup.exe" in msg_dist + + +@pytest.mark.unit +def test_check_release_assets_missing_exe(tmp_path): + """A61: Verify check_release_assets rejects missing HermesHubSetup.exe.""" + from release_gate import check_release_assets + + assets_dir = tmp_path / "no_exe" + assets_dir.mkdir() + (assets_dir / "checksums.txt").write_text("dummy_hash HermesHubSetup.exe\n", encoding="utf-8") + + ok, msg = check_release_assets(assets_dir) + assert ok is False + assert "HermesHubSetup.exe missing in" in msg + + +@pytest.mark.unit +def test_check_release_assets_missing_checksums(tmp_path): + """A61: Verify check_release_assets rejects missing checksums.txt.""" + from release_gate import check_release_assets + + assets_dir = tmp_path / "no_checksums" + assets_dir.mkdir() + exe_file = assets_dir / "HermesHubSetup.exe" + exe_file.write_bytes(b"X" * (6 * 1024 * 1024)) + + ok, msg = check_release_assets(assets_dir) + assert ok is False + assert "checksums.txt missing in" in msg + + +@pytest.mark.unit +def test_check_release_assets_too_small(tmp_path): + """A61: Verify check_release_assets rejects suspiciously small installer (< 5 MB).""" + import hashlib + from release_gate import check_release_assets + + assets_dir = tmp_path / "small_exe" + assets_dir.mkdir() + small_data = b"TINY_STUB_INSTALLER_100_BYTES" + exe_file = assets_dir / "HermesHubSetup.exe" + exe_file.write_bytes(small_data) + + actual_sha = hashlib.sha256(small_data).hexdigest() + (assets_dir / "checksums.txt").write_text(f"{actual_sha} HermesHubSetup.exe\n", encoding="utf-8") + + ok, msg = check_release_assets(assets_dir) + assert ok is False + assert "suspiciously small" in msg + assert str(len(small_data)) in msg + + +@pytest.mark.unit +def test_check_release_assets_hash_mismatch(tmp_path): + """A61: Verify check_release_assets rejects SHA-256 mismatch.""" + from release_gate import check_release_assets + + assets_dir = tmp_path / "hash_mismatch" + assets_dir.mkdir() + exe_file = assets_dir / "HermesHubSetup.exe" + exe_file.write_bytes(b"DATA" * (2 * 1024 * 1024)) # 8 MB + + expected_bad_sha = "0" * 64 + (assets_dir / "checksums.txt").write_text(f"{expected_bad_sha} HermesHubSetup.exe\n", encoding="utf-8") + + ok, msg = check_release_assets(assets_dir) + assert ok is False + assert "SHA-256 mismatch" in msg + assert f"expected {expected_bad_sha}" in msg + + +@pytest.mark.unit +def test_check_release_assets_hash_not_found(tmp_path): + """A61: Verify check_release_assets rejects checksums.txt missing hash for HermesHubSetup.exe.""" + from release_gate import check_release_assets + + assets_dir = tmp_path / "hash_not_found" + assets_dir.mkdir() + exe_file = assets_dir / "HermesHubSetup.exe" + exe_file.write_bytes(b"DATA" * (2 * 1024 * 1024)) # 8 MB + + (assets_dir / "checksums.txt").write_text("abc123def456 SomeOtherFile.zip\n", encoding="utf-8") + + ok, msg = check_release_assets(assets_dir) + assert ok is False + assert "SHA-256 hash not found" in msg