From fc4707d9d958cae4e1c190b903a7ce57ec3b9a0e Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:02:43 +0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(linux):=20=D0=B7=D0=BD=D0=B0=D1=87?= =?UTF-8?q?=D0=BE=D0=BA=20.desktop-=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=D1=80=D0=B8=D0=BB?= =?UTF-8?q?=D1=81=D1=8F;=20=D1=85=D0=B0=D0=B1=20=D0=BD=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D1=8F=20=D0=B1=D1=8B=D0=BB=D0=BE=20=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено и проверено живым прогоном на этой машине (реальный GTK-рабочий стол, реальные профили agy) — не по чтению кода. 1. Значок .desktop-записи был .ico. Измерено: GdkPixbuf.Pixbuf.new_from_file на HermesHub.ico падает с "Compressed icons are not supported". .desktop-файл с нерендерящейся иконкой меню приложений и файловый менеджер просто показывают пустым — без ошибки, молча. PNG в тех же ассетах уже был и загружается (проверено: 256x256). Подставлен app_icon_256.png. 2. Хаб нельзя было остановить иначе как из терминала. На Windows сервер стартует из HermesHubWeb.exe, который держит значок в системном трее — оттуда «Exit» останавливает процесс. На Linux сервер остаётся в фоне после закрытия окна браузера (так и задумано — не переустанавливать каждый раз), но ни кнопки в интерфейсе (её нет ни на одной платформе), ни трея, ни пункта меню не было вовсе. launcher/hermes-hub-stop.sh — недостающий эквивалент «Exit из трея»: доступен из меню приложений через собственный .desktop-пункт, использует ту же функцию, что и install/uninstall. Проверено живым прогоном: сервер запущен, остановлен через новый лаунчер, curl после этого получает connection refused. 3. uninstall-linux.sh не останавливал работающий хаб перед удалением файлов — та же причина, что install уже чинил для установки: с --purge-user-data это ещё и rm -rf каталогов, на которые у живого процесса открыты файловые дескрипторы. Измерено: сервер, запущенный в песочнице, оставался в списке процессов после uninstall-linux.sh до этой правки. Добавлена та же остановка, тем же кодом. 4. stop_running_hub была вписана отдельно в install-linux.sh и uninstall-linux.sh — две копии, которые разошлись бы при правке одной незамеченной для другой. Вынесена в installer/lib_stop_running_hub.sh, источается обоими скриптами и новым лаунчером остановки. Всё проверено дважды: прямым запуском install-linux.sh/uninstall-linux.sh в изолированной песочнице (не ~/.hermes) и через собранный dist/hermes-hub-setup.sh — тот самый файл, который уходит в релиз. Тесты: 738 -> 740 passed, 2 skipped, 4 deselected. Новый тест на .ico падает на прежней версии install-linux.sh (проверено git stash) и проходит после фикса. ruff check . чисто. Co-Authored-By: Claude Sonnet 5 --- installer/install-linux.sh | 97 ++++++++++++----------- installer/lib_stop_running_hub.sh | 54 +++++++++++++ installer/uninstall-linux.sh | 36 ++++++++- launcher/hermes-hub-stop.sh | 49 ++++++++++++ tests/test_installer_windows_and_linux.py | 57 +++++++++++++ 5 files changed, 243 insertions(+), 50 deletions(-) create mode 100644 installer/lib_stop_running_hub.sh create mode 100644 launcher/hermes-hub-stop.sh diff --git a/installer/install-linux.sh b/installer/install-linux.sh index 3f9556d..dd89f24 100644 --- a/installer/install-linux.sh +++ b/installer/install-linux.sh @@ -49,51 +49,8 @@ echo "" # крутить старый код в памяти, и владелец видел прежний интерфейс при новом # номере сборки. Три сборки подряд ставились в файлы, но не в работу. echo "[0/6] Остановка работающего Hermes Hub..." - -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 ' -' ' ')" - - 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 -} - +# shellcheck source=./lib_stop_running_hub.sh +. "$SCRIPT_DIR/lib_stop_running_hub.sh" stop_running_hub || true echo "" @@ -266,9 +223,35 @@ mkdir -p "$HERMES_HOME/bin" cp "$LAUNCHER_SRC" "$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 +# +# Иконка — 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" -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 ICON_PATH="utilities-terminal" fi @@ -289,6 +272,28 @@ StartupWMClass=hermes-hub-web EOF 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 < "$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 update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true fi diff --git a/installer/lib_stop_running_hub.sh b/installer/lib_stop_running_hub.sh new file mode 100644 index 0000000..23ad09c --- /dev/null +++ b/installer/lib_stop_running_hub.sh @@ -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 +} diff --git a/installer/uninstall-linux.sh b/installer/uninstall-linux.sh index e7e8fa3..fb1d2f8 100644 --- a/installer/uninstall-linux.sh +++ b/installer/uninstall-linux.sh @@ -7,6 +7,8 @@ set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + DEFAULT_HERMES_HOME="$HOME/.hermes" HERMES_HOME="${HERMES_HOME:-$DEFAULT_HERMES_HOME}" @@ -23,18 +25,35 @@ echo "======================================================================" echo "Hermes Home : $HERMES_HOME" 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 -echo "[1/3] Removing plugin integration..." +echo "[1/4] Removing plugin integration..." if [ -d "$HERMES_HOME/plugins/antigravity-provider" ]; then rm -rf "$HERMES_HOME/plugins/antigravity-provider" echo " Removed $HERMES_HOME/plugins/antigravity-provider" fi # 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 "$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-stop.desktop" if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true @@ -42,17 +61,26 @@ fi # 3. User Data Handling 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 -rf "$HERMES_HOME/agy_profiles" rm -rf "$HERMES_HOME/codex_profiles" rm -rf "$HERMES_HOME/opencode_profiles" echo " User configuration and profiles purged." 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." 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 " HERMES HUB UNINSTALLED SUCCESSFULLY FROM LINUX " diff --git a/launcher/hermes-hub-stop.sh b/launcher/hermes-hub-stop.sh new file mode 100644 index 0000000..03eb744 --- /dev/null +++ b/launcher/hermes-hub-stop.sh @@ -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 diff --git a/tests/test_installer_windows_and_linux.py b/tests/test_installer_windows_and_linux.py index 44aa558..37af70a 100644 --- a/tests/test_installer_windows_and_linux.py +++ b/tests/test_installer_windows_and_linux.py @@ -87,6 +87,17 @@ def test_linux_installer_script_structure(): assert "HERMES_HOME" 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(): """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 "Preserving user data and credentials" 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 не убирает лаунчер остановки" From 377c567b859c582165226cd7d69873ac77a60b23 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:57:08 +0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(installer,release):=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=BA=D0=B8=20agy=20=D1=81=20=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20Windows-=D0=BC=D0=B0=D1=88=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=20(A61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ветка 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 --- installer/HermesHubSetup.cs | 19 +++++- scripts/release_gate.py | 49 ++++++++++++++- scripts/verify_multi_provider_router.py | 11 +++- .../updater/update_manager.py | 13 ++++ tests/test_installer.py | 30 +++++++-- tests/test_p0_release_gate.py | 61 ++++++++++++++++--- 6 files changed, 166 insertions(+), 17 deletions(-) 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 без нужной строки признан достаточным"