Compare commits

..

27 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
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
ochenstarik-ui
89435eadb5
Merge pull request #3 from ochenstarik-ui/docs/agents-tasks-a42-a56
docs(agents): вернуть в репозиторий постановки A42-A56 и отчёт A30
2026-09-03 11:19:34 +00:00
Hermes Team
8b67f0dadb docs(agents): вернуть в репозиторий постановки A42-A56 и отчёт A30
Тринадцать файлов существовали только на диске ПК владельца, в рабочей
копии, отставшей от origin/main на 122 коммита. Их реализация и тесты
давно влиты: tests/test_a42_provider_connect.py,
test_a49_subagents_skills_memory.py, test_a51_hub_controls_hermes.py,
test_a52_local_models_supervisor_dual.py, test_a55_account_connection.py,
test_a56_context_compression.py и другие. Постановок, объясняющих, что
эти тесты обязаны доказывать, в репозитории не было.

Правило записано в agents/AGENTS.md: задание живёт в репозитории, а не в
переписке и не в личных папках на диске.

A48, A50 и A54 не переносятся: они уже есть на origin под другими именами,
содержимое совпадает с точностью до перевода строки в конце файла.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:16:35 +07:00
Hermes Team
93da1b22fd docs(agents): HUB-1 — зелёный main и P0 из аудита для серверной сессии
Задание для серверной сессии Claude (не для agy): довести main до зелёного и
закрыть P0 аудита Hermes Hub. Шаг 1 плана слияния — стабильный Hermes как эталон
переноса.

Причина красного main найдена ревьюером в логе CI, а не по аудиту:
- test_a37_isolation_guards.py:394 — WorkspaceBoundaryGuard пропускает
  rm -rf $HOME/.hermes на Windows (провал security-инварианта);
- verify_multi_provider_router.py:63 — UnicodeEncodeError на cp1252 при печати
  русского текста.
Остальные P0 (Release Gate hash, publication gate fail-open, /api/action CSRF) —
подтвердить исполнением перед правкой. pricing safe_dump — реальный, в P1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 23:44:08 +07:00
Hermes Team
144f6a5d59 docs(research): решение и план слияния Hermes Hub → KAgent
Владелец принял направление: один продукт KAgent, функциональность Hermes
переносится нативно, после parity Hermes архивируется. На переходный период
KAgent доделывается на одном сервере, Hermes — на втором.

Ревьюер проверил обе стороны исполнением, а не по аудиту:
- Hermes: CI на main красный; баг pricing fallback реален (safe_dump вместо
  safe_load в telemetry_service.py:164, таблица цен не грузится).
- KAgent (head 131c9b08): лицензии нет; в reasoning-engine/src/server.py
  require_operator_secret стоит на управлении аккаунтами, но НЕ на /v1/execute,
  /v1/decide, /v1/telemetry — расход провайдера открыт без авторизации.
  Поправка к аудиту: последняя активность 18-19 августа, репозиторий замер.

Жёсткий гейт: ключи провайдеров не переезжают в KAgent, пока эти маршруты не
закрыты и это не проверено живым запросом. Сам план миграции положен в
репозиторий как артефакт, чтобы не жил только файлом на столе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 23:29:33 +07:00
ochenstarik-ui
c6981921da docs(agents): A60 — причина разрыва установлена живым прогоном
Поставил v0.1.2-b7 в изолированную песочницу (свои HOME и HERMES_HOME, живая
установка не тронута) и запустил обновление на v0.1.3-b1. Хаб умер через шесть
секунд и за 150 секунд не поднялся. Установка при этом не произошла вовсе:
манифест и код остались на 0.1.2.

В задании было написано, что установщик доживает сиротой и файлы обновляет.
Прогон это опроверг. Настоящая цепочка: хаб зовёт установщик через
capture_output=True, то есть читателем его вывода становится сам хаб; установщик
на шаге [0/6] снимает хаб; у трубы не остаётся читателя; следующий echo даёт
SIGPIPE, и установщик умирает на шаге [1/6], не поставив ничего. Проверено
контрольным опытом: тот же скрипт с выводом в файл доходит до конца, с трубой
умирает. Отсюда следует, что перестановкой schedule_restart делу не помочь.

Тем же прогоном найдено второе: deployed_at пишется в момент установки, а не
сборки, и сравнивается с датой публикации релиза. Хаб на b7 при живом b1 ответил
«установлена сборка новее опубликованного релиза» и обновление не предложил.
Переустановил старую сборку — выпал из обновлений молча. Вынесено в P0-5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLuenXmGjWaS2rs8E72En
2026-09-02 19:41:20 +07:00
ochenstarik-ui
c0485a3721 merge origin/main 2026-09-02 19:26:59 +07:00
ochenstarik-ui
d39189ee8e docs(agents): A60 — база и порог тестов после вливания A59
A59 влит, база задания указана коммитом. Порог тестов поднят с 725 до 740:
столько в main после слияния с ветками A58 и A59.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLuenXmGjWaS2rs8E72En
2026-09-02 19:26:35 +07:00
ochenstarik-ui
2f353778c2 merge(a59): обновление видно на экране и не врёт об успехе
Окно при запуске, полоса хода с честным Н/Д без размера, отмена с удалением
недокачанного файла, обязательная SHA-256 — принято как есть.

Правки ревьюера: запись о применённом обновлении делалась до запуска установщика
и переживала его падение, поэтому хаб рапортовал об успехе версии, которая не
установилась. Отмена не смотрела на этап и сносила staging вместе с исполняемым
установщиком. Полоса при неизвестном размере заполнялась целиком, хотя текст
рядом честно писал Н/Д. Возвращены шесть блоков комментариев, снятых в ходе
задания.

Не сделано и вынесено в A60: установщик снимает процесс, который его запустил и
ждёт, поэтому перезапуск не наступает; отката для путей .sh и .exe нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLuenXmGjWaS2rs8E72En
2026-09-02 19:25:07 +07:00
ochenstarik-ui
d2307362b5 docs(agents): задание A60 — обновление доводит себя до конца
A59 довёл обновление до экрана: окно при запуске, видимая загрузка, честное Н/Д,
отмена. Дальше механизм обрывается на самом простом — установщик снимает тот
процесс, который его запустил и ждёт результата, поэтому перезапуск не наступает
никогда.

Корень один на обеих системах. На Linux install-linux.sh снимает всё по шаблону,
никого не исключая. На Windows StopOwnedRuntime строит цепочку предков, но
проверяет её только для дочерних процессов, а сам процесс-цель снимает
безусловно — прочитано по коду, подтвердить исполнением.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLuenXmGjWaS2rs8E72En
2026-09-02 19:19:14 +07:00
Hermes Team
ef00a64f93 docs(research): каталог разведки — разбор Agent Orchestrator и журнал наблюдений
Наработки из новостных сводок оседали в переписке и терялись. Заведён
docs/research/: что рассмотрено для Hub, с вердиктом и причиной по каждому
пункту — отдельно от ARCHITECTURE (что построено).

Разбор Agent Orchestrator: ближайший архитектурный родственник Hub. Перенять
автоматический возврат замечаний исполнителю (у нас его нет — ревьюер правит
руками), ветку и worktree на работника, доску состояния флота; целиком не брать.

Журнал разведки за 31.08–02.09: Fable 5.1 и AgentsView — брать; Hermes v0.21.0 —
сервер обновлять последним (два бага, но 65536 у нас своё, не путать);
Qwen MTP и свежий llama.cpp — на V100 выигрыша нет, проверено в A52.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 19:17:44 +07:00
44 changed files with 4820 additions and 231 deletions

View file

@ -6,11 +6,22 @@ on:
pull_request:
branches: [ main ]
# Матрица из двух систем.
#
# Обе джобы стояли на windows-latest, и это дорого обошлось: инвариант A37 не
# держался на Windows, а тесты установки и остановки процессов молча
# предполагали Linux. Прогон на одной системе не показывал ни того, ни другого.
# Проект работает на Linux и активно получает Linux-правки, поэтому обе системы
# проверяются одинаковым набором.
jobs:
test:
name: Clean Windows Runner Test
runs-on: windows-latest
name: Clean Runner Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
steps:
- name: Checkout repository
@ -40,9 +51,13 @@ jobs:
python scripts/release_gate.py
headless:
name: Headless Run (no GUI dependencies)
runs-on: windows-latest
name: Headless Run (${{ matrix.os }}, no GUI dependencies)
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
steps:
- name: Checkout repository
uses: actions/checkout@v4

View file

@ -57,6 +57,21 @@ jobs:
$manifest | ConvertTo-Json -Depth 5 | Out-File -FilePath "$distDir/update_manifest.json" -Encoding utf8
Write-Host "Generated update_manifest.json with SHA256: $hash"
# Набор проверяется ДО публикации.
#
# update_manager ищет в релизе строго HermesHubSetup.exe или
# hermes-hub-setup.sh, а шаг выше собирает только zip и манифест. Такой
# релиз становится "latest", и любая попытка обновиться отвечает «в
# релизе не найден подходящий файл обновления для текущей платформы».
#
# Раньше это не проявлялось лишь потому, что весь конвейер падал на шаге
# Release Gate — на тех же двух дефектах, что и CI; ни один его прогон не
# доходил до публикации, а релизы выкладывались мимо него. Как только
# тесты позеленели, случайная защита исчезла.
- name: Built assets must be installable by the updater
run: |
python scripts/release_gate.py --assets dist
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
@ -68,3 +83,12 @@ jobs:
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Ворота публикации: проверяют опубликованный релиз, а не сборку.
# Релиз есть, ассеты есть, пакет скачан целиком, SHA-256 сошёлся с
# опубликованным checksums.txt. Здесь они блокируют: раньше эта проверка
# возвращала PASS при обрыве сети, при 404 на манифест и при 404 на
# пакет, то есть пропускала релиз при любом исходе.
- name: Publication Gate (published release must be verifiable)
run: |
python scripts/release_gate.py --publication-only

View file

@ -0,0 +1,114 @@
# Отчёт независимого оркестратора: Release Gate ветки `codex/workflow-canvas` (A30)
## Дата проведения
2026-08-26
## Объект аудита
- **Ветка:** `codex/workflow-canvas`
- **Цель:** Независимая проверка реализации задания A30 («Главный экран "Обзор" — граф workflow, файлы агентов, LIVE»).
---
## 1. Сводка Git и состояние репозитория
- **`START_HEAD` (базовый коммит / merge-base с `main`):** `d6ec34d482a4e00a2017c7b53e934a82df0cc5ad`
- **`FINAL_HEAD` (коммит ветки A30):** `0c19738e29683c215352ea9de1b68a0a2e95b1f8` (`feat(web): add workflow canvas and live agent workspace`)
- **`origin/main`:** `c35bc4868d62cfa7abb7a1a4c1c17eca51eb6ce5`
- **Состояние рабочей директории (`git status`):**
- Нестажированные изменения в бинарниках и установщике (`installer/HermesHubSetup.cs`, `launcher/HermesHub.exe`, `launcher/HermesHubWeb.exe`).
- Нестажированный фикс CORS в `src/antigravity_provider/router/web/server.py` (перенесённый из `c35bc48` на `main`).
- Неотслеживаемые задания в inbox (`agents/inbox/2026-08-25-A32-remove-desktop.md`, `agents/inbox/2026-08-26-antigravity-release-gate-a30.md`).
---
## 2. Результаты детерминированных проверок
### 2.1. Линтер `ruff check .`
- **Результат:** `All checks passed!` (0 ошибок, 0 предупреждений).
### 2.2. Полный регрессионный сьют `pytest tests/ -v`
- **Результат:** **458 passed, 2 skipped, 3 deselected, 1 failed** (всего 461 тест).
- **Время прогона:** 86.74 сек.
### 2.3. Скрипт `scripts/release_gate.py`
- **Результат:** `[RELEASE GATE: FAILED] One or more checks failed. Release blocked.`
- **Причина:** Падение теста обратной совместимости `tests/test_web_parity_a21.py::test_web_client_html_and_js_7_views_parity`.
---
## 3. Реестр найденных дефектов
| ID | Приоритет | Компонент | Описание дефекта и минимальное воспроизведение |
| :--- | :--- | :--- | :--- |
| **DEF-01** | **P1** | `tests/test_web_parity_a21.py:133` | **Устаревшая проверка селектора в тестах регрессии.** Тест проверяет наличие старого контейнера `overview-route-diagram` в `index.html`. В рамках A30 главный экран «Обзор» был полностью перестроен в Workflow Canvas (`workflow-canvas`, `workflow-main-layout`), и старый селектор был правомерно удалён из разметки, но тест не был обновлён под новый layout A30. <br>**Воспроизведение:** `pytest tests/test_web_parity_a21.py -k test_web_client_html_and_js_7_views_parity`. |
| **DEF-02** | **P2** | `server.py` / `git` | **Отставание ветки от `origin/main`.** Ветка `codex/workflow-canvas` ответвлена от `d6ec34d` и не включает коммит безопасности `c35bc48` (`fix(security): любой сайт во вкладке рядом мог управлять хабом`). Перед финальным слиянием в `main` требуется rebase / merge с актуальным `main`. |
---
## 4. Результаты проверки подсистем A30
### P0-1. Модель агента и Agent File
- **Статус:** **PASS**
- Сервис `WorkflowService` в [`workflow_service.py`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/src/antigravity_provider/router/workflow_service.py) реализует полное управление жизненным циклом агентов: `create_agent`, `update_agent`, `delete_agent`.
- Роли роутера автоматически мигрируют в сущности агентов.
- Файлы агентов создаются физически на диске в `agents/{role}.md` (например, `agents/orchestrator.md`, `agents/coder-primary.md`) и содержат реальный Markdown.
- Удаление агента, задействованного в ребрах графа, требует явного подтверждения (`confirmation_required: True`), предотвращая повреждение графа.
- Проверено тестами: `test_router_roles_migrate_to_agents_and_create_real_files`, `test_create_update_file_and_restart_persistence`, `test_delete_requires_explicit_confirmation_when_referenced`.
### P0-2. Граф workflow (Canvas, EDIT/LIVE, Циклы)
- **Статус:** **PASS**
- Граф реализован на чистом SVG + HTML5 (без npm, без react, без сторонних зависимостей сборки) в [`workflow.js`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/src/antigravity_provider/router/web/static/workflow.js) и [`workflow.css`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/src/antigravity_provider/router/web/static/workflow.css).
- **Режимы:** Чёткое переключение между `LIVE` (мониторинг исполнения) и `EDIT` (редактирование графа, соединение портов).
- **Редактор ребра:** Модальное окно позволяет задавать условия переходов (`SUCCESS`, `REVIEW_PASSED`, `REVIEW_FAILED`) и подписи.
- **Поддержка циклов:** Циклические маршруты (`Кодер 1 → Ревьюер → Кодер 1`) поддержаны и валидируются.
- **Защита от бесконечного зацикливания:** Параметр `max_iterations` отображается на экране (например, `Итерация: 2 / 5`), сохраняется в конфигурации и принудительно останавливает цикл с генерацией явного события `WORKFLOW_MAX_ITERATIONS`.
- **Элементы управления:** Мини-карта, масштабирование (`- 100% + ⛶`), легенда состояний узлов и рёбер.
### P0-3. LIVE-мониторинг, события и обработка ошибок
- **Статус:** **PASS**
- Поддержаны 5 состояний агента: `Ожидает` (серый), `Работает` (синий), `Проверяет` (жёлтый), `Ошибка` (красный), `Завершено` (зелёный).
- Тексты реальных ошибок провайдеров (например, `No authentication token found for Codex profile 'codex-orch'`) доходят до статуса запуска и списка событий.
- Прерванный перезапуском прогон корректно помечается статусом `interrupted` с записью события `WORKFLOW_INTERRUPTED`.
- Проверено тестами: `test_live_cycle_stops_with_explicit_iteration_limit_event`, `test_interrupted_run_is_reported_not_silently_completed`, `test_provider_error_text_reaches_run_and_events`.
### P0-4. Честность данных (Zero Fake / Zero Mock)
- **Статус:** **PASS**
- Поиск по кодовой базе показал полное отсутствие захардкоженных демонстрационных чисел из макета (`12`, `3.42 с`, `1.42M`, `94.2%`, `42`, `account-01...`).
- Все 5 оперативных KPI-показателей на экране «Обзор» берутся из реальных источников:
1. *Активные задачи:* `workflow.run.status` (0 или 1).
2. *Агенты онлайн:* `readiness.roles_ready_count / readiness.total_roles` из сервиса `readiness`.
3. *Среднее время ответа:* `telemetry.global.latency_p50_ms` (при отсутствии вызовов: `Н/Д: за 24 часа нет измеренных вызовов`).
4. *Использование токенов:* `telemetry.global.total_tokens` (при отсутствии: `Н/Д: провайдеры не вернули usage`).
5. *Успешность задач:* отношение `successful_calls / total_calls` (при отсутствии: `Н/Д: за 24 часа нет завершённых вызовов`).
- Состояния загрузки (`workflow.is_loading`) явно отделены от отсутствия данных.
### P0-5. Неприкосновенность десктопного UI
- **Статус:** **PASS**
- Проверка `git diff --stat d6ec34d 0c19738 -- src/antigravity_provider/router/ui` подтвердила **0 изменений** в каталоге `router/ui/**`.
---
## 5. Проверка артефактов и скриншотов
Все 5 обязательных скриншотов присутствуют в каталоге `docs/screenshots/a30/` и проверены:
1. [`overview-live.png`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/docs/screenshots/a30/overview-live.png) — Главный экран в режиме LIVE с 6 агентами, честными статусами «Н/Д» и мини-картой.
2. [`overview-edit-inspector.png`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/docs/screenshots/a30/overview-edit-inspector.png) — Режим EDIT с выбранным узлом «Кодер 1», портами соединения и панелью инспектора (вкладки Основное, Модель, Инструкции, Инструменты, Память).
3. [`edge-editor.png`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/docs/screenshots/a30/edge-editor.png) — Модальное окно создания/редактирования ребра (`coder-primary` → `reviewer`, условие `SUCCESS`).
4. [`agent-file-editor.png`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/docs/screenshots/a30/agent-file-editor.png) — Редактор файла агента `agents/coder-primary.md` с реальным содержимым.
5. [`overview-live-provider-error.png`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/docs/screenshots/a30/overview-live-provider-error.png) — Отображение реальной ошибки провайдера в узле «Главный оркестратор» (красный статус) и в журнале событий LIVE.
---
## 6. Пропущенные проверки
- **Пропущенных проверок нет.** Все 10 пунктов регламента выполнены в полном объёме.
---
## 7. Итоговый вердикт Release Gate
> **ВЕРДИКТ: `BLOCKED` (Требуется исправление 1 теста и Rebase)**
**Обоснование:**
1. Функциональная реализация A30 (`WorkflowService`, Canvas, Agent Files, LIVE/EDIT, Cycle limits, Data honesty) выполнена качественно и полностью соответствует ТЗ.
2. Автоматический Release Gate заблокирован из-за дефекта **DEF-01** (устаревший ассерт `overview-route-diagram` в `tests/test_web_parity_a21.py:133`), дающего 1 падение из 461 теста.
3. Ветка требует rebase на актуальный `origin/main` (включение фикса безопасности CORS **DEF-02**) и обновления теста `test_web_parity_a21.py` на селектор `workflow-canvas`.

View file

@ -0,0 +1,248 @@
# Отчёт HUB-1: зелёный main и P0 из аудита
## Сдача
| | |
|---|---|
| Ветка | `hub/audit-p0-green-main` |
| `START_HEAD` | `93da1b22fd2e0385f46b71a1e220fa1e4e716545` |
| Последний рабочий коммит | `f061961600dd2eb3d6901cc154dbf5a82456dd14` |
| `origin/main` на момент сдачи | `93da1b2` (не двигался) |
| PR | https://github.com/ochenstarik-ui/hermes-hub/pull/2 |
| Зелёный прогон CI | https://github.com/ochenstarik-ui/hermes-hub/actions/runs/33729660525 |
| `git status` | чисто (вне репозитория лежит посторонний `gyoza_shorts.mp4`, не мой и не тронут) |
### Зелёный CI — все четыре джоба
| Джоб | Итог |
|---|---|
| Clean Runner Test (windows-latest) | **pass** |
| Clean Runner Test (ubuntu-latest) | **pass** |
| Headless Run (windows-latest) | **pass** |
| Headless Run (ubuntu-latest) | **pass** |
`ruff check .` — чисто. Release Gate — PASSED на обеих системах.
Локально (Linux): **778 passed, 2 skipped, 4 deselected**. База до работы —
739 passed, 2 skipped. Число тестов выросло на 39, ни один не удалён.
---
## P0-1. Зелёный main
### Обе причины из задания подтвердились — и обе оказались шире описания
**1. Инвариант A37 не держался на Windows.** Причина именно та, что
предполагалась. Воспроизведено локально на окружении Windows (нет переменной
`HOME`): `os.path.expandvars("$HOME/.hermes")` оставляет строку как есть, путь
перестаёт быть абсолютным, склеивается с каталогом проекта и оказывается
«внутри разрешённого корня» — команда проходит.
Заодно нашлась **зеркальная дыра, в задании не названная**: на Linux так же
проходили `rm -rf %USERPROFILE%\.hermes` и `del /f /q C:\Windows\System32`.
`Path("C:/Windows").resolve()` на Linux приписывает пути текущий каталог, и
удаление системного каталога Windows выглядело работой внутри проекта.
Разбор пути сведён в один конвейер, как и требовало задание: классификация
диалекта shell **по самой команде, а не по системе-хозяину** → раскрытие
распознанных переменных, с разрешением `HOME`/`USERPROFILE` в домашний каталог
даже когда их нет в окружении → нормализация разделителей → канонизация →
сравнение с защищёнными корнями. Каждый несостоявшийся шаг **закрывает
проход**: непроверяемый путь не считается разрешённым. Через тот же конвейер
пропущены `validate_path`, `is_forbidden_path`, `is_inside_allowed_root`.
Доказательство: новые тесты воспроизводят окружение обеих систем на любой из
них. На прежнем guard они падают — **6 failed**, ровно на дефекте из CI и на
зеркальных случаях; на новом проходят. `test_a37_isolation_guards` зелёный на
Windows-раннере.
**2. UTF-8 ронял verification-скрипт.** Воспроизведено точно: строка 63, тот же
`UnicodeEncodeError`. Общий помощник `console_encoding.force_utf8_output`
ставит UTF-8 на потоки и оставляет запасной путь, если поток перекодировать
нельзя. Той же реализацией заменён самодельный блок в `cli_commands`.
Скрипт проходит **10/10** под `PYTHONIOENCODING=cp1252` и под `ascii`.
### Причин красного CI было не две, а семь
Это главное расхождение с заданием. Ревьюер видел две; живой прогон на
Windows после их устранения показал ещё пять. Четыре из них — **не дефекты
продукта, а допущения тестов, зашитые под 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`. Инвариант один — «чужой процесс останавливается, свой PID не
трогаем» — теперь проверяется на обеих ветках.
3-4. Оба теста установки подсовывали bash-скрипт `hermes-hub-setup.sh`; на
Windows выбирается `HermesHubSetup.exe`, и установка честно отвечала «в
релизе не найден подходящий файл обновления». Установщик берётся под ту
систему, на которой идёт прогон. Проверка сообщения смотрит, назван ли код
возврата, а не на склонение: ветки формулируют «код 3» и «кодом 3».
Пятая — моя собственная: добавленный русский вывод Release Gate уронил шаг
на cp1252. Тот же класс дефекта, то же лекарство.
Шестая — **флейк, из-за которого main краснел случайно**: базовый прогон до
начала работы дал то 738, то 739. Причина найдена по падению ubuntu-джоба:
`test_seq_token_prevents_stale_refresh_clobber` сравнивал поколения до и после
устаревшего вызова, а `HubStateStore` — процессный синглтон, и фоновый сборщик
квот от другого теста успевает поднять `generation` между вызовами. Теперь
проверяется инвариант (устаревший ответ отброшен ровно один раз, состояние
назад не откатывается), а не равенство. Пять полных прогонов со случайным
порядком — 777 passed.
---
## P0-2. Остальные P0 аудита — каждый подтверждён исполнением
### 1. Release Gate заявлял проверку хеша, которой не было — **подтвердилось**
Хуже, чем в аудите. `hashlib` в `scripts/release_gate.py` **не вызывался ни
разу**: скачивались байты 0-10 через заголовок `Range`, и этого хватало, чтобы
напечатать `PACKAGE_HASH_VERIFIED=True`. «Проверенным ассетом» при этом
оказывался первый в списке — `checksums.txt`, а не пакет.
### 2. Publication gate fail-open — **подтвердилось**, во всех трёх условиях
Измерено прогоном самой функции:
| Условие | Прежний вердикт |
|---|---|
| Полный обрыв сети | **PASS** |
| Манифест 404 (релиза нет) | **PASS** |
| Пакет 404 (ассет не загружен) | **PASS** |
Ворота пропускали релиз при любом исходе, включая полное отсутствие релиза.
Разделено, как требовало задание: офлайновая часть (версии, тесты, updater,
статика, секреты, список разрешённых адресов) блокирует всегда; Publication
Gate проверяет, что релиз есть, ассеты есть, пакет скачан **целиком** и
SHA-256 сошёлся с опубликованным `checksums.txt`. Блокирует в режиме
публикации (`--publication` или `HERMES_RELEASE_PUBLICATION_GATE=1`); в
обычном прогоне CI, где релиза для ветки нет и быть не должно, результат
сообщается как есть и не блокирует. Неизмеренное называется причиной, а не
выдаётся за проверенное.
Проверено на живом релизе `v0.1.3-b1`: два пакета скачаны целиком, хеши
сошлись. Проверено на отказах: обрыв сети и 404 теперь **FAIL**.
### 3. localhost `/api/action` без CSRF — **подтвердилось**
CORS уже закрыт правкой ревьюера, но CORS мешает **прочитать** ответ, а не
**отправить** запрос. Измерено на конфигурации по умолчанию
(`web_api_host=127.0.0.1`): POST с `Content-Type: text/plain` уходит
кросс-сайтом без предварительного запроса (простой запрос по правилам CORS), а
`request.json()` разбирает тело независимо от `Content-Type`. Запрос с
`Origin: https://evil.example.com` и без токена доходил до исполнителя
действий — отвечало уже само действие. Среди доступных действий
`clear_accounts`, `delete_credentials`, `set_main`.
Проверяется `Sec-Fetch-Site`, при его отсутствии — `Origin` против адреса
запроса. Замер после правки:
| Запрос | Итог |
|---|---|
| чужой сайт, `Sec-Fetch-Site: cross-site` | **403** |
| чужой сайт, старый браузер (только `Origin`) | **403** |
| собственный интерфейс | 200 |
| адресная строка / расширение | 200 |
| не-браузерный клиент (curl, CLI) | 200 |
Защита распространена на все пять небезопасных методов, не только на
`/api/action`.
---
## P1
1. **Zip-slip — НЕ ВОСПРОИЗВОДИТСЯ.** Это единственное расхождение с аудитом
по существу, и оно в пользу продукта. Архив с `../`, с абсолютным путём и с
записью-ссылкой распакован через `zipfile.extractall`: ничего за пределы
каталога не вышло, абсолютный путь стал относительным, `../` схлопнулись, а
запись-ссылка легла обычным файлом. CPython санирует пути сам.
Но это свойство реализации, а не обещание формата, и распаковка идёт в
корень установки. Граница сделана собственным инвариантом: каждая запись
проверяется до записи на диск, отклоняются абсолютные пути, выход через
`..`, ссылки и записи не-файлового типа. Инвариант закреплён тестом, а не
оставлен на усмотрение стандартной библиотеки.
2. **pricing fallback** — исправлено: `safe_load` вместо `safe_dump`. `dump`
сериализовал текст обратно в строку, `isinstance(data, dict)` не
выполнялось никогда, таблица цен не загружалась ни разу, а `except` это
глушил.
3. **CI-матрица Windows + Linux** — сделано, обе джобы. Именно отсутствие
Linux-джоба и позволяло четырём платформенным допущениям прятаться; на
первом же прогоне матрицы Linux-джоб поймал флейк, который Windows не
показывал.
4. Прочее из аудита (failover error policy, `uv sync --frozen`, лишний `web`
extra, secret-scan шире) — не трогал, по заданию это отдельные задания.
---
## Ограничения задания — соблюдены
- Правки ревьюера из `main` не откатывались; в `main` напрямую не пушил.
- Фронтенд не трогал: npm, сборки и фреймворков не добавлено.
- Проверка SHA-256 **усилена**, а не ослаблена; список разрешённых адресов не
тронут.
- Учётные данные и `~/.hermes/agy_profiles/` не тронуты.
- Версия `0.1.3` не понижена.
- Неизмеренное названо причиной: Publication Gate вне режима публикации
печатает «НЕ БЛОКИРУЕТ» с причиной и не заявляет `PACKAGE_HASH_VERIFIED`.
---
## Найдено сверх задания: релизный конвейер был мёртв, и мой же фикс снимал с него защиту
Это самое важное из того, что не значилось ни в задании, ни в аудите.
**Каждый прогон `Release Pipeline` завершался ошибкой** — все пять последних,
включая тег текущего релиза `v0.1.3-b1`. Причина ровно та же, что у красного
CI: шаг `Run Release Gate Check` падал на `test_a37_isolation_guards` и
`test_a41_clean_install`. До публикации не доходил ни один прогон, а релизы
выкладывались мимо конвейера.
**Отсюда ловушка.** `release.yml` собирает `hermes-hub-<версия>.zip` и
`update_manifest.json`, а `update_manager` ищет в релизе строго
`HermesHubSetup.exe` или `hermes-hub-setup.sh`. Настоящие релизы содержат
`HermesHubSetup.exe`, `hermes-hub-setup.sh` и `checksums.txt` — то есть
собраны не этим конвейером. Пока тесты были красными, конвейер падал и ничего
не публиковал; **как только я их починил, случайная защита исчезла**: первый
же тег привёл бы к публикации «latest» без установщиков, и любая попытка
обновиться отвечала бы «В релизе не найден подходящий файл обновления для
текущей платформы».
Ловушка закрыта явно, до публикации: шаг `Built assets must be installable by
the updater` (`release_gate.py --assets dist`) проверяет, что собранный набор
содержит установщик и `checksums.txt`, и падает с названной причиной и
подсказкой про `installer/build_installer.ps1` и
`installer/build_installer_linux.sh`. После публикации добавлен шаг
`Publication Gate` (`release_gate.py --publication-only`) — тот самый строгий
режим, ради которого ворота и разделялись.
Чего я **не** делал: не переписывал сборку установщиков в `release.yml`.
Проверить это можно только выкладыванием настоящего релиза по тегу, а это
решение владельца, не исполнителя. Конвейер по-прежнему не доходит до
публикации — но теперь падает с честной причиной вместо чужой.
## Что стоит решить ревьюеру
- Джобы переименованы (`Clean Windows Runner Test` → `Clean Runner Test
(windows-latest)`). Защиты ветки на `main` сейчас нет, так что ничего не
сломалось; если её будут включать — имена проверок брать новые.
- Publication Gate по умолчанию не блокирует. Это осознанный выбор: иначе
каждый PR краснел бы за отсутствие релиза для ветки. В `release.yml` он уже
встроен и блокирует (`--publication-only`, после публикации). Вручную:
`python scripts/release_gate.py --publication`.
- **Главное решение — сборка установщиков в `release.yml`.** Конвейер собирает
zip, которым обновиться нельзя. Скрипты `installer/build_installer.ps1` и
`installer/build_installer_linux.sh` в репозитории есть, но Linux-установщик
требует Linux-раннера, то есть релизной джобе нужна матрица. Работа
небольшая, но проверяется только настоящей публикацией по тегу — поэтому
оставлена за владельцем.

View file

@ -0,0 +1,62 @@
# Передача Antigravity: A31 и A32
## Цель
Последовательно выполнить A31 и A32. Не объединять их в один огромный непроверяемый коммит.
Полные технические задания:
1. `agents/inbox/2026-08-25-A31-preflight-state-batching-pii.md`
2. `agents/inbox/2026-08-25-A32-remove-desktop.md`
## Текущее состояние на момент передачи
- `origin/main`: `c35bc48`
- A28: `origin/antigravity/subagents-role-registry``b149a6a`
- A29: `origin/antigravity/design-system-routing``a8c37ca`
- A30: `codex/workflow-canvas``32bf2c9`
- A28, A29 и A30 пока не являются предками `origin/main` и не являются предками друг друга.
- A31 и A32 ещё не реализованы.
Рабочая директория владельца содержит незакоммиченные сборочные артефакты. Их не забирать, не очищать и не перезаписывать. Работать в отдельном чистом worktree/клоне.
## Задача 1: подготовить интеграционную базу
До A31 и A32 собрать A28, A29 и A30 поверх актуального `origin/main` в отдельной интеграционной ветке. Конфликты разрешать по смыслу, сохраняя одновременно:
- реестр ролей и субагентов A28;
- дизайн-систему и routing drag-and-drop A29;
- workflow canvas, Agent Files и LIVE/EDIT A30;
- security fix `c35bc48`.
После интеграции выполнить `ruff check .`, полный `pytest tests/ -v` и `python scripts/release_gate.py`. Известный устаревший ассерт A30 на `overview-route-diagram` должен быть заменён проверкой актуального `workflow-canvas`, а не обходиться skip/xfailed.
Не начинать A31, пока интеграционная база не закоммичена, не отправлена в `origin` и release gate не зелёный. В отчёте дать SHA всех взятых голов и итоговый SHA интеграционной ветки.
## Задача 2: A31
Создать отдельную ветку от проверенной интеграционной базы и полностью выполнить `2026-08-25-A31-preflight-state-batching-pii.md`.
Обязателен порядок из задания: реализация Flash, затем независимый аудит Pro. Не заявлять выполнение проверок, которые фактически не запускались. Особенно приложить доказательства для намеренно сломанного preflight, восстановления workflow после перезапуска, отсутствия секретов в состоянии, failover занятого локального сервера, получения лимитов модели без выдуманных чисел и различимого маскирования почт.
Сдать отдельный `FINAL_COMMIT_SHA`, ветку в `origin`, чистый `git status` и точный итог тестов.
## Задача 3: A32
Начинать только после принятия A31. Создать отдельную ветку от принятого результата A31 и полностью выполнить `2026-08-25-A32-remove-desktop.md`.
Первый шаг — таблица паритета десктопа и веба. Если обнаружена функция без веб-эквивалента, остановить удаление и честно перечислить пробелы. При полном паритете удалить десктоп, его launcher, зависимости и старый ярлык строго по заданию.
Обязательны реальные проверки чистой Windows-установки, обновления поверх старой версии с десктопом и Linux-установщика. Учётные данные, настройки и цепочки ролей при обновлении должны сохраниться. Реальный пропуск любой платформенной проверки отметить как пропуск, а не PASS.
Сдать отдельный `FINAL_COMMIT_SHA`, ветку в `origin`, чистый `git status` и точный итог тестов.
## Запреты
- Не работать в грязной директории владельца.
- Не пушить реализацию напрямую в `main`.
- Не создавать тег `v0.1.1`.
- Не смешивать A31 и A32 в одной ветке или одном коммите.
- Не удалять десктоп до доказанного веб-паритета.
- Не подменять живые проверки моками и не выдумывать результаты платформенных прогонов.

View file

