fix(installer,release): находки agy с живой Windows-машины (A61) #6

Merged
ochenstarik-ui merged 13 commits from installer/a61-port-live-fixes into main 2026-09-03 18:03:14 +00:00
6 changed files with 166 additions and 17 deletions
Showing only changes of commit 377c567b85 - Show all commits

View file

@ -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; TargetInstallDir = defaultTarget;
// Check if already installed // Check if already installed
@ -595,6 +605,12 @@ namespace HermesHubSetup
private static void CreateStartMenuShortcut() 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 try
{ {
string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs); string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs);
@ -641,6 +657,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);

View file

@ -241,6 +241,13 @@ CHECKSUMS_ASSET_NAME = "checksums.txt"
# ассет не должен превращать ворота в отказ в обслуживании самим себе. # ассет не должен превращать ворота в отказ в обслуживании самим себе.
MAX_PACKAGE_BYTES = 512 * 1024 * 1024 MAX_PACKAGE_BYTES = 512 * 1024 * 1024
# Нижняя граница размера установщика — защита от усечённой сборки. Найдено
# живым прогоном на Windows (A61): собранный HermesHubSetup.exe считался
# готовым к публикации даже будучи почти пустым — сборка прервалась, а файл
# остался. 1 МБ — заведомо меньше любого настоящего установщика (несёт
# исходники плагина вшитым ресурсом), но отличает пустышку от файла.
MIN_PACKAGE_BYTES = 1024 * 1024
def is_publication_mode() -> bool: def is_publication_mode() -> bool:
"""Требуется ли блокирующая проверка публикации.""" """Требуется ли блокирующая проверка публикации."""
@ -272,6 +279,21 @@ def _download_and_hash(url: str) -> tuple[str, int]:
return digest.hexdigest(), size 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]: def _parse_checksums(text: str) -> dict[str, str]:
"""Разобрать строки вида '<sha256> <имя файла>'.""" """Разобрать строки вида '<sha256> <имя файла>'."""
table: dict[str, str] = {} table: dict[str, str] = {}
@ -390,6 +412,12 @@ def check_publishable_assets(dist_dir: Path) -> tuple[bool, str]:
на тех же двух дефектах, что и CI: каждый его прогон завершался ошибкой, а на тех же двух дефектах, что и CI: каждый его прогон завершался ошибкой, а
релизы публиковались мимо него. Как только тесты позеленели, случайная релизы публиковались мимо него. Как только тесты позеленели, случайная
защита исчезла поэтому набор проверяется явно. защита исчезла поэтому набор проверяется явно.
Помимо присутствия файлов размер и хеш КАЖДОГО найденного установщика
против локального checksums.txt. Найдено живым прогоном на Windows
(A61): сборка может прерваться на середине и оставить усечённый файл, а
checksums.txt и сам установщик могут разойтись ещё до всякой публикации.
Проверка одного присутствия этого не ловит.
""" """
if not dist_dir.is_dir(): if not dist_dir.is_dir():
return False, f"Каталог сборки не найден: {dist_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" 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(): def run_release_gate():

View file

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

@ -1031,6 +1031,19 @@ class UpdateManager:
chosen_url = assets[linux_name] chosen_url = assets[linux_name]
break 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 # Fallback to any .zip package in assets or manifest package_url
if not chosen_url: if not chosen_url:
for a_name, a_url in assets.items(): for a_name, a_url in assets.items():

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,29 @@ 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.
#
# Пустые touch-файлы python.exe/hermes.exe раньше делали живую проверку
# зависимостей на установленной машине бессмысленной: файл существовал,
# но не был исполняемым питоном, и по-настоящему установщик его не
# запускал ни разу. Найдено прогоном на настоящей машине (A61): junction
# (или symlink) на реальный 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 +72,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

@ -653,12 +653,59 @@ def test_publishable_assets_check_rejects_uninstallable_release(tmp_path):
assert ok is False, "набор без checksums.txt признан пригодным" assert ok is False, "набор без checksums.txt признан пригодным"
assert "checksums.txt" in msg 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 / "нет-такого") ok, msg = release_gate.check_publishable_assets(tmp_path / "нет-такого")
assert ok is False, "отсутствующий каталог сборки должен быть отказом" assert ok is False, "отсутствующий каталог сборки должен быть отказом"
def _write_installer_with_checksum(directory, name: str, content: bytes) -> None:
"""Собрать <name> + 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 без нужной строки признан достаточным"