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

Merged
ochenstarik-ui merged 13 commits from installer/a61-port-live-fixes into main 2026-09-03 18:03:14 +00:00

13 commits

Author SHA1 Message Date
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
713441ae39 docs(agents): отчёт HUB-1 — ссылки на итоговый прогон и рабочий коммит
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:01:30 +07:00
ochenstarik-ui
ec656a0e08 fix(release): конвейер не публикует релиз, которым нельзя обновиться
Найдено сверх задания и сверх аудита.

Каждый прогон Release Pipeline завершался ошибкой — все пять последних,
включая тег текущего релиза v0.1.3-b1. Причина та же, что у красного CI: шаг
Release Gate падал на test_a37_isolation_guards и test_a41_clean_install. До
публикации не доходил ни один прогон, релизы выкладывались мимо конвейера.

Отсюда ловушка. release.yml собирает hermes-hub-<версия>.zip и
update_manifest.json, а update_manager ищет строго HermesHubSetup.exe или
hermes-hub-setup.sh. Настоящие релизы содержат установщики и checksums.txt,
то есть собраны не этим конвейером. Пока тесты были красными, конвейер падал
и ничего не публиковал; как только они позеленели, случайная защита исчезла:
первый же тег опубликовал бы "latest" без установщиков, и любое обновление
отвечало бы "В релизе не найден подходящий файл обновления для текущей
платформы".

Ловушка закрыта до публикации: release_gate.py --assets dist проверяет, что
собранный набор содержит установщик и checksums.txt, и падает с названной
причиной и подсказкой про installer/build_installer.*. После публикации
добавлен шаг release_gate.py --publication-only — строгий режим, ради
которого ворота и разделялись.

Сборку установщиков в release.yml не переписывал: проверяется только
настоящей публикацией по тегу, это решение владельца. Конвейер по-прежнему не
доходит до публикации, но падает теперь с честной причиной вместо чужой.

Отчёт перенесён в agents/done/ по конвенции репозитория.

Тесты: 777 -> 778 passed, 2 skipped, 4 deselected. ruff check . чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:01:30 +07:00
ochenstarik-ui
922c437689 docs(agents): отчёт HUB-1 — точные FINAL_HEAD и ссылка на прогон
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:01:30 +07:00
ochenstarik-ui
1b1143feeb docs(agents): отчёт HUB-1 — зелёный main и P0 аудита
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:01:30 +07:00
ochenstarik-ui
1fe4549b22 fix(tests): проверка устаревшего refresh не зависит от фоновых потоков
Джоб на ubuntu упал с «assert 32 == 31» в
test_seq_token_prevents_stale_refresh_clobber. Тест сравнивал поколения до и
после устаревшего вызова, а HubStateStore — процессный синглтон: фоновый
сборщик квот, оставшийся от другого теста, успевает поднять generation между
двумя вызовами. В логе прогона рядом видно как раз такую фоновую попытку.

Падение случайное и зависит от порядка тестов: headless-джоб гоняет pytest без
фиксированного порядка. Тем же объясняется разброс 738/739 в базовом прогоне
до начала работы.

Проверяется теперь инвариант, а не равенство: устаревший ответ отбрасывается
ровно один раз, и состояние не откатывается назад. Пять полных прогонов со
случайным порядком — 777 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:01:30 +07:00
ochenstarik-ui
7fb8c6a6c5 feat(ci): матрица Windows + Linux; распаковка обновления ограничена каталогом
P1 после зелёного main.

1. CI-матрица. Обе джобы стояли на windows-latest, и это дорого обошлось:
   инвариант A37 не держался на Windows, а четыре теста молча предполагали
   Linux. Прогон на одной системе не показывал ни того, ни другого. Проект
   работает на Linux и активно получает Linux-правки — теперь обе системы
   проверяются одинаковым набором.

2. Zip-slip из аудита НЕ ВОСПРОИЗВОДИТСЯ — измерено, а не принято на веру.
   Архив с "../", с абсолютным путём и с записью-ссылкой распакован через
   zipfile.extractall: ничего за пределы каталога не вышло, абсолютный путь
   стал относительным, "../" схлопнулись, а запись-ссылка легла обычным
   файлом. CPython санирует пути сам.

   Но это свойство реализации, а не обещание формата, и распаковка идёт в
   корень установки. Граница сделана собственным инвариантом: каждая запись
   проверяется до записи на диск, отклоняются абсолютные пути, выход через
   "..", ссылки и записи не-файлового типа. Инвариант закреплён тестом, а не
   оставлен на усмотрение стандартной библиотеки.

Тесты: 776 -> 777 passed, 2 skipped, 4 deselected. ruff check . чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:01:30 +07:00
ochenstarik-ui
61e7933334 fix(release): отчёт ворот не роняет прогон на cp1252-консоли
Шаг Release Gate падал UnicodeEncodeError'ом на Windows-раннере: отчёт
печатается по-русски, консоль раннера — cp1252. Тот же класс дефекта, что и
в verification-скрипте, и то же лекарство — force_utf8_output до первого
вывода. Проверено прогоном под PYTHONIOENCODING=cp1252.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:01:30 +07:00
ochenstarik-ui
eac8352dc2 fix(release,web): ворота публикации перестали пропускать всё подряд, /api/action — межсайтовые запросы
P0 аудита, каждый сначала подтверждён исполнением, а не принят со слов.

1. Release Gate объявлял проверку хеша, которой не было. Печаталась строка
   PACKAGE_HASH_VERIFIED=True при том, что hashlib в scripts/release_gate.py
   не вызывался ни разу: скачивались байты 0-10 через заголовок Range, и
   этого хватало, чтобы счесть хеш проверенным. «Проверенным ассетом» при
   этом оказывался первый в списке — checksums.txt, а не пакет.