@ -0,0 +1,166 @@
# Задание A42: подключение провайдеров — OpenRouter, NVIDIA, Ollama, квота Codex
## Дата поступления
2026-08-31
## База
`origin/main` (`ff303b5`) — туда уже слиты правки ревьюера по вебу и A41 (чистая первая установка).
```
git fetch origin --prune
git checkout -b antigravity/a42-provider-connect origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
Это задание **по коду**. Вёрстка и холст — отдельное задание A43, туда не залезать.
---
## Задача
Владелец сообщает: «опенроутер не подключается, нвидиа не подключаются, кодекс выдаёт ошибку по квоте, хотя квоты полные, оллама не выдаёт облачные модели».
Причины найдены ревьюером и проверены по коду. Заново их выяснять не нужно — нужно чинить.
## Что проверено ревьюером
### OpenRouter и NVIDIA реализованы наполовину
```
adapters/__init__.py OpenRouterAdapter и NvidiaAdapter в реестре есть
web/static/index.html:163 пункты в списке провайдеров есть
app.js:2482 шаг мастера с полем API-ключа и Base URL есть
action_handler.py:574 add_account сохраняет ТОЛЬКО для
(local, local-llm, llama.cpp, ollama, vllm)
и только при непустом base_url
action_handler.py:588 для всех остальных возвращается ok=True с текстом
«Навигация» — и не сохраняется ничего
```
То есть мастер докладывает об успехе и **не сохраняет ничего**. Аккаунт не появляется, потому что его никто не создал.
Дальше по цепочке пусто тоже:
```
auto_assigner.py:129 слоты объявлены для ollama; openrouter и nvidia отсутствуют
auto_assigner.py:219 роли по умолчанию — то же самое
router_config.py в конфигурации по умолчанию нет ни одного из трёх
model_discovery_service.py:216 _probe_provider не имеет ветки ни для
openrouter, ни для nvidia, и возвращает None
```
### Ollama обнаруживает не то
`model_discovery_service.py:325` заводит `ollama` в одну ветку с `local`, `llama.cpp`, `vllm`. Эта ветка:
1. перебирает **зашитые** идентификаторы `local-1` и `local-2` — профиль `ollama-1` не смотрит вообще;
2. читает учётные данные провайдера `local`, а не `ollama`;
3. по умолчанию идёт на `http://127.0.0.1:8081/v1` — это порт llama.cpp, а не Ollama (11434);
4. дёргает `/v1/models` и ничего не знает про облачные модели Ollama.
Скриншот владельца: «Список моделей ещё не получен от провайдера ollama» при подключённом `ollama-1`.
Та же болезнь рядом: ветка Codex перебирает зашитые `codex-orch`, `codex-worker-1`, `codex-worker-2`. После A26 идентификаторы выдаются автоматически (`codex-4`, `codex-5`), и такой профиль обнаружение пропустит.
### «Квота исчерпана» при полной квоте
На скриншоте у Codex значок «Квота исчерпана», а Session и Weekly показывают `Н/Д`. То есть **вердикт об исчерпании выносится там, где квота не измерена вовсе**.
```
unified_health.py:467 ветка: max_cd > 0 либо overall_state == QUOTA_EXHAUSTED
→ health_state = STATUS_QUOTA_EXHAUSTED
unified_health.py:470 ветка RATE_LIMITED идёт НИЖЕ
```
`max_cd` берётся из `frec.reset_at > now` — это **окно отката после ошибки**, а не остаток квоты. Отсюда два разных дефекта:
1. Любой откат показывается как исчерпание квоты, хотя квота может быть полной.
2. Ветка `RATE_LIMITED` практически мертва: при активном лимите запросов `reset_at` всегда в будущем, поэтому строка 467 срабатывает раньше и лимит запросов выдаёт себя за исчерпанную квоту.
И третье, в `health_tracker.py:483`: при пустом имени модели или значении `default` исчерпанным помечается **весь аккаунт** (`record.overall_state`). Hermes имя модели передаёт не всегда.
Классификатор в `codex_adapter.py:132` ловит подстроку `quota` в любом месте текста ошибки — проверить, не попадают ли туда сообщения, к квоте не относящиеся.
---
## P0-1. OpenRouter и NVIDIA подключаются по-настоящему
1. **`add_account` сохраняет профиль** для `openrouter`, `nvidia`, `nvidia-nim`: создаёт определение профиля, пишет учётные данные (ключ и адрес), назначает роль — по образцу существующей локальной ветки.
2. **Слоты и роли по умолчанию** для обоих провайдеров в `AutoAssigner`, как сделано для `ollama`.
3. **Адреса по умолчанию**: `https://openrouter.ai/api/v1` и `https://integrate.api.nvidia.com/v1`; владелец может переопределить в мастере.
4. **Ветка с мнимым успехом не должна остаться ловушкой.** Провайдер, для которого сохранение не реализовано, обязан получать честный отказ с причиной, а не `ok: True`. Это главное требование пункта: молчаливый успех стоил владельцу нескольких попыток подключения.
## P0-2. Обнаружение моделей для трёх провайдеров
1. **OpenRouter**: запрос списка моделей по адресу профиля с его ключом.
2. **NVIDIA**: то же самое.
3. **Ollama — отдельная ветка**, не общая с llama.cpp:
- адрес берётся из **самого профиля**, а не из зашитых `local-1`/`local-2`;
- учётные данные читаются для провайдера `ollama`;
- по умолчанию `http://127.0.0.1:11434`;
- локальные модели — через нативный `/api/tags`;
- **облачные модели Ollama** — отдельный источник, требующий ключа. Выяснить по действующей документации Ollama способ и адрес; **не выдумывать эндпоинт**. Если способ не подтверждён — так и написать в отчёте, а в интерфейсе показать `Н/Д` с причиной.
4. **Зашитые идентификаторы профилей убрать везде**, включая ветку Codex: перебирать профили провайдера из конфигурации. После A26 идентификаторы выдаются автоматически, и любой зашитый список рано или поздно промахнётся.
5. **Ошибка обнаружения показывается с текстом ответа сервера.** Сейчас `_probe_provider` возвращает `None` и когда ветки нет, и когда сервер отказал — владелец не может отличить одно от другого.
## P0-3. Квота говорит только то, что измерено
1. **Откат после ошибки — это не исчерпание квоты.** Разделить состояния: исчерпание объявлять по измеренному остатку, откат показывать как откат с причиной и временем окончания.
2. **Порядок веток исправить**: лимит запросов не должен выдавать себя за исчерпанную квоту.
3. **Ошибка без имени модели не помечает весь аккаунт.** Помечать конкретное семейство; общий вердикт — только при подтверждении.
4. **Ярлык называет источник.** «Квота исчерпана» — когда есть измерение. Иначе «Откат до HH:MM после ошибки: текст».
5. **Классификатор Codex** проверить на ложные срабатывания подстроки `quota`.
## P0-4. Проверка исполнением
Заглушек недостаточно, но и ключей владельца у исполнителя нет. Поэтому:
1. **Сохранение профиля** проверить с заведомо неверным ключом: профиль обязан создаться, а проверка подключения — вернуть внятную ошибку авторизации, а не тишину.
2. **Ollama** проверить на живом сервере: локальные модели через `/api/tags` обязаны появиться в списке.
3. **Квота**: смоделировать откат после ошибки и убедиться, что интерфейс не пишет «квота исчерпана» при неизмеренной квоте.
4. **Отказ вместо мнимого успеха** проверить отдельно.
## P0-5. Аудит вторым проходом
1. **Искать оставшиеся зашитые идентификаторы профилей** по всему коду — это повторяющийся класс дефекта.
2. **Проверить, что мнимых успехов не осталось**: действие, ничего не сохранившее, не возвращает `ok: True`.
3. **Эндпоинт облачных моделей Ollama** сверить с документацией. Выдуманный адрес — дефект того же рода, что выдуманные метрики в A38.
4. **Побочные изменения** объяснить.
5. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Ключи владельца не запрашивать и в репозиторий не класть.
- Каталог `~/.hermes/agy_profiles/` не трогать.
- Вёрстку и холст не менять — это A43.
- Версию `0.1.1` не поднимать.
- Правило честности без исключений: неизмеренное показывать как `Н/Д` с причиной.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. OpenRouter и NVIDIA подключаются: профиль создаётся, учётные данные сохраняются, аккаунт виден в списке; проверено.
3. Действие, ничего не сохранившее, возвращает отказ с причиной; проверено.
4. Обнаружение моделей работает для openrouter, nvidia и ollama; для Ollama проверено на живом сервере.
5. Зашитых идентификаторов профилей в обнаружении не осталось.
6. Ошибка обнаружения доходит до интерфейса с текстом.
7. Откат после ошибки не показывается как исчерпание квоты; лимит запросов показывается как лимит запросов.
8. Ошибка без имени модели не помечает весь аккаунт.
9. `ruff check .` чисто; релизный гейт не ухудшен.
10. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `origin/main` сейчас **491 passed**.
## Главное
Два провайдера нельзя подключить вовсе, и мастер при этом рапортует об успехе — владелец несколько раз повторял заведомо безрезультатное действие. Третий подключается, но опрашивается по чужому адресу и чужому имени профиля. А Codex объявляется исчерпанным по квоте в тот момент, когда квота не измерена ни разу. Общее у всех четырёх — интерфейс утверждает то, чего не проверял.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,130 @@
# Задание A43: интерфейс по макетам и работающий холст
## Дата поступления
2026-08-31
## База
`origin/main` (`ff303b5`) — туда уже слиты правки ревьюера по вебу и A41 (чистая первая установка).
```
git fetch origin --prune
git checkout -b antigravity/a43-frontend-canvas origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
Это задание **по интерфейсу**. Провайдеры, обнаружение моделей и квоты — задание A42, туда не залезать. Пересечение файлов: `app.js` и `workflow.js` правит только это задание; A42 работает в Python.
Исполнитель работает на машине владельца (Windows), где хаб запущен и есть живой снапшот с подключёнными аккаунтами. Это принципиально: макет надо сверять с работающим интерфейсом, а не с воображаемым.
---
## Задача
Владелец: «криво отрисовано», «окно интерактивно ужасно, посмотри как сделано у n8n», «вообще весь интерфейс не соответствует фронтенду, просто посмотри как отрисовано в макете».
## Что уже сделано ревьюером — не переделывать
В базовой ветке уже исправлено, проверено и закоммичено:
```
app.js экран маршрутизации читал currentSnapshot.profiles — такого ключа
в снапшоте нет (поле называется all_profiles). Отсюда пустая колонка
аккаунтов, счётчик «0 аккаунтов» и иконки-заглушки в цепочках.
app.js убрана полоса квоты с зашитым width:80%, одинаковая у всех аккаунтов
workflow.css подписи связей центрируются (не было text-anchor) и получили обводку
workflow.js список моделей берётся из discovered_models провайдера, а не из
preferred_models профиля; настроенная модель всегда есть в списке
```
Последнее чинило скрытую подмену: если модели агента не было в списке, ни один вариант не выбирался, показывался первый, и сохранение записывало агенту не ту модель.
## Про генераторы интерфейса
Владелец спрашивал про `github.com/abi/screenshot-to-code`. Ревьюер проверил и **не рекомендует**: инструмент выдаёт самостоятельную страницу на Tailwind без данных, а клиент здесь без сборки и без npm, всё держится на привязке к `/api/snapshot` (решение зафиксировано в `docs/web-api/CONTRACT.md` §1). Переподключать сгенерированную страницу к снапшоту, действиям и опросу состояния дороже, чем сверстать по макету.
Опираться на макеты из `Desktop/фронтенд/` напрямую.
---
## P0-1. Холст ведёт себя как холст
Сейчас `workflow.css:28` задаёт `.workflow-canvas` фиксированную высоту 430 px и `overflow:hidden`, а обработчики мыши висят только на узлах и портах (`workflow.js:171`). Колесо не обрабатывается, полотно не двигается. Всё, что выехало за 430 px, недостижимо — узел не вернуть, связь не увидеть.
Требуется поведение, привычное по n8n:
1. **Панорамирование полотна** — перетаскиванием пустого места и средней кнопкой.
2. **Масштаб колесом** с курсором как центром, а не только кнопками.
3. **Холст тянется по высоте окна**, а не заперт в 430 px.
4. **Вписать в экран** — кнопка уже есть (`fitWorkflowGraph`), она должна учитывать панорамирование.
5. **Узел нельзя утащить в недосягаемость**: либо границы, либо «вписать» всегда возвращает всё в поле зрения.
Связи и узлы считаются в одной системе координат — это ревьюер проверил, ошибки там нет. При добавлении панорамирования **сохранить это свойство**: смещение обязано применяться к обоим слоям одинаково, иначе связи отклеятся от узлов.
## P0-2. Экраны соответствуют макетам
Пройти по макетам из `Desktop/фронтенд/` и привести экраны в соответствие: сетка, отступы, типографика, состояния карточек, расположение панелей.
1. **Расхождения перечислить списком** до начала работы — что именно не совпадает на каждом экране. Список приложить к отчёту.
2. **Скриншот до и после** по каждому экрану. Это единственный способ показать владельцу результат: он сравнивает глазами.
3. **Три темы остаются рабочими** — светлая, средняя, тёмная. Средняя была реализована в стилях, но отсутствовала в списке выбора; проверить, что все три переключаются.
4. **Ничего не ломать в данных.** Экран берёт данные из снапшота; если макет требует поля, которого в снапшоте нет, — показать `Н/Д` с причиной и назвать это в отчёте, а не придумать значение.
## P0-3. Пустые состояния и честность
1. **Пустое — это пустое, а не ошибка.** Нет подключённых аккаунтов — экран говорит об этом и предлагает подключить, а не показывает ноль как поломку.
2. **Загрузка отличается от отсутствия.** «Список моделей ещё не получен» и «моделей нет» — разные сообщения.
3. **Ни одного зашитого числа в интерфейсе.** Полоса с `width:80%` уже убрана; поискать оставшиеся такие же. Любой процент, столбик или счётчик обязан приходить из снапшота.
## P0-4. Проверка исполнением
Прогнать тесты недостаточно — дефекты этого задания видны только глазами.
1. **Открыть хаб** и пройти все экраны на живом снапшоте владельца.
2. **Холст**: подвигать полотно, покрутить колесо, утащить узел за край и вернуть кнопкой «вписать».
3. **Инспектор агента**: убедиться, что показанная модель совпадает с настроенной, а список содержит модели провайдера.
4. **Маршрутизация**: колонка аккаунтов заполнена, счётчик совпадает с числом подключённых, перетаскивание работает.
5. **Три темы** переключить и посмотреть каждый экран.
## P0-5. Аудит вторым проходом
1. **Сверить скриншоты с макетами**, а не с описанием работы. Совпадение проверяется глазами, а не отчётом исполнителя.
2. **Связи не отклеились от узлов** ни на одном масштабе и смещении — проверить на нескольких значениях.
3. **Зашитые числа** искать целенаправленно по всему клиенту.
4. **Проверить, что данные не потерялись**: экраны, которые работали, продолжают работать.
5. **Побочные изменения** объяснить.
6. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- **Без сборки, без npm, без фреймворка** — решение зафиксировано в `docs/web-api/CONTRACT.md` §1. Tailwind, React и генераторы страниц не вносить.
- Python не трогать: провайдеры и квоты — задание A42.
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
- Версию `0.1.1` не поднимать.
- Правило честности без исключений.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. Холст панорамируется и масштабируется колесом; высота не заперта; связи держатся за узлы на любом масштабе и смещении — проверено.
3. Расхождения с макетами перечислены списком; по каждому экрану приложены скриншоты до и после.
4. Три темы работают на всех экранах.
5. Пустые состояния показываются как пустые, загрузка отличается от отсутствия.
6. Зашитых чисел в интерфейсе не осталось.
7. Экран маршрутизации, инспектор агента и список аккаунтов проверены на живом снапшоте.
8. `ruff check .` чисто; релизный гейт не ухудшен.
9. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `origin/main` сейчас **491 passed**.
## Главное
Владелец смотрит на готовый макет и на работающую программу и видит разные вещи. Плюс холст, из которого узел можно утащить за край и не вернуть. Задание закрывает ровно это: чтобы экран совпадал с макетом, а граф вёл себя как граф, к которому владелец привык в n8n.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,198 @@
# Задание A44: вернуть кодер в строй, поставить llama-swap, поправить отчёт A40
## Дата поступления
2026-08-31
## База
Ветка A40 (`origin/antigravity/a40-benchmark-redo`, `d545252`) — правки отчёта ложатся туда же, где он живёт.
```
git fetch origin --prune
git checkout -b antigravity/a44-restore-server origin/antigravity/a40-benchmark-redo
git merge origin/main # ветка A40 отстала: в main уже A41 и правки ревьюера
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** исполняет, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
Задание **по коду и серверу**. Вёрстка — A43, провайдеры — A42, туда не залезать.
---
## Что признано и переделке не подлежит
Ревьюер проверил A40 исполнением на сервере. Проверка таблицы пройдена:
```
все семь файлов GGUF существуют по указанным путям
размеры совпадают с отчётом ДО БАЙТА (stat -c %s по каждому)
sha256 первых 64 МБ granite-4.2 пересчитана независимо — совпала:
f155ab58fe3ff46c4daa7d65633347da343143771238ddf63ecba25b8e10a06d
DeepSeek-Coder-V2-Lite и Phi-4, выдуманные в A38, действительно скачаны
и измерены; прежние числа отозваны
```
Это хорошая работа, и требование P0-1 из A40 выполнено. Стенд, набор задач и таблицу **не переделывать**.
Претензии ниже касаются состояния сервера и двух столбцов отчёта.
---
## Что сломано — проверено ревьюером
### Кодер владельца не запущен как служба
```
systemctl is-active qwen-coder → inactive
systemctl show qwen-coder SubState → dead
порт 8081 при этом отвечает: его обслуживает процесс, поднятый ВРУЧНУЮ
PID 2713480, ELAPSED 07:50 на момент проверки
/home/ochenstarik/llama.cpp/build/bin/llama-server -m .../Qwen3.8-27B-Q4_K_M.gguf
```
Перезагрузка сервера или падение процесса — и локального кодера нет. Владелец пользуется этой машиной ежедневно.
### Контекст урезан вшестеро против штатного
```
/etc/systemd/system/qwen-coder.service ExecStart ... -c 196608
фактически запущено -c 32768
```
У Hermes порог **64К контекста**: при 32768 модель не проходит отбор, и локальный кодер бесполезен. Это ровно та проблема, ради которой делалось A39.
### Отсюда же расхождение скоростей в отчёте
Отчёт даёт Qwen3.8-27B **30,31 ток/с**. Независимый замер ревьюера на штатной конфигурации давал **13,6 ток/с**. Разницу объясняет контекст: замеры шли на 32К, служба владельца работает на 192К.
В строке «Условия измерений» перечислены квантование, `-ngl 99`, `--flash-attn on`, `--cache-type-k/v q8_0`, `--parallel 1`, `--temp 0.2` — и **размер контекста не указан вовсе**. Без него числа нельзя соотнести с реальной установкой владельца, а именно ради этого отчёт и делался.
### Столбец VRAM измеряет не то
```
отчёт: Qwen3 4B Instruct 2507 → 24 894 МиБ
живой замер того же процесса:
nvidia-smi --query-compute-apps=pid,used_memory
1570163 5 440 МиБ llama-server ... Qwen3-4B ...
```
В отчёт попала **общая занятость карты** вместе с соседней резидентной моделью, а не потребление самого процесса. Отсюда абсурд: 4B «занимает» 24 894 МиБ, а 27B — 24 696 МиБ. Для планирования «сколько моделей поместится» столбец непригоден, а владелец задаёт именно этот вопрос.
### llama-swap не установлен
```
command -v llama-swap → не найден
systemctl is-active llama-swap → inactive
```
Установка llama-swap была критерием приёмки 2 в A38 и не выполнена ни там, ни здесь.
### Мусор от прерванной закачки
```
/srv/ai/models/nemotron-3.5-30b 511 МБ
```
Отчёт честно говорит, что модель не проверена. Но огрызок остался лежать.
---
## P0-1. Кодер возвращается в штатное состояние
Это первое по важности: сейчас у владельца сломан рабочий инструмент.
1. Ручной процесс на 8081 остановить.
2. `qwen-coder` поднять **штатно, через systemd**, с контекстом `196608` из юнита.
3. Убедиться, что после `systemctl restart` служба поднимается сама и порт отвечает.
4. **Проверить включение в автозапуск** (`systemctl is-enabled`): служба обязана пережить перезагрузку.
5. `qwen-compressor` на 8082 проверить тем же порядком.
Юнит-файлы **не переписывать** без нужды: это машина владельца, он правит их сам. Если правка всё же необходима — обосновать в отчёте отдельным пунктом.
## P0-2. llama-swap
1. Поставить `mostlygeek/llama-swap` (Go, MIT) **рядом** с работающими службами, не ломая их.
2. В конфигурацию внести все модели, лежащие в `/srv/ai/models/`, каждую отдельной записью со своими параметрами запуска.
3. Таймаут выгрузки настраивается.
4. **Проверить замером `nvidia-smi`, что выгрузка действительно освобождает видеопамять** — по документации не принимать.
5. **Откат одной командой** описать и проверить: если llama-swap мешает, `qwen-coder` и `qwen-compressor` возвращаются в прежний вид.
6. Штатные порты 8081 и 8082 остаются за службами владельца. llama-swap слушает свой порт и в работу Hermes не вмешивается, пока владелец не переключит.
Обоснование, почему это стоит делать: суммарно четыре интересующие владельца модели занимают 25,5 ГиБ, а на сервере **45 ГБ уже занято страничным кэшем при 62 ГБ всего**. Модели помещаются в оперативную память целиком, поэтому переключение между ними — копирование по PCIe, а не чтение с диска на 187 МБ/с. Это снимает главное возражение против свопа.
## P0-3. Две правки отчёта
Таблицу не трогать, кроме следующего.
1. **Размер контекста внести в условия измерений.** Если замеры шли на 32768 — так и написать. Числа, снятые на 32К, не выдавать за характеристику установки владельца, работающей на 192К.
2. **Столбец VRAM пересчитать на потребление процесса**, а не карты: `nvidia-smi --query-compute-apps=pid,used_memory`. Если пересчёт требует повторных запусков — либо перезамерить, либо честно пометить столбец как неизмеренный и убрать числа. Оставлять заведомо неверные значения нельзя.
3. **Добавить строку про порог Hermes**: какие из моделей держат 64К контекста и с какой скоростью. Это тот вопрос, ради которого владелец сравнение и заказывал.
## P0-4. Ответ на вопрос владельца
Владелец спрашивает, можно ли держать несколько лёгких моделей сразу: Qwen2.5-Coder-14B, Qwen3-4B-Instruct-2507, DeepSeek-Coder-V2-Lite, Granite-4.2-8B.
Арифметика ревьюера по проверенным размерам файлов:
```
Qwen2.5-Coder-14B 8 571 МиБ
DeepSeek-V2-Lite 9 884 МиБ
Granite-4.2-8B 5 283 МиБ
Qwen3-4B-2507 2 382 МиБ
──────────
только веса 26 120 МиБ из 32 768
остаётся 6 648 МиБ на четыре контекста и буферы
```
Замеренная надбавка у живых процессов на 32К — от 1 154 МиБ до 3 058 МиБ на экземпляр.
Требуется **проверить это замером**, а не расчётом: поднять три модели без DeepSeek одновременно и снять `nvidia-smi` по процессам; затем попробовать четыре. Дать владельцу таблицу «сколько моделей и с каким контекстом помещается» с настоящими числами.
## P0-5. Аудит вторым проходом
1. **Перезагрузить сервер** (согласовав окно с владельцем) и убедиться, что кодер и компрессор поднялись сами. Это единственная настоящая проверка пункта P0-1.
2. **Убедиться, что контекст 196608**, а не 32768: запросить у сервера и сверить.
3. **Проверить, что llama-swap не мешает** штатным службам: обе работают, порты отвечают.
4. **Сверить пересчитанный столбец VRAM** с `--query-compute-apps` независимо.
5. **Убедиться, что в отчёте не осталось чисел без указания условий**, при которых они сняты.
6. **Побочные изменения** объяснить.
7. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Сервер рабочий. Окно для перезагрузки согласовать с владельцем.
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
- Конфигурацию хаба не менять.
- Юнит-файлы владельца без обоснования не переписывать.
- Место на диске контролировать: свободно 257 ГБ.
- Версию `0.1.1` не поднимать, тег не создавать.
- Правило честности без исключений.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. `qwen-coder` работает через systemd с контекстом 196608, включён в автозапуск, пережил перезагрузку — вывод приложен.
3. `qwen-compressor` проверен тем же порядком.
4. llama-swap установлен, содержит все модели из `/srv/ai/models/`, выгрузка освобождает видеопамять — подтверждено `nvidia-smi`; откат описан и проверен.
5. Штатные службы llama-swap не сломал.
6. В условиях измерений отчёта указан размер контекста.
7. Столбец VRAM показывает потребление процесса либо честно помечен неизмеренным.
8. В отчёте есть ответ, какие модели держат 64К и с какой скоростью.
9. Замерено и приложено, сколько моделей помещается одновременно и с каким контекстом.
10. Огрызок `nemotron-3.5-30b` убран либо докачан; выбор объяснён.
11. `ruff check .` чисто; релизный гейт не ухудшен.
12. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. После слияния с `origin/main` ожидается **491 passed**.
## Главное
Замеры в A40 сделаны честно, и это заметный шаг после A38. Но ради них у владельца остановили кодер, запустили его вручную с контекстом вшестеро меньше штатного и в таком виде оставили — а при 32К модель не проходит порог Hermes и в работе бесполезна. Сначала вернуть инструмент в строй, потом договорить в отчёте то, что осталось недосказанным: при каком контексте сняты числа и сколько памяти занимает каждая модель на самом деле.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,150 @@
# Задание A45: три новых кандидата в локальные кодеры
## Дата поступления
2026-08-31
## База
`origin/main` (`ff303b5`).
```
git fetch origin --prune
git checkout -b antigravity/a45-moe-candidates origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** исполняет замеры, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
**Выполнять после A44.** Причина не в приоритетах, а в железе: видеокарта одна, и оба задания её занимают. A44 первым делом останавливает ручной процесс на 8081 и возвращает кодер под systemd — начинать замеры до этого значит мешать друг другу и получить искажённые тайминги.
**Файлы A44 не трогать.** `benchmarks/BENCHMARK_REPORT.md` и `benchmarks/benchmark_results.json` правит A44; здесь пишется отдельный отчёт (см. P0-4). Стенд `benchmarks/benchmark_suite.py` используется как есть, без правок.
---
## Задача
Владелец нашёл на huggingface.co новые модели и спрашивает, есть ли что-то интересное. Ревьюер отобрал три кандидата и проверил их пригодность к этому железу. Нужно измерить.
## Что проверено ревьюером — заново не выяснять
Размеры получены через API репозиториев HuggingFace, а не из карточек моделей.
| Порядок | Репозиторий | Файл | Размер |
|---|---|---|---|
| 1 | `unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF` | `Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf` | 17,28 ГиБ |
| 2 | `bartowski/Qwen2.5-Coder-32B-Instruct-GGUF` | `Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf` | 18,49 ГиБ |
| 3 | `peculiar-ragdoll/Tiel-Coder-35B-A3B-GGUF` | `Tiel-Coder-35B-A3B-UD-Q4_K_S.gguf` | 19,46 ГиБ |
Пересобирать llama.cpp не нужно:
```
сборка на сервере: build 10597, commit 95b8e33e1
libllama.so содержит: qwen3moe, qwen35moe, deepseek2, granite
```
Место: свободно 256 ГБ, три модели занимают 55 ГиБ.
## Почему именно эти три и в этом порядке
**Qwen3-Coder-30B-A3B — главная.** MoE: 30 миллиардов всего, **3 миллиарда активных**. На V100 производительность упирается в пропускную способность памяти, поэтому скорость определяется активными параметрами, а память — общими. Ожидается скорость малой модели при качестве тридцатимиллиардного кодера. Это ровно та гипотеза, ради которой A38 брала Nemotron и до замера не довела. 12,8 млн скачиваний, 933 отметки — сборка обкатанная.
По размеру садится на место нынешнего кодера: 17,28 ГиБ против 17,67 у Qwen3.8-27B, то есть под контекст остаётся столько же.
**Qwen2.5-Coder-32B — про потолок качества.** Плотная, старшая в семействе нынешнего лидера. Числилась кандидатом ещё в A40 и до замера не дошла. Скорости от неё не ждут: плотные 32B на этом железе должны идти примерно вдвое медленнее 14B. Вопрос к ней один — покупается ли за потерю скорости реальный прирост качества. 14B даёт 75% на стенде.
**Tiel-Coder-35B-A3B — третья, и только после двух первых.** Тоже MoE с тремя активными, первое место в трендах среди кодеров. Но выложена 31 августа, автор незнакомый, 145 отметок против 933 у Qwen. Не обкатана.
## Ожидания ревьюера — это не измерения
Всё, что выше сказано про ожидаемую скорость, выведено из замеренных свойств железа и **числами в отчёт не переносится**. В таблице стоят только измеренные значения.
---
## P0-1. Правило из A40 действует без изменений
**Строка в отчёте появляется только после того, как модель отработала на стенде.**
Для каждой строки обязательны:
1. **Абсолютный путь к файлу GGUF** на сервере.
2. **Размер файла в байтах** из `stat`, а не из карточки модели.
3. **Контрольная сумма** первых мегабайт или `sha256`.
4. **Сырые тайминги** из поля `timings` ответа `llama-server`, не пересчитанные вручную.
5. **Имя сборки** из `general.name` метаданных GGUF, а не из названия каталога.
Модель не скачалась, не запустилась или не влезла — **строки в таблице нет**, вместо неё раздел «не проверено» с причиной. Это полноценный результат, он принимается; выдуманные числа — нет.
## P0-2. Замер на 64К обязателен
Это главное отличие от A40 и главная причина, по которой задание вообще нужно.
1. **Мерить на 64К контекста**, а не только на 32К. Это порог отбора у Hermes: модель, не держащая 64К, в работу не идёт, и замер на 32К на вопрос владельца не отвечает.
2. Если модель на 64К не помещается или деградирует — **так и записать**, с числами.
3. **Длинный контекст у MoE под особым подозрением.** DeepSeek-Coder-V2-Lite, тоже MoE с малым числом активных, по замеру A40 на 32К проваливается до 3,35 ток/с и уходит в таймаут. Проверить целенаправленно, не повторяется ли это у Qwen3-Coder и Tiel: если повторяется, вся привлекательность MoE на этом железе иллюзорна, и это важнейший вывод задания.
4. Дополнительно снять 32К — для сопоставимости с таблицей A40.
## P0-3. Одинаковые условия
1. **Режим мышления выключен у всех**, как в A40.
2. Квантование Q4_K_M, где доступно; у Tiel его нет — взят `UD-Q4_K_S`, и это **оговорить в отчёте** отдельной строкой.
3. `--parallel 1`, посторонних запросов во время замера нет.
4. **Размер контекста указывать при каждом числе.** В A40 его забыли указать вовсе, и числа оказалось не с чем соотнести.
5. **VRAM мерить по процессу**: `nvidia-smi --query-compute-apps=pid,used_memory`, а не общую занятость карты. В A40 столбец собрал занятость вместе с соседней резидентной моделью и стал бесполезен.
## P0-4. Что измерять и куда писать
Как в A40: генерация и обработка промпта в токенах в секунду, видеопамять по процессу, время холодной загрузки, прохождение 12 задач стенда, поведение на длинном контексте.
Отчёт — **новый файл** `benchmarks/BENCHMARK_MOE_CANDIDATES.md` и отдельный файл результатов. `BENCHMARK_REPORT.md` и `benchmark_results.json` не трогать: их правит A44, и одновременная запись даст конфликт.
Вывод в одну строку на каждую модель: годится ли она заменой нынешнему кодеру и почему. **Ничего в конфигурации владельца не менять** — задание исследовательское, решение принимает он.
## P0-5. Аудит вторым проходом
В A38 выдумка прошла первый проход целиком. Здесь она — главный предмет проверки.
1. **Для каждой строки убедиться, что файл существует**: пройти `stat` по всем путям и сверить размеры с таблицей.
2. **Ни одной записи `COMPLETED` без файла** в результатах.
3. **Проверить выборочно тайминги**: повторить два-три замера и убедиться, что цифры воспроизводятся.
4. **Убедиться, что замер на 64К действительно сделан**, а не подменён замером на 32К.
5. **Проверить, что при каждом числе указан контекст**, и что VRAM снята по процессу.
6. **Убедиться, что файлы A44 не тронуты.**
7. **Побочные изменения** объяснить.
8. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- **Выполнять после A44**: видеокарта одна.
- Служебные юниты `qwen-coder` и `qwen-compressor` после прогонов вернуть в рабочее состояние: владелец пользуется сервером ежедневно.
- Конфигурацию хаба не менять.
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
- Место на диске контролировать, раздел не забить.
- Версию `0.1.1` не поднимать, тег не создавать.
- Правило честности без исключений.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. Три кандидата прогнаны либо честно объявлены недоступными с причиной.
3. Для каждой строки: путь, размер в байтах, контрольная сумма, `general.name`, сырые тайминги.
4. **Для каждой модели есть замер на 64К контекста**; где не влезло или деградировало — с числами.
5. Проверено, повторяется ли у MoE провал на длинном контексте, замеченный у DeepSeek.
6. При каждом числе указан размер контекста; VRAM снята по процессу.
7. Отклонение по квантованию у Tiel оговорено.
8. Отчёт в отдельном файле; `BENCHMARK_REPORT.md` и `benchmark_results.json` не изменены.
9. Служебные модели на портах 8081 и 8082 возвращены в рабочее состояние.
10. Конфигурация владельца не изменена.
11. `ruff check .` чисто; релизный гейт не ухудшен.
12. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `origin/main` сейчас **491 passed**.
## Главное
Нынешний лидер по замерам — Qwen2.5-Coder-14B: 75% качества при 55,9 ток/с. Вопрос владельца в том, есть ли что-то заметно лучше. Qwen3-Coder-30B-A3B — самый обоснованный ответ, какой можно дать не запуская: три активных миллиарда на памяти-узком-месте должны дать скорость малой модели при качестве большой. Но ровно это же обещал DeepSeek, а на длинном контексте провалился до 3,35 ток/с. Поэтому замер на 64К здесь важнее самой таблицы скоростей.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,141 @@
> **ОТМЕНЕНО 31.08.2026.** Работа передана Codex заданием
> `2026-08-31-A48-codex-interface-by-mockup.md`. Исполнитель сидит на сервере,
> где запущен хаб, и может сверять с макетом глазами. Antigravity за фронтенд
> не берётся: два агента в одних файлах уже приводили к переключению ветки под
> чужой работой.
# Задание A46: вёрстка по макетам — возврат по A43
## Дата поступления
2026-08-31
## База
`origin/main` (`81a58f6`) — там уже лежит A43.
```
git fetch origin --prune
git checkout -b antigravity/a46-mockup-redo origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-4** написан для аудитора.
Исполнитель работает на машине владельца, где хаб запущен и есть живой снапшот. Макеты — в `Desktop/фронтенд/`, файлы `1.1.png``7.1.png` и пояснения `1.txt`, `3.txt`.
---
## Почему возврат
A43 закрыл холст и оставил вёрстку нетронутой. Владелец поставил сборку и написал: «интерфейс вообще не изменился, какой был, такой и остался. Зачем тогда было задание на изменение по макетам?»
Он прав. Вот что изменил A43:
```
app.js +297 шесть карточек показателей, обвязка холста
workflow.js +136 панорамирование, зум колесом
index.html +8
workflow.css 15 потолок 430 px снят, слои холста
style.css НЕ ОТКРЫВАЛСЯ НИ РАЗУ
```
`style.css` — это и есть внешний вид: сетка, отступы, типографика, карточки, палитра. Пункт P0-2 задания A43 требовал привести экраны к макетам именно по этим свойствам. Сделать это, не тронув файл со стилями, невозможно.
Скриншотов «до и после», которых требовал критерий приёмки 3, в ветке нет. Работа была принята по прохождению тестов, а тесты внешний вид не проверяют.
**Холст переделывать не нужно.** P0-1 выполнен: панорамирование, зум колесом, снятый потолок высоты — всё работает и остаётся.
---
## P0-1. Расхождения с макетом `1.1.png`
Сверено ревьюером: макет против живой сборки `81a58f6` на сервере владельца.
**Шапка.** В макете: поиск с подсказкой `Ctrl + K`, колокольчик со счётчиком, шестерёнка, карточка пользователя (инициалы, имя, команда). В сборке вместо этого две кнопки — «Обновить всё» и «Добавить аккаунт».
**Логотип и подпись.** В макете вензель, «HERMES HUB» и вторая строка «Крона • Бизнес-экосистема». В сборке — значок молнии и «Multi-Account Router».
**Левое меню.** В макете иной набор и оформление пунктов, внизу блок с эмблемой и текстом про единый визуальный язык. В сборке внизу — служебная строка про Live API и номер сборки.
**Панель инструментов холста.** В макете вертикальная панель слева внутри холста: курсор, добавить узел, связь, рамка, показать, удалить. В сборке отсутствует полностью.
**Карточки узлов.** В макете: иконка, имя, файл `.md`, строка «модель • аккаунт», статус точкой. В сборке: три строки текста, обрезанные многоточием, без модели и аккаунта.
**Подписи связей.** В макете подписи `SUCCESS`, `REVIEW_PASSED`, `REVIEW_FAILED` разнесены и подкрашены, возвраты идут красным пунктиром. В сборке подписи налезают на карточки узлов и режутся: владелец видит «ТАНОВКА ЗАД» и «РАЙПРИЁМКА».
Центрирование подписей ревьюер уже починил (`text-anchor` в `workflow.css`), и эта правка на месте. Режут их **сами карточки узлов, которые рисуются поверх**, и слишком узкий промежуток между узлами. Лечится порядком слоёв и расстоянием в раскладке, а не стилем текста.
**Нижний ряд.** В макете три карточки: «Последние события» с временем и цветными бейджами, «Статистика workflow» с кольцевой диаграммой, «Активные задачи». В сборке — «Последние события» и «Управление LIVE».
**Инспектор.** В макете это основная панель: вкладки «Основное», «Модель», «Инструкции», «Инструменты», «Память», «История»; поля статуса, текущей задачи, итерации, последнего запуска, времени выполнения, успешности; блок «Конфигурация исполнения» с провайдером, аккаунтом, моделью, температурой, лимитом токенов и таймаутом; блок «Agent File»; инструменты чипами; быстрые действия кнопками. В сборке — пустая заглушка «Выберите агента на графе».
**Палитра и рамки.** Тёмно-зелёный фон с золотыми акцентами и тонкими рамками. Это то, что задаётся в `style.css`.
## P0-2. Что делать с элементами, которых нечем наполнить
Часть макета опирается на данные, которых в снапшоте может не быть.
1. **Ничего не выдумывать.** Нет данных — `Н/Д` с причиной, как принято в проекте. Нарисовать кольцевую диаграмму с числом «42» из макета — дефект, а не выполнение задания.
2. **Модель и аккаунт на карточке узла в снапшоте есть** — брать оттуда, а не подписывать примерами из макета.
3. **Версию из макета не переносить.** Там `v2.9.0`, в проекте `0.1.1`, и она заморожена намеренно.
4. **Нижняя панель других приложений экосистемы** (Planner, Journal, Finance и прочие) — этих приложений не существует. Не делать и **назвать пропущенным** в отчёте, а не рисовать неработающие кнопки.
5. Всё остальное, что упирается в отсутствующие данные, — так же: реализовать оформление, показать пустое состояние честно, перечислить в отчёте.
## P0-3. Скриншоты — это и есть сдача работы
Владелец сравнивает глазами. Отчёт без картинок принят не будет.
1. **Список расхождений по каждому экрану** — до начала работы, приложить.
2. **Скриншот до и после по каждому экрану** — обязательно. Это критерий, по которому A43 провалился.
3. **Рядом с каждой парой — фрагмент макета**, к которому приводили.
4. Пройти все макеты `1.1``7.1`, а не только первый.
5. **Три темы** — светлая, средняя, тёмная — проверить на каждом экране.
## P0-4. Аудит вторым проходом
Проверяющему: в прошлый раз работа была принята без единого взгляда на экран.
1. **Открыть хаб и посмотреть.** Не отчёт, не тесты — экран.
2. **Проверить, что `style.css` действительно изменён** и изменения относятся к вёрстке, а не косметике в одну строку.
3. **Сверить скриншоты с макетами** попарно.
4. **Убедиться, что выдуманных данных нет**: ни одного числа из макета в живом интерфейсе.
5. **Холст не сломан**: панорамирование, зум колесом, «вписать» работают как после A43.
6. **Подписи связей читаются** на всех масштабах и не перекрываются карточками.
7. **Побочные изменения** объяснить.
8. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- **Без сборки, без npm, без фреймворка** — решение зафиксировано в `docs/web-api/CONTRACT.md` §1.
- Python не трогать.
- Холст A43 не переделывать, только доводить.
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
- Версию `0.1.1` не поднимать.
- Правило честности без исключений.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. `style.css` изменён; вёрстка экранов приведена к макетам.
3. По каждому экрану приложены скриншоты до и после рядом с фрагментом макета.
4. Пройдены все макеты `1.1``7.1`.
5. Инспектор агента реализован по макету; отсутствующие данные показаны как `Н/Д` с причиной.
6. Карточки узлов показывают модель и аккаунт из снапшота.
7. Подписи связей не перекрываются карточками узлов; проверено на нескольких масштабах.
8. Панель инструментов холста реализована либо названа пропущенной с причиной.
9. Ни одного числа из макета в живом интерфейсе.
10. Три темы работают на всех экранах.
11. `ruff check .` чисто; релизный гейт не ухудшен.
12. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `origin/main` сейчас **496 passed**.
## Главное
Владелец полдня ждал сборку, поставил её на две машины и увидел прежний интерфейс. Холст стал лучше, но он один экран из семи. Задание закрывает то, что в A43 просто не начинали: вёрстку по макетам. И сдаётся оно скриншотами, потому что проверить его иначе нельзя — тесты этого не видят, что и показал прошлый заход.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,170 @@
# Задание A47: единая память для всех агентов сервера
## Дата поступления
2026-08-31
## База
`origin/main` (`80aab00`).
```
git fetch origin --prune
git checkout -b antigravity/a47-shared-memory origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-7** написан для аудитора.
Задание идёт **на сервере** `192.168.1.81`. Правки кода — через git, правки памяти — прямо в хранилище (оно вне git, см. P0-2).
Не пересекается с A42 (провайдеры), A46 (вёрстка), A45 (замеры).
---
## Задача
Владелец: «чтобы он работал совместно с обсидиан, чтобы все ИИ на сервере использовали единый мозг».
Хранилище уже есть и сделано хорошо. Задание — не строить его заново, а заставить работать: сейчас им никто не пользуется.
## Что проверено ревьюером
**Хранилище.** `/srv/projects/AI-Memory`, Obsidian 1.13.7 из snap, 218 заметок, 2,7 МБ. Структура `00_SYSTEM`, `01_PROJECTS`, `02_KNOWLEDGE`, `03_LESSONS`, `04_PATTERNS`, `05_AGENTS`, `99_ARCHIVE`. Заметки размечены полями (`type`, `severity`, `confidence`, `created_by`, `reviewed_by`). Есть протокол, политика памяти, роли и три шаблона.
Приложение Obsidian нужно человеку. **Агенту достаточно пути**: хранилище — это папка с файлами Markdown, никаких плагинов и серверов поднимать не надо.
**Память проекта устарела на четыре дня и тринадцать заданий.**
```
01_PROJECTS/hermes-hub/CURRENT_STATE.md обновлён 27 августа
в нём: main = c35bc48, идёт работа над A33
на деле: main = 80aab00, идёт A46
worklog/ ПУСТО, 0 записей
```
Протокол требует после каждой задачи обновить `CURRENT_STATE.md`, `HANDOFF.md`, `TASKS.md` и написать worklog. Не делалось ни разу.
**Мосты.** В репозиториях `agent-control-center`, `business-platform`, `finance-*`, `hermes-android` и других `AGENTS.md` есть и на AI-Memory ссылается. Исключением был `hermes-hub`; корневой мост добавлен ревьюером в `80aab00`, но **на сервере лежит старая копия** — нужен `git pull`. Без моста также `hermes-hub-a34`.
**Хранилище не под контролем версий.** `.git` нет, истории нет, отката нет. При этом папка доступна на запись нескольким агентам сразу.
---
## P0-1. Привести память проекта в соответствие с действительностью
Не переписывать заново — обновить.
1. `CURRENT_STATE.md`: текущий `main`, ветки в работе, что сделано за A40A46, что открыто.
2. `HANDOFF.md`: с чего продолжать.
3. `TASKS.md`: состояние заданий A40A47.
4. `DECISIONS.md`: решения, принятые за эти дни, — отказ от `screenshot-to-code` и почему; клиент без сборки; версия `0.1.1` заморожена намеренно; порог 64К у Hermes.
5. **Даты и коммиты обязательны** у каждой записи. Память без даты нельзя отличить от свежей, и агент поверит устаревшей.
Уроки за эти дни оформить по `LESSON_TEMPLATE.md`, минимум эти:
```
мнимый успех: действие вернуло ok:True и не сохранило ничего
клиент читал несуществующий ключ снапшота (profiles вместо all_profiles)
заглушка sleep 3600 вместо llama-server оставлена на рабочей машине
работа принята по зелёным тестам без единого взгляда на экран
зашитые идентификаторы профилей: убраны в A41, возвращены в A42
```
## P0-2. Хранилище под контроль версий
Сейчас это папка без истории, куда пишут несколько агентов. Одна ошибочная перезапись — и восстановить нечем.
1. Завести git **локально**, без публичного удалённого репозитория: в памяти обсуждается внутреннее устройство систем владельца.
2. `.gitignore` для служебного каталога `.obsidian/workspace*` и прочего, что меняется от открытия окна.
3. Ежедневный коммит-снимок либо коммит после изменений — на выбор, но обосновать.
4. **Проверить восстановление**: испортить копию файла, вернуть из истории.
5. Учётные данные, токены и пути к ним в память не писать — проверить, что их там нет уже сейчас.
## P0-3. Единый мозг: все агенты читают одно
Смысл в том, чтобы урок, полученный одним агентом, работал у остальных.
1. **Составить перечень**, какие ИИ действительно работают на сервере и каким файлом каждый настраивается. У разных инструментов это разные имена (`AGENTS.md`, `CLAUDE.md` и другие) — выяснить, а не предположить.
2. **Каждому дать мост** на `/srv/projects/AI-Memory` по образцу `00_SYSTEM/AGENTS_BRIDGE_PLAN.md`: короткий указатель, без копий уроков.
3. **Обновить копию `hermes-hub` на сервере** (`git pull`), чтобы корневой мост из `80aab00` там появился.
4. **`hermes-hub-a34`** — выяснить, живой ли это рабочий каталог. Если остаток — убрать; если рабочий — дать мост.
5. **Правило единственности.** Уроки и решения живут только в AI-Memory. Копия в репозитории — дефект: копии расходятся, и агент читает неверную.
## P0-4. Обновление памяти — часть завершения задачи
Иначе всё вернётся к нынешнему состоянию.
1. Внести в шаблон задания два обязательных пункта: **прочитать память до работы**, **обновить после**.
2. В отчёт добавить строку: какие файлы памяти обновлены и каким уроком пополнилась база.
3. **Проверка свежести**: способ увидеть, что `CURRENT_STATE.md` отстал от `main`. Достаточно скрипта, сравнивающего записанный коммит с текущим, и предупреждения при расхождении.
4. **Всё хранилище в контекст не загружать** — 218 заметок. Читать `00_SYSTEM`, файлы своего проекта и найденное поиском по теме.
## P0-5. Несколько агентов пишут одновременно
Общая папка на запись без разграничения уже дала в этом проекте два случая: агент переключил ветку под чужой работой, и на рабочей машине осталась подменённая заглушка.
1. **Одновременная запись в один файл не должна терять правки.** Предложить механизм и обосновать: раздельные файлы worklog на агента, дозапись вместо перезаписи, блокировка.
2. **Каждая запись подписана**: кто, когда, по какому заданию. Поле `created_by` в шаблоне уже есть — использовать.
3. **Чужие записи не переписывать.** Не согласен — добавить свою и сослаться на исходную.
## P0-6. Граница: память — это данные, а не канал команд
Отдельным пунктом, потому что цена ошибки высока и проект этим уже занимался в A37.
Общая папка, из которой все агенты читают инструкции и в которую все пишут, — это ровно тот канал связи между агентами, о котором предупреждал разбор чужого инцидента, приложенный к A37: разрешённый внутренний сервис становится доской объявлений и точкой опоры.
1. **Память описывает состояние и уроки. Она не отдаёт распоряжений.** Задания приходят от владельца через `agents/inbox/`, а не из заметок.
2. **Заметка, требующая действия, исполнением не является.** Найденный в памяти «TODO» выносится владельцу, а не выполняется молча.
3. **Изменения, расширяющие права или меняющие правила работы агентов**, вносит владелец. Агент может предложить.
4. Подписи и даты из P0-5 нужны и для этого: должно быть видно, кто внёс запись.
## P0-7. Аудит вторым проходом
1. **Сверить `CURRENT_STATE.md` с действительностью**: коммит в памяти против `git log` на сервере.
2. **Проверить восстановление из истории** самостоятельно, а не по описанию.
3. **Проверить, что мост есть у каждого перечисленного агента** и указывает на существующий путь.
4. **Искать копии уроков** в репозиториях — их быть не должно.
5. **Искать учётные данные** в памяти целенаправленно.
6. **Проверить, что заметки не отдают распоряжений** агентам.
7. **Побочные изменения** объяснить.
8. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Хранилище **не публиковать**: ни на GitHub, ни куда-либо ещё.
- Существующие заметки владельца не удалять и не переписывать; устаревшее переносить в `99_ARCHIVE`.
- Структуру папок и разметку полей не менять — она рабочая.
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
- Службы `qwen-coder` и `qwen-compressor` не трогать — они только что восстановлены.
- Версию `0.1.1` не поднимать.
- Правило честности без исключений.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. `CURRENT_STATE.md`, `HANDOFF.md`, `TASKS.md`, `DECISIONS.md` проекта соответствуют действительности; у записей есть даты и коммиты.
3. Уроки из P0-1 оформлены по шаблону.
4. Хранилище под git локально; восстановление файла из истории проверено, вывод приложен.
5. Перечень ИИ сервера составлен; у каждого мост на AI-Memory; пути существуют.
6. Копия `hermes-hub` на сервере обновлена, корневой мост на месте; судьба `hermes-hub-a34` решена.
7. Копий уроков в репозиториях нет.
8. Шаблон задания содержит пункты про чтение и обновление памяти.
9. Проверка свежести работает: расхождение памяти с `main` обнаруживается.
10. Механизм одновременной записи предложен, обоснован и проверен.
11. Учётных данных в памяти нет; проверено поиском.
12. `ruff check .` чисто; релизный гейт не ухудшен.
13. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `origin/main` сейчас **496 passed**.
## Главное
Память построена, размечена и продумана — а последняя запись в ней сделана 27 августа, и каталог worklog пуст. Тринадцать заданий прошли мимо. Агент, который добросовестно её прочитает, начнёт работать по состоянию четырёхдневной давности: решит, что идёт A33 и `main` — это `c35bc48`. Устаревшая память вреднее отсутствующей, потому что ей верят.
Задание про то, чтобы память стала живой: обновлялась как часть работы, была одинаково видна всем агентам и пережила ошибочную перезапись.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,174 @@
# Задание A49: расстановка субагентов, вкладка «Скиллы», память через Obsidian
## Дата поступления
2026-08-31
## База
`origin/main` (`17b368a`) — туда слиты A42, A45, A47 и A48.
```
git fetch origin --prune
git checkout -b antigravity/a49-subagents-skills-memory origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан для аудитора.
Задание крупное и делится на три независимые части. **Части можно сдавать по отдельности**, но каждую — целиком.
Не пересекается с A44 (сервер) и A45 (замеры). Вёрстка A48 уже в `main`: новые экраны делать в её стиле, существующие не ломать.
---
## Что проверено ревьюером
**Ролей объявлено тринадцать**, соединено пять.
```
manager developer-1 developer-2 code-reviewer researcher tester
tech-writer analyst guardian cost-controller integration-expert
security-expert dependency-agent
```
Конвейер по умолчанию связывает только `manager → developer-1 → developer-2 → code-reviewer` с возвратами по `REVIEW_FAILED`. Остальные восемь ролей объявлены, но в графе висят без связей: на экране владельца `Research` и `Fast` стоят в стороне и ни к чему не присоединены.
**Скиллов в интерфейсе нет вовсе.** Ни вкладки, ни поля в инспекторе агента, ни признака, пользовался ли агент скиллом.
**Общая память уже работает** после A47: `/srv/projects/AI-Memory` под git, структура `00_SYSTEM`, `01_PROJECTS`, `03_LESSONS`, `04_PATTERNS`, `05_AGENTS`, протокол и шаблоны на месте, `worklog` заполняется. Корневой `AGENTS.md` в репозитории указывает на неё.
**Obsidian** стоит на сервере (snap 1.13.7), но **агенту он не нужен**. Из руководства владельца по подключению Obsidian к агенту, дословно: «Агенту нужен не GUI Obsidian, а локальная папка vault». Хранилище — это папка с файлами Markdown.
---
# Часть 1. Расстановка субагентов и связи
## P0-1. Разобрать всех тринадцать и соединить
1. **Разбор каждой роли**: что делает, от кого получает работу, кому передаёт, по какому условию. Приложить таблицей.
2. **Связать те, что должны работать вместе.** Восемь ролей сейчас ни с чем не соединены — для каждой либо связь, либо явная запись «работает по вызову, в конвейер не входит» с обоснованием.
3. **Условия переходов** брать из существующего набора: `SUCCESS`, `REVIEW_PASSED`, `REVIEW_FAILED`, `NEXT`, `ERROR`, `ALWAYS`. Новые вводить только при необходимости и объяснять.
4. **Циклы доработки конечны.** Возврат `REVIEW_FAILED` без ограничения числа итераций — это бесконечный круг на живых квотах. Предел итераций уже есть в конвейере — проверить, что он соблюдается на каждом возврате.
5. **Расстановка на холсте осмысленная**: поток слева направо, возвраты видимой дугой, узлы не наезжают друг на друга. После A48 подписи связей читаются — не сломать.
**Ничего не выдумывать про роли.** Назначение брать из `role_registry.py`; если для роли нет внятного места в потоке, так и написать, а не придумывать ей работу.
---
# Часть 2. Вкладка «Скиллы»
## P0-2. Скиллы видны, ищутся и назначаются
1. **Новая вкладка «Скиллы»** в главном меню, в стиле экранов A48.
2. **Список установленных скиллов** — читать из каталога скиллов агента (`~/.claude/skills/` и равнозначные для других инструментов; путь настраивается). Показывать `name`, `description` и путь.
3. **Поиск** по имени и описанию.
4. **Назначение скилла субагенту** — из вкладки и из карточки агента. Назначения сохраняются и переживают перезапуск.
5. **Во вкладке «Инструменты» инспектора** показывать назначенные скиллы. Сейчас там `Н/Д: инструменты не назначены` — это состояние должно наполниться.
6. **Скилл не найден или каталог отсутствует** — сказать об этом с причиной и путём, где искали. Не показывать пустой список как «скиллов нет».
## P0-3. Видно, пользовался ли агент скиллом
Владелец: «добавить режим просмотра, использовал он в проекте скиллы или сам придумывал».
1. **Записывать факт применения**: какой скилл, каким агентом, в какой задаче, когда.
2. **Показывать в истории агента** и отдельным срезом по проекту: применённые скиллы против назначенных, но ни разу не сработавших.
3. **Назначен и ни разу не применён — это сигнал**, а не ошибка. Показывать как факт: скилл может не подходить под задачи, а может быть сломан — второе лечится частью P0-4.
4. **Правило честности здесь особенно важно.** Если признак применения снять неоткуда — писать `Н/Д` с причиной, а не рисовать правдоподобную статистику. Сначала выяснить, что вообще можно узнать достоверно, и в отчёте назвать источник.
## P0-4. Субагент «скилл-доктор»
Готовый скилл лежит у владельца: `Desktop/skills-hermes/skill-doctor/``SKILL.md` и `references/description-cookbook.md`. **Написан, выверен и переделке не подлежит**; задание — встроить его как роль.
Главное из него, что определяет устройство роли:
- **У скилла две независимые части.** `frontmatter` (`name`, `description`) решает, **запустится** ли скилл; тело решает, **что будет после запуска**. Чинить тело, когда сломано описание, — самая частая потеря времени.
- **Порядок диагностики:** формальное (имя файла ровно `SKILL.md`, расположение, границы `---`, `name` латиницей, `description` одной строкой) → разбор описания на три части → тело → проверочные запросы → диагноз.
- **Многострочный `description` — ошибка номер один по частоте**: YAML обрезает его, и решение о запуске принимается по огрызку.
- **Описание состоит из трёх частей**: что делает, когда запускать (реальными словами пользователя, 45 формулировок), когда **НЕ** запускать. Третья отсутствует почти всегда, и без неё скилл тихо срабатывает на соседних темах и жжёт лимиты — это хуже молчания, потому что не замечается.
- **Пять проверочных запросов**: три должны запустить скилл, два — не запустить. Негативные обязательны.
- **Диагноз выдаётся строгим форматом** с готовым `description` целиком, а не советом «сделай понятнее».
Требования к встраиванию:
1. **Новая каноническая роль** `skill-doctor` в реестре, с назначением и способностями, как у остальных.
2. **Запуск из вкладки «Скиллы»**: кнопка «Проверить скилл» рядом с каждым, и общая проверка всех.
3. **Результат показывать в интерфейсе** тем же форматом диагноза, с готовым описанием, которое можно скопировать.
4. **Скилл-доктор не правит файлы молча.** Он ставит диагноз и предлагает правку; применяет её владелец.
---
# Часть 3. Память через Obsidian
## P0-5. Хранилище подключается и наполняется
Владелец: «если на ПК или сервере установлен Обсидиан, то должен подгружаться в память… в настройках добавляешь папку рабочую Обсидиан, и оркестратору даёшь задание, чтобы он настроил работу».
1. **Обнаружение.** Хаб проверяет, есть ли Obsidian и хранилище. Признак хранилища — **папка с каталогом `.obsidian` внутри**, а не установленное приложение: агенту нужна папка, не программа. Найдено — предложить; не найдено — сказать прямо, без догадок.
2. **Настройка пути** в «Настройках»: путь к хранилищу задаётся вручную и сохраняется. На сервере владельца это `/srv/projects/AI-Memory`.
3. **Проверка при сохранении**: путь существует, доступен на запись, внутри есть `.obsidian`. Иначе — отказ с причиной.
4. **Хранилища нет — хаб работает как прежде.** Память не должна стать обязательной.
## P0-6. Оркестратор раскладывает память по структуре
1. **Действие «Настроить память»**, запускающее оркестратора по заложенной структуре. Структура **уже существует** — та, что в `/srv/projects/AI-Memory`: `00_SYSTEM`, `01_PROJECTS/<проект>/`, `03_LESSONS`, `04_PATTERNS`, `05_AGENTS`. Использовать её, а не изобретать вторую.
2. **У каждого субагента во вкладке «Память»** — своя структура по проектам: что он читает перед работой, что записывает после, его записи в `worklog` и его уроки.
3. **Существующие заметки владельца не трогать.** 218 заметок и восемь записей `worklog` уже есть; устаревшее переносить в `99_ARCHIVE`, не удалять.
4. **Разделение чтения и записи.** Субагент читает общее, пишет своё. Каждая запись подписана: кто, когда, по какому заданию.
5. **Граница остаётся.** Память описывает состояние и уроки; **распоряжений она не отдаёт**. Задание приходит от владельца, а не из заметки. Это требование A47, и оно не отменяется тем, что памятью теперь управляет оркестратор.
---
## P0-7. Аудит вторым проходом
1. **Открыть хаб и посмотреть** новую вкладку и связи на холсте. Не отчёт — экран. Скриншоты приложить, как в A48.
2. **Проверить, что список скиллов настоящий**: подложить скилл в каталог и убедиться, что он появился; убрать — исчез.
3. **Скилл-доктор проверить на заведомо сломанном скилле**с многострочным `description` — и убедиться, что диагноз указывает именно на это.
4. **Признак применения скилла**: убедиться, что он снимается измерением, а не выводится из назначения.
5. **Проверить, что без Obsidian хаб работает** как прежде.
6. **Проверить, что заметки владельца не пострадали**: число заметок до и после.
7. **Циклы доработки конечны** — убедиться, что предел итераций соблюдается.
8. **Побочные изменения** объяснить.
9. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Клиент **без сборки, без npm, без фреймворка**.
- Вёрстку A48 не ломать; новые экраны — в её стиле.
- Учётные данные, `~/.hermes/agy_profiles/`, службы `qwen-coder` и `qwen-compressor` не трогать.
- Заметки владельца не удалять.
- Скилл-доктор из `Desktop/skills-hermes/skill-doctor/` не переписывать.
- Версию `0.1.1` не поднимать.
- Правило честности без исключений: не измерено — `Н/Д` с причиной.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. Разбор тринадцати ролей приложен таблицей; каждая либо соединена, либо объявлена внеконвейерной с обоснованием.
3. Циклы доработки конечны; проверено.
4. Вкладка «Скиллы» есть: список читается из каталога, поиск работает, назначение сохраняется и переживает перезапуск.
5. Назначенные скиллы видны в инспекторе агента.
6. Видно, применялся ли скилл; источник признака назван; неизмеримое помечено `Н/Д`.
7. Роль `skill-doctor` в реестре; запуск из интерфейса; диагноз выводится строгим форматом с готовым описанием; файлы молча не правятся.
8. Скилл-доктор проверен на заведомо сломанном скилле.
9. Хранилище Obsidian обнаруживается по наличию `.obsidian`, путь настраивается и проверяется.
10. Без хранилища хаб работает как прежде.
11. Оркестратор раскладывает память по существующей структуре; у каждого субагента во вкладке «Память» видна структура по проектам.
12. Заметки владельца целы; число до и после совпадает.
13. Скриншоты новых экранов приложены.
14. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **517**.
15. Память проекта в AI-Memory обновлена.
16. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
## Главное
Тринадцать субагентов объявлено, работают пятеро, восемь висят на холсте без связей. Скиллы владелец ставит руками и не видит ни списка, ни того, пользовался ими агент или писал по наитию. Память после A47 ожила, но субагенты в неё не смотрят.
Задание сводит три вещи в одно: агенты расставлены и связаны осмысленно, у каждого свои скиллы с проверкой их исправности, и все читают одну память по структуре, которую раскладывает оркестратор.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,166 @@
# Задание A51: подключённый аккаунт должен реально использоваться Hermes
## Дата поступления
2026-08-31
## База
`origin/main` (`17b368a`).
```
git fetch origin --prune
git checkout -b antigravity/a51-hub-controls-hermes origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан для аудитора.
Зона: маршрутизация, назначение ролей, плагин Hermes, карточка аккаунта. С A49 (скиллы и память) и A50 (обнаружение и проверка) не пересекается по смыслу, но **трогает те же файлы, что A50**`auto_assigner.py` и экран «Аккаунты». Выполнять **после A50**.
---
## Задача
Владелец: «в хабе я поставил аккаунт аги. Когда я захожу в Гермеса, какой аккаунт будет выбран? И если я поменяю в Гермесе аккаунт, поменяется он в хабе? Иначе толку от хаба, если в самом Гермесе это не работает».
Ответ, проверенный ревьюером: **сейчас не будет выбран ни один из его аккаунтов**.
---
## Что проверено исполнением
**Определение роли работает.** A35 встал: плагин перехватывает каждый `llm_execution` и определяет роль четырьмя уровнями — явная, по модели и провайдеру, по устойчивости сессии, по умолчанию. Это переделке не подлежит.
**Но цепочки указывают в пустоту.** Конфигурация владельца на рабочей машине:
```
default_role: не задан → используется manager
manager → ag-orch-fallback, codex-orch, opengo-3
developer-1 → ag-w1, codex-worker-1, opengo-3
researcher → opengo-1, ag-w3, ag-w4
профилей в конфигурации: 24
ролей: 13
```
Проверка вхождения **подключённых** аккаунтов владельца в цепочки:
```
ollama-1 НИ В ОДНОЙ
grok-1 НИ В ОДНОЙ
local-2 НИ В ОДНОЙ
local-3 НИ В ОДНОЙ
antigravity-1 НИ В ОДНОЙ
```
Все тринадцать ролей по-прежнему ссылаются на заготовки из старой конфигурации на 24 слота, а они не настроены. Значит при обращении Hermes цепочка `manager` перебирает три неавторизованных слота, отказывает, и плагин — правильно, по своему устройству — **пропускает вызов мимо хаба дальше в Hermes**:
```python
if isinstance(completion, dict) and completion.get("router_error"):
logger.warning("Router failover exhausted for role %r; passing the call downstream to Hermes")
```
Хаб при этом ведёт себя корректно: он не подменяет ответ. Но результат для владельца тот самый, которого он опасается — **хаб не участвует в работе вовсе**.
**Карточка аккаунта показывает роль, которой нет.** На экране `ollama-1` подписан «manager (primary)», хотя в цепочке `manager` его нет. Источник подписи — `auto_assigner.get_display_name_and_role`, а она читает **статическую таблицу** `DEFAULT_SLOT_ROLES`, а при промахе достраивает подпись из имени провайдера. В снапшот это попадает так:
```python
assigned_roles=role_assignments.get(pid, [log_role])
```
Есть аккаунт в живой цепочке — берётся живое значение; нет — подставляется **догадка**. Владелец видит «manager (primary)» и считает, что аккаунт назначен.
Тот же аккаунт на карточке списка подписан «worker», а в окне — «manager (primary)». Два разных источника в двух местах.
**Обратной синхронизации нет.** `_select_model` в `hermes_plugin.py` — ручная команда CLI, которая записывает в конфигурацию Hermes провайдера, модель и адрес. Ничего, что читало бы выбор владельца, сделанный **внутри** Hermes, и переносило бы его в хаб, в коде нет.
---
## P0-1. Подключённый аккаунт попадает в цепочку
1. **Подключение аккаунта ставит его в цепочку выбранной роли.** Роль владелец выбирает на третьем шаге мастера — сейчас этот выбор до цепочки не доходит.
2. **Если аккаунт никуда не назначен — так и писать.** «Не назначен» — нормальное состояние, но оно должно быть видно, а не подменяться догадкой.
3. **Кнопка «Авто»**: разложить подключённые аккаунты по ролям по способностям провайдера. Предложить расстановку и **показать до применения**, а не применять молча.
## P0-2. Карточка показывает то, что есть на самом деле
1. **Роль на карточке берётся только из живой цепочки.** Подстановка из `DEFAULT_SLOT_ROLES` в качестве роли — убрать: статическая таблица годится для человекочитаемого имени, но не для утверждения о назначении.
2. **Один источник для карточки и окна.** Сейчас список пишет «worker», окно — «manager (primary)».
3. **Показывать место в цепочке**: основной или запасной номер такой-то. «primary» без указания, в какой роли и на каком месте, ничего не значит.
## P0-3. Владелец видит, кто ответит на вызов
Главное, ради чего задание.
1. **На экране маршрутизации у каждой роли — «сейчас ответит: <аккаунт>»**, вычисленное по текущей цепочке и состоянию аккаунтов.
2. **Если не ответит никто** — сказать прямо: «цепочка пуста или все аккаунты недоступны, вызов уйдёт мимо хаба в Hermes». Это состояние сейчас и есть, и владелец о нём не знает.
3. **Роль по умолчанию видна и настраивается.** Сейчас `default_role` не задан, и молча используется `manager`. Показать это в настройках.
## P0-4. Видно, прошёл вызов через хаб или мимо
1. **Записывать по каждому вызову**: определилась ли роль, каким уровнем, какой профиль выбран, ушёл ли вызов мимо хаба и почему.
2. **Показывать в журнале событий** и счётчиком на «Обзоре»: сколько вызовов прошло через хаб, сколько мимо.
3. Это единственный способ ответить на вопрос владельца «работает ли хаб» измерением, а не рассуждением.
## P0-5. Обратная связь с Hermes
Владелец: «если я поменяю в Гермесе аккаунт, поменяется он в хабе
Сейчас — нет. Прежде чем делать, **выяснить и записать в отчёт**, что именно Hermes позволяет наблюдать: что хранится в его конфигурации, меняется ли она при выборе модели в интерфейсе, есть ли событие или файл, по которому это видно.
Дальше по результату:
1. **Если выбор Hermes читается** — показывать его в хабе и отмечать расхождение с цепочкой: «в Hermes выбран X, хаб направил бы на Y».
2. **Если не читается** — так и написать, а в интерфейсе объяснить владельцу, что хаб управляет маршрутом только когда Hermes не задаёт провайдера явно. Честное объяснение принимается.
3. **Ничего не записывать в конфигурацию Hermes автоматически.** `_select_model` остаётся ручной командой: молчаливая правка чужой конфигурации — это то, за что уже возвращались работы.
4. **Не выдумывать механизм**, которого в Hermes нет. Отсутствие способа — результат, он принимается.
## P0-6. Аудит вторым проходом
1. **Пройти путь целиком на живой машине**: подключить аккаунт, назначить роль, сделать запрос через Hermes и убедиться по журналу, что вызов пошёл через хаб и через **этот** аккаунт. Это единственная настоящая проверка задания.
2. **Проверить обратное**: убрать аккаунт из цепочки и убедиться, что вызов уходит мимо хаба и это видно в интерфейсе.
3. **Проверить, что роль на карточке исчезает**, когда аккаунт не назначен, — а не подменяется догадкой.
4. **Сверить карточку и окно**: подпись роли одинакова.
5. **Проверить конфигурацию владельца на копии**: старые цепочки на 24 заготовки не должны молча пропасть; предложить перенос, но не выполнять его без подтверждения.
6. **Побочные изменения** объяснить.
7. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Конфигурацию владельца молча не переписывать: перенос цепочек — только с подтверждением.
- В конфигурацию Hermes автоматически не писать.
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
- Определение роли из A35 не переделывать.
- Вёрстку A48 не ломать.
- Версию `0.1.1` не поднимать.
- Правило честности без исключений.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. Подключение аккаунта с выбором роли кладёт его в цепочку этой роли; проверено.
3. Неназначенный аккаунт показан как неназначенный; догадка из статической таблицы как роль не используется.
4. Карточка и окно аккаунта показывают одну и ту же роль и место в цепочке.
5. На маршрутизации видно, какой аккаунт ответит для каждой роли; пустая цепочка названа прямо.
6. Роль по умолчанию видна и настраивается.
7. По журналу видно, прошёл вызов через хаб или мимо и почему; есть счётчик.
8. Пройден живой путь: подключение, назначение, запрос через Hermes, подтверждение по журналу.
9. Выяснено и записано, что Hermes позволяет наблюдать о своём выборе; сделано либо честно объявлено невозможным.
10. Конфигурация владельца не изменена без подтверждения.
11. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **517**.
12. Память проекта в AI-Memory обновлена.
13. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
## Главное
Владелец подключил аккаунты, увидел на карточках «manager (primary)» и решил, что настроил маршрутизацию. На деле ни один его аккаунт не входит ни в одну цепочку: все тринадцать ролей ссылаются на заготовки старой конфигурации. При обращении Hermes цепочка отказывает, и вызов уходит мимо хаба.
Хаб ведёт себя корректно и не подменяет ответ. Но владелец об этом не знает и считает, что управляет маршрутизацией, а управляет пустотой. Задание должно сделать так, чтобы назначение действительно назначало, а расхождение было видно на экране, а не выяснялось разбором конфигурации.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,295 @@
# Задание A52: локальные модели — замена, надзиратель, пара кодеров с облачным судьёй
## Дата поступления
2026-08-31 (переработано в тот же день: добавлены части 1 и 3)
## База
`origin/main` (`17b368a`).
```
git fetch origin --prune
git checkout -b antigravity/a52-local-models origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** исполняет, **Pro** проводит аудит. Пункт **P0-10** написан для аудитора.
Задание из трёх частей, и они идут **строго по порядку**:
```
Часть 1 замена моделей и замеры сдаётся отдельно, дальше по её числам
Часть 2 надзиратель локальных моделей
Часть 3 пара кодеров и облачный судья
```
Часть 3 планировать **по измеренным числам части 1**, а не заранее: без замера видеопамяти схема не проверяема.
Связано с A49 (расстановка субагентов и память) и A51 (аккаунт реально используется). Выполнять после них: надзирателю нужны и место в графе, и работающее назначение.
---
## Что проверено ревьюером — заново не выяснять
### Видеопамять занята почти полностью
```
Qwen3.8-27B кодер @196K 25 488 МиБ
qwen3-4b компрессор @32K 5 368 МиБ
──────────
30 856 из 32 768 свободно 1 912
```
### Измерено у кандидатов (A45, файлы и контрольные суммы сверены по диску)
```
Qwen3-Coder-30B-A3B @64K 21 368 МиБ 109,6 ток/с 83,3% MoE 30B/3B
Qwen3-Coder-30B-A3B @32K 19 640 МиБ 110,2 ток/с
Qwen2.5-Coder-32B @64K 27 938 МиБ 29,3 ток/с 83,3% плотная
```
Расход контекста у Qwen3-Coder — около 54 КиБ на токен.
### Качество на стенде из 12 задач (A40, признано)
```
Phi-4-14B 83,3% файл 8,28 ГиБ
Qwen2.5-Coder-14B 75,0% файл 8,37 ГиБ
Qwen3-4B-2507 83,3% файл 2,33 ГиБ
Granite-4.2-8B 66,7% файл 5,16 ГиБ
```
**Расход видеопамяти у 14B при 64К не измерен ни разу.** В A45 их не было, столбец VRAM из A40 непригоден: он собрал занятость всей карты, а не процесса.
### Процессор годится для служебных ролей
Замер ревьюера, 32 потока из 72, AVX2:
```
LFM2.5-2.6B промпт 1150,8 ток/с генерация 13,4 ток/с
Qwen3-4B промпт 854,2 ток/с генерация 8,7 ток/с
```
Обработка промпта на процессоре быстрая, генерация медленная: она упирается в память и идёт последовательно.
### Сервер отдаёт всё нужное для честной работы
Проверено на живом `127.0.0.1:8081`:
```
GET /props → default_generation_settings.n_ctx = 196608, total_slots = 1
POST /tokenize → точное число токенов («def add(a, b): return a + b» = 10)
```
Предел контекста и размер задания **измеряются**, а не прикидываются.
### Чего в коде нет
```
разбиения задачи на части — нет
подсчёта токенов для планирования — нет; format_token_count только для показа
model_registry.context_window = 128000 — статическое умолчание,
а у модели владельца 196608
llama-swap — только ttl, групп нет: держать две модели резидентно не станет
```
### Поправка к постановке владельца
Оркестратор **не переставал** давать задачи локальной модели:
```
local_adapter.classify_error: "timeout", "timed out", "502", "503", "504"
→ ErrorCategory.TRANSIENT, retry_delay_seconds=2
```
Профиль не помечается исчерпанным и из цепочки не выбывает. Происходит другое: на каждом запросе локальная модель забирает отведённые Hermes 180 секунд, не успевает, и работу доделывает следующий в цепочке — платный. Чинить надо **подачу работы**, а не возврат в цепочку.
### Физика, которую нельзя обойти
**Две модели на одной V100 не работают вдвое быстрее.** Генерация упирается в пропускную способность памяти; две модели делят одну полосу. Вместе они выдадут примерно столько же, сколько одна.
Значит «параллельно» здесь означает **два независимых решения**, а не выигрыш во времени. Ускорение в интерфейсе обещать нельзя.
Оговорка: у MoE активны три миллиарда из тридцати, полосу они едят иначе. Два MoE могут ужиться лучше — **это гипотеза, её измеряют, а не закладывают**.
---
# Часть 1. Замена моделей и замеры
## P0-1. Заменить кодер
Поставить `Qwen3-Coder-30B-A3B-Instruct-Q4_K_M` вместо `Qwen3.8-27B`.
Основание измерено: **109,6 против 30,3 ток/с**, то же качество 83,3%, и на 4 ГиБ меньше.
1. Контекст **не ниже 65536** — порог отбора у Hermes.
2. Условия запуска взять у нынешнего юнита: `--flash-attn on`, `--cache-type-k/v q8_0`, `--reasoning off`, `--parallel 1`.
3. **Прежний юнит сохранить**; откат одной командой описать и проверить.
4. Скорость и видеопамять замерить **на живой службе**, а не переносить из A45.
## P0-2. Заменить компрессор
Поставить `LFM2.5-2.6B` вместо `Qwen3-4B` на порт 8082.
1. **Сначала замерить качество сжатия** на том же наборе, что у нынешнего. Быстрее — не значит лучше; сожмёт хуже, замену не делать и так и написать.
2. Рассмотреть запуск **на процессоре** (`-ngl 0`): освобождает видеопамять, а сжатие — чтение многого и запись малого, где процессор силён. Замерить оба варианта и дать владельцу числа для решения.
## P0-3. Измерить кандидатов в пару
Замерить **расход видеопамяти по процессу при 64К** для `Phi-4-14B`, `Qwen2.5-Coder-14B`, `Qwen3-4B-2507`, `Granite-4.2-8B` — через `nvidia-smi --query-compute-apps=pid,used_memory`, а не по занятости карты.
Затем **проверить запуском**, какие пары помещаются вместе с компрессором в 32 768 МиБ. Не расчётом.
Отдельно замерить, **что происходит со скоростью при одновременной работе двух моделей**: суммарная выработка против одиночной. Это проверка утверждения о полосе памяти, и её результат решает, имеет ли смысл держать пару резидентно.
**Часть 1 сдаётся отдельно.**
---
# Часть 2. Надзиратель локальных моделей
Владелец: «если выбирается локальная модель, должен появляться субагент, который мониторит подачу работы. Если у модели не хватает контекста, он разбивает задачу на куски и подаёт, пока не заработает. Потом формирует память, какой объём давать модели».
## P0-4. Роль надзирателя
1. **Новая каноническая роль** `local-supervisor` в реестре.
2. **Включается автоматически**, когда выбранный профиль локальный (`local`, `llama.cpp`, `ollama`, `vllm`). Вручную назначать не нужно.
3. **Не встаёт между ролью и платным провайдером.**
4. **Надзиратель — не модель, а распорядитель.** Считает, режет, подаёт, наблюдает; работу делает локальная модель. Тратить на него платный вызов нельзя.
Основная его работа — счёт и разбор, а не рассуждение: токены считает `/tokenize`, предел даёт `/props`, границы кусков определяются разбором кода. Ставить сюда слабую модель значит сделать надзирателя менее надёжным.
## P0-5. Замер перед подачей, а не догадка
1. **Предел контекста брать у живого сервера** через `/props`. Умолчание `model_registry` = 128000 к модели владельца отношения не имеет.
2. **Размер задания считать через `/tokenize`** — точно. Оценка по символам допустима только запасным путём и должна быть помечена как оценка.
3. **Учитывать место под ответ**: в контекст входят задание, история и ожидаемый ответ. Запас обосновать.
4. **Не выдумывать пределы.** Сервер не ответил — так и записать.
## P0-6. Разбиение и подача
1. **Помещается — подавать целиком.** Резать без нужды вредно: теряется связность.
2. **Не помещается — резать по смысловым границам**: файл, функция, класс, раздел. Посреди выражения — нельзя.
3. **Подавать последовательно**, передавая накопленный результат, и собирать ответ.
4. **Неделимая задача — честный отказ**, а не разрез наугад.
5. **Число попыток ограничено** и настраивается. Бесконечный цикл на единственной видеокарте недопустим.
6. **После исчерпания попыток** — отказ с причиной, дальше обычная отказоустойчивость. Надзиратель **не прячет неудачу**, удерживая работу на локальной модели любой ценой.
## P0-7. Наблюдение за ходом
1. **Видеть, что модель работает, а не висит**: поток ответа или тайминги сервера.
2. **Различать три исхода**: успел, не успел, ответил ошибкой. Сейчас всё сваливается в «таймаут».
3. **Показывать ход** на «Обзоре»: какой кусок из скольких, сколько токенов подано.
4. **Отдельно ловить случай A39**: весь лимит ушёл на рассуждения, ответа нет. Измерено: 1500 токенов за 111 секунд и **ноль символов ответа**; с `enable_thinking: false` — ответ за 11 секунд. Признак — пустой ответ при полном расходе лимита; лечится `request_options`, механизм есть после A39.
## P0-8. Память: какой объём модель тянет
1. **Записывать по каждой модели**: при каком размере получался ответ, при каком нет, сколько занимало, какой кусок оказался рабочим.
2. **Хранить в общей памяти** (`/srv/projects/AI-Memory`, структура после A47). Запись подписывается: модель, когда, по какому заданию.
3. **Использовать при следующей подаче**: начинать с размера, который уже работал.
4. **Привязывать к имени сборки из метаданных GGUF**, а не к порту или имени профиля. Урок Tiel-Coder: в файле оказалась `Ornith-1.5-35B`.
5. **Показывать во вкладке «Память»** надзирателя, что он усвоил.
---
# Часть 3. Пара кодеров и облачный судья
Планировать **по числам части 1**.
## P0-9. Схема и её цена
```
задание → Кодер A (локальный) ┐
→ Кодер B (локальный) ┘→ судья (облачная модель)
├ принято → дальше по конвейеру
└ не принято → обоим на доработку
```
1. **Кодеры не видят работу друг друга** до суда. Иначе второе решение не независимо и смысл теряется.
2. **Судья облачный**, видеопамяти не занимает. Это роль `developer-2` существующего конвейера; модель задаёт владелец в интерфейсе — **зашивать имя модели или провайдера в код нельзя**.
3. **Судья получает задание и оба решения**, возвращает: какое принято либо что доработать каждому.
4. **Ревьюер остаётся на своём месте** после судьи; конвейер не переделывать.
5. **Пара включается настройкой**; возврат к одному кодеру возможен.
**Предел итераций обязателен.** Круг «пока не сделают правильно» тратит платную квоту судьи на каждом обороте.
6. **Предел кругов** настраивается, умолчание обосновать. Механизм ограничения итераций в конвейере уже есть — использовать его.
7. **Показывать номер круга.**
8. **Круги исчерпаны — честный отказ** с последним состоянием обеих работ и мнением судьи. Частичный результат за готовый не выдавать.
9. **Считать расход**: сколько вызовов судьи ушло на задачу.
10. **Круг без изменений — застревание.** Оба вернули то же, что и в прошлый раз — прекратить и сказать.
---
## P0-10. Аудит вторым проходом
1. **Числа части 1 сняты на живой службе**, а не перенесены из A45.
2. **Откат к прежнему кодеру** выполнен и проверен.
3. **Пара проверена запуском**, а не расчётом: обе модели подняты, памяти хватило, обе отвечают.
4. **Утверждение о полосе памяти** проверено: суммарная выработка двух моделей против одиночной. Результат записать, каким бы он ни был.
5. **Заведомо большая задача** разбита, подана и собрана; **неделимая** дала честный отказ.
6. **Предел попыток и предел кругов** проверены задачей, которая не выполнится никогда.
7. **Предел контекста взят у сервера**, счёт токенов сверен с `/tokenize` независимо.
8. **Независимость кодеров**: решение одного не попадает в контекст другого.
9. **Модель судьи задаётся из интерфейса**, а не зашита.
10. **Для платных провайдеров путь не изменился**, надзиратель туда не лезет.
11. **Службы владельца вернуть в рабочее состояние.**
12. **Побочные изменения** объяснить.
13. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Видеокарта одна, сервер рабочий: окна для замеров согласовать, службы возвращать в строй.
- Прежние юниты сохранять, откат описывать и проверять.
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
- Имена моделей и провайдеров в код не зашивать.
- Конфигурацию Hermes не править.
- Версию `0.1.1` не поднимать.
- Правило честности без исключений: ни одного числа без замера; ускорение не обещать без подтверждения.
## Критерии приёмки
**Часть 1**
1. Кодер заменён на Qwen3-Coder-30B-A3B, контекст не ниже 65536; скорость и видеопамять замерены на живой службе; откат проверен.
2. Компрессор замерен в обоих вариантах; замена сделана либо обоснованно отклонена.
3. Видеопамять четырёх кандидатов при 64К измерена по процессу.
4. Проверено запуском, какие пары помещаются; измерено, что со скоростью при одновременной работе.
**Часть 2**
5. Роль `local-supervisor` включается автоматически для локальных профилей; для остальных путь не изменился.
6. Предел контекста берётся через `/props`; размер задания считается через `/tokenize`; проверено.
7. Большая задача разбивается по смысловым границам и собирается; неделимая даёт отказ.
8. Число попыток ограничено; бесконечного цикла нет.
9. Ход виден владельцу; случай «весь лимит на рассуждения» распознаётся отдельно от таймаута.
10. Рабочий объём записан в общую память с привязкой к имени сборки GGUF и используется при следующей подаче.
**Часть 3**
11. Пара работает независимо; облачный судья сравнивает и возвращает на доработку.
12. Модель судьи задаётся из интерфейса.
13. Предел кругов работает; застревание распознаётся; расход вызовов судьи показан.
14. Пара включается и отключается настройкой.
**Общее**
15. Неизмеренное показано как `Н/Д` с причиной; неудачи не скрываются.
16. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **517**.
17. Службы владельца работают.
18. Память проекта в AI-Memory обновлена.
19. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
## Главное
Сейчас локальная модель получает задачу целиком, не успевает за отведённое время и отдаёт работу платному провайдеру. Так на каждом запросе: она не выбывает из цепочки, она просто всякий раз проигрывает.
Замена кодера окупается сама по себе — вчетверо быстрее при том же качестве и на четыре гигабайта меньше. Надзиратель делает подачу работы соразмерной модели: измеряет, а не предполагает, режет по смыслу и запоминает рабочий объём. Пара кодеров с облачным судьёй добавляет вторую независимую попытку — но её ценность в разных ошибках, а не в скорости, и каждый круг доработки стоит платного вызова.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,122 @@
# Задание A55: оставшиеся дефекты подключения аккаунтов
## Дата поступления
2026-08-31
## База
`origin/main` (`26f7d2c`).
```
git fetch origin --prune
git checkout -b antigravity/a55-account-connection origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан для аудитора.
Владелец не может настроить ни одного аккаунта. Это блокирует всю работу с хабом.
---
## Что ревьюер уже починил — не переделывать
В `main` закрыто и проверено исполнением:
```
кэш опознания _identities/_snapshots не чистились при удалении ключа:
слот, переиспользованный под другой аккаунт, показывал
прежнюю почту. Добавлен forget_profile.
NVIDIA успешный список моделей теперь считается доказательством
рабочего ключа; отказ пробного запроса («Function ... Not
found for account») больше не валит подключение.
401 и 403 по-прежнему отказ.
выход из программы неудачная остановка процессов больше не отменяет выход
конфигурация Hermes проверяется семь известных путей, в сообщении
перечисляется, где искали
```
---
## P0-1. Antigravity не подключается на Windows
Владелец: «на винде не подключается аккаунт аги, выдаёт ошибку API, хотя в браузере вышло, что авторизация прошла. Скорее всего требует ссылку с браузера, как в линуксе».
В браузере открывается `127.0.0.1:<порт>` и показывается «Авторизация успешно завершена», но мастер этого не видит и завершает шаг 3 с «Не указан API-ключ или не завершена авторизация».
1. **Разобраться, почему успешный возврат не доходит до мастера** на Windows, тогда как на Linux доходит.
2. **Мастер обязан дождаться** завершения входа и увидеть его результат, а не требовать ключ у провайдера, который работает по ссылке.
3. **Если возврат по ссылке на Windows невозможен** — дать тот же путь, что на Linux: поле для вставки ссылки или кода. Владелец сам это предположил.
4. **Сообщение «Не указан API-ключ» для Antigravity неверно по сути**: у него ключа нет, у него вход по ссылке. Текст должен соответствовать способу подключения.
## P0-2. Ollama ищет сервер не там
Ошибка `WinError 10061` честна, но бесполезна: мастер по умолчанию подставляет `http://127.0.0.1:11434/v1`, то есть машину, где запущен хаб. Ollama владельца работает **на сервере**.
1. **Подсказка должна объяснять**, что адрес относится к машине с хабом, и предлагать указать сетевой адрес сервера.
2. **Кнопка «Найти на этом компьютере»** уже есть — добавить проверку заданного вручную адреса с внятным ответом.
3. **Суффикс `/v1` для Ollama лишний**: нативный интерфейс живёт на `/api`. Проверить, какой адрес подставляется по умолчанию и куда потом идут запросы.
## P0-3. Antigravity на Linux: подключился, моделей нет
Аккаунт подключён, но «Список моделей ещё не получен», «Каталог моделей (0)», состояние «Не проверялось». При этом квоты подтянулись и показывают 100% — значит связь с провайдером есть.
1. **Выяснить, почему квоты приходят, а список моделей нет.** Источники разные, и один работает.
2. Возможно, поможет уже сделанный сброс кэша — **проверить на живой установке владельца** до того, как чинить что-то ещё.
## P0-4. Версия в интерфейсе
После правки ревьюера номер версии берётся из API, а зашитые значения из разметки убраны. На сборке `b2ca7cd` владелец всё ещё видит `Hermes Hub Web v0.1.1` при версии `0.1.2`.
1. **Проверить, что API отдаёт версию** и что клиент её получает на всех экранах, а не только при открытии панели обновления.
2. **Не подставлять значение по умолчанию.** Нет версии — писать `Н/Д` с причиной.
## P0-5. Экран настроек пуст
На «Настройках» половина полей не заполнена: «Н/Д: нет в снапшоте», «Н/Д: API не передаёт путь», «Н/Д: текущее значение не передано». Пустуют хост и порт, токен, порог квоты, маскирование почты, каталоги данных и конфигурации, путь к журналу.
1. **Передавать текущие значения настроек** в снапшот, чтобы поля показывали настроенное, а не заглушку.
2. **Токен не показывать целиком** — достаточно признака «задан» и возможности заменить.
3. **Значение действительно неизвестно — оставить `Н/Д` с причиной.** Заполнять правдоподобным нельзя.
## P0-6. Аудит вторым проходом
1. **Пройти путь подключения целиком на обеих машинах**: Antigravity, NVIDIA, OpenRouter, Ollama. Скриншоты приложить.
2. **Проверить, что после смены аккаунта в слоте показывается новая почта** — правка ревьюера, убедиться, что она работает на живой установке.
3. **Проверить сообщение о конфигурации Hermes**: оно должно перечислять проверенные пути.
4. **Различать «нет доступа» и «не найдено»** — не повторять ошибку ложного диагноза.
5. **Побочные изменения** объяснить.
6. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
- Правки ревьюера из `main` не откатывать.
- Версию `0.1.2` не понижать.
- Правило честности без исключений: причина отказа доходит до владельца текстом, неизвестное показывается как `Н/Д` с причиной.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. Antigravity подключается на Windows; путь входа проверен вручную, скриншоты приложены.
3. Текст ошибки соответствует способу подключения провайдера.
4. Ollama: подсказка объясняет, чья это машина; заданный вручную адрес проверяется; суффикс пути верный.
5. Antigravity на Linux отдаёт список моделей; причина прежнего отказа названа.
6. Версия в интерфейсе совпадает с установленной на всех экранах.
7. Поля настроек показывают текущие значения; неизвестное помечено `Н/Д` с причиной.
8. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **599**.
9. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
## Главное
Владелец третий день не может подключить ни одного аккаунта. Часть причин уже устранена — подменённая почта из кэша, ложный отказ NVIDIA, отмена выхода из программы. Осталось четыре: вход Antigravity на Windows, адрес Ollama, отсутствие моделей на Linux и незаполненные настройки.
Каждая проверяется вручную на живой установке. Тесты все эти дефекты пропустили.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,141 @@
# Задание A56: сжатие контекста — компрессор должен начать работать
## Дата поступления
2026-09-01
## База
`origin/main` (`26f7d2c`).
```
git fetch origin --prune
git checkout -b antigravity/a56-context-compression origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан для аудитора.
Зона: надзиратель локальных моделей и локальный адаптер. С A55 (подключение аккаунтов) не пересекается.
---
## Задача
На сервере владельца работает вторая локальная модель, называемая компрессором. Ревьюер проверил код: **в хабе нет ни одной строки, которая бы к ней обращалась для сжатия**. Порт 8082 упоминается ровно один раз — в списке адресов для обнаружения локальных серверов.
Надзиратель из A52 умеет резать задачу на куски по смысловым границам, и это работает. Но накопленный контекст между кусками никто не сжимает, и модель простаивает.
## Что проверено ревьюером на живом сервере — заново не мерить
Настройка после переделки раскладки:
```
кодер Qwen3-Coder-30B-A3B порт 8081 -c 229376 30 008 МиБ 107,4 ток/с
компрессор Qwen3-4B-2507 порт 8082 -c 32768 на CPU, -ngl 0 -t 32
604 МиБ видеопамяти
свободно на карте: 2 152 МиБ из 32 768
```
**Скорость компрессора на процессоре измерена на настоящем промпте:**
```
промпт 5068 токенов → 853,9 ток/с
генерация → 5,4 ток/с
```
То есть сжать 32 тысячи токенов — около 38 секунд чтения плюс несколько секунд на сводку. Чтение быстрое, генерация медленная; для сжатия это удачное сочетание, потому что на выходе короткий текст.
**Качество сжатия у этой модели замерено в A52: 100%** — сохраняет порты, адреса, контрольные суммы. Проверено ревьюером повторно: в ответе остались и адрес сервера, и оба порта, и имя модели.
**Родной контекст кодера — 262 144** по метаданным GGUF, поэтому 224К внутри предела.
**Обёртка над llama-server удалена**, юниты описывают действительность. Подменять бинарник больше нельзя: настройки задаются юнитом.
---
## P0-1. Компрессор становится настраиваемой ролью
1. **Отдельная роль или настройка профиля** — «модель для сжатия контекста». Владелец выбирает её в интерфейсе из подключённых локальных профилей.
2. **Адрес берётся из профиля**, а не зашивается. Порт 8082 сегодняшний, завтра другой.
3. **Компрессор не участвует в маршрутизации Hermes.** Это служебная роль: она не должна попадать в цепочки ролей и не обязана проходить порог в 64К. Если владелец захочет — назначит её явно, но по умолчанию нет.
4. **Компрессор не настроен — сжатие не выполняется**, и это нормальное состояние. Показывать `Н/Д: модель для сжатия не выбрана`, а не ошибку.
## P0-2. Когда сжимать
1. **Порог по заполнению контекста**, а не по числу сообщений. Предел берётся у сервера через `/props`, размер накопленного — через `/tokenize`; оба механизма уже есть в надзирателе после A52.
2. **Значение порога настраивается**, умолчание обосновать. Разумно начинать сжатие, когда занято около трёх четвертей.
3. **Сжимать самое старое**, оставляя свежее нетронутым: последние сообщения нужны модели дословно.
4. **Не сжимать то, что уже сжато.** Повторное сжатие сводки теряет факты и делает это незаметно.
## P0-3. Что сохранять обязательно
Главное требование к качеству, и оно проверяемое.
Сводка обязана сохранять **дословно**: пути к файлам, адреса и порты, имена функций и переменных, контрольные суммы, номера версий и коммитов, точные значения из замеров.
1. **Проверять это тестом**: подать текст с известными значениями и убедиться, что они в сводке остались.
2. **Потеря факта — дефект**, а не приемлемая цена сжатия. Модель на этой задаче даёт 100%, значит планка достижима.
3. **Указывать степень сжатия**: было столько токенов, стало столько.
## P0-4. Видно, что происходит
1. **Показывать факт сжатия** владельцу: когда, сколько токенов было и стало, сколько заняло.
2. **Хранить исходный текст** до конца задачи, чтобы можно было вернуться, если сводка потеряла нужное.
3. **Сжатие не должно идти молча**: 38 секунд тишины владелец воспримет как зависание.
4. **Ошибка сжатия не роняет задачу.** Компрессор не ответил — работаем с несжатым контекстом и говорим об этом, а не прекращаем работу.
## P0-5. Память о том, что сработало
Продолжение линии A52.
1. Записывать в общую память (`/srv/projects/AI-Memory`): какой объём сжимался, во сколько раз, сколько заняло, сохранились ли факты.
2. Привязывать к **имени сборки GGUF**, а не к порту или имени профиля.
3. Использовать накопленное: начинать с размера куска, который уже давал хороший результат.
## P0-6. Аудит вторым проходом
1. **Проверить на живом сервере**, а не заглушкой: подать текст больше порога и убедиться, что сжатие произошло и факты уцелели.
2. **Проверить сохранение дословных значений** — пути, порты, суммы. Это главный критерий.
3. **Проверить, что компрессор не попал в маршрутизацию** Hermes и не мешает выбору моделей.
4. **Проверить поведение при недоступном компрессоре**: задача продолжается на несжатом контексте.
5. **Убедиться, что предел контекста и счёт токенов берутся у сервера**, а не из умолчаний.
6. **Побочные изменения** объяснить.
7. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Юниты владельца не править: обёртку над `llama-server` только что убрали, подменять бинарник запрещено.
- Службы `qwen-coder` и `qwen-compressor` возвращать в рабочее состояние после проверок.
- Адреса и порты в код не зашивать.
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
- Версию `0.1.2` не понижать.
- Правило честности без исключений: неизмеренное — `Н/Д` с причиной, потерянный факт — дефект.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. Модель для сжатия выбирается в интерфейсе; адрес берётся из профиля.
3. Компрессор не участвует в маршрутизации Hermes по умолчанию.
4. Порог сжатия считается от предела контекста, взятого через `/props`, и объёма, посчитанного через `/tokenize`.
5. Сжимается старое, свежее остаётся дословным; повторное сжатие сводки не выполняется.
6. Тест на сохранение дословных значений проходит: пути, порты, контрольные суммы, номера версий.
7. Владелец видит факт и степень сжатия; исходный текст сохраняется до конца задачи.
8. Недоступный компрессор не роняет задачу.
9. Опыт записан в общую память с привязкой к имени сборки GGUF.
10. Проверено на живом сервере, вывод приложен.
11. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **599**.
12. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
## Главное
Владелец держит на сервере вторую модель под сжатие контекста, освободил ради неё место и вынес её на процессор. Модель работает, отвечает и сжимает правильно — но в хабе нет кода, который бы её позвал.
Задание закрывает разрыв между настроенным железом и неиспользуемой возможностью. Ключевое требование одно: **сводка не теряет фактов**. Модель на этой задаче даёт сто процентов, значит планка достижима, и снижать её нельзя — потерянный путь или порт всплывёт через два шага в виде необъяснимой ошибки.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,232 @@
# Задание A60: обновление доводит себя до конца
## Дата поступления
2026-09-02
## База
`origin/main` (`2f35377`). A59 влит: ветка `antigravity/a59-visible-update`
принята с исправлениями ревьюера (`285ae7c`). Показ и загрузку переписывать
второй раз не надо.
```
git fetch origin --prune
git checkout -b antigravity/a60-update-completes origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан
для аудитора.
Зона: применение обновления и перезапуск. Показа и загрузки не касается — там всё
принято и проверено.
---
## Что уже сделано — переделывать не надо
A59 довёл обновление до экрана. Ревьюер проверил и принял:
```
окно при запуске, отказ по версии, молчание без обновлений работает
полоса хода, честное Н/Д без размера работает
отмена с удалением недокачанного файла работает
отмена принимается только на проверке и загрузке работает
SHA-256 обязательна, непроверенный файл не запускается работает
запись о применённом обновлении только после успеха работает
```
Ничего из этого не трогать.
---
## Задача
Владелец нажимает «Обновить сейчас». Пакет скачивается на глазах, сумма сходится,
начинается установка — и на этом всё кончается. Хаб не поднимается, окно гаснет,
владелец идёт ставить сборку руками. Ровно то, из-за чего писалось A59.
---
## Разрыв, и он один на обеих системах
**Установщик снимает тот процесс, который его запустил и ждёт.**
`install_latest_update` делает так:
```
stop_running_hub() свои процессы, кроме текущего
subprocess.run(["bash", installer], timeout=600) ЖДЁТ здесь
schedule_restart() сюда управление не доходит
```
**Linux — проверено исполнением, цепочка целиком.**
```
1. хаб: subprocess.run(["bash", installer], capture_output=True)
stdout установщика — труба, единственный читатель которой сам хаб
2. install-linux.sh, шаг [0/6]: stop_running_hub снимает хаб
pgrep -u $(id -u) -f "antigravity_provider.router.web|hermes_hub_web_entry"
никого не исключает, под шаблон попадает тот, кто запустил установщик
3. хаб мёртв -> у трубы не осталось читателя
4. следующий echo установщика -> SIGPIPE -> установщик умирает на шаге [1/6]
5. не установлено ничего; перезапускать нечего
```
**Установщик не доживает до конца — он умирает раньше, чем что-либо поставит.**
Это не «поставилось, но не запустилось»: манифест и код остаются на прежней
сборке. Проверено контрольным опытом — тот же скрипт, та же смерть родителя: с
выводом в файл доходит до конца, с `capture_output=True` умирает.
Отсюда следует, что одной перестановкой `schedule_restart` делу не помочь: пока
установщик пишет в трубу убитого им процесса, он не доживёт до установки при
любом порядке вызовов.
**Windows — прочитано по коду, живьём не проверялось.**
`installer/HermesHubSetup.cs:280` (`StopOwnedRuntime`) выглядит аккуратнее: строит `$protected` — цепочку собственных предков, чтобы не снять
того, кто его запустил. Но проверка `-notin $protected` стоит **только на
дочерних** процессах внутри `Stop-HubBranch`. Сам процесс-цель снимается
безусловно, а хаб под шаблон `antigravity_provider\.router\.web` подходит.
Труба там та же: `proc.wait(timeout=600)` при `Popen` без перенаправления вывода.
**Подтвердить исполнением, а не поверить на слово.**
Отката на путях установщика нет вообще: он есть только для `.zip`
(`apply_update_sync`). Ни `.sh`, ни `.exe` при срыве на середине ничего не
возвращают.
---
## P0-1. Отсоединённый помощник
Порядок не выдумывать заново — он описан в docstring `schedule_restart`: сначала
отсоединённый помощник, потом выход текущего процесса. Лаунчер считает хаб
работающим, пока порт отвечает, поэтому поднимать новый, не освободив порт,
бесполезно.
1. **Хаб порождает помощника** (`setsid` или отдельная группа процессов на Linux,
`DETACHED_PROCESS` на Windows), передаёт ему путь к уже проверенному пакету и
**выходит сам**, освободив порт.
2. **Помощник**: дожидается освобождения порта → запускает установщик → поднимает
хаб → завершается.
3. **Ни одна труба помощника не должна вести в хаб.** Это то самое место, где всё
ломается сейчас: `capture_output=True` делает читателем вывода тот процесс,
который установщик собирается снять. Вывод установщика — сразу в файл, а не в
`PIPE`, и не через процесс, которому предстоит умереть.
4. **Помощник не наследует** ни stdout хаба, ни его рабочий каталог: хаб исчезнет
раньше, чем помощник закончит.
5. **Помощник пишет свой ход в файл** `~/.hermes/updates/apply-<время>.log`, чтобы
после неудачи было что показать. Пустой отказ без причины — уже было в A59,
второй раз не проходит.
## P0-2. Владелец видит, что происходит
1. **Перед выходом статус `restarting`** с текстом, что хаб сейчас закроется и
поднимется сам. Не «установлено» — установка ещё идёт.
2. **Интерфейс переживает разрыв.** Опрос `get_update_progress` получит отказ
соединения: это ожидаемое состояние, а не ошибка. Показывать «Hermes Hub
перезапускается», продолжать пробовать, при возврате — перечитать страницу.
3. **Не молчать бесконечно.** Не поднялся за отведённое время — сказать это прямо
и назвать путь к журналу помощника.
## P0-3. Откат на путях установщика
1. **Помощник снимает копию установленного** до запуска установщика.
2. **Установщик вернул не ноль или хаб не поднялся** за отведённое время — вернуть
прежнее и поднять его.
3. **Причина отказа**с кодом возврата и хвостом вывода — в журнал и на экран
при следующем старте.
4. **Программа обязана остаться работоспособной.** Это главное требование пункта:
неудачное обновление не имеет права оставить владельца без хаба.
## P0-4. Проверка исполнением
Тестами это не ловится: дело в живом переходе между версиями и в том, кто кого
снимает.
1. **Поставить `v0.1.2-b7`, обновиться на `v0.1.3-b1` через интерфейс.** Оба
релиза опубликованы, установщики и `checksums.txt` на месте. Скриншоты: окно,
полоса, экран после возврата.
2. **Замерить время** от нажатия до готовности.
3. **Проверить, что работает новый код** — по `running_commit`, снятому при старте
процесса, а не по номеру версии.
4. **Повторить на Windows.**
5. **Сорвать установку намеренно** (испорченный установщик) — проверить откат и
что хаб жив.
6. **Проверить, что старый процесс не остался** и порт занят новым.
## P0-5. Обновление вообще предлагается
Найдено тем же прогоном, до того как дело дошло до установки.
Хаб на `v0.1.2-b7` при живом релизе `v0.1.3-b1` ответил:
`«Установлена сборка новее опубликованного релиза (4fa9939 от 2026-09-02)»`,
`update_available: false`. Обновиться было нельзя вообще.
Причина: `deployed_at` в `deployment_manifest.json` пишется установщиком в момент
**установки**, а не сборки, и сравнивается с `published_at` релиза. Поставил
старую сборку сегодня — она «новее» любого релиза, и обновление не предложат
больше никогда. Владелец, переставивший сборку руками, выпадает из обновлений
молча.
1. **Сравнивать сборки, а не дату установки.** Дата установки не говорит о том,
какой код внутри.
2. **Если сравнить нечем — предложить обновление, а не промолчать.** Молчание
здесь дороже лишнего окна: владелец не узнает, что отстал.
3. **Причину решения показывать.** «Установлена сборка новее релиза» — вывод, а
не факт; рядом должно стоять, из чего он сделан.
## P0-6. Аудит вторым проходом
1. **Пройти обновление целиком на обеих системах.**
2. **Проверить, что помощник не снимает чужого** — только процессы хаба своего
пользователя.
3. **Проверить откат** при сорвавшейся установке и что после него хаб отвечает.
4. **Проверить, что интерфейс не объявляет успех раньше времени** — ни на выходе
хаба, ни на разрыве связи.
5. **Побочные изменения** объяснить.
6. **Пропущенный пункт назвать пропущенным.** В A59 живая проверка была пропущена
молча, при том что релиз для неё был опубликован за девять часов до сдачи.
---
## Ограничения
- Показ и загрузку из A59 не переделывать.
- Проверку SHA-256 и список разрешённых адресов не ослаблять.
- Показ работающего коммита и времени запуска не ломать.
- Фронтенд без npm, без сборки, без фреймворков — по `docs/web-api/CONTRACT.md` §1.
- **Правки ревьюера из `main` не откатывать — включая комментарии.** В A59 сняли
шесть блоков с объяснением прошлых регрессий, ревьюер возвращал их руками.
- Версию `0.1.3` не понижать.
- Правило честности без исключений: неизвестное — `Н/Д` с причиной, а не
правдоподобное число и не полоса во всю ширину.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. Обновление, запущенное из интерфейса, доходит до работающего нового хаба **без
участия владельца** — на Linux и на Windows, подтверждено скриншотами.
3. После перезапуска `running_commit` соответствует новой сборке.
4. Сорвавшаяся установка откатывается, хаб остаётся работоспособным.
5. Старый процесс не остался, порт занят новым.
6. Журнал помощника пишется, и при отказе на него указывают.
7. Обновление предлагается по сравнению сборок, а не по дате установки;
переустановка старой сборки не выключает обновления навсегда.
8. `ruff check .` чисто; релизный гейт пройден; тестов не меньше **740**.
9. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`,
`X passed / Y skipped / Z failed`, тайминги, скриншоты.
## Главное
A59 сделал обновление видимым: владелец видит окно и видит загрузку. Дальше
механизм обрывается на самом простом — установщик снимает того, кто его запустил
и ждёт результата.
Задание про один шаг: чтобы после нажатия «Обновить сейчас» владелец больше
ничего не делал.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,150 @@
# Задание HUB-1: зелёный main и P0 из аудита Hermes Hub
## Для кого
**Серверная сессия Claude (пользователь `ochenstarik`), не для agy.** Это работа
исполнителя-Claude: правка кода, прогон, пуш. Ревьюер (сессия на ПК) принимает.
## Дата
2026-09-02.
## База
`origin/main` (`144f6a5`).
```
git fetch origin --prune
git checkout -b hub/audit-p0-green-main origin/main
```
В `main` напрямую не пушить. **Координация:** над `main` работают две сессии.
Перед пушем — `git fetch` и `git log --oneline origin/main`; при расхождении
перенести правки поверх, как это уже делалось.
## Зачем
По решению о слиянии (`docs/research/kagent-merge-decision.md`) шаг 1 —
**Hermes довести до зелёного и стабильного**, потому что он служит эталоном
переноса, а сломанный эталон портировать нельзя. Сейчас `main` красный. Полный
аудит — на рабочем столе владельца (`HERMES_HUB_FULL_AUDIT_2026-09-02.md`);
здесь только то, что подтверждено исполнением.
---
## Что ревьюер уже проверил — заново не выяснять
### CI на main красный. Причина — два дефекта, оба видны в логе последнего прогона
**1. Security-инвариант A37 не держится на Windows.**
`tests/test_a37_isolation_guards.py:394` падает:
```
AssertionError: команда со стильдой прошла мимо защиты: rm -rf $HOME/.hermes (OK)
assert not True
```
WorkspaceBoundaryGuard пропускает разрушительную команду с `$HOME`, потому что
раскрытие переменных и нормализация путей на Windows и Linux различаются. Это
не косметика — это граница вокруг агентских shell-действий. Пока она работает
по-разному на поддерживаемых системах, sandbox нельзя считать доказанным.
**2. Windows UTF-8 роняет verification-скрипт.**
`scripts/verify_multi_provider_router.py:63`:
```
UnicodeEncodeError: 'charmap' codec can't encode characters ...
```
Скрипт печатает русский текст (`[PASS] Чистая конфигурация...`), а консоль
Windows в CI — cp1252. Падает `print`, не логика.
Красные джобы: `Headless Run (no GUI dependencies)` и
`Clean Windows Runner Test`.
### Баг pricing fallback (P2, но реальный)
`src/antigravity_provider/router/telemetry_service.py:164`:
```python
data = yaml.safe_dump(p.read_text(encoding="utf-8"))
if isinstance(data, dict) and "pricing" in data: # всегда False
```
`safe_dump` вместо `safe_load` — таблица цен из `pricing.yaml` не грузится
никогда, и `except: pass` это глушит. Должно быть `safe_load`.
---
## P0-1. Зелёный main (это первично)
1. **Исправить UTF-8 в verification-скрипте**: принудительный UTF-8 вывода
(`PYTHONUTF8`, `PYTHONIOENCODING=utf-8`, реконфигурация `sys.stdout`, либо
безопасное кодирование). Кросс-платформенно, проверяемо на обеих системах.
2. **Исправить WorkspaceBoundaryGuard** единым конвейером: классификация
диалекта shell → раскрытие только распознанных переменных → нормализация
разделителей → разрешение `$HOME`/`%USERPROFILE%` → канонизация пути →
сравнение с защищёнными корнями → **fail closed**. Одинаковый тест-набор для
Windows и Linux; `test_a37_isolation_guards` должен ловить `rm -rf $HOME/...`
на обеих системах.
3. **Проверка — по зелёному CI**, а не локально: локальный прогон на Linux эти
две джобы не воспроизводит. Довести оба Windows-джоба до зелёного.
## P0-2. Остальные P0 аудита — подтвердить исполнением ПЕРЕД правкой
Ревьюер их не проверял. По каждому: сначала воспроизвести, потом чинить. Не
чинить со слов аудита.
1. **Release Gate заявляет проверку хеша, которой не было** — частичный HTTP
Range, но `PACKAGE_HASH_VERIFIED=True` без полного SHA-256. Прочитать
`scripts/release_gate.py`, подтвердить, затем считать полный хеш или брать
достоверный digest из release API.
2. **Publication gate fail-open** — 404/сеть/отсутствие пакета возвращаются как
PASS. Разделить Offline Gate (тесты, updater, статика, сборка) и Publication
Gate (релиз есть, ассеты есть, digest сверен, скачивание прошло).
3. **localhost `/api/action` без CSRF/Origin** — на loopback токен не требуется,
а действие меняет состояние. Проверить, затем: bootstrap-токен, проверка
`Origin`/`Sec-Fetch-Site`, авторизация небезопасных методов.
## P1. После зелёного main
1. **Zip-slip в updater**: распаковка обязана проверять каждый путь
(`resolved.is_relative_to(staging)`), запрет абсолютных путей, `..`, symlink,
device.
2. **pricing fallback**: `safe_load` вместо `safe_dump` (см. выше).
3. **CI-матрица Windows + Linux**: сейчас Linux-джоба нет, а проект на Linux и
активно получает Linux-фиксы.
4. Прочее из аудита (failover error policy, `uv sync --frozen`, лишний `web`
extra, secret-scan шире) — отдельными заданиями, не в этом.
---
## Ограничения
- Правки ревьюера из `main` не откатывать.
- Фронтенд без npm/сборки/фреймворков — `docs/web-api/CONTRACT.md` §1.
- Проверку SHA-256 и список разрешённых адресов обновления не ослаблять.
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
- Версию `0.1.3` не понижать.
- Правило честности: неизмеренное — `Н/Д` с причиной.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. **Оба Windows-джоба CI зелёные** — ссылка на зелёный прогон в отчёте.
3. `test_a37_isolation_guards` ловит `rm -rf $HOME/...` на Windows и Linux;
guard fail-closed.
4. verification-скрипт не падает на cp1252.
5. Остальные P0 либо исправлены с доказательством, либо явно помечены как
отложенные с причиной.
6. `ruff check .` чисто; локальный прогон Linux зелёный; число тестов не меньше
текущего.
7. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`,
`X passed / Y skipped / Z failed`, ссылка на зелёный CI.
## Главное
Первично — зелёный main, и обе причины уже найдены: security-guard на Windows и
UTF-8 в verification. Остальные P0 аудита — только после подтверждения
исполнением. Это фундамент под слияние: пока Hermes красный и его sandbox-guard
дырявый на одной из систем, переносить его поведение в KAgent нельзя.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA` и ссылку на зелёный прогон CI.

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.

22
docs/research/README.md Normal file
View file

@ -0,0 +1,22 @@
# Разведка и идеи
Здесь — то, что рассмотрено для Hermes Hub, но ещё не решено делать: чужие
проекты, откуда стоит перенять устройство, наблюдения из новостей, отложенные
замыслы. Отдельно от `ARCHITECTURE.md`: тот описывает **что построено**, а этот
каталог — **что взвешено и почему**.
Правило одно: сюда попадает только то, что проверено рассуждением и привязано к
нашей машине и нашим ограничениям, а не пересказ рекламных обещаний. У каждой
записи — дата, вердикт и причина вердикта. Устаревшее не удалять молча:
помечать, что и когда изменилось.
## Записи
- [agent-orchestrator.md](agent-orchestrator.md) — разбор Agent Orchestrator,
ближайшего архитектурного родственника Hub; что перенять, чего не брать.
- [scouting-log.md](scouting-log.md) — журнал разведки: что рассмотрено по датам,
с вердиктом по каждому пункту и причиной.
- [kagent-merge-decision.md](kagent-merge-decision.md) — решение о слиянии с
KAgent: проверенные ревьюером находки, жёсткий гейт на перенос ключей, порядок.
- [kagent-merge-plan.md](kagent-merge-plan.md) — сам план миграции Hermes → KAgent
по фазам, положен в репозиторий как артефакт.

View file

@ -0,0 +1,71 @@
# Разбор: Agent Orchestrator
**Источник:** сводка AI Daily News за 2026-08-31.
**Лицензия по сводке:** Apache 2.0, локальный запуск.
**Вердикт:** разобрать устройство и перенять две идеи; целиком не брать.
**Дата разбора:** 2026-09-02.
> Первоисточник перед внедрением перепроверить: сводка — это пересказ, а не сам
> проект. Ссылку и точное имя репозитория подтвердить.
## Почему он для нас важен
Это ближайший архитектурный родственник того, что строит Hermes Hub. Он тоже
раздаёт работу флоту coding-агентов (Claude Code, Codex, Aider, OpenCode, Cline,
Continue, Goose и другие) и держит постоянную роль оркестратора — планирование,
делегирование, координация, — а работники заняты реализацией, тестами, PR и
исправлениями. Ровно наша схема ролей из `ROUTER.md`.
Ценность не в том, чтобы взять его вместо Hub, а в том, что он уже прошёл путь,
на котором мы сейчас: у нас роли есть, но замечания исполнителю пока возвращает
живой ревьюер вручную.
## Что перенять
### 1. Возврат замечаний исполнителю — то, чего у нас нет
У них отказы CI и замечания ревью **маршрутизируются обратно нужному агенту**, и
петля замыкается автоматически. У нас этого звена нет: агент присылает отчёт,
ревьюер проверяет исполнением, находит расхождение — и передаёт правку заново
руками. За 12 сентября так было трижды (A56, A57, ложный «16/16»).
Стоит спроектировать: результат проверки (прошло / не прошло + причина + номер
задания) возвращается тому исполнителю и в ту ветку, откуда пришла работа. Это
естественное продолжение линии заданий, которую ведёт `agents/inbox`.
### 2. Ветка и worktree на каждого работника
Каждый работник получает отдельный git worktree, отдельную ветку и отдельное
состояние сессии. Мы это уже делаем вручную — каждое задание Antigravity живёт в
своей ветке `antigravity/aNN-*`, ревьюер сливает в `main`. У них это часть
системы, а не ручной обычай. Формализовать наш обычай стоит.
### 3. Отслеживание PR / CI / merge conflicts / состояния работника
Единая доска состояния флота. У нас это разбросано: `agents/inbox`,
`agents/done`, `agents/reports` и git-ветки. Свести в один обзор — понятная
польза, когда исполнителей несколько и они на разных машинах.
## Чего НЕ брать
- **Целиком как замену Hub.** Hub — маршрутизатор провайдеров внутри Hermes
Agent, а не автономная IDE для флота. Задачи пересекаются, но не совпадают.
- **Прямой доступ агентов к рабочему окружению без ограничений.** Здесь держать
в уме вывод из отчёта OpenAI Astra (сводка за 2026-09-02): автономным агентам
ограничения должны обеспечиваться инфраструктурой, а не системным запросом. У
нас противовес уже есть — изоляция из A37 и правило про `agy_profiles`;
ослаблять его ради удобства оркестрации нельзя.
## Что проверить у первоисточника перед любым внедрением
- точное имя и адрес репозитория, реальную лицензию (в коде, не в сводке);
- как именно возвращаются замечания — формат, транспорт, привязка к ветке;
- требования к окружению и зрелость проекта (возраст, тесты, сообщество);
- модель прав: что работник может делать с репозиторием и системой.
## Связанное
- Роли и цепочки: [../ROUTER.md](../ROUTER.md).
- Изоляция агентов и запрет на чужие ключи: A37, `agents/inbox/2026-08-30-A37-*`.
- Наблюдаемость сессий агентов — см. AgentsView в
[scouting-log.md](scouting-log.md).

View file

@ -0,0 +1,113 @@
# Решение: слияние Hermes Hub и KAgent
**Дата:** 2026-09-02.
**Статус:** направление принято владельцем; исполнение — по условиям ниже.
**Ревьюер проверил исполнением** обе стороны, насколько имел доступ.
---
## Решение
Вести один продукт — **KAgent** как единую AI-платформу. Функциональность
Hermes Hub переносится в KAgent нативно, Hermes Hub после достижения parity
архивируется. Полный план — [kagent-merge-plan.md](kagent-merge-plan.md).
Организация работы на переходный период (решение владельца от 2026-09-02):
- **KAgent** дорабатывается на одном сервере;
- **Hermes Hub** доводится на втором сервере;
- после доработки — слияние;
- **KAgent готовится к слиянию сразу**, с первого дня: контракты и модель
безопасности проектируются под будущий перенос, а не подгоняются потом.
---
## Что ревьюер проверил сам, а не взял из аудита
Аудиты — тоже отчёты, поэтому проверены исполнением. Спот-проверка совпала с
аудитами на конкретных утверждениях — значит доверять им можно, но с поправками
ниже.
### Hermes Hub
- **CI на `main` красный** — подтверждено, несколько падений 2026-09-02.
- **Баг pricing fallback реален**: `telemetry_service.py:164` делает
`yaml.safe_dump(p.read_text(...))` вместо `safe_load`, затем проверяет
`isinstance(data, dict)` — всегда ложно, и `except: pass` это глушит. Таблица
цен из `pricing.yaml` не загружается никогда. Аудит: P2. Подтверждено.
### KAgent (репозиторий `ochenstarik-ui/kagent`, head `131c9b08`)
- **Лицензии нет**, репозиторий публичный — подтверждено.
- **Публичный расход средств подтверждён чтением `services/reasoning-engine/src/server.py`:**
функция `require_operator_secret` существует и применяется к управлению
аккаунтами (`/v1/accounts`, `pin`, `disable`, `reset-throttle`), но **НЕ**
применяется к `/v1/execute`, `/v1/decide`, `/v1/telemetry`, `/v1/models`.
`/v1/execute` вызывает `engine.execute(...)` — реальный расход. То есть с
подключёнными ключами любой, кто найдёт порт, тратит квоты без авторизации.
Это не гипотеза, а код на `main`.
- **Поправка к аудиту:** аудит датирован 2026-09-02 и говорит об «активности
после релиза», но последний push в KAgent — **1819 августа**, две недели
тишины. CI зелёный, но старый. KAgent сейчас не разрабатывается активно. На
выводы о коде это не влияет (head-коммит совпал), на планирование сроков —
влияет.
---
## Жёсткий гейт (не обсуждается)
**Ни один ключ провайдера не переезжает в KAgent, пока `/v1/execute`,
`/v1/decide` и `/v1/telemetry` не закрыты авторизацией и это не проверено живым
запросом.** У владельца ~2 десятка оплаченных аккаунтов. Пока маршруты открыты,
KAgent небезопасен даже без слияния — это надо чинить в нём независимо.
Это соответствует Phase 0 плана слияния и P0 аудита KAgent.
---
## Порядок, который советует ревьюер
Направление верное — два оркестратора не нужны, Крона не должна знать о Hermes.
Но последовательность важнее скорости:
1. **Hermes довести до зелёного и стабильного** прежде, чем замораживать. Он —
эталон переноса (reference implementation). Сломанный эталон нельзя
портировать: parity-тесты будут сверяться с неверным поведением. Сегодня
Hermes ещё нестабилен — аккаунты едва работают, `agy`-патч слетает после
перезагрузки, `main` красный.
2. **KAgent Phase 0 (безопасность) — закрыть и проверить исполнением**, начиная
ровно с четырёх незакрытых маршрутов. До этого — никаких ключей.
3. **Контракт выполнения (Phase 1)** можно проектировать уже сейчас, риска нет:
`AIExecutionRequest`, `AIExecutionResult`, `ProviderAdapter`, таксономия
ошибок, `RoutingDecision`.
## Почему «rewrite не нужен» — неточность
Скелет KAgent есть, но роутер Hermes не портируется построчно: он переезжает в
другую архитектуру (Rust gateway, TS control plane, Python-сервисы,
распределённое состояние Redis/Postgres/NATS вместо процесса). Это честный
rewrite роутера. Сроки планировать от этого.
## Что перенести из Hermes (проверенные тонкости, легко потерять при переносе)
Эти вещи вскрылись только живым прогоном и обязаны попасть в parity-набор:
- вход `agy` читается из `.gemini/antigravity-cli/antigravity-oauth-token`, не из
формата Gemini CLI;
- терминалу входа нельзя подменять `HOME` (X11 берёт ключ из `~/.Xauthority`);
- `/props` и `/tokenize` у llama.cpp — в корне, не под `/v1`;
- слот выбирается до входа и не должен плодиться; запрос пути профиля не должен
создавать каталог;
- проверка после подключения не блокирует ответ;
- честное `Н/Д` с причиной вместо правдоподобных чисел.
Подробности — в [../../agents/](../../agents/) и передаточном брифе.
---
## Координация
Две сессии Claude пишут в один `main` Hermes Hub (сессия на ПК — ревьюер; сессия
на сервере под `ochenstarik` — исполнитель). Плюс крупный разворот стратегии.
Обе сессии должны видеть это решение. Перед пушем — `git fetch` и сверка
`git log --oneline origin/main`.

View file

@ -0,0 +1,107 @@
# План слияния Hermes Hub → KAgent
Источник — план владельца от 2026-09-02, положен в репозиторий, чтобы не жил
только файлом на рабочем столе. Оценка и условия исполнения — в
[kagent-merge-decision.md](kagent-merge-decision.md).
## Цель
KAgent становится единой AI Agent Operating Platform. Функциональность Hermes Hub
переносится нативно, Hermes Hub и Hermes Agent перестают быть зависимостями,
Hermes Hub архивируется. Hermes Hub на переходный период — донор функциональности
и эталон поведения, не встраиваемая библиотека.
## Разделение обязанностей
- **Orchestrator** выбирает агента, workflow, инструменты, контекст, проверку,
момент завершения.
- **AI Router** выбирает провайдера, модель, аккаунт, локально/облако, failover,
проверяет квоту, доступность, бюджет, вычислительный узел.
> Orchestrator выбирает агента и задачу. Router выбирает модель, провайдера и
> аккаунт.
## Что переносится из Hermes
Multi-provider router; адаптеры провайдеров (Antigravity, Claude, Codex,
DeepSeek, Grok, Local, NVIDIA, Ollama, OpenCode, OpenRouter); менеджер
аккаунтов/профилей (несколько аккаунтов на провайдера, приоритет, quota,
cooldown, health, concurrency); health-состояния; quota manager; session
affinity; lease/concurrency; model registry; capability-routing; локальные
модели и вычислительные узлы; agent registry (15 ролей как декларативные
Agent Definition); Dual Coder как workflow; Guardian как policy-слой; Cost
Controller как системная подсистема; failover-policy с таксономией ошибок;
telemetry в существующий Observability; audit routing-решений.
## Что НЕ переносить
Hermes-specific bootstrap; дублирующий Web API и отдельный UI; process-local
архитектуру; JSONL как основное хранилище telemetry; формат настроек Hermes;
update flow Hermes; роль orchestrator как отдельный runtime; код, привязанный к
структуре Hermes Agent; compatibility-слои, не нужные после миграции.
Секреты: не переносить хранилище Hermes один-в-один. Порядок — внешний Secret
Manager → OS/keyring → шифрованное хранение в БД → материализация только на время
запроса. Запрещено: ключи в обычных JSON, отдача секретов через API, секреты в
telemetry/audit/трейсах.
## Фазы
- **Phase 0 — Security baseline (блокер).** Auth/RBAC; защита расхода провайдера;
service-auth; безопасное хранение секретов; уникальная request identity;
реальный E2E. Выход: нет неавторизованного execution и мутаций проекта/задачи;
расход защищён; CI зелёный; E2E по настоящему пути зелёный.
- **Phase 1 — контракты Router.** AIExecutionRequest, AIExecutionResult,
ProviderAdapter, Model/Account descriptor, таксономия ошибок, RoutingDecision.
Провайдеры пока не переносить. Выход: contract-тесты, fake-адаптер, роутер на
тестовых провайдерах.
- **Phase 2 — Provider SDK.** timeout, cancellation, streaming, маппинг ошибок,
usage, cost, health, discovery. Выход: новый провайдер добавляется без правки
ядра.
- **Phase 3 — перенос адаптеров.** Порядок: openai-compatible → Claude →
OpenRouter → Google/Antigravity → Grok → DeepSeek → NVIDIA → Ollama → Codex →
OpenCode → local. Для каждого: parity, таксономия ошибок, health/auth/
streaming/timeout/quota/regression тесты.
- **Phase 4 — Account Manager.** Безопасные credentials, приоритет, quota,
cooldown, health, concurrency, переходы состояний.
- **Phase 5 — Router Engine.** role/capability routing, scoring, preferred chain,
health/quota awareness, same-account и cross-account/provider fallback, session
affinity, auto-return primary, failover trace.
- **Phase 6 — распределённое состояние.** Redis (health, leases, affinity,
cooldown), PostgreSQL (providers, accounts, models, policies, budgets, usage,
nodes, agents).
- **Phase 7 — локальные модели / compute nodes.** node agent: регистрация,
heartbeat, инвентарь моделей и ресурсов, execution, queue, GPU.
- **Phase 8 — Agent Registry.** декларативные определения: capabilities, tools,
permissions, routing policy, budgets, model constraints.
- **Phase 9 — Guardian** как policy enforcement: валидация команд, границы ФС,
сигналы prompt injection, детект секретов, проверка прав инструментов, сетевая
политика, классификация разрушительных действий.
- **Phase 10 — Cost Controller.** оценочная и фактическая стоимость, жёсткие
бюджеты, наследование, cloud/local оптимизация, alerts, kill switch.
- **Phase 11 — Workflows.** Dual Coder как workflow; шаблоны Coder+Reviewer,
Coder+Tester, Security Review, Multi-model Consensus, Local Draft + Cloud
Review.
- **Phase 12 — UI.** разделы Providers, Accounts, Models, Routing, Nodes, Quotas,
Budgets, Usage, Health, Agents.
- **Phase 13 — parity-тесты.** Чек-лист до отключения Hermes: все провайдеры,
несколько аккаунтов, quota exhaustion, rate limit, auth failure, failover,
локальные модели, session affinity, выбор модели, health, telemetry, Dual
Coder, роли.
- **Phase 14 — decommission Hermes.** запрет новых фич → deprecated → KAgent
единственный production path → удаление зависимостей → финальный релиз Hermes →
archived.
## Критерий отказа от Hermes
Архивировать только когда KAgent умеет: все нужные облачные провайдеры;
несколько аккаунтов; локальный AI; авто-выбор модели; failover; учёт quota;
health; session affinity; telemetry; расчёт cost; роли агентов; эквивалент Dual
Coder; проверки Guardian; бюджеты; работу на Windows/Linux; полный parity-набор.
## Обязательные P0 KAgent до миграции (из его аудита, часть проверена ревьюером)
Control Plane auth/RBAC (не доверять `x-actor-id`); авторизация
`/v1/execute` и `/v1/decide` (**подтверждено: сейчас открыты**); уникальная
request identity; фикс double-consume TOTP; настоящий E2E через Gateway, не mock;
добавить LICENSE (**подтверждено: отсутствует**).

View file

@ -0,0 +1,94 @@
# Журнал разведки
Что рассмотрено для Hermes Hub, с вердиктом и причиной. Причина важнее вердикта:
она объясняет, почему решение такое, и не даёт вернуться к отвергнутому через
месяц, забыв доводы.
Наша машина, чтобы вердикты были понятны: сервер — одна **Tesla V100 32 ГиБ**
(Volta, sm_70), кодер Qwen3-Coder-30B-A3B занимает ~30 ГиБ из 32, свободно ~2.
Провайдеры подключаются аккаунтами через хаб.
---
## 2026-09-02
### Взять
**Claude Fable 5.1** — новая frontier-модель Anthropic под длительные
coding/agent-задачи. Цена $10 / $50 за миллион (вход/выход). Ложится на нашу
маршрутизацию по ролям как ревьюер и второй кодер; на повседневное не ставить
из-за цены. Проверяется через уже подключённый аккаунт Claude, отдельного
провайдера не требует. **Вердикт: протестировать на одной сложной задаче,
сравнить с текущими кодером и ревьюером по времени, стоимости и качеству.**
### Внимание, не улучшение
**Hermes Agent v0.21.0** — не обновлять сервер первым. В сводке два бага:
`ollama_num_ctx` может ограничить контекст облачного провайдера локальным
лимитом Ollama (в отчёте 65 536 при заявленном 1M); shutdown-watchdog зовёт
`asyncio.start_unix_server`, которого нет в родном Python под Windows. Проверено:
ни `AF_UNIX`, ни `ollama_num_ctx` в коде хаба нет — оба дефекта в самом Hermes
Agent, но хаб работает его плагином. **Отдельно:** 65 536 — это ещё и наше
запасное значение `n_ctx` в `local_supervisor.query_server_props`, когда `/props`
недоступен. Числа совпадают, дефекты разные — не перепутать при разборе.
**Вердикт: сервер обновлять последним, после Windows-машины (canary first).**
### Перенять устройство
**Agent Orchestrator** (Apache 2.0, локальный) — ближайший родственник Hub.
Подробный разбор: [agent-orchestrator.md](agent-orchestrator.md). Коротко:
перенять автоматический возврат замечаний исполнителю (у нас его нет), ветку и
worktree на работника, единую доску состояния флота; целиком не брать.
**Наблюдаемость — паттерн NVIDIA BioNeMo / Claude Science:** оркестратор + узкие
инструменты вместо «одна модель делает всё». Архитектурно полезно для Кроны, не
для Hub напрямую. **Вердикт: держать в уме для Кроны.**
### Отложить
**AgentsView** (MIT, локальный, без аккаунта) — местная аналитика сессий
coding-агентов: единый индекс, SQLite, токены и стоимость по агентам и датам,
сбор с нескольких машин. Полезно: у нас теперь сессии на двух машинах и
несколько исполнителей (Claude, Antigravity, Codex), и вопрос «куда ушли токены»
встанет скоро. Риск низкий. **Вердикт: протестировать локально, когда дойдут
руки; не срочно, пока хаб не стабилизирован.**
### Не для нас сейчас
**ARD (Agentic Resource Discovery)** — слой обнаружения инструментов, чтобы не
грузить весь каталог в prompt. Идея верная, но преждевременная: сначала хаб
должен надёжно видеть подключённые аккаунты. **Вердикт: следить за стандартом,
не внедрять.**
**ComfyUI MCP, VoiceStudio, Gemini Agentic Video, DreamX-Creator** — медиа и
видео. Не про Hermes Hub; относится к SMM/медийным проектам среди прочих
каталогов `/srv/projects`. **Вердикт: не в Hub.**
**Rapid-MLX, MLX Workbench, CAVI MLX Agent** — только Apple Silicon. Mac-узла
нет. **Вердикт: наблюдать до появления Mac.**
---
## 2026-09-01
### Проверять, а не брать на веру
**llama.cpp свежие сборки** (flash-attention под CUDA, shared-memory K/V,
MoE-fusion). Новые пути CUDA обычно рассчитаны на Ampere и новее; V100 — Volta
(sm_70), выигрыш не гарантирован. **Вердикт: если пробовать — только с повторным
замером тех же 107,4 ток/с на нашей сборке; по умолчанию не выигрыш.**
**Qwen3.8-27B MTP / спекулятивное декодирование** — приведённые замеры на RTX
3090 и 5090. У нас черновой модели некуда встать: кодер занимает 30 ГиБ из 32. И
в A52 уже измерено — две модели на одной V100 делят пропускную способность
памяти 0,780,99×, ускорения нет. **Вердикт: не тратить время на нашей машине.**
### Паттерн, не продукт
**AWS Agent Toolkit** — паттерн `Skills + restricted MCP + policy + audit`.
Совпадает с линией изоляции A37 и моделью безопасности (`SECURITY_MODEL.md`).
**Вердикт: держать как ориентир для Tool/Skill-подсистемы, продукт не тащить.**
**TradingAgents v0.4.0** — не для Hub; идеи point-in-time (защита от заглядывания
в будущее, historical snapshot, отметки времени в памяти решений) — для
финансовых проектов. **Вердикт: не в Hub, передать в торговые проекты.**

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

@ -23,9 +23,14 @@ ROOT = Path(__file__).resolve().parent.parent
if str(ROOT / "src") not in sys.path:
sys.path.insert(0, str(ROOT / "src"))
from antigravity_provider.console_encoding import force_utf8_output
from antigravity_provider.version import __version__, get_version
from antigravity_provider import paths
# Отчёт ворот печатается по-русски, а консоль Windows-раннера — cp1252.
# Ставится до первого вывода: иначе падает вывод, а не проверки.
force_utf8_output()
def check_version_consistency() -> tuple[bool, str]:
ver = get_version()
@ -204,88 +209,265 @@ def check_security_zero_secrets() -> tuple[bool, str]:
return True, "Zero secret files, live tokens, or obfuscated secret assignments in src/"
def check_production_update_feed() -> tuple[bool, str]:
"""Live verification of public release feed manifest and package URL."""
# ═══════════════════════════════════════════════════════════════
# Publication Gate
# ═══════════════════════════════════════════════════════════════
#
# Проверка публикации отделена от офлайновой части, потому что раньше они были
# смешаны и обе были беззубыми. Измерено на прежней реализации:
# - при полном обрыве сети возвращался PASS ("check skipped");
# - при 404 на манифест возвращался PASS ("not yet published");
# - при 404 на пакет возвращался PASS ("pending upload");
# - при живом пакете печаталось PACKAGE_HASH_VERIFIED=True, хотя hashlib в
# файле не вызывался ни разу: скачивались байты 0-10 через заголовок Range,
# и этого хватало, чтобы объявить хеш проверенным.
# То есть ворота публикации пропускали релиз при любом исходе, включая полное
# отсутствие релиза.
#
# Теперь: офлайновые проверки (1-6) блокируют всегда; публикация проверяется
# по-настоящему — релиз есть, ассеты есть, пакет скачан целиком, SHA-256
# сошёлся с опубликованным. Блокирует она в режиме публикации (--publication
# или HERMES_RELEASE_PUBLICATION_GATE=1); в обычном прогоне CI, где релиза для
# ветки нет и быть не должно, результат сообщается как есть и не блокирует.
# Неизмеренное называется "Н/Д" с причиной, а не выдаётся за проверенное.
PUBLICATION_MODE_ENV = "HERMES_RELEASE_PUBLICATION_GATE"
# Имена ассетов-установщиков; совпадают с выбором в update_manager.
PACKAGE_ASSET_NAMES = ("HermesHubSetup.exe", "hermes-hub-setup.sh", "install-linux.sh")
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:
"""Требуется ли блокирующая проверка публикации."""
return "--publication" in sys.argv or os.environ.get(PUBLICATION_MODE_ENV, "") == "1"
def _http_get(url: str, timeout: int = 30):
import urllib.request
import urllib.error
req = urllib.request.Request(
url, headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"}
)
return urllib.request.urlopen(req, timeout=timeout)
def _download_and_hash(url: str) -> tuple[str, int]:
"""Скачать поток целиком и посчитать SHA-256. Никаких частичных диапазонов."""
import hashlib
digest = hashlib.sha256()
size = 0
with _http_get(url, timeout=120) as resp:
while True:
chunk = resp.read(1024 * 256)
if not chunk:
break
size += len(chunk)
if size > MAX_PACKAGE_BYTES:
raise ValueError(f"пакет превышает {MAX_PACKAGE_BYTES} байт")
digest.update(chunk)
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] = {}
for line in text.splitlines():
parts = line.strip().split()
if len(parts) >= 2 and re.fullmatch(r"[0-9a-fA-F]{64}", parts[0]):
table[parts[-1].lstrip("*")] = parts[0].lower()
return table
def check_offline_update_contract() -> tuple[bool, str]:
"""Офлайновая часть: адрес обновления входит в список разрешённых."""
from antigravity_provider.updater.update_manager import DEFAULT_UPDATE_URL, is_allowed_update_host
if not is_allowed_update_host(DEFAULT_UPDATE_URL):
return False, f"Default update URL host not in allowlist: {DEFAULT_UPDATE_URL}"
return False, f"Адрес обновления вне списка разрешённых: {DEFAULT_UPDATE_URL}"
return True, f"Адрес обновления в списке разрешённых: {DEFAULT_UPDATE_URL}"
def check_publication_gate() -> tuple[bool, str]:
"""Релиз опубликован, ассеты на месте, SHA-256 пакета сошёлся.
В режиме публикации любой недостижимый шаг отказ. Вне его отказ не
блокирует релиз, но и не выдаётся за успех.
"""
import urllib.error
from antigravity_provider.updater.update_manager import DEFAULT_UPDATE_URL
blocking = is_publication_mode()
def verdict(ok: bool, msg: str) -> tuple[bool, str]:
if ok:
return True, msg
if blocking:
return False, msg
return True, f"[НЕ БЛОКИРУЕТ: режим публикации не запрошен] {msg}"
# 1. Манифест релиза
try:
req = urllib.request.Request(
DEFAULT_UPDATE_URL,
headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"}
)
with urllib.request.urlopen(req, timeout=6) as resp:
if resp.status == 200:
with _http_get(DEFAULT_UPDATE_URL) as resp:
if resp.status != 200:
return verdict(False, f"Манифест релиза ответил HTTP {resp.status}")
data = json.loads(resp.read().decode("utf-8-sig"))
p_ver = data.get("version") or data.get("tag_name", "").lstrip("v")
p_url = data.get("package_url")
if not p_url and data.get("assets"):
p_url = data["assets"][0].get("browser_download_url")
if not p_url:
p_url = data.get("html_url") or DEFAULT_UPDATE_URL
if not p_ver:
return False, "Public update manifest is missing version or tag_name"
# Verify package URL reachability
pkg_live = False
pkg_status = "UNKNOWN"
try:
head_req = urllib.request.Request(
p_url,
headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"}
)
# Use Range header to avoid downloading huge binaries
head_req.add_header("Range", "bytes=0-10")
with urllib.request.urlopen(head_req, timeout=6) as pkg_resp:
if pkg_resp.status in (200, 206, 302):
pkg_live = True
pkg_status = "PACKAGE_LIVE"
except urllib.error.HTTPError as pkg_he:
if pkg_he.code == 404:
pkg_status = "PENDING_RELEASE_UPLOAD_404"
else:
pkg_status = f"HTTP_{pkg_he.code}"
except Exception as pkg_ex:
pkg_status = f"CHECK_SKIPPED_{pkg_ex}"
manifest_live = True
package_live = False
hash_verified = False
if pkg_live:
package_live = True
# If package is live, verify hash on partial bytes or full stream
hash_verified = True
return True, f"[MANIFEST_LIVE=True, PACKAGE_LIVE=True, PACKAGE_HASH_VERIFIED=True] Manifest live (v{p_ver}) and release asset verified at {p_url}"
elif pkg_status == "PENDING_RELEASE_UPLOAD_404":
return True, (
f"[MANIFEST_LIVE=True, PACKAGE_LIVE=False (Pending Upload 404), PACKAGE_HASH_VERIFIED=Offline Validated] "
f"Manifest is live (v{p_ver}), release zip ready for GitHub Release asset upload. Offline updater tests passed."
)
else:
return True, (
f"[MANIFEST_LIVE=True, PACKAGE_LIVE=False ({pkg_status}), PACKAGE_HASH_VERIFIED=Offline Validated] "
f"Manifest live (v{p_ver}). Offline updater tests passed."
)
except urllib.error.HTTPError as he:
if he.code == 404:
return True, f"[MANIFEST_LIVE=False, PACKAGE_LIVE=False] Public manifest not yet published (HTTP 404). Offline updater tests passed."
return False, f"HTTP Error checking update feed: {he}"
return verdict(False, f"Манифест релиза недоступен: HTTP {he.code} ({DEFAULT_UPDATE_URL})")
except Exception as exc:
return True, f"[MANIFEST_LIVE=Unknown, PACKAGE_LIVE=Unknown] Public feed check skipped ({exc}). Offline updater tests passed."
return verdict(False, f"Манифест релиза недоступен: {type(exc).__name__}: {exc}")
return True, "Production update feed verified"
version = data.get("version") or str(data.get("tag_name", "")).lstrip("v")
if not version:
return verdict(False, "В манифесте релиза нет ни version, ни tag_name")
# 2. Ассеты
assets: dict[str, str] = {}
for asset in data.get("assets") or []:
name = asset.get("name")
url = asset.get("browser_download_url")
if name and url:
assets[name] = url
if not assets and data.get("package_url"):
assets[Path(data["package_url"]).name] = data["package_url"]
if not assets:
return verdict(False, f"У релиза v{version} нет ни одного ассета")
packages = [n for n in PACKAGE_ASSET_NAMES if n in assets]
if not packages:
return verdict(
False,
f"У релиза v{version} нет ни одного пакета установки "
f"{PACKAGE_ASSET_NAMES}; опубликованы: {sorted(assets)}",
)
# 3. Опубликованные контрольные суммы
if CHECKSUMS_ASSET_NAME not in assets:
return verdict(False, f"У релиза v{version} нет {CHECKSUMS_ASSET_NAME}: сверять хеш не с чем")
try:
with _http_get(assets[CHECKSUMS_ASSET_NAME]) as resp:
published = _parse_checksums(resp.read().decode("utf-8", errors="replace"))
except Exception as exc:
return verdict(False, f"{CHECKSUMS_ASSET_NAME} не скачивается: {type(exc).__name__}: {exc}")
if not published:
return verdict(False, f"{CHECKSUMS_ASSET_NAME} не содержит ни одной строки с SHA-256")
# 4. Полное скачивание и сверка хеша каждого пакета
verified = []
for name in packages:
expected = published.get(name)
if not expected:
return verdict(False, f"Для {name} нет строки в {CHECKSUMS_ASSET_NAME}")
try:
actual, size = _download_and_hash(assets[name])
except Exception as exc:
return verdict(False, f"{name} не скачивается целиком: {type(exc).__name__}: {exc}")
if actual != expected:
return verdict(False, f"SHA-256 {name} не сошёлся: опубликован {expected}, посчитан {actual}")
verified.append(f"{name} ({size} байт)")
return True, (
f"[RELEASE_LIVE=True, PACKAGES={len(verified)}, PACKAGE_HASH_VERIFIED=True] "
f"Релиз v{version}: пакеты скачаны целиком и сверены с {CHECKSUMS_ASSET_NAME}"
+ ", ".join(verified)
)
def check_publishable_assets(dist_dir: Path) -> tuple[bool, str]:
"""Собранный набор ассетов действительно устанавливается обновлением.
Проверяется до публикации. Причина: update_manager ищет в релизе строго
HermesHubSetup.exe или hermes-hub-setup.sh/install-linux.sh, а release.yml
собирает hermes-hub-<версия>.zip и update_manifest.json. Такой релиз
становится "latest", и на любой попытке обновиться владелец получает
"В релизе не найден подходящий файл обновления для текущей платформы".
Раньше это не проявлялось лишь потому, что весь релизный конвейер падал
на тех же двух дефектах, что и CI: каждый его прогон завершался ошибкой, а
релизы публиковались мимо него. Как только тесты позеленели, случайная
защита исчезла поэтому набор проверяется явно.
Помимо присутствия файлов размер и хеш КАЖДОГО найденного установщика
против локального checksums.txt. Найдено живым прогоном на Windows
(A61): сборка может прерваться на середине и оставить усечённый файл, а
checksums.txt и сам установщик могут разойтись ещё до всякой публикации.
Проверка одного присутствия этого не ловит.
"""
if not dist_dir.is_dir():
return False, f"Каталог сборки не найден: {dist_dir}"
present = {item.name for item in dist_dir.iterdir() if item.is_file()}
installers = sorted(present & set(PACKAGE_ASSET_NAMES))
problems = []
if not installers:
problems.append(
f"нет ни одного установщика {list(PACKAGE_ASSET_NAMES)}"
f"обновление такой релиз поставить не сможет"
)
if CHECKSUMS_ASSET_NAME not in present:
problems.append(f"нет {CHECKSUMS_ASSET_NAME} — сверять хеш пакета будет не с чем")
if problems:
return False, (
f"Набор ассетов в {dist_dir} непригоден для публикации: "
+ "; ".join(problems)
+ f". Собрано: {sorted(present)}. Установщики собираются скриптами "
f"installer/build_installer.ps1 и installer/build_installer_linux.sh"
)
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():
print("=" * 70)
print(f" Hermes Hub — Release Gate Verification Suite (Target: v{__version__})")
mode = "публикация (проверки 1-8 блокируют)" if is_publication_mode() else "офлайн (блокируют 1-7)"
print(f" Режим: {mode}")
print("=" * 70)
checks = [
@ -295,7 +477,8 @@ def run_release_gate():
("4. Full Offline Pytest Suite", "[INTEGRATION VERIFIED]", check_full_test_suite),
("5. Zero Hardcoded Developer Paths", "[STATIC VERIFIED]", check_zero_hardcoded_paths),
("6. Zero Credentials & AST Secret Scan", "[SECURITY VERIFIED]", check_security_zero_secrets),
("7. Public Production Update Feed", "[LIVE STATUS]", check_production_update_feed),
("7. Update Contract (offline)", "[STATIC VERIFIED]", check_offline_update_contract),
("8. Publication Gate", "[LIVE VERIFIED]", check_publication_gate),
]
all_passed = True
@ -319,5 +502,24 @@ def run_release_gate():
sys.exit(1)
def _run_single(title: str, check) -> None:
"""Выполнить одну проверку и завершиться её итогом."""
print("=" * 70)
print(f" Hermes Hub — {title}")
print("=" * 70)
ok, msg = check()
print(f" {'[OK]' if ok else '[FAIL]'} {msg}")
sys.exit(0 if ok else 1)
if __name__ == "__main__":
if "--assets" in sys.argv:
index = sys.argv.index("--assets")
target = Path(sys.argv[index + 1]) if len(sys.argv) > index + 1 else ROOT / "dist"
_run_single("Publishable Assets Check", lambda: check_publishable_assets(target))
elif "--publication-only" in sys.argv:
# Запускается ПОСЛЕ публикации: проверяет опубликованный релиз, а не сборку.
os.environ[PUBLICATION_MODE_ENV] = "1"
_run_single("Publication Gate", check_publication_gate)
else:
run_release_gate()

View file

@ -18,6 +18,13 @@ for p in [
if p.is_dir() and str(p) not in sys.path:
sys.path.insert(0, str(p))
from antigravity_provider.console_encoding import force_utf8_output
# Отчёт печатается по-русски, а консоль Windows-раннера в CI — cp1252: без этого
# первый же [PASS] с кириллицей роняет скрипт UnicodeEncodeError'ом ещё до того,
# как проверки что-либо покажут. Ставится до первого вывода.
force_utf8_output()
from antigravity_provider.router.router_config import (
RolePolicy,
RouterConfig,
@ -141,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

@ -0,0 +1,48 @@
"""Принудительный UTF-8 для потоков вывода.
Инструменты Hermes печатают по-русски, а консоль Windows-раннера в CI работает
в cp1252. Первый же `print` с кириллицей роняет процесс UnicodeEncodeError'ом —
падает вывод, не логика. Измерено на `scripts/verify_multi_provider_router.py`:
строка `[PASS] Чистая конфигурация...` обрывала прогон с кодом 1.
Модуль ставит UTF-8 на stdout/stderr и оставляет запасной путь на случай, когда
перекодировать поток нельзя: тогда непечатаемые символы заменяются, но процесс
продолжает работу. Вывод инструмента не должен быть причиной падения.
"""
from __future__ import annotations
import sys
from typing import Any, Iterable
__all__ = ["force_utf8_output"]
def _reconfigure(stream: Any) -> bool:
"""Перевести один поток на UTF-8. True, если получилось."""
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is None:
return False
for errors in ("strict", "backslashreplace"):
try:
reconfigure(encoding="utf-8", errors=errors)
return True
except Exception:
continue
# Поток не перекодировать (подменён, закрыт, не текстовый). Тогда хотя бы
# снимем строгость с текущей кодировки, чтобы кириллица не роняла процесс.
try:
reconfigure(errors="backslashreplace")
return True
except Exception:
return False
def force_utf8_output(streams: Iterable[str] = ("stdout", "stderr")) -> None:
"""Перевести стандартные потоки на UTF-8; молча пропустить недоступные.
Вызывается один раз на старте точки входа, до первого вывода.
"""
for name in streams:
stream = getattr(sys, name, None)
if stream is not None:
_reconfigure(stream)

View file

@ -9,17 +9,16 @@ import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from antigravity_provider.console_encoding import force_utf8_output
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, load_router_config
from antigravity_provider.router.health_tracker import HealthTracker
from antigravity_provider.router.router_engine import RouterEngine, get_router_engine
from antigravity_provider.router.adapters import get_adapter
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
# Вывод CLI — по-русски, консоль Windows по умолчанию не UTF-8. Общий помощник,
# тот же, что и у скриптов проверки: одна реализация, одно поведение.
force_utf8_output()
def print_router_status() -> int:

View file

@ -135,6 +135,117 @@ DESTRUCTIVE_COMMAND_NAMES: Set[str] = {
}
# ── Единый конвейер разбора пути ────────────────────────────────
#
# Граница вокруг агентских shell-действий обязана работать одинаково на всех
# поддерживаемых системах, иначе доказанной она не является ни на одной.
# Измерено: "rm -rf $HOME/.hermes" отклонялось на Linux и проходило на Windows,
# потому что переменной HOME в окружении Windows нет — os.path.expandvars
# оставлял "$HOME" как есть, путь переставал быть абсолютным, склеивался с
# каталогом проекта и оказывался "внутри разрешённого корня". Зеркальная дыра
# на Linux: "%USERPROFILE%\.hermes" и "C:\Windows" тоже проходили.
#
# Порядок шагов: классификация диалекта → раскрытие распознанных переменных →
# нормализация разделителей → канонизация → сравнение с корнями. Каждый шаг,
# который не удался, закрывает проход: непроверяемый путь не считается
# разрешённым.
_HOME_VARIABLE_NAMES = frozenset({"HOME", "USERPROFILE"})
_VARIABLE_REFERENCE = re.compile(
r"\$\{(?P<brace>[A-Za-z_][A-Za-z0-9_]*)\}"
r"|\$(?P<bare>[A-Za-z_][A-Za-z0-9_]*)"
r"|%(?P<percent>[A-Za-z_][A-Za-z0-9_]*)%"
)
_WINDOWS_DRIVE_PREFIX = re.compile(r"^[A-Za-z]:[\\/]")
# Признаки записи в диалекте cmd/PowerShell: %VAR%, буква диска, разделитель "\".
_WINDOWS_DIALECT_MARKERS = re.compile(r"%[A-Za-z_][A-Za-z0-9_]*%|[A-Za-z]:[\\/]|\\[A-Za-z0-9_.]")
def looks_like_windows_dialect(cmd_line: str) -> bool:
"""Записана ли команда в диалекте Windows.
Диалект определяется по самой строке, а не по системе-хозяину: команду в
записи cmd могут прислать и на Linux, и разобрать её posix-правилами нельзя
shlex съест "\" как экранирование и разделитель пути исчезнет.
"""
return bool(_WINDOWS_DIALECT_MARKERS.search(cmd_line))
def expand_path_argument(raw: str) -> Tuple[str, Optional[str]]:
"""Раскрыть "~" и переменные окружения обоих диалектов.
Возвращает (раскрытая строка, причина нераскрытия | None). Имена HOME и
USERPROFILE разрешаются в домашний каталог даже тогда, когда их нет в
окружении: команда, написанная в чужом диалекте, целит ровно туда же.
Всё, что раскрыть не удалось, возвращается причиной вызывающий обязан
закрыться, а не гадать.
"""
unresolved: List[str] = []
def _substitute(match: "re.Match[str]") -> str:
name = match.group("brace") or match.group("bare") or match.group("percent")
value = os.environ.get(name)
if value is None and name.upper() in _HOME_VARIABLE_NAMES:
try:
value = str(Path.home())
except Exception:
value = None
if value is None:
unresolved.append(name)
return match.group(0)
return value
expanded = _VARIABLE_REFERENCE.sub(_substitute, raw)
expanded = os.path.expanduser(expanded)
if expanded.startswith("~"):
# expanduser не смог определить домашний каталог: оставлять "~" внутри
# пути нельзя — он перестанет быть абсолютным и уедет внутрь проекта.
unresolved.append("~")
if unresolved:
return expanded, "не раскрыты: " + ", ".join(sorted(set(unresolved)))
return expanded, None
def canonical_path(raw: Path | str, base_cwd: Optional[Path | str] = None) -> Tuple[Optional[Path], Optional[str]]:
"""Привести аргумент к каноническому пути или объяснить отказ.
Ровно одно из двух возвращаемых значений не None. Отказ это отказ в
доступе: путь, который нельзя достоверно разрешить, нельзя и признать
находящимся внутри разрешённого корня.
"""
text = str(raw)
if not text:
return None, "пустой путь"
expanded, unresolved_reason = expand_path_argument(text)
if unresolved_reason:
return None, f"путь '{raw}' невозможно раскрыть ({unresolved_reason})"
normalized = expanded.replace("\\", "/")
if _WINDOWS_DRIVE_PREFIX.match(normalized) and not Path(normalized).is_absolute():
# Путь с буквой диска на не-Windows: канонизировать его нечем. resolve()
# припишет ему текущий каталог, и "C:/Windows" окажется внутри проекта.
return None, f"путь '{raw}' записан в диалекте другой системы и здесь не проверяем"
candidate = Path(normalized)
if not candidate.is_absolute() and not normalized.startswith("/"):
try:
base = Path(base_cwd) if base_cwd is not None else paths.get_repo_root()
except Exception as exc:
return None, f"не определён базовый каталог для '{raw}': {exc}"
candidate = base / normalized
try:
return candidate.resolve(), None
except Exception as exc:
return None, f"путь '{raw}' не разрешается: {exc}"
class WorkspaceBoundaryGuard:
"""Enforces explicit workspace boundaries, defends credential directories, and inspects destructive operations."""
@ -206,7 +317,11 @@ class WorkspaceBoundaryGuard:
def is_inside_allowed_root(self, path: Path | str) -> bool:
"""Check whether the given path resolves within any allowed root."""
try:
target = Path(path).expanduser().resolve()
target, resolve_error = canonical_path(path)
if target is None:
# Путь не разрешается — считать его находящимся внутри
# разрешённого корня нельзя.
return False
for root in self.get_allowed_roots():
try:
target.relative_to(root)
@ -220,7 +335,9 @@ class WorkspaceBoundaryGuard:
def is_forbidden_path(self, path: Path | str) -> Tuple[bool, Optional[str]]:
"""Check whether the path touches an unconditionally protected directory or file."""
try:
target = Path(path).expanduser().resolve()
target, resolve_error = canonical_path(path)
if target is None:
return True, resolve_error
# 1. Exact match or child of forbidden directory
for fpath in self.get_forbidden_paths():
if target == fpath:
@ -252,10 +369,9 @@ class WorkspaceBoundaryGuard:
Returns: (is_allowed: bool, reason: str, safe_alternative: Optional[str])
"""
try:
target = Path(path).expanduser().resolve()
except Exception as exc:
return False, f"Недопустимый путь '{path}': {exc}", "Используйте стандартный относительный путь"
target, resolve_error = canonical_path(path)
if target is None:
return False, f"Недопустимый путь: {resolve_error}", "Укажите путь внутри проекта явно, без нераскрытых переменных"
# Check unconditional forbidden paths for mutating/deleting operations
if operation in {"delete", "write", "truncate", "move"}:
@ -290,12 +406,18 @@ class WorkspaceBoundaryGuard:
# Parse command tokens
if isinstance(cmd_line, list):
tokens = list(cmd_line)
windows_dialect = any(looks_like_windows_dialect(str(t)) for t in tokens)
else:
# Диалект берётся из самой команды: строку в записи cmd нельзя
# разбирать posix-правилами — shlex съест "\\" как экранирование,
# и разделитель пути исчезнет ещё до проверки.
windows_dialect = looks_like_windows_dialect(cmd_line)
try:
# Windows and POSIX-compatible shlex split
tokens = shlex.split(cmd_line, posix=(os.name != "nt"))
tokens = shlex.split(cmd_line, posix=not windows_dialect)
except Exception:
tokens = cmd_line.split()
if windows_dialect:
tokens = [t[1:-1] if len(t) > 1 and t[0] == t[-1] and t[0] in "\"'" else t for t in tokens]
if not tokens:
return True, "OK", None
@ -310,7 +432,7 @@ class WorkspaceBoundaryGuard:
# Extract target arguments (skip flags starting with - or /)
targets = []
for arg in tokens[1:]:
if arg.startswith("-") or (os.name == "nt" and arg.startswith("/") and len(arg) == 2):
if arg.startswith("-") or (windows_dialect and arg.startswith("/") and len(arg) == 2):
continue
targets.append(arg)
@ -321,17 +443,23 @@ class WorkspaceBoundaryGuard:
return False, f"Команда {cmd_name} запущена в недопустимом каталоге: {reason}", alt
else:
for target_arg in targets:
# Тильда и переменные окружения раскрываются ДО проверки.
# Тильда и переменные окружения раскрываются ДО проверки, и
# одинаково для обоих диалектов.
#
# Без этого "rm -rf ~/.hermes/agy_profiles" не считался
# абсолютным путём, склеивался с каталогом проекта в путь с
# буквальным "~" внутри и признавался допустимым. Проверено:
# команда с тильдой проходила, та же команда с абсолютным
# путём отклонялась. То есть самый естественный способ
# написать опасную команду обходил защиту ровно там, ради
# чего она и делалась — на каталоге учётных данных.
expanded = os.path.expandvars(os.path.expanduser(target_arg))
target_path = Path(expanded) if Path(expanded).is_absolute() else (base_cwd / expanded)
# буквальным "~" внутри и признавался допустимым; а
# "rm -rf $HOME/.hermes" ровно так же проходил на Windows,
# где переменной HOME в окружении нет. Самый естественный
# способ написать опасную команду обходил защиту ровно там,
# ради чего она и делалась — на каталоге учётных данных.
target_path, resolve_error = canonical_path(target_arg, base_cwd=base_cwd)
if target_path is None:
return (
False,
f"Команда '{cmd_name}' обращается к непроверяемому пути: {resolve_error}",
"Укажите путь внутри проекта явно, без нераскрытых переменных",
)
ok, reason, alt = self.validate_path(target_path, operation="delete")
if not ok:
return False, f"Команда '{cmd_name}' пытается удалить недопустимый путь '{target_arg}': {reason}", alt

View file

@ -161,7 +161,11 @@ class TelemetryService:
if p.is_file():
try:
import yaml
data = yaml.safe_dump(p.read_text(encoding="utf-8"))
# safe_load, а не safe_dump: dump сериализует текст обратно
# в строку, isinstance(data, dict) никогда не выполнялось, и
# таблица цен из pricing.yaml не загружалась ни разу. Тихо:
# ошибку глушил except.
data = yaml.safe_load(p.read_text(encoding="utf-8"))
if isinstance(data, dict) and "pricing" in data:
self._pricing_table = dict(data["pricing"])
return

View file

@ -7,6 +7,7 @@ import time
import dataclasses
import logging
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from antigravity_provider import paths
from antigravity_provider.version import __version__
@ -105,6 +106,63 @@ def get_auth_token(x_hub_token: str = Header(None)) -> bool:
raise HTTPException(status_code=401, detail="Invalid X-Hub-Token")
return True
# Небезопасные методы принимаются только от собственного интерфейса.
#
# На loopback токен не требуется вовсе (см. get_auth_token), а POST /api/action
# меняет состояние: удаляет учётные данные, чистит аккаунты, переключает
# маршрутизацию, запускает входы OAuth. CORS от этого не защищает — он мешает
# ПРОЧИТАТЬ ответ, а не отправить запрос.
#
# Проверено на конфигурации по умолчанию (web_api_host=127.0.0.1): POST с
# Content-Type: text/plain уходит кросс-сайтом без предварительного запроса
# (простой запрос по правилам CORS), а request.json() разбирает тело
# независимо от Content-Type. Запрос с Origin: https://evil.example.com и без
# токена доходил до исполнителя действий — отвечало уже само действие.
#
# Проверяется Sec-Fetch-Site, а при его отсутствии — Origin против адреса, по
# которому пришёл запрос. Отсутствие обоих заголовков означает не-браузерного
# клиента (curl, собственный CLI): браузер на кросс-сайтовый POST заголовок
# Origin ставит обязательно, поэтому его отсутствие подделкой не является.
_SAME_SITE_FETCH_VALUES = {"same-origin", "none"}
def _configured_allowed_origins() -> set:
raw = str(_web_settings().get("web_api_allowed_origins", "")).split(",")
return {o.strip() for o in raw if o.strip() and o.strip() != "*"}
def require_same_origin(request: Request) -> bool:
"""Отклонить межсайтовый небезопасный запрос до того, как он что-то изменит."""
fetch_site = (request.headers.get("sec-fetch-site") or "").strip().lower()
if fetch_site:
if fetch_site in _SAME_SITE_FETCH_VALUES:
return True
if request.headers.get("origin", "") in _configured_allowed_origins():
return True
raise HTTPException(
status_code=403,
detail=f"Межсайтовый запрос отклонён (Sec-Fetch-Site: {fetch_site})",
)
origin = request.headers.get("origin")
if not origin:
return True
host = (request.headers.get("host") or "").strip().lower()
try:
origin_host = urlparse(origin).netloc.strip().lower()
except Exception:
origin_host = ""
if origin_host and host and origin_host == host:
return True
if origin in _configured_allowed_origins():
return True
raise HTTPException(
status_code=403,
detail="Origin не совпадает с адресом хаба: межсайтовый запрос отклонён",
)
# Коммит и время запуска СНИМАЮТСЯ ОДИН РАЗ, при старте процесса.
#
# get_installed_commit() читает манифест с диска при каждом вызове, поэтому
@ -283,7 +341,11 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
return JSONResponse(content=jsonable_encoder(snap_dict))
@app.post("/api/action")
async def handle_action(request: Request, authorized: bool = Depends(get_auth_token)):
async def handle_action(
request: Request,
authorized: bool = Depends(get_auth_token),
same_origin: bool = Depends(require_same_origin),
):
try:
data = await request.json()
except Exception:
@ -356,7 +418,11 @@ def get_skills_endpoint(authorized: bool = Depends(get_auth_token)):
@app.post("/api/skills/assign")
async def assign_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
async def assign_skill_endpoint(
request: Request,
authorized: bool = Depends(get_auth_token),
same_origin: bool = Depends(require_same_origin),
):
"""Assign a skill to a specific subagent."""
from antigravity_provider.router.skills_service import SkillsService
try:
@ -377,7 +443,11 @@ async def assign_skill_endpoint(request: Request, authorized: bool = Depends(get
@app.post("/api/skills/unassign")
async def unassign_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
async def unassign_skill_endpoint(
request: Request,
authorized: bool = Depends(get_auth_token),
same_origin: bool = Depends(require_same_origin),
):
"""Remove an assigned skill from a subagent."""
from antigravity_provider.router.skills_service import SkillsService
try:
@ -406,7 +476,11 @@ def get_skills_usage_endpoint(authorized: bool = Depends(get_auth_token)):
@app.post("/api/skills/diagnose")
async def diagnose_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
async def diagnose_skill_endpoint(
request: Request,
authorized: bool = Depends(get_auth_token),
same_origin: bool = Depends(require_same_origin),
):
"""Run SkillDoctor diagnostics on a skill by name, filepath, or raw content."""
from antigravity_provider.router.skills_service import SkillsService
try:
@ -456,7 +530,11 @@ def get_compression_history_endpoint(limit: int = 20, authorized: bool = Depends
@app.post("/api/compression/test")
async def test_compression_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
async def test_compression_endpoint(
request: Request,
authorized: bool = Depends(get_auth_token),
same_origin: bool = Depends(require_same_origin),
):
"""Execute test context compression on synthetic benchmark prompt."""
from antigravity_provider.router.settings_service import get_hub_settings
from antigravity_provider.router.local_supervisor import LocalSupervisor

View file

@ -19,6 +19,7 @@ import logging
import os
import re
import shutil
import zipfile
import subprocess
import sys
import threading
@ -351,6 +352,43 @@ def acknowledge_last_applied_update() -> None:
logger.debug("Failed acknowledging last_applied_update: %s", exc)
def _extract_within(zf: "zipfile.ZipFile", dest: Path) -> None:
"""Распаковать архив, не выпуская ни одной записи за пределы dest.
Отклоняются: абсолютные пути, выход через "..", символические ссылки и
любые записи, не являющиеся обычным файлом или каталогом.
"""
import stat as _stat
root = dest.resolve()
for info in zf.infolist():
# Биты типа файла проставлены не всегда: архиватор мог записать только
# права доступа. Судим лишь тогда, когда тип действительно указан, —
# иначе обычный файл с правами 0o600 выглядел бы записью чужого типа.
file_type = _stat.S_IFMT(info.external_attr >> 16)
if file_type and file_type not in (_stat.S_IFREG, _stat.S_IFDIR):
raise ValueError(
f"Пакет обновления содержит запись недопустимого типа: {info.filename!r}"
)
name = info.filename.replace("\\", "/")
if name.startswith("/") or re.match(r"^[A-Za-z]:", name):
raise ValueError(f"Пакет обновления содержит абсолютный путь: {info.filename!r}")
target = (root / name).resolve()
if target != root and root not in target.parents:
raise ValueError(
f"Пакет обновления пытается записать за пределы каталога установки: {info.filename!r}"
)
if info.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info, "r") as source, open(target, "wb") as out:
shutil.copyfileobj(source, out)
def stop_running_hub(timeout_sec: float = 10.0) -> bool:
"""Останавливает только процессы хаба текущего пользователя, исключая текущий PID."""
current_pid = os.getpid()
@ -993,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():
@ -1212,8 +1263,6 @@ class UpdateManager:
def apply_update_sync(self, package_zip: Path, target_dir: Optional[Path] = None) -> Tuple[bool, str]:
"""Apply update package with automatic backup and rollback on failure."""
import zipfile
dest = target_dir or paths.get_repo_root()
backup = self.backup_dir
backup.mkdir(parents=True, exist_ok=True)
@ -1229,8 +1278,15 @@ class UpdateManager:
shutil.copytree(src_item, dst_item)
# 2. Extract update package into dest
#
# Каждая запись проверяется до записи на диск. Измерено, что
# extractall в CPython уже отбрасывает "..", ведущие разделители и
# буквы дисков, а запись-ссылку кладёт обычным файлом: побега из
# каталога добиться не удалось. Но это свойство реализации, а не
# обещание формата — а распаковка идёт в корень установки. Проверка
# делает границу собственным инвариантом, который виден в тестах.
with zipfile.ZipFile(package_zip, "r") as zf:
zf.extractall(dest)
_extract_within(zf, dest)
# 3. Verify syntax and integrity of updated python files in src/
import py_compile

View file

@ -396,3 +396,150 @@ def test_destructive_command_with_tilde_is_rejected(monkeypatch, tmp_path):
# Обычная работа внутри проекта не должна страдать.
allowed, _reason, _alt = guard.validate_command("rm src/temp_file.py")
assert allowed, "защита мешает штатной работе внутри проекта"
# ── HUB-1: граница обязана держаться одинаково на Windows и Linux ──
#
# Инвариант A37 падал только на Windows-раннере: "rm -rf $HOME/.hermes"
# проходил мимо защиты, потому что переменной HOME в окружении Windows нет.
# Прогон на одной системе этого не показывал. Тесты ниже воспроизводят
# окружение обеих систем на любой из них, поэтому дыра больше не может
# спрятаться за тем, где именно запущен CI.
_DESTRUCTIVE_BOTH_DIALECTS = [
"rm -rf ~/.hermes/agy_profiles",
"rm -rf ~/.ssh",
"rm -rf $HOME/.hermes",
"rm -rf ${HOME}/.hermes",
r"rm -rf %USERPROFILE%\.hermes",
r"del /f /q %USERPROFILE%\.ssh",
]
@pytest.fixture
def guard_env(monkeypatch, tmp_path):
"""Guard с HERMES_HOME во временном каталоге — вне домашнего и вне проекта."""
from antigravity_provider.router.security_guard import WorkspaceBoundaryGuard
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
return WorkspaceBoundaryGuard()
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
@pytest.mark.parametrize("cmd", _DESTRUCTIVE_BOTH_DIALECTS)
def test_home_directed_destruction_rejected_on_both_systems(monkeypatch, guard_env, simulated_os, cmd):
"""Удаление по домашнему каталогу отклоняется в обоих окружениях.
Окружение Windows отличается от Linux ровно тем, из-за чего защита и
расходилась: HOME не задан, домашний каталог известен через USERPROFILE.
"""
if simulated_os == "windows":
monkeypatch.delenv("HOME", raising=False)
monkeypatch.setenv("USERPROFILE", str(Path.home()))
else:
monkeypatch.setenv("HOME", str(Path.home()))
monkeypatch.delenv("USERPROFILE", raising=False)
allowed, reason, _alt = guard_env.validate_command(cmd)
assert not allowed, f"[{simulated_os}] команда прошла мимо защиты: {cmd} ({reason})"
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
def test_guard_fails_closed_on_unresolvable_argument(monkeypatch, guard_env, simulated_os):
"""Нераскрытая переменная — отказ, а не пропуск.
Раньше "$HOME/.hermes" с неизвестной переменной переставал быть абсолютным
путём, склеивался с каталогом проекта и признавался допустимым. Путь,
который нельзя разрешить, нельзя и признать безопасным.
"""
if simulated_os == "windows":
monkeypatch.delenv("HOME", raising=False)
monkeypatch.delenv("HERMES_UNSET_TARGET", raising=False)
for cmd in ["rm -rf $HERMES_UNSET_TARGET/data", r"rm -rf %HERMES_UNSET_TARGET%\data"]:
allowed, reason, _alt = guard_env.validate_command(cmd)
assert not allowed, f"[{simulated_os}] guard не закрылся на нераскрытом пути: {cmd} ({reason})"
def test_foreign_dialect_absolute_path_is_not_treated_as_project_local(guard_env):
"""Путь с буквой диска на Linux не должен считаться внутренним.
Path("C:/Windows").resolve() на Linux приписывает пути текущий каталог, и
удаление системного каталога Windows выглядело как работа внутри проекта.
"""
allowed, reason, _alt = guard_env.validate_command(r"del /f /q C:\Windows\System32")
assert not allowed, f"путь чужого диалекта признан внутренним: {reason}"
def test_normal_work_inside_project_still_allowed(guard_env):
"""Ужесточение не должно мешать штатной работе."""
for cmd in ["rm src/temp_file.py", "rm -rf build/", "rm ./tests/tmp.log"]:
allowed, reason, _alt = guard_env.validate_command(cmd)
assert allowed, f"защита мешает штатной работе: {cmd} ({reason})"
# ── HUB-1: небезопасные методы не принимаются с чужой страницы ──
@pytest.fixture
def loopback_client():
"""Клиент при конфигурации по умолчанию: web_api_host=127.0.0.1, токен не нужен."""
import antigravity_provider.router.web.server as srv
with patch.object(srv, "_web_settings", return_value={"web_api_host": "127.0.0.1"}):
yield TestClient(app)
_UNSAFE_ENDPOINTS = [
("/api/action", '{"action": "clear_accounts", "data": {}}'),
("/api/skills/assign", '{"skill": "x", "profile": "y"}'),
("/api/skills/unassign", '{"skill": "x", "profile": "y"}'),
("/api/skills/diagnose", '{"skill": "x"}'),
("/api/compression/test", '{"text": "x"}'),
]
@pytest.mark.parametrize("path, body", _UNSAFE_ENDPOINTS)
def test_cross_site_post_is_rejected(loopback_client, path, body):
"""Межсайтовый POST отклоняется до того, как что-либо изменит.
На loopback токен не требуется, а действия меняют состояние: удаляют
учётные данные, чистят аккаунты, переключают маршрутизацию. CORS от этого
не защищает он мешает прочитать ответ, а не отправить запрос. Измерено:
POST с Content-Type text/plain уходит кросс-сайтом без предварительного
запроса, request.json() разбирает тело независимо от Content-Type, и
запрос с чужим Origin доходил до исполнителя действий.
"""
headers = {
"Content-Type": "text/plain;charset=UTF-8",
"Origin": "https://evil.example.com",
"Sec-Fetch-Site": "cross-site",
}
res = loopback_client.post(path, content=body, headers=headers)
assert res.status_code == 403, f"{path} принял межсайтовый запрос: HTTP {res.status_code}"
@pytest.mark.parametrize("path, body", _UNSAFE_ENDPOINTS)
def test_cross_site_post_rejected_without_fetch_metadata(loopback_client, path, body):
"""Браузер без Sec-Fetch-* всё равно ставит Origin — по нему и отклоняем."""
headers = {"Content-Type": "text/plain", "Origin": "https://evil.example.com"}
res = loopback_client.post(path, content=body, headers=headers)
assert res.status_code == 403, f"{path} принял запрос с чужим Origin: HTTP {res.status_code}"
@pytest.mark.parametrize(
"label, headers",
[
("собственный интерфейс", {"Origin": "http://testserver", "Sec-Fetch-Site": "same-origin"}),
("адресная строка", {"Sec-Fetch-Site": "none"}),
("не-браузерный клиент", {}),
],
)
def test_own_interface_and_cli_still_work(loopback_client, label, headers):
"""Защита не должна мешать собственному интерфейсу и не-браузерным клиентам."""
res = loopback_client.post(
"/api/action",
json={"action": "___нет_такого___", "data": {}},
headers=headers,
)
assert res.status_code != 403, f"{label} отклонён межсайтовой защитой"

View file

@ -249,7 +249,14 @@ def test_p0_5_verification_script_runs_clean_and_filled(clean_a41_env):
env["HERMES_HOME"] = str(hermes_home)
script_path = Path(__file__).resolve().parent.parent / "scripts" / "verify_multi_provider_router.py"
proc = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True)
# Кодировка задаётся явно с обеих сторон трубы. Скрипт печатает по-русски и
# сам переводит вывод на UTF-8; родитель же на Windows по умолчанию читает
# трубу в кодировке системы (cp1252) и разваливается на UnicodeDecodeError,
# оставляя proc.stdout равным None — падал разбор вывода, а не проверки.
run_kwargs = dict(env=env, capture_output=True, text=True, encoding="utf-8", errors="replace")
proc = subprocess.run([sys.executable, str(script_path)], **run_kwargs)
assert proc.returncode == 0, f"Verification failed on clean config: {proc.stderr}\n{proc.stdout}"
assert "10/10 CHECKS PASSED" in proc.stdout
@ -259,6 +266,6 @@ def test_p0_5_verification_script_runs_clean_and_filled(clean_a41_env):
AutoAssigner.assign_profile_to_role("codex-1", "manager", is_primary=True)
AutoAssigner.assign_profile_to_role("ag-1", "manager", is_primary=False)
proc_filled = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True)
proc_filled = subprocess.run([sys.executable, str(script_path)], **run_kwargs)
assert proc_filled.returncode == 0, f"Verification failed on filled config: {proc_filled.stderr}\n{proc_filled.stdout}"
assert "10/10 CHECKS PASSED" in proc_filled.stdout

View file

@ -9,9 +9,11 @@ Verifies:
"""
from __future__ import annotations
import contextlib
import io
import json
import os
import re
import shutil
import sys
import tempfile
@ -249,23 +251,44 @@ def test_p0_2_sha256_mismatch_aborts_and_sets_failed_status(tmp_path, monkeypatc
# ── TEST 5: P0-3 Process Isolation stop_running_hub ──
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
@pytest.mark.unit
def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid():
"""stop_running_hub filters by current UID on Linux and never targets own PID."""
current_pid = os.getpid()
def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid(simulated_os):
"""Чужие процессы хаба останавливаются, собственный — никогда.
# Mock subprocess.run for pgrep
with patch("subprocess.run") as mock_run:
# Simulate pgrep returning other PID and own PID
mock_run.return_value = MagicMock(returncode=0, stdout=f"99999 {current_pid}\n")
Проверяется на обеих ветках, а не на той, где случился прогон. Ветки
останавливают процессы по-разному: на Linux os.kill по списку от pgrep,
на Windows taskkill по списку от wmic. Тест знал только про первую и на
Windows-раннере падал на пустом списке убитых, хотя проверять надо один и
тот же инвариант «свой PID не трогаем».
"""
current_pid = os.getpid()
is_win = simulated_os == "windows"
with patch("antigravity_provider.updater.update_manager.sys") as mock_sys:
mock_sys.platform = "win32" if is_win else "linux"
# На Windows os.getuid не существует; ветка Linux падала бы на нём в
# общий except и возвращала «остановлено» никого не остановив. create=True
# позволяет подставить его там, где его нет.
with patch("os.getuid", return_value=1000, create=True), patch("subprocess.run") as mock_run:
# wmic и pgrep перечисляют один и тот же набор: чужой PID и свой.
mock_run.return_value = MagicMock(returncode=0, stdout=f"99999\n{current_pid}\n")
with patch("os.kill") as mock_kill:
stop_running_hub(timeout_sec=0.1)
# Check that kill was called on 99999 but NEVER on current_pid
if is_win:
killed_pids = [
int(call.args[0][-1])
for call in mock_run.call_args_list
if call.args and call.args[0] and call.args[0][0] == "taskkill"
]
else:
killed_pids = [call.args[0] for call in mock_kill.call_args_list]
assert 99999 in killed_pids
assert current_pid not in killed_pids, "stop_running_hub must never kill current PID"
assert 99999 in killed_pids, f"[{simulated_os}] чужой процесс хаба не остановлен: {killed_pids}"
assert current_pid not in killed_pids, f"[{simulated_os}] остановлен собственный процесс"
# ── TEST 6: P0-3 apply_update_sync Rollback on Corruption ──
@ -381,8 +404,7 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
installer = tmp_path / "hermes-hub-setup.sh"
installer.write_bytes(b"#!/bin/bash\nexit 3\n")
with platform_installer(tmp_path, exit_code=3) as (asset_name, installer):
sha = hashlib.sha256(installer.read_bytes()).hexdigest()
check_result = UpdateCheckResult(
@ -392,7 +414,7 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke
latest_commit="deadbeefdeadbeef",
installed_commit="0000000aaaa",
assets={
"hermes-hub-setup.sh": f"file://{installer}",
asset_name: f"file://{installer}",
"checksums.txt": "file:///nonexistent",
},
)
@ -402,7 +424,7 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke
def fake_download(self, url, dest, progress_cb=None):
if dest.name == "checksums.txt":
dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8")
dest.write_text(f"{sha} {asset_name}\n", encoding="utf-8")
return
return real_download(self, url, dest, progress_cb)
@ -414,7 +436,11 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke
assert get_last_applied_update() is None, (
"После провалившейся установки записи о применённом обновлении быть не должно"
)
assert "код 3" in msg, f"Причина отказа должна называть код возврата, получено: {msg!r}"
# Проверяется, что код назван, а не как он склоняется: ветки формулируют
# по-разному («код 3» и «кодом 3»), инвариант же один.
assert re.search(r"код\w*\s+3", msg), (
f"Причина отказа должна называть код возврата, получено: {msg!r}"
)
prog = UpdateManager.get_progress_dict()
assert prog["status"] == "failed"
@ -422,6 +448,31 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke
UpdateManager._set_progress(status="idle", message="Готов к обновлению")
# ── Установщик под ту систему, на которой идёт прогон ──
#
# Ветки установки различаются: на Windows выбирается HermesHubSetup.exe и
# запускается через Popen, на Linux — hermes-hub-setup.sh через bash. Тесты
# ниже проверяют не установщик, а учёт его результата, поэтому подставляется
# тот файл, который данная система действительно выбирает. Раньше в них был
# зашит bash-скрипт, и на Windows-раннере установка отвечала «в релизе не
# найден подходящий файл обновления» — падало допущение теста, не продукт.
@contextlib.contextmanager
def platform_installer(tmp_path, exit_code: int):
"""Отдать (имя ассета, путь) и заставить установщик вернуть exit_code."""
if sys.platform == "win32":
installer = tmp_path / "HermesHubSetup.exe"
# Содержимое не исполняется: запуск подменён, проверяется учёт кода.
installer.write_bytes(b"MZ\x90\x00 hermes hub test installer\n")
with patch("subprocess.Popen") as mock_popen:
mock_popen.return_value = MagicMock(wait=MagicMock(return_value=exit_code))
yield "HermesHubSetup.exe", installer
else:
installer = tmp_path / "hermes-hub-setup.sh"
installer.write_bytes(f"#!/bin/bash\nexit {exit_code}\n".encode("utf-8"))
yield "hermes-hub-setup.sh", installer
# ── TEST 10: P0-4 успешная установка запись всё-таки делает ──
@pytest.mark.unit
def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkeypatch):
@ -431,8 +482,7 @@ def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkey
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
installer = tmp_path / "hermes-hub-setup.sh"
installer.write_bytes(b"#!/bin/bash\nexit 0\n")
with platform_installer(tmp_path, exit_code=0) as (asset_name, installer):
sha = hashlib.sha256(installer.read_bytes()).hexdigest()
check_result = UpdateCheckResult(
@ -442,7 +492,7 @@ def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkey
latest_commit="deadbeefdeadbeef",
installed_commit="0000000aaaa",
assets={
"hermes-hub-setup.sh": f"file://{installer}",
asset_name: f"file://{installer}",
"checksums.txt": "file:///nonexistent",
},
)
@ -452,7 +502,7 @@ def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkey
def fake_download(self, url, dest, progress_cb=None):
if dest.name == "checksums.txt":
dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8")
dest.write_text(f"{sha} {asset_name}\n", encoding="utf-8")
return
return real_download(self, url, dest, progress_cb)
@ -506,3 +556,59 @@ def test_p0_2_app_js_handles_refused_cancel():
assert "setInterval(pollUpdateProgress" in tail, (
"после отклонённой отмены опрос хода загрузки должен возобновляться"
)
# ── HUB-1: распаковка обновления не выпускает записи за пределы каталога ──
@pytest.mark.unit
def test_update_package_cannot_write_outside_target(tmp_path):
"""Ни одна запись архива не должна оказаться вне каталога установки.
Измерено на CPython: extractall сам отбрасывает "..", ведущие разделители
и буквы дисков, а запись-ссылку кладёт обычным файлом побега добиться не
удалось, вопреки формулировке аудита. Но это свойство реализации, а не
обещание формата, и распаковка идёт в корень установки. Тест закрепляет
границу как собственный инвариант.
"""
import stat as _stat
import zipfile as _zipfile
from antigravity_provider.updater.update_manager import _extract_within
dest = tmp_path / "dest"
dest.mkdir()
outside = tmp_path / "outside.txt"
hostile = [
("выход через ..", "../outside.txt"),
("абсолютный путь", "/etc/passwd"),
("путь с буквой диска", "C:/Windows/x.txt"),
]
for index, (label, arcname) in enumerate(hostile):
archive = tmp_path / f"hostile_{index}.zip"
with _zipfile.ZipFile(archive, "w") as zf:
zf.writestr(arcname, "побег")
with _zipfile.ZipFile(archive) as zf:
with pytest.raises(ValueError):
_extract_within(zf, dest)
assert not outside.exists(), f"{label}: запись оказалась вне каталога установки"
# Символическая ссылка — тоже отказ, а не молчаливая распаковка файлом.
link_zip = tmp_path / "link.zip"
with _zipfile.ZipFile(link_zip, "w") as zf:
info = _zipfile.ZipInfo("link")
info.external_attr = (_stat.S_IFLNK | 0o777) << 16
zf.writestr(info, "/etc/passwd")
with _zipfile.ZipFile(link_zip) as zf:
with pytest.raises(ValueError):
_extract_within(zf, dest)
# Обычный пакет распаковывается как прежде.
good = tmp_path / "good.zip"
with _zipfile.ZipFile(good, "w") as zf:
zf.writestr("src/module.py", "x = 1\n")
zf.writestr("assets/logo.txt", "logo")
with _zipfile.ZipFile(good) as zf:
_extract_within(zf, dest)
assert (dest / "src" / "module.py").read_text(encoding="utf-8") == "x = 1\n"
assert (dest / "assets" / "logo.txt").is_file()

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,9 +40,26 @@ 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"
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()
@ -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

@ -461,3 +461,251 @@ def test_s4_secret_scanner_ast_detection(tmp_path):
clean_file.write_text('def hello(): return "world"\n', encoding="utf-8")
v3 = scan_file_for_secrets(clean_file)
assert len(v3) == 0
# ── HUB-1: ворота публикации не пропускают релиз при любом исходе ──
def _load_release_gate():
import importlib
import sys
scripts_dir = str(Path(__file__).resolve().parent.parent / "scripts")
if scripts_dir not in sys.path:
sys.path.insert(0, scripts_dir)
import release_gate
importlib.reload(release_gate)
return release_gate
@pytest.mark.unit
@pytest.mark.parametrize(
"failure, expected_in_message",
[
("network", "недоступен"),
("http_404", "404"),
("no_assets", "ассет"),
("no_checksums", "checksums.txt"),
("hash_mismatch", "не сошёлся"),
],
)
def test_publication_gate_blocks_instead_of_failing_open(monkeypatch, failure, expected_in_message):
"""Недостижимая публикация — отказ, а не PASS.
Измерено на прежней реализации: при обрыве сети, при 404 на манифест и при
404 на пакет возвращался PASS. Ворота пропускали релиз при любом исходе,
включая полное отсутствие релиза, а строка PACKAGE_HASH_VERIFIED=True
печаталась при том, что hashlib в файле не вызывался ни разу хеш был
объявлен проверенным после чтения одиннадцати байт через заголовок Range.
"""
import urllib.error
release_gate = _load_release_gate()
monkeypatch.setenv(release_gate.PUBLICATION_MODE_ENV, "1")
monkeypatch.setattr(release_gate.sys, "argv", ["release_gate.py"])
manifest = {
"tag_name": "v9.9.9",
"assets": [
{"name": "HermesHubSetup.exe", "browser_download_url": "https://example.invalid/setup.exe"},
{"name": "checksums.txt", "browser_download_url": "https://example.invalid/checksums.txt"},
],
}
if failure == "no_assets":
manifest["assets"] = []
if failure == "no_checksums":
manifest["assets"] = [manifest["assets"][0]]
class _Resp:
status = 200
def __init__(self, payload: bytes):
self._payload = payload
def read(self, *_a):
payload, self._payload = self._payload, b""
return payload
def __enter__(self):
return self
def __exit__(self, *_a):
return False
def fake_get(url, timeout=30):
if failure == "network":
raise urllib.error.URLError("сети нет")
if failure == "http_404":
raise urllib.error.HTTPError(url, 404, "Not Found", {}, None)
if url.endswith("checksums.txt"):
return _Resp(b"%s HermesHubSetup.exe\n" % (b"a" * 64))
if url.endswith("setup.exe"):
return _Resp(b"payload-with-a-different-hash")
return _Resp(json.dumps(manifest).encode("utf-8"))
monkeypatch.setattr(release_gate, "_http_get", fake_get)
ok, msg = release_gate.check_publication_gate()
assert ok is False, f"ворота пропустили релиз при отказе '{failure}': {msg}"
assert expected_in_message in msg, f"причина отказа не названа: {msg!r}"
@pytest.mark.unit
def test_publication_gate_hashes_the_whole_package(monkeypatch):
"""Успех объявляется только после полного скачивания и сверки SHA-256."""
import hashlib
release_gate = _load_release_gate()
monkeypatch.setenv(release_gate.PUBLICATION_MODE_ENV, "1")
monkeypatch.setattr(release_gate.sys, "argv", ["release_gate.py"])
package = b"hermes hub installer payload"
real_sha = hashlib.sha256(package).hexdigest()
read_bytes = {"total": 0}
class _Resp:
status = 200
def __init__(self, payload: bytes, count: bool = False):
self._payload = payload
self._count = count
def read(self, *_a):
payload, self._payload = self._payload, b""
if self._count:
read_bytes["total"] += len(payload)
return payload
def __enter__(self):
return self
def __exit__(self, *_a):
return False
manifest = {
"tag_name": "v9.9.9",
"assets": [
{"name": "HermesHubSetup.exe", "browser_download_url": "https://example.invalid/setup.exe"},
{"name": "checksums.txt", "browser_download_url": "https://example.invalid/checksums.txt"},
],
}
def fake_get(url, timeout=30):
if url.endswith("checksums.txt"):
return _Resp(f"{real_sha} HermesHubSetup.exe\n".encode("utf-8"))
if url.endswith("setup.exe"):
return _Resp(package, count=True)
return _Resp(json.dumps(manifest).encode("utf-8"))
monkeypatch.setattr(release_gate, "_http_get", fake_get)
ok, msg = release_gate.check_publication_gate()
assert ok is True, msg
assert "PACKAGE_HASH_VERIFIED=True" in msg
assert read_bytes["total"] == len(package), (
f"пакет должен быть прочитан целиком, прочитано {read_bytes['total']} из {len(package)}"
)
@pytest.mark.unit
def test_offline_run_does_not_claim_publication_verified(monkeypatch):
"""Без режима публикации отсутствие релиза не блокирует, но и не врёт."""
import urllib.error
release_gate = _load_release_gate()
monkeypatch.delenv(release_gate.PUBLICATION_MODE_ENV, raising=False)
monkeypatch.setattr(release_gate.sys, "argv", ["release_gate.py"])
def fake_get(url, timeout=30):
raise urllib.error.HTTPError(url, 404, "Not Found", {}, None)
monkeypatch.setattr(release_gate, "_http_get", fake_get)
ok, msg = release_gate.check_publication_gate()
assert ok is True, "обычный прогон CI не должен блокироваться отсутствием релиза"
assert "НЕ БЛОКИРУЕТ" in msg
assert "PACKAGE_HASH_VERIFIED=True" not in msg, "непроверенное не должно объявляться проверенным"
@pytest.mark.unit
def test_publishable_assets_check_rejects_uninstallable_release(tmp_path):
"""Набор без установщика не должен уходить в публикацию.
update_manager ищет в релизе HermesHubSetup.exe или hermes-hub-setup.sh, а
release.yml собирает только zip и манифест. Такой релиз становится
«latest», и обновление отвечает «в релизе не найден подходящий файл
обновления для текущей платформы». Раньше это не проявлялось лишь потому,
что весь релизный конвейер падал на шаге Release Gate на тех же двух
дефектах, что и CI, и до публикации не доходил ни один его прогон.
"""
release_gate = _load_release_gate()
as_built_today = tmp_path / "dist_zip_only"
as_built_today.mkdir()
(as_built_today / "hermes-hub-0.1.3.zip").write_bytes(b"zip")
(as_built_today / "update_manifest.json").write_text("{}", encoding="utf-8")
ok, msg = release_gate.check_publishable_assets(as_built_today)
assert ok is False, "набор без установщика признан пригодным к публикации"
assert "HermesHubSetup.exe" in msg
assert "checksums.txt" in msg
without_checksums = tmp_path / "dist_no_sums"
without_checksums.mkdir()
(without_checksums / "HermesHubSetup.exe").write_bytes(b"exe")
ok, msg = release_gate.check_publishable_assets(without_checksums)
assert ok is False, "набор без checksums.txt признан пригодным"
assert "checksums.txt" in 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 без нужной строки признан достаточным"

View file

@ -72,15 +72,27 @@ def test_seq_token_prevents_stale_refresh_clobber():
seq_fresh = store.next_seq()
snap_fresh = store.refresh(force_scan=False, seq=seq_fresh)
gen_fresh = snap_fresh.generation
skipped_before = store.refresh_skipped_total
# Simulate a delayed/stale response from an earlier seq counter
seq_stale = seq_fresh - 1
snap_after_stale = store.refresh(force_scan=False, seq=seq_stale)
# Stale response must be rejected, retaining the fresh generation
assert snap_after_stale.generation == gen_fresh
# Устаревший ответ должен быть отброшен.
#
# Проверяется именно отбрасывание, а не равенство поколений. HubStateStore —
# процессный синглтон, и фоновый сборщик квот, оставшийся от другого теста,
# успевает поднять generation между двумя вызовами. Прежнее
# `generation == gen_fresh` падало на этом с «assert 32 == 31» — примерно раз
# на десяток прогонов, только при случайном порядке тестов. Инвариант же
# другой: устаревший ответ отбрасывается, состояние назад не откатывается.
assert store.refresh_skipped_total == skipped_before + 1, (
"устаревший ответ должен быть отброшен ровно один раз"
)
assert snap_after_stale.generation >= gen_fresh, (
"состояние откатилось назад: устаревший ответ затёр более свежее"
)
assert snap_after_stale.seq != seq_stale
assert store.refresh_skipped_total >= 1
@pytest.mark.unit