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
This commit is contained in:
Hermes Team 2026-09-03 22:20:39 +07:00
parent 89435eadb5
commit b0644a3d24
10 changed files with 263 additions and 14 deletions

View file

@ -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);

Binary file not shown.

Binary file not shown.

View file

@ -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()

View file

@ -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} (не создан)")

View file

@ -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:

View file

@ -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"

View file

@ -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

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
@ -39,9 +40,19 @@ 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"
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()
@ -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

View file

@ -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