fix(installer,release): находки agy с живой Windows-машины (A61)
Ветка installer/a61-live-verification (agy, коммит b0644a3) частично
пересекалась с HUB-1, частично добавляла то, чего в HUB-1 не было. Пункты
взяты по одному, дубли — нет.
## Взято
1. verify_multi_provider_router.py проверял изоляцию пути на ID "ag-w2" —
на живой машине владельца это существующий подключённый профиль, и
"assert not pdir.exists()" падал не из-за бага, а потому что каталог
реального аккаунта и так был на месте (код возврата 12). ID заменён на
заведомо не боевой "ag-probe-isolation-test".
2. HermesHubSetup.cs: CreateStartMenuShortcut/RemoveStartMenuShortcut не
уважали HERMES_HUB_NO_REGISTRY — переменная гасила запись в реестр (A4),
но ярлык в настоящем меню Пуск изолированные тесты всё равно писали.
Добавлена та же проверка, что уже стоит перед записью в реестр. Заодно
LOCALAPPDATA читается из окружения раньше SpecialFolder — расхождение
найдено живым прогоном.
3. test_installer.py: /silent-тест линковался на venv настоящей машины
junction'ом (Windows) или symlink'ом вместо пустых touch-файлов — раньше
проверка живых Win32-зависимостей ничего по сути не проверяла.
4. update_manager.py: запасной перебор известных имён установщика
(hermes-hub-setup.sh/install-linux.sh/HermesHubSetup.exe) до отката на
.zip — подстраховка на случай расхождения определения платформы.
5. release_gate.py: --assets проверял только присутствие файлов. Добавлена
нижняя граница размера (усечённая сборка, найдено вживую) и сверка
SHA-256 каждого установщика с локальным checksums.txt — до всякой
публикации. Своя реализация (agy: только HermesHubSetup.exe, только
argparse-обвязка, несовместимая с --publication-only из HUB-1), но идея
и обе живые находки — его. Проверено полным циклом: собран настоящий
dist/hermes-hub-setup.sh, посчитаны настоящие контрольные суммы,
--assets прошёл на них 12588887 байт, SHA-256 сошёлся.
## Не взято — уже есть шире в HUB-1
- security_guard.py: точечный "$HOME" в тексте команды вместо конвейера
(диалект по команде, ${HOME}, %USERPROFILE%, fail-closed) — версия HUB-1
шире и уже зелёная на настоящем Windows CI.
- test_a59: заглушка os.getuid без проверки ветки Windows (taskkill/wmic) —
версия HUB-1 параметризована на обе ветки.
- Скомпилированные .exe — не переношу: пересборка на Windows после этого
коммита, здесь compилятора нет.
Тесты: 780 -> 781 passed, 2 skipped, 4 deselected. ruff check . чисто.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
d45c36c433
commit
377c567b85
6 changed files with 166 additions and 17 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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} (не создан)")
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 без нужной строки признан достаточным"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue