diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index 4de074e..23cf80c 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -86,7 +86,17 @@ namespace HermesHubSetup } } - string defaultTarget = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\HermesHub"); + // GetFolderPath(LocalApplicationData) не всегда возвращает то, что + // ждёт установщик — найдено живым прогоном (A61): в некоторых + // окружениях (изолированный тестовый профиль, нестандартный + // пользовательский куст реестра) значение расходится с + // фактическим %LOCALAPPDATA%. Читаем переменную окружения первой. + 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 +605,12 @@ namespace HermesHubSetup private static void CreateStartMenuShortcut() { + // Изолированные прогоны (HERMES_HUB_NO_REGISTRY=1) уже не пишут в + // реестр (см. HERMES_HUB_NO_REGISTRY ниже), но ярлык в настоящем + // меню Пуск владельца этим не перекрывался — найдено живым + // прогоном тестов на A61: /silent-тест с этой переменной всё + // равно оставлял значок в реальном Пуск. + if (Environment.GetEnvironmentVariable("HERMES_HUB_NO_REGISTRY") == "1") return; try { string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs); @@ -641,6 +657,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/scripts/release_gate.py b/scripts/release_gate.py index 2198931..15e5faf 100644 --- a/scripts/release_gate.py +++ b/scripts/release_gate.py @@ -241,6 +241,13 @@ CHECKSUMS_ASSET_NAME = "checksums.txt" # ассет не должен превращать ворота в отказ в обслуживании самим себе. MAX_PACKAGE_BYTES = 512 * 1024 * 1024 +# Нижняя граница размера установщика — защита от усечённой сборки. Найдено +# живым прогоном на Windows (A61): собранный HermesHubSetup.exe считался +# готовым к публикации даже будучи почти пустым — сборка прервалась, а файл +# остался. 1 МБ — заведомо меньше любого настоящего установщика (несёт +# исходники плагина вшитым ресурсом), но отличает пустышку от файла. +MIN_PACKAGE_BYTES = 1024 * 1024 + def is_publication_mode() -> bool: """Требуется ли блокирующая проверка публикации.""" @@ -272,6 +279,21 @@ def _download_and_hash(url: str) -> tuple[str, int]: return digest.hexdigest(), size +def _hash_local_file(path: Path) -> tuple[str, int]: + """Посчитать SHA-256 локального файла целиком. Размер — побочный продукт.""" + import hashlib + digest = hashlib.sha256() + size = 0 + with open(path, "rb") as f: + while True: + chunk = f.read(1024 * 256) + if not chunk: + break + size += len(chunk) + digest.update(chunk) + return digest.hexdigest(), size + + def _parse_checksums(text: str) -> dict[str, str]: """Разобрать строки вида ' <имя файла>'.""" table: dict[str, str] = {} @@ -390,6 +412,12 @@ def check_publishable_assets(dist_dir: Path) -> tuple[bool, str]: на тех же двух дефектах, что и CI: каждый его прогон завершался ошибкой, а релизы публиковались мимо него. Как только тесты позеленели, случайная защита исчезла — поэтому набор проверяется явно. + + Помимо присутствия файлов — размер и хеш КАЖДОГО найденного установщика + против локального checksums.txt. Найдено живым прогоном на Windows + (A61): сборка может прерваться на середине и оставить усечённый файл, а + checksums.txt и сам установщик могут разойтись ещё до всякой публикации. + Проверка одного присутствия этого не ловит. """ if not dist_dir.is_dir(): return False, f"Каталог сборки не найден: {dist_dir}" @@ -413,7 +441,26 @@ def check_publishable_assets(dist_dir: Path) -> tuple[bool, str]: f"installer/build_installer.ps1 и installer/build_installer_linux.sh" ) - return True, f"Набор ассетов пригоден для публикации: {installers} + {CHECKSUMS_ASSET_NAME}" + local_checksums = _parse_checksums((dist_dir / CHECKSUMS_ASSET_NAME).read_text(encoding="utf-8-sig", errors="replace")) + verified = [] + for name in installers: + actual_hash, size = _hash_local_file(dist_dir / name) + if size < MIN_PACKAGE_BYTES: + return False, ( + f"{name} подозрительно мал ({size} байт, ожидался хотя бы {MIN_PACKAGE_BYTES}) " + f"— похоже на прерванную сборку" + ) + expected_hash = local_checksums.get(name) + if not expected_hash: + return False, f"Для {name} нет строки в {CHECKSUMS_ASSET_NAME} — сверить хеш не с чем" + if actual_hash != expected_hash: + return False, ( + f"SHA-256 {name} не сошёлся с {CHECKSUMS_ASSET_NAME}: " + f"файл {actual_hash}, записан {expected_hash}" + ) + verified.append(f"{name} ({size} байт, SHA-256 сошёлся)") + + return True, f"Набор ассетов пригоден для публикации: {', '.join(verified)}" def run_release_gate(): diff --git a/scripts/verify_multi_provider_router.py b/scripts/verify_multi_provider_router.py index 919935d..10702d8 100644 --- a/scripts/verify_multi_provider_router.py +++ b/scripts/verify_multi_provider_router.py @@ -148,8 +148,15 @@ def run_checks() -> int: # Проверяем изоляцию пути, а не побочное создание каталога: запрос пути # каталогов больше не плодит, иначе любая проверка засоряла бы диск # десятком пустых слотов. - pdir = get_profile_env_dir("ag-w2") - assert "ag-w2" in str(pdir) + # + # ID заведомо не боевой. Было "ag-w2" — на живой машине владельца это + # существующий подключённый профиль, и "assert not pdir.exists()" падал + # не из-за бага, а потому что каталог реального аккаунта и так был на + # месте. Найдено прогоном на настоящей установке (A61): скрипт возвращал + # код 12, хотя изоляция путей работала верно. + 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/updater/update_manager.py b/src/antigravity_provider/updater/update_manager.py index e3933ba..28dbf65 100644 --- a/src/antigravity_provider/updater/update_manager.py +++ b/src/antigravity_provider/updater/update_manager.py @@ -1031,6 +1031,19 @@ class UpdateManager: chosen_url = assets[linux_name] break + # Платформа не распознана штатной веткой (is_win разошёлся с тем, что + # реально есть в релизе) — переберём любое известное имя установщика, + # прежде чем сдаваться на .zip. Найдено живым прогоном (A61): без + # этой подстраховки единственная опечатка в определении платформы + # роняла обновление с «не найден подходящий файл», хотя нужный + # установщик в релизе был. + if not chosen_url: + for known_name in ("hermes-hub-setup.sh", "install-linux.sh", "HermesHubSetup.exe"): + if known_name in assets: + chosen_asset_name = known_name + chosen_url = assets[known_name] + break + # Fallback to any .zip package in assets or manifest package_url if not chosen_url: for a_name, a_url in assets.items(): diff --git a/tests/test_installer.py b/tests/test_installer.py index d8e7d28..dfc9181 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,29 @@ 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. + # + # Пустые touch-файлы python.exe/hermes.exe раньше делали живую проверку + # зависимостей на установленной машине бессмысленной: файл существовал, + # но не был исполняемым питоном, и по-настоящему установщик его не + # запускал ни разу. Найдено прогоном на настоящей машине (A61): junction + # (или symlink) на реальный 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 +72,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 430501c..7b2e613 100644 --- a/tests/test_p0_release_gate.py +++ b/tests/test_p0_release_gate.py @@ -653,12 +653,59 @@ def test_publishable_assets_check_rejects_uninstallable_release(tmp_path): assert ok is False, "набор без checksums.txt признан пригодным" assert "checksums.txt" in msg - as_published_really = tmp_path / "dist_full" - as_published_really.mkdir() - for name in ("HermesHubSetup.exe", "hermes-hub-setup.sh", "checksums.txt"): - (as_published_really / name).write_bytes(b"x") - ok, msg = release_gate.check_publishable_assets(as_published_really) - assert ok is True, msg - ok, msg = release_gate.check_publishable_assets(tmp_path / "нет-такого") assert ok is False, "отсутствующий каталог сборки должен быть отказом" + + +def _write_installer_with_checksum(directory, name: str, content: bytes) -> None: + """Собрать + checksums.txt с настоящим SHA-256 для теста.""" + import hashlib + + (directory / name).write_bytes(content) + digest = hashlib.sha256(content).hexdigest() + (directory / "checksums.txt").write_text(f"{digest} {name}\n", encoding="utf-8") + + +@pytest.mark.unit +def test_publishable_assets_check_verifies_size_and_hash(tmp_path): + """A61: присутствия файлов мало — размер и хеш проверяются по-настоящему. + + Найдено живым прогоном на Windows: сборка может прерваться на середине и + оставить усечённый файл, а checksums.txt и сам установщик — разойтись ещё + до всякой публикации. Проверка одного присутствия этого не ловит. + """ + release_gate = _load_release_gate() + + # 1. Настоящий размер, настоящий хеш — проходит. + good = tmp_path / "dist_good" + good.mkdir() + _write_installer_with_checksum(good, "HermesHubSetup.exe", b"X" * release_gate.MIN_PACKAGE_BYTES) + ok, msg = release_gate.check_publishable_assets(good) + assert ok is True, msg + assert "SHA-256 сошёлся" in msg + + # 2. Файл меньше нижней границы — похоже на прерванную сборку. + truncated = tmp_path / "dist_truncated" + truncated.mkdir() + _write_installer_with_checksum(truncated, "HermesHubSetup.exe", b"x" * 10) + ok, msg = release_gate.check_publishable_assets(truncated) + assert ok is False, "усечённый установщик признан пригодным к публикации" + assert "мал" in msg + + # 3. Хеш в checksums.txt не совпадает с настоящим файлом. + mismatched = tmp_path / "dist_mismatch" + mismatched.mkdir() + content = b"Y" * release_gate.MIN_PACKAGE_BYTES + (mismatched / "HermesHubSetup.exe").write_bytes(content) + (mismatched / "checksums.txt").write_text("0" * 64 + " HermesHubSetup.exe\n", encoding="utf-8") + ok, msg = release_gate.check_publishable_assets(mismatched) + assert ok is False, "разошедшийся хеш признан пригодным к публикации" + assert "не сошёлся" in msg + + # 4. checksums.txt не называет установленный файл вовсе. + unnamed = tmp_path / "dist_unnamed" + unnamed.mkdir() + (unnamed / "HermesHubSetup.exe").write_bytes(b"Z" * release_gate.MIN_PACKAGE_BYTES) + (unnamed / "checksums.txt").write_text("0" * 64 + " other-file.bin\n", encoding="utf-8") + ok, msg = release_gate.check_publishable_assets(unnamed) + assert ok is False, "checksums.txt без нужной строки признан достаточным"