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-коммита. Раньше здесь // Подставляется сборщиком из фактического git-коммита. Раньше здесь
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из // жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
// какого кода собран установщик. // какого кода собран установщик.
public const string BuildCommit = "e431e39"; public const string BuildCommit = "9e2afe1";
public const string MIN_HERMES_VERSION = "0.20.0"; public const string MIN_HERMES_VERSION = "0.20.0";
public const string MAX_TESTED_HERMES = "0.20.4"; 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; TargetInstallDir = defaultTarget;
// Check if already installed // Check if already installed
@ -595,6 +600,7 @@ namespace HermesHubSetup
private static void CreateStartMenuShortcut() private static void CreateStartMenuShortcut()
{ {
if (Environment.GetEnvironmentVariable("HERMES_HUB_NO_REGISTRY") == "1") return;
try try
{ {
string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs); string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs);
@ -641,6 +647,7 @@ namespace HermesHubSetup
private static void RemoveStartMenuShortcut() private static void RemoveStartMenuShortcut()
{ {
if (Environment.GetEnvironmentVariable("HERMES_HUB_NO_REGISTRY") == "1") return;
try try
{ {
string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs); 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 from __future__ import annotations
import argparse
import ast import ast
import hashlib
import json import json
import os import os
import re import re
@ -283,7 +285,81 @@ def check_production_update_feed() -> tuple[bool, str]:
return True, "Production update feed verified" 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("=" * 70)
print(f" Hermes Hub — Release Gate Verification Suite (Target: v{__version__})") print(f" Hermes Hub — Release Gate Verification Suite (Target: v{__version__})")
print("=" * 70) print("=" * 70)
@ -319,5 +395,17 @@ def run_release_gate():
sys.exit(1) 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__": if __name__ == "__main__":
run_release_gate() main()

View file

@ -141,8 +141,9 @@ def run_checks() -> int:
# Проверяем изоляцию пути, а не побочное создание каталога: запрос пути # Проверяем изоляцию пути, а не побочное создание каталога: запрос пути
# каталогов больше не плодит, иначе любая проверка засоряла бы диск # каталогов больше не плодит, иначе любая проверка засоряла бы диск
# десятком пустых слотов. # десятком пустых слотов.
pdir = get_profile_env_dir("ag-w2") probe_id = "ag-probe-isolation-test"
assert "ag-w2" in str(pdir) pdir = get_profile_env_dir(probe_id)
assert probe_id in str(pdir)
assert "agy_profiles" in str(pdir) assert "agy_profiles" in str(pdir)
assert not pdir.exists(), "запрос пути не должен создавать каталог" assert not pdir.exists(), "запрос пути не должен создавать каталог"
print(f" [PASS] Profile directory isolated at {pdir} (не создан)") 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) target_path = Path(expanded) if Path(expanded).is_absolute() else (base_cwd / expanded)
ok, reason, alt = self.validate_path(target_path, operation="delete") ok, reason, alt = self.validate_path(target_path, operation="delete")
if not ok: if not ok:

View file

@ -1001,6 +1001,13 @@ class UpdateManager:
chosen_url = a_url chosen_url = a_url
break 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: if not chosen_url and check_result.manifest and check_result.manifest.package_url:
chosen_url = 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" 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 ── # ── TEST 5: P0-3 Process Isolation stop_running_hub ──
@pytest.mark.unit @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.""" """stop_running_hub filters by current UID on Linux and never targets own PID."""
current_pid = os.getpid() 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 # Mock subprocess.run for pgrep
with patch("subprocess.run") as mock_run: with patch("subprocess.run") as mock_run:
# Simulate pgrep returning other PID and own PID # Simulate pgrep returning other PID and own PID

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import json import json
import os import os
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -39,12 +40,22 @@ def test_silent_installer_execution_with_hermes(tmp_path):
if not SETUP_EXE.is_file(): if not SETUP_EXE.is_file():
pytest.skip("HermesHubSetup.exe not built yet") 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" agent_dir = tmp_path / "hermes" / "hermes-agent"
venv_scripts = agent_dir / "venv" / "Scripts" agent_dir.mkdir(parents=True, exist_ok=True)
venv_scripts.mkdir(parents=True, exist_ok=True) real_venv = Path(sys.prefix)
(venv_scripts / "python.exe").touch() target_venv = agent_dir / "venv"
(venv_scripts / "hermes.exe").touch() 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 = dict(os.environ)
env["HERMES_HOME"] = str(tmp_path / "hermes") 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" env["HERMES_HUB_NO_REGISTRY"] = "1"
res = subprocess.run([str(SETUP_EXE), "/silent"], env=env, capture_output=True, text=True) 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 @pytest.mark.installer

View file

@ -17,10 +17,15 @@ from __future__ import annotations
import json import json
import os import os
import sys
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest 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.paths import get_hermes_home, get_profile_dir
from antigravity_provider.router.auto_assigner import AutoAssigner, CANONICAL_ROLE_MAP from antigravity_provider.router.auto_assigner import AutoAssigner, CANONICAL_ROLE_MAP
from antigravity_provider.router.role_registry import RoleRegistry 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") clean_file.write_text('def hello(): return "world"\n', encoding="utf-8")
v3 = scan_file_for_secrets(clean_file) v3 = scan_file_for_secrets(clean_file)
assert len(v3) == 0 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