Merge pull request #6 from ochenstarik-ui/installer/a61-port-live-fixes

fix(installer,release): находки agy с живой Windows-машины (A61)
This commit is contained in:
ochenstarik-ui 2026-09-03 18:03:13 +00:00 committed by GitHub
commit 3235239d5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 409 additions and 67 deletions

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

@ -49,51 +49,8 @@ echo ""
# крутить старый код в памяти, и владелец видел прежний интерфейс при новом # крутить старый код в памяти, и владелец видел прежний интерфейс при новом
# номере сборки. Три сборки подряд ставились в файлы, но не в работу. # номере сборки. Три сборки подряд ставились в файлы, но не в работу.
echo "[0/6] Остановка работающего Hermes Hub..." echo "[0/6] Остановка работающего Hermes Hub..."
# shellcheck source=./lib_stop_running_hub.sh
stop_running_hub() { . "$SCRIPT_DIR/lib_stop_running_hub.sh"
local pattern="antigravity_provider.router.web|hermes_hub_web_entry"
local pids
# Только процессы ЭТОГО пользователя и только те, что относятся к хабу.
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '
' ' ')"
if [ -z "$pids" ]; then
echo " Работающий хаб не найден — останавливать нечего."
return 0
fi
echo " Найдены процессы хаба: $pids"
# shellcheck disable=SC2086
kill $pids 2>/dev/null || true
local waited=0
while [ "$waited" -lt 10 ]; do
sleep 1
waited=$((waited + 1))
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '
' ' ')"
[ -z "$pids" ] && break
done
if [ -n "$pids" ]; then
echo " Не завершились за 10 секунд, снимаю принудительно: $pids"
# shellcheck disable=SC2086
kill -9 $pids 2>/dev/null || true
sleep 1
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '
' ' ')"
fi
if [ -n "$pids" ]; then
# Не прерываем установку: файлы обновятся, а владельцу скажем правду.
echo " ⚠ Остались процессы: $pids. Снимите их вручную, иначе будет работать старый код."
return 1
fi
echo " Хаб остановлен."
return 0
}
stop_running_hub || true stop_running_hub || true
echo "" echo ""
@ -266,9 +223,35 @@ mkdir -p "$HERMES_HOME/bin"
cp "$LAUNCHER_SRC" "$HERMES_HOME/bin/hermes-hub-web" cp "$LAUNCHER_SRC" "$HERMES_HOME/bin/hermes-hub-web"
chmod +x "$HERMES_HOME/bin/hermes-hub-web" chmod +x "$HERMES_HOME/bin/hermes-hub-web"
# Лаунчер остановки — эквивалент «Exit» из системного трея Windows.
#
# На Windows сервер стартует из HermesHubWeb.exe, который держит значок в
# трее: закрыть его оттуда может сам владелец. На Linux сервер после закрытия
# окна остаётся в фоне без единого способа его остановить — ни кнопки в
# интерфейсе (её нет ни на одной платформе), ни трея, ни пункта меню. Кладём
# lib_stop_running_hub.sh рядом со скриптом остановки: он ищет её сначала
# рядом с собой.
STOP_LAUNCHER_SRC="$REPO_ROOT/launcher/hermes-hub-stop.sh"
if [ ! -f "$STOP_LAUNCHER_SRC" ]; then
STOP_LAUNCHER_SRC="$SCRIPT_DIR/../launcher/hermes-hub-stop.sh"
fi
STOP_LAUNCHER_BIN="$HOME/.local/bin/hermes-hub-stop"
if [ -f "$STOP_LAUNCHER_SRC" ]; then
cp "$STOP_LAUNCHER_SRC" "$STOP_LAUNCHER_BIN"
chmod +x "$STOP_LAUNCHER_BIN"
cp "$SCRIPT_DIR/lib_stop_running_hub.sh" "$HOME/.local/bin/lib_stop_running_hub.sh"
fi
# Create .desktop file # Create .desktop file
#
# Иконка — PNG, не .ico. Измерено на настоящем GTK-рабочем столе:
# GdkPixbuf.Pixbuf.new_from_file на HermesHub.ico падает с "Compressed icons
# are not supported", а .desktop-файл с несуществующей или неподдерживаемой
# иконкой Nautilus и меню приложений просто показывают пустое место — без
# ошибки, молча. Значок был бы вечно пустым на любом GTK-окружении (GNOME,
# большинство производных). PNG в тех же ассетах уже есть и загружается.
DESKTOP_FILE="$HOME/.local/share/applications/hermes-hub-web.desktop" DESKTOP_FILE="$HOME/.local/share/applications/hermes-hub-web.desktop"
ICON_PATH="$HERMES_HOME/plugins/antigravity-provider/assets/branding/app/HermesHub.ico" ICON_PATH="$HERMES_HOME/plugins/antigravity-provider/assets/branding/app/app_icon_256.png"
if [ ! -f "$ICON_PATH" ]; then if [ ! -f "$ICON_PATH" ]; then
ICON_PATH="utilities-terminal" ICON_PATH="utilities-terminal"
fi fi
@ -289,6 +272,28 @@ StartupWMClass=hermes-hub-web
EOF EOF
chmod +x "$DESKTOP_FILE" chmod +x "$DESKTOP_FILE"
# Второй пункт меню — «Остановить». Terminal=true: без окна владелец не
# увидит, остановился ли хаб на самом деле, и не заметит «⚠ Остались
# процессы» из lib_stop_running_hub.sh, если что-то пошло не так.
if [ -f "$STOP_LAUNCHER_BIN" ]; then
STOP_DESKTOP_FILE="$HOME/.local/share/applications/hermes-hub-stop.desktop"
cat <<EOF > "$STOP_DESKTOP_FILE"
[Desktop Entry]
Version=1.0
Type=Application
Name=Stop Hermes Hub
GenericName=Stop the Hermes Hub background server
Comment=Останавливает фоновый сервер Hermes Hub
Exec=$STOP_LAUNCHER_BIN
Icon=$ICON_PATH
Terminal=true
Categories=Development;Utility;
StartupNotify=false
EOF
chmod +x "$STOP_DESKTOP_FILE"
fi
if command -v update-desktop-database >/dev/null 2>&1; then if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
fi fi

View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# ==============================================================================
# Hermes Hub — общая функция остановки работающего хаба (Linux/POSIX).
#
# До этого файла одна и та же функция была отдельно вписана в install-linux.sh
# и в uninstall-linux.sh — две копии, которые разошлись бы при первой же
# правке одной из них незамеченной для другой. Источник источается («source»)
# обоими скриптами и лаунчером остановки, поэтому логика одна.
#
# Использование: `source "$(dirname "$0")/lib_stop_running_hub.sh"`, затем
# вызвать `stop_running_hub`. Функция сама печатает ход дела и возвращает
# 0 (остановлен или нечего было останавливать) либо 1 (что-то осталось —
# вызывающий решает, прерывать ли из-за этого).
# ==============================================================================
stop_running_hub() {
local pattern="antigravity_provider.router.web|hermes_hub_web_entry"
local pids
# Только процессы ЭТОГО пользователя и только те, что относятся к хабу.
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '\n' ' ')"
if [ -z "$pids" ]; then
echo " Работающий хаб не найден — останавливать нечего."
return 0
fi
echo " Найдены процессы хаба: $pids"
# shellcheck disable=SC2086
kill $pids 2>/dev/null || true
local waited=0
while [ "$waited" -lt 10 ]; do
sleep 1
waited=$((waited + 1))
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '\n' ' ')"
[ -z "$pids" ] && break
done
if [ -n "$pids" ]; then
echo " Не завершились за 10 секунд, снимаю принудительно: $pids"
# shellcheck disable=SC2086
kill -9 $pids 2>/dev/null || true
sleep 1
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '\n' ' ')"
fi
if [ -n "$pids" ]; then
echo " ⚠ Остались процессы: $pids. Снимите их вручную."
return 1
fi
echo " Хаб остановлен."
return 0
}

View file

@ -7,6 +7,8 @@
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_HERMES_HOME="$HOME/.hermes" DEFAULT_HERMES_HOME="$HOME/.hermes"
HERMES_HOME="${HERMES_HOME:-$DEFAULT_HERMES_HOME}" HERMES_HOME="${HERMES_HOME:-$DEFAULT_HERMES_HOME}"
@ -23,18 +25,35 @@ echo "======================================================================"
echo "Hermes Home : $HERMES_HOME" echo "Hermes Home : $HERMES_HOME"
echo "" echo ""
# 0. Остановка работающего хаба.
#
# Тот же порядок, что в install-linux.sh, и по той же причине: файлы под
# работающим процессом здесь не просто устаревают, а исчезают. С
# --purge-user-data это ещё и rm -rf каталогов, на которые у живого процесса
# открыты файловые дескрипторы — на Linux это не роняет процесс, но он
# продолжает отвечать по старому порту после «успешного» удаления, и
# следующая попытка что-то с ним сделать бьётся об уже удалённые файлы.
echo "[0/4] Остановка работающего Hermes Hub..."
# shellcheck source=./lib_stop_running_hub.sh
. "$SCRIPT_DIR/lib_stop_running_hub.sh"
stop_running_hub || true
echo ""
# 1. Remove Plugin Integration # 1. Remove Plugin Integration
echo "[1/3] Removing plugin integration..." echo "[1/4] Removing plugin integration..."
if [ -d "$HERMES_HOME/plugins/antigravity-provider" ]; then if [ -d "$HERMES_HOME/plugins/antigravity-provider" ]; then
rm -rf "$HERMES_HOME/plugins/antigravity-provider" rm -rf "$HERMES_HOME/plugins/antigravity-provider"
echo " Removed $HERMES_HOME/plugins/antigravity-provider" echo " Removed $HERMES_HOME/plugins/antigravity-provider"
fi fi
# 2. Remove Launchers and Shortcuts # 2. Remove Launchers and Shortcuts
echo "[2/3] Removing application launchers and desktop entries..." echo "[2/4] Removing application launchers and desktop entries..."
rm -f "$HOME/.local/bin/hermes-hub-web" rm -f "$HOME/.local/bin/hermes-hub-web"
rm -f "$HERMES_HOME/bin/hermes-hub-web" rm -f "$HERMES_HOME/bin/hermes-hub-web"
rm -f "$HOME/.local/bin/hermes-hub-stop"
rm -f "$HOME/.local/bin/lib_stop_running_hub.sh"
rm -f "$HOME/.local/share/applications/hermes-hub-web.desktop" rm -f "$HOME/.local/share/applications/hermes-hub-web.desktop"
rm -f "$HOME/.local/share/applications/hermes-hub-stop.desktop"
if command -v update-desktop-database >/dev/null 2>&1; then if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
@ -42,17 +61,26 @@ fi
# 3. User Data Handling # 3. User Data Handling
if [ "$PURGE_USER_DATA" = "true" ]; then if [ "$PURGE_USER_DATA" = "true" ]; then
echo "[3/3] Purging user data (--purge-user-data specified)..." echo "[3/4] Purging user data (--purge-user-data specified)..."
rm -f "$HERMES_HOME/config/router_profiles.yaml" rm -f "$HERMES_HOME/config/router_profiles.yaml"
rm -rf "$HERMES_HOME/agy_profiles" rm -rf "$HERMES_HOME/agy_profiles"
rm -rf "$HERMES_HOME/codex_profiles" rm -rf "$HERMES_HOME/codex_profiles"
rm -rf "$HERMES_HOME/opencode_profiles" rm -rf "$HERMES_HOME/opencode_profiles"
echo " User configuration and profiles purged." echo " User configuration and profiles purged."
else else
echo "[3/3] Preserving user data and credentials." echo "[3/4] Preserving user data and credentials."
echo " Your router profiles, auth keys, and settings in $HERMES_HOME remain intact." echo " Your router profiles, auth keys, and settings in $HERMES_HOME remain intact."
fi fi
# 4. Post-uninstall verification: пойманный хаб действительно молчит.
echo "[4/4] Verifying no hub process remains..."
REMAINING="$(pgrep -u "$(id -u)" -f "antigravity_provider.router.web|hermes_hub_web_entry" 2>/dev/null | tr '\n' ' ')"
if [ -n "$REMAINING" ]; then
echo " ⚠ Всё ещё работает: $REMAINING — удаление файлов это не остановило."
else
echo " Хаб не работает."
fi
echo "" echo ""
echo "======================================================================" echo "======================================================================"
echo " HERMES HUB UNINSTALLED SUCCESSFULLY FROM LINUX " echo " HERMES HUB UNINSTALLED SUCCESSFULLY FROM LINUX "

View file

@ -0,0 +1,49 @@
#!/usr/bin/env bash
# ==============================================================================
# Hermes Hub — Stop (Linux)
#
# На Windows фоновый сервер запускается из HermesHubWeb.exe, который держит
# значок в системном трее — оттуда «Exit» останавливает процесс. На Linux
# сервер стартует через nohup и остаётся в фоне после закрытия окна браузера
# (так и задумано: не переустанавливать при каждом перезапуске окна), но
# остановить его после этого было решительно нечем — ни кнопки в интерфейсе
# (её нет ни на одной платформе), ни трея, ни пункта меню. Только терминал и
# pkill вручную, либо переустановка/удаление, которые останавливают хаб
# только как побочный эффект.
#
# Этот скрипт — тот недостающий эквивалент «Exit из трея»: доступен из меню
# приложений через собственный .desktop-пункт, использует ту же проверенную
# функцию остановки, что installer/install-linux.sh и uninstall-linux.sh.
# ==============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Устанавливается рядом (в ~/.hermes/bin) install-linux.sh — оттуда и берём
# общую функцию. Если скрипт запущен не из установленного места (например,
# прямо из репозитория), ищем installer/ на уровень выше.
LIB=""
for candidate in \
"$SCRIPT_DIR/lib_stop_running_hub.sh" \
"$SCRIPT_DIR/../installer/lib_stop_running_hub.sh"
do
if [ -f "$candidate" ]; then
LIB="$candidate"
break
fi
done
if [ -z "$LIB" ]; then
echo "❌ Не найдена installer/lib_stop_running_hub.sh — переустановите Hermes Hub." >&2
exit 1
fi
# shellcheck source=../installer/lib_stop_running_hub.sh
. "$LIB"
echo "Останавливаю Hermes Hub..."
if stop_running_hub; then
exit 0
fi
exit 1

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,9 +40,26 @@ 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)
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.mkdir(parents=True, exist_ok=True)
(venv_scripts / "python.exe").touch() (venv_scripts / "python.exe").touch()
(venv_scripts / "hermes.exe").touch() (venv_scripts / "hermes.exe").touch()
@ -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

@ -87,6 +87,17 @@ def test_linux_installer_script_structure():
assert "HERMES_HOME" in install_sh assert "HERMES_HOME" in install_sh
assert "antigravity-provider" in install_sh assert "antigravity-provider" in install_sh
# Значок .desktop-записи должен быть растровым (PNG/SVG), не .ico.
#
# Измерено на настоящем GTK-рабочем столе: GdkPixbuf.Pixbuf.new_from_file
# на .ico падает с "Compressed icons are not supported", а .desktop-файл
# с нерендерящейся иконкой меню приложений и файловый менеджер просто
# показывают пустым — без ошибки, молча. HermesHub.ico в тех же ассетах
# существует и раньше подставлялся сюда, поэтому мало проверить, что путь
# не пуст — нужно, чтобы это не был именно .ico.
icon_line = next(line for line in install_sh.splitlines() if line.startswith("ICON_PATH="))
assert ".ico" not in icon_line, f"иконка .desktop-записи — .ico, GTK его не рендерит: {icon_line!r}"
def test_linux_launcher_script_headless_and_app_mode(): def test_linux_launcher_script_headless_and_app_mode():
"""Verify hermes-hub-web.sh checks DISPLAY, prints SSH port forwarding on headless, and uses --app on desktop.""" """Verify hermes-hub-web.sh checks DISPLAY, prints SSH port forwarding on headless, and uses --app on desktop."""
@ -111,3 +122,49 @@ def test_linux_uninstaller_preserves_user_data():
assert "--purge-user-data" in uninstall_sh assert "--purge-user-data" in uninstall_sh
assert "Preserving user data and credentials" in uninstall_sh assert "Preserving user data and credentials" in uninstall_sh
assert "hermes-hub-web.desktop" in uninstall_sh assert "hermes-hub-web.desktop" in uninstall_sh
def test_linux_uninstaller_stops_running_hub():
"""A61/A62: uninstall-linux.sh must stop the hub before deleting its files.
Раньше удаление не трогало работающий процесс: rm -rf уходил под живым
сервером, а с --purge-user-data ещё и сносил каталоги, на которые у
процесса были открыты файловые дескрипторы. Хаб продолжал отвечать по
старому порту после «успешного» удаления. Измерено живым прогоном:
сервер, запущенный в песочнице, оставался в списке процессов после
uninstall-linux.sh до этой правки.
"""
uninstall_sh = (INSTALLER_DIR / "uninstall-linux.sh").read_text(encoding="utf-8")
assert "lib_stop_running_hub.sh" in uninstall_sh
assert "stop_running_hub" in uninstall_sh
def test_linux_stop_launcher_exists_and_reuses_shared_logic():
"""A61/A62: должен существовать способ остановить хаб не из терминала руками.
На Windows это «Exit» из системного трея HermesHubWeb.exe. На Linux до
этого не было ничего ни кнопки в интерфейсе (её нет ни на одной
платформе), ни трея, ни пункта меню: сервер, оставленный в фоне после
закрытия окна браузера, можно было остановить только pkill'ом из
терминала. launcher/hermes-hub-stop.sh недостающий эквивалент,
устанавливается install-linux.sh как второй пункт меню приложений.
"""
stop_sh = (LAUNCHER_DIR / "hermes-hub-stop.sh").read_text(encoding="utf-8")
assert "#!/usr/bin/env bash" in stop_sh
assert "stop_running_hub" in stop_sh
lib_sh = (INSTALLER_DIR / "lib_stop_running_hub.sh").read_text(encoding="utf-8")
assert "stop_running_hub()" in lib_sh
# Общий источник, а не третья копия той же функции: install и uninstall
# обязаны ссылаться на тот же файл, а не хранить свою версию.
install_sh = (INSTALLER_DIR / "install-linux.sh").read_text(encoding="utf-8")
uninstall_sh = (INSTALLER_DIR / "uninstall-linux.sh").read_text(encoding="utf-8")
for script_name, script_text in (("install-linux.sh", install_sh), ("uninstall-linux.sh", uninstall_sh)):
assert "lib_stop_running_hub.sh" in script_text, f"{script_name} не источает общую функцию"
assert script_text.count("stop_running_hub() {") == 0, (
f"{script_name} держит собственную копию функции вместо общего источника"
)
assert "hermes-hub-stop" in install_sh, "install-linux.sh не разворачивает лаунчер остановки"
assert "hermes-hub-stop.desktop" in install_sh, "у лаунчера остановки нет пункта меню"
assert "hermes-hub-stop" in uninstall_sh, "uninstall-linux.sh не убирает лаунчер остановки"

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 без нужной строки признан достаточным"