2. Ворота публикации были fail-open. Измерено в трёх условиях: полный обрыв
   сети -> PASS, манифест 404 -> PASS, пакет 404 -> PASS. Ворота пропускали
   релиз при любом исходе, включая полное отсутствие релиза.

   Разделено на офлайновую часть (проверки 1-7: версии, тесты, updater,
   статика, секреты, список разрешённых адресов) и Publication Gate: релиз
   есть, ассеты есть, пакет скачан ЦЕЛИКОМ, SHA-256 сошёлся с опубликованным
   checksums.txt. Публикационные ворота блокируют в режиме публикации
   (--publication или HERMES_RELEASE_PUBLICATION_GATE=1); в обычном прогоне
   CI, где релиза для ветки нет и быть не должно, результат сообщается как
   есть и не блокирует. Неизмеренное называется причиной, а не выдаётся за
   проверенное. Проверено на живом релизе v0.1.3-b1: два пакета скачаны
   целиком, хеши сошлись.

3. POST /api/action на loopback принимал межсайтовые запросы. Токен там не
   требуется, а действие меняет состояние: удаляет учётные данные, чистит
   аккаунты, переключает маршрутизацию, запускает входы OAuth. CORS от этого
   не защищает — он мешает прочитать ответ, а не отправить запрос. Измерено
   на конфигурации по умолчанию: POST с Content-Type text/plain уходит
   кросс-сайтом без предварительного запроса, request.json() разбирает тело
   независимо от Content-Type, и запрос с Origin чужого сайта без токена
   доходил до исполнителя действий.

   Проверяется Sec-Fetch-Site, при его отсутствии — Origin против адреса
   запроса. Собственный интерфейс, адресная строка и не-браузерные клиенты
   работают как раньше. Защита распространена на все пять небезопасных
   методов, не только на /api/action.

4. pricing fallback: safe_load вместо safe_dump. dump сериализовал текст
   обратно в строку, проверка isinstance(data, dict) не выполнялась никогда,
   таблица цен не загружалась ни разу, а except это глушил.

5. Симуляция Linux в тесте stop_running_hub падала на Windows: os.getuid там
   не существует.

Тесты: 756 -> 776 passed, 2 skipped, 4 deselected. ruff check . чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:01:30 +07:00
ochenstarik-ui
a3373f9f76 fix(tests): платформенные допущения тестов не выдают себя за дефекты продукта
Прогон на Windows-раннере показал, что причин красного CI больше двух.
Четыре падения — не в продукте, а в допущениях тестов, зашитых под Linux.

1. test_a41 читал вывод скрипта в кодировке системы. Скрипт теперь пишет
   UTF-8, а родитель на Windows читал трубу как cp1252 и разваливался на
   UnicodeDecodeError, оставляя proc.stdout равным None. Кодировка задана
   явно с обеих сторон трубы.

2. test_p0_3_stop_running_hub знал только про ветку Linux: os.kill по списку
   от pgrep. На Windows процессы останавливает taskkill по списку от wmic,
   os.kill не вызывается — тест падал на пустом списке убитых. Инвариант же
   один для обеих веток: чужой процесс хаба останавливается, собственный
   PID не трогается. Теперь он проверяется на обеих.

3-4. Оба теста установки подсовывали bash-скрипт hermes-hub-setup.sh. На
   Windows выбирается HermesHubSetup.exe, и установка честно отвечала «в
   релизе не найден подходящий файл обновления для текущей платформы».
   Установщик теперь берётся под ту систему, на которой идёт прогон.
   Проверка сообщения об ошибке смотрит на то, назван ли код возврата, а не
   на склонение: ветки формулируют «код 3» и «кодом 3», инвариант один.

Тесты: 755 -> 756 passed, 2 skipped, 4 deselected. ruff check . чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:01:30 +07:00
ochenstarik-ui
7136ab2878 fix(security): граница workspace держится одинаково на Windows и Linux
Оба красных Windows-джоба CI падали по причинам, воспроизведённым локально.

1. Инвариант A37 не держался на Windows. "rm -rf $HOME/.hermes" проходил
   мимо защиты: переменной HOME в окружении Windows нет, expandvars оставлял
   "$HOME" как есть, путь переставал быть абсолютным, склеивался с каталогом
   проекта и оказывался "внутри разрешённого корня". Зеркальная дыра на
   Linux: "%USERPROFILE%\.hermes" и "C:\Windows" проходили так же.

   Разбор пути сведён в один конвейер: классификация диалекта shell по самой
   команде (а не по системе-хозяину) -> раскрытие распознанных переменных, с
   разрешением HOME/USERPROFILE в домашний каталог даже когда их нет в
   окружении -> нормализация разделителей -> канонизация -> сравнение с
   защищёнными корнями. Каждый несостоявшийся шаг закрывает проход:
   непроверяемый путь не считается разрешённым. Через тот же конвейер
   пропущены validate_path, is_forbidden_path и is_inside_allowed_root.

2. UnicodeEncodeError ронял verify_multi_provider_router.py на cp1252-консоли
   Windows-раннера — падал вывод, не логика. Общий помощник
   console_encoding.force_utf8_output ставит UTF-8 на потоки и оставляет
   запасной путь, если перекодировать поток нельзя. Той же реализацией
   заменён самодельный блок в cli_commands.

Проверено: скрипт проходит 10/10 под PYTHONIOENCODING=cp1252 и ascii.
Новые тесты воспроизводят окружение обеих систем на любой из них и падают
на прежнем guard ровно на дефекте из CI (6 failed), проходят на новом.

Тесты: 739 -> 755 passed, 2 skipped, 4 deselected. ruff check . чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:01:30 +07:00