Compare commits

..

7 commits

Author SHA1 Message Date
ochenstarik-ui
3235239d5b
Merge pull request #6 from ochenstarik-ui/installer/a61-port-live-fixes
fix(installer,release): находки agy с живой Windows-машины (A61)
2026-09-03 18:03:13 +00:00
ochenstarik-ui
2afd95fe3f
Merge pull request #4 from ochenstarik-ui/docs/a61-installer-release-task
docs(agents): задание A61 — установщик и релиз на настоящей машине
2026-09-03 18:02:07 +00:00
ochenstarik-ui
c1ba34d369
Merge pull request #2 from ochenstarik-ui/hub/audit-p0-green-main
HUB-1: зелёный main — граница workspace и UTF-8 в verification
2026-09-03 17:59:50 +00:00
ochenstarik-ui
377c567b85 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>
2026-09-04 00:57:08 +07:00
ochenstarik-ui
d45c36c433 Merge branch 'linux/a62-installer-desktop-icon' into installer/a61-port-live-fixes 2026-09-04 00:50:54 +07:00
ochenstarik-ui
fc4707d9d9 fix(linux): значок .desktop-записи не рендерился; хаб нельзя было остановить
Найдено и проверено живым прогоном на этой машине (реальный 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 <noreply@anthropic.com>
2026-09-03 22:02:43 +07:00
ochenstarik-ui
372be71de7 docs(agents): задание A61 — установщик и релиз на настоящей машине
Для agy: три installer-теста и компиляция C# исключены из каждого прогона
CI (addopts = "not installer") и никогда не выполнялись на реальной сборке;
ни один тег release.yml не дошёл до публикации (все падали на Release Gate,
устранено в HUB-1, но конвейер после починки ни разу не прогонялся). Задание
просит собрать dist/HermesHubSetup.exe, прогнать installer-тесты и один цикл
release.yml на настоящей Windows-машине с реальными учётными данными agy —
это то, что серверная сессия сделать не может.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 21:39:56 +07:00
12 changed files with 616 additions and 67 deletions

View file

@ -0,0 +1,207 @@
# Задание A61: установщик и релизный конвейер — проверка на настоящей машине
## Для кого
**agy** (машина владельца, Windows, реальные учётные данные и `agy`). Не для
серверной сессии: у неё нет `csc.exe`, нет Windows-реестра, нет прав публиковать
релиз от имени владельца. Ревьюер (сессия на ПК) принимает.
## Дата поступления
2026-09-03
## База
`origin/main` (`89435ea`).
```
git fetch origin --prune
git checkout -b installer/a61-live-verification origin/main
```
В `main` напрямую не пушить.
---
## Задача
HUB-1 довёл CI до зелёного на Windows и Linux и закрыл дыру в релизных
воротах: `release_gate.py` перестал заявлять проверку хеша, которой не было, и
перестал быть fail-open при обрыве сети или 404. Заодно нашлось — и осталось
непроверенным вживую, потому что для этого нужна настоящая Windows-машина, а
не CI-раннер:
Установщик — единственный способ, которым продукт попадает к владельцу, и он
**не проверяется нигде за пределами CI-раннера**, который сам его никогда не
собирает. Ни один прогон `pytest -m installer` не выполнялся на настоящей
установке. Ни один релиз ещё не прошёл через конвейер целиком — все прошлые
теги падали на `Release Gate Check` (см. `agents/done/2026-09-02-HUB1-audit-p0-green-main.md`,
раздел «Найдено сверх задания»), а действующие релизы на GitHub собраны и
выложены вручную, мимо `release.yml`.
Это задание не про код хаба — про то, что установщик и конвейер публикации
делают на реальной машине то же, что декларируют.
---
## Что уже проверено — заново не выяснять
### CI зелёный, но установщика не касается
`pyproject.toml`:
```
addopts = "-m 'not live and not network and not installer'"
```
Три теста в `tests/test_installer.py`, помеченные `@pytest.mark.installer`
(`test_setup_exe_exists`, `test_silent_installer_execution_with_hermes`,
`test_silent_installer_fails_without_hermes`), **исключены из каждого прогона**
по умолчанию, и ни в `.github/workflows/ci.yml`, ни в `release.yml` нет шага,
который передавал бы `-m installer` явно. К тому же все три сами пропускают
себя (`pytest.skip`), если `dist/HermesHubSetup.exe` не собран — а его никто
не собирает ни в CI, ни в конвейере релиза.
`tests/test_installer_windows_and_linux.py::test_windows_csharp_launchers_and_setup_compile`
пропускается в CI с `csc.exe compiler not found in standard .NET Framework
location` — компилятор ищется по путям `C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe`
и `...\Framework\v4.0.30319\csc.exe`; на `windows-latest` раннере GitHub его
нет. На обычной Windows 10/11 он есть — так и написано в
`installer/README.md`: «compiles `HermesHubSetup.cs` using standard .NET
Framework `csc.exe` present on all Windows 10/11 machines without extra
toolchains».
### Тесты уже изолированы от твоего реестра
`test_silent_installer_execution_with_hermes` и
`test_silent_installer_fails_without_hermes` подставляют `HERMES_HOME`,
`LOCALAPPDATA`, `APPDATA`, `USERPROFILE` во временный каталог и ставят
`HERMES_HUB_NO_REGISTRY=1` — это отключает запись в `HKCU\...\Uninstall`
(закрыто ещё в A4, см. `agents/done/2026-08-21-A4-antigravity-credential-isolation.md`).
Прогон этих тестов не трогает твой реальный реестр и твою реальную установку.
`installer/README.md` отдельно требует того же: «Unit Tests: Must NEVER modify
user Windows Registry or Start Menu shortcuts» — этому требованию тесты уже
следуют, проверить нужно исполнением, а не читать код на слово.
### Релиз ещё никогда не публиковался этим конвейером
`gh run list --workflow=release.yml` на момент HUB-1 показывал failure на всех
пяти последних тегах, включая `v0.1.3-b1` — падение на `Run Release Gate
Check`, той же причине, что красила CI. HUB-1 эту причину устранил, но
**ни разу после починки конвейер не запускался** — значит и новые шаги
(`Built assets must be installable by the updater`,
`Publication Gate (published release must be verifiable)`, оба добавлены в
HUB-1) ни разу не выполнялись на настоящем прогоне GitHub Actions, только
локально функциями напрямую.
---
## P0-1. Собрать установщик и прогнать installer-тесты на настоящей машине
1. Собрать: `installer/build_installer.ps1` (компилирует `HermesHub.cs`,
`HermesHubWeb.cs`, `HermesHubSetup.cs` через `csc.exe`, кладёт
`dist/HermesHubSetup.exe`). Приложить вывод сборки.
2. Прогнать три `installer`-теста явно, отдельно от общего набора:
```
pytest -m installer tests/test_installer.py -v
```
Все три должны выполниться (не `SKIPPED`) и пройти. Приложить полный вывод.
3. Прогнать `test_windows_csharp_launchers_and_setup_compile` отдельно —
на твоей машине `csc.exe` должен найтись. Приложить вывод; если и здесь
`SKIPPED` — назвать точный путь, по которому компилятор искался и не
нашёлся, и где он есть на самом деле.
4. **Подтвердить исполнением, что реестр не тронут**: снять состояние
`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\HermesHub` до и
после прогона (`reg query`), приложить оба вывода. Совпадают — тесты не
соврали про изоляцию.
## P0-2. Полный цикл `/silent` на реальной установке
1. Установить через `dist/HermesHubSetup.exe /silent` в **реальный**
(не временный) профиль — как ставит владелец.
2. Проверить коды возврата по правилу из `agents/AGENTS.md` §4: `0`, `10`,
`11`, `12` — на тех сценариях, для которых они определены (обычная
установка, установка без Hermes Agent, повторная установка, откат).
Каждый код — с описанием сценария, который его вызвал.
3. После установки — обычный рабочий цикл: хаб запускается, видит существующие
профили `agy`, ничего не потеряно. Если что-то потерялось — это находка, а
не повод откатывать проверку молча.
4. **Не удалять существующие профили и учётные данные для эксперимента.**
Если для чистоты нужна отдельная установка — использовать переменные
изоляции (`HERMES_HOME` и т.д.), как это уже делают тесты, а не боевой
каталог.
## P0-3. Один настоящий прогон релизного конвейера — без публикации владельцу
Цель — увидеть, что новые шаги `release.yml` (Release Gate → сборка →
проверка пригодности ассетов → публикация → Publication Gate) действительно
отрабатывают на GitHub Actions, а не только в теории.
1. **Не создавать публичный релиз без отдельного разрешения владельца.**
Вместо реального тега: либо (а) временный форк/ветка с ручным запуском
`workflow_dispatch`, если конвейер его поддерживает — иначе не добавлять
`workflow_dispatch` ради этого задания, это отдельное решение; либо (б)
прогнать шаги локально в том порядке, в котором их вызывает `release.yml`:
```
python scripts/release_gate.py
# сборка через build_installer.ps1 в dist/
python scripts/release_gate.py --assets dist
```
и явно объяснить, что осталось непроверенным без настоящей публикации
(шаг `Publish GitHub Release` и `--publication-only` после него).
2. Если владелец в диалоге явно разрешит настоящий тестовый тег — тогда можно
довести до конца, включая `release_gate.py --publication-only` на
опубликованном релизе. **Без этого разрешения — не пушить тег.**
3. Итог — что именно проверено, а что нет и почему (например: «сборка и
проверка пригодности ассетов проверены локально в точности как в
`release.yml`; публикация и Publication Gate не проверены — нужен реальный
тег, разрешения не спрашивал/владелец отказал»).
## P0-4. Проверка исполнением, а не по чтению кода
Как и в HUB-1: там, где что-то не запускалось — не писать «должно работать»,
запустить и приложить вывод. Не удалось — сказать `Н/Д` с точной причиной
(например: «на этой машине нет .NET Framework 4.0, только .NET 8» — если это
окажется так).
---
## Ограничения
- **Не публиковать релиз на GitHub без явного разрешения владельца в этом
диалоге.** Прогон `release.yml` через настоящий тег создаёт публичный релиз.
- Реальные учётные данные и `~/.hermes/agy_profiles/` не удалять и не менять
ради эксперимента; для изоляции — переменные окружения, как в существующих
тестах.
- Правки ревьюера из `main` не откатывать; правки HUB-1 (P0-1, P0-2 из этого
задания опираются на них) не переписывать без причины.
- Версию `0.1.3` не понижать и не менять без необходимости.
- Правило честности без исключений: неизмеренное — `Н/Д` с причиной.
- Если для `workflow_dispatch` нужно менять `.github/workflows/release.yml`
делать это отдельным, явно описанным шагом, не молча.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. `dist/HermesHubSetup.exe` собран на настоящей Windows-машине; вывод сборки
приложен.
3. Все installer-тесты (`pytest -m installer` + компиляция C#) выполнены не
как `SKIPPED`; вывод каждого приложен.
4. `HKCU\...\Uninstall\HermesHub` до и после прогона тестов идентичен —
оба снятых состояния приложены.
5. `/silent` установка проверена на реальном профиле; коды возврата названы
со сценарием каждого.
6. Локальный прогон шагов `release.yml` (Release Gate → сборка → проверка
ассетов) воспроизведён и приложен; либо — с явного разрешения владельца —
доведён до настоящего тега и `--publication-only`.
7. Каждый непроверенный пункт назван явно, с причиной — не пропущен молча.
8. Отчёт: что собрано, что запущено, точные команды и их вывод, что осталось
`Н/Д` и почему.
## Главное
HUB-1 сделал ворота честными на уровне кода: они больше не заявляют проверку,
которой не было. Это задание проверяет ту же честность на уровне машины —
что установщик, который получит владелец, действительно собирается, ставится
и обновляется так, как об этом говорит код. Пока это не проверено на
настоящей Windows, «зелёный CI» доказывает только код, а не установщик.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA` и полный вывод всех проверок из P0-1—P0-3.

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;
// 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);

View file

@ -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 <<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
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
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
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 "

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
# Нижняя граница размера установщика — защита от усечённой сборки. Найдено
# живым прогоном на 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]:
"""Разобрать строки вида '<sha256> <имя файла>'."""
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():

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 not pdir.exists(), "запрос пути не должен создавать каталог"
print(f" [PASS] Profile directory isolated at {pdir} (не создан)")

View file

@ -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():

View file

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

View file

@ -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 не убирает лаунчер остановки"

View file

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