Compare commits

..

70 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
2034565345 merge main
# Conflicts:
#	src/antigravity_provider/router/web/server.py
2026-09-02 19:23:31 +07:00
ochenstarik-ui
285ae7cc07 review(a59): обновление, которое видно, принято с исправлениями
Аудит A59. Механизм показа и загрузки собран верно, но три вещи говорили
владельцу неправду.

Запись о применённом обновлении делалась ДО запуска установщика. Установщик
падал, запись оставалась, и при следующем старте хаб писал в журнал «успешно
обновлён», а интерфейс показывал тост об успехе — про версию, которая не
установилась. Теперь «было» снимается до установки (иначе после подмены файлов
прежняя сборка совпадёт с новой), а сама запись делается только на путях успеха.

Причина отказа .sh-установщика была пустой: «Установка не удалась: » без единого
признака. Установщик может завершиться, не сказав ни слова, поэтому в сообщение
добавлен код возврата.

Отмена не смотрела, что происходит. Действие cancel_update открыто в HTTP-API, и
вызов на этапе установки чистил staging вместе с исполняемым в этот момент
файлом, отвечая «отменено» поверх продолжающейся установки. Отмена теперь
принимается только на проверке и загрузке, отказ называет причину, интерфейс
возобновляет опрос вместо замершего окна.

Полоса хода при неизвестном размере заполнялась целиком: текст рядом честно
писал «Н/Д: сервер не сообщил размер», а полная полоса читалась как «готово».
Заменена бегущим отрезком на всех этапах с неизвестной долей.

Обработчик хода при неизвестном размере получал выдуманный ноль на каждом чанке —
теперь старая форма обработчика в этом случае просто не вызывается.

Возвращены комментарии ревьюера, снятые в ходе задания: про running_commit как
единственный признак живого кода, про версию только из API, про «отсутствие
суммы — не разрешение», про ожидание установщика и порядок перезапуска.

Тесты: 723 passed, 2 skipped (было 718/2). Добавлены проверки провала установки,
отказа в отмене и честной полосы. ruff чисто, релизный гейт пройден.

НЕ СДЕЛАНО и требует живого прогона: на Linux install-linux.sh снимает сам хаб,
пока тот ждёт установщик, поэтому schedule_restart не выполняется. Отката для
путей .sh и .exe по-прежнему нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLuenXmGjWaS2rs8E72En
2026-09-02 19:22:59 +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
ochenstarik-ui
30c283f705 merge(a58): состояние проверки доступности agy — только чтение
Проверено исполнением на настоящем ~/.local/bin/agy:
- SHA-256 бинарника до и после определения состояния совпал (файл не тронут);
- на живом файле получено check_active «Проверка на месте», версия 1.1.24;
- сигнатуры детектора побайтово совпадают с гейтом open-antigravity-patcher;
- «определить не удалось» отделено от «не пропатчен»;
- опроса в цикле не добавлено (setInterval/setTimeout в диффе app.js нет);
- ruff чисто, 721 passed / 2 skipped (порог задания — 704).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1hLjqc1n8grdmhb8JiDte
2026-09-02 13:19:32 +07:00
ochenstarik-ui
cc18e26d1c feat(updater): A59 visible update modal, progress bar, cancel, isolation and restart tracking 2026-09-02 13:12:08 +07:00
Hermes Team
4fa993938b docs(agents): задание A59 — обновление, которое видно и доводит себя до конца
Владелец показал, как это сделано в Cockpit Tools: окно при запуске, видимая
загрузка, останов служб, установка и запуск без участия человека.

Строить заново нечего. Проверено: проверка при запуске уже выполняется, но в
тихом режиме; обработчик хода скачивания в загрузчике уже написан, но его
результат никуда не выводится; останов и перезапуск держатся на schedule_restart
и не проверены на обеих системах.

Внешнее условие: лента релизов отстала на v0.1.2-b7 при установленной 0.1.3 —
пока свежий релиз не опубликован, обновлять не на что.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:12:26 +07:00
Hermes Team
58cb88e529 fix(antigravity): вход через терминал не регистрировал аккаунт
Владелец: «в программе нет аккаунтов. я добавил первый… аккаунты так и не
появились». При этом вход проходил, и agy отдавал одиннадцать моделей.

Список аккаунтов строится по конфигурации маршрутизатора. Вход через терминал
завершается своим путём, мимо add_account, и записи в конфигурации не создаёт:
учётные данные на диске, get_profile_status отвечает «подключён», а в интерфейсе
пусто. Воспроизведено исполнением — профиль с ключами agy и нулём записей в
конфигурации не виден нигде.

Теперь при успешном завершении входа профиль регистрируется, и только потом
идёт обновление состояния. Неудача регистрации возвращается отказом с причиной,
а не молчаливым успехом: аккаунт, о котором сказано «подключён», но которого
нигде нет, — та же слепота, что и зависший мастер.

Проверено исполнением до и после правки: было «профилей в конфиге: 0, видно:
ничего», стало «профилей: 1 [ag-5], видно: antigravity[ag-5]».

708 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 02:05:08 +07:00
ochenstarik-ui
4860c5685e feat(agy): A58 read-only eligibility state detection and owner patcher controls 2026-09-02 01:26:46 +07:00
Hermes Team
7b36538ded docs(agents): задание A58 — хаб видит состояние проверки доступности agy
Владелец теряет время не на сам обход, а на то, что узнаёт о слетевшем патче
случайно, по невнятному отказу посреди работы.

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

Хаб только читает и сообщает; действие остаётся за владельцем и выполняется его
собственным средством. Патчить чужой бинарник хабу запрещено отдельным пунктом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 00:58:26 +07:00
Hermes Team
e431e39915 fix(ui): настройка прокси исчезала с экрана
Владелец не нашёл настройку. Она была в разметке, но её не было на экране.

arrangeSettingsPanels пересобирает настройки по жёсткому списку
идентификаторов, переносит перечисленные строки в новые карточки, а исходную
удаляет целиком — вместе со всем, чего в списке нет. Новое поле попало под
удаление и просто перестало существовать.

Добавлена группа «Сеть и доступ» с полем прокси. Название уточнено до
«Прокси / VPN для провайдеров»: владелец называет это впном, и искать он будет
по этому слову.

Устройство, которое так теряет настройки, тоже исправлено: строки, не попавшие
ни в одну группу, собираются в карточку «Прочие настройки», а не выбрасываются.
Забыть настройку в списке всё ещё можно, потерять её с экрана — уже нет.
Обращение к отсутствующему элементу защищено: опечатка в списке больше не
роняет сборку экрана целиком.

Четыре теста закрывают это: каждый перечисленный идентификатор существует в
разметке, поле прокси сгруппировано, остаток забирается до удаления карточки.

704 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 00:30:47 +07:00
Hermes Team
90aeceb9fd fix(ui): уведомления шли без остановки и закрывали интерфейс
Владелец: «справа постоянно выходят статусы, прям без остановки. я вообще
ничего не вижу за ними».

Это был не таймер, а замкнутый круг. Отрисовка настроек запускала опрос
состояния сжатия; executeAction на успехе вызывал fetchSnapshot; тот снова
перерисовывал настройки — и так без конца. Интерфейс сам себя кормил запросами
к серверу, показывая на каждом обороте два тоста: на запрос и на ответ. Тем же
путём заливал «poll_native_auth» во время входа.

Опросы, которые запускает сам интерфейс, а не владелец, теперь молчат и не
дёргают снапшот — второе и разрывает круг. Отказ опроса показывается там, где
его запросили: у мастера входа для этого своя область сообщений.

Состояние сжатия запрашивается при открытии экрана настроек и по кнопке, а не
при каждой отрисовке.

700 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:47:55 +07:00
Hermes Team
fa7bbef8af feat(antigravity): выход через прокси вместо патча бинарника
Вход через терминал прошёл: agy запустился в изолированном каталоге ag-5 и
опознал аккаунт владельца. Отказал Google: «Eligibility check failed: not
currently available in your location». Проверка смотрит на адрес выхода.

У владельца есть узлы 3x-ui в разных странах. Поэтому обход делается выходом
через разрешённую страну, а не патчем чужого бинарника: ничего не ломается при
обновлении Antigravity и не выполняется сторонний код.

Адрес задаётся общий в настройках и отдельный на профиль — разным аккаунтам
может требоваться разная страна. Применяется к запросу каталога моделей, к
вызовам моделей и к сценарию входа в терминале.

Пишутся и заглавные, и строчные имена переменных: Go читает HTTPS_PROXY,
многие библиотеки — https_proxy; ALL_PROXY нужен для socks5.

Адрес проверяется по существу, а не по схеме: приписать socks5:// можно чему
угодно, и тогда мусор выглядел бы принятым, а обращения провайдера молча
ломались бы без внятной причины.

695 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 16:49:04 +07:00
Hermes Team
28f35f863f fix(antigravity): терминал не открывался из-за подменённого HOME; видно работающую сборку
Мастер честно сообщил: «Терминал /usr/bin/xfce4-terminal завершился сразу с
кодом 1, окно не открылось» — новая проверка запуска сработала.

Причина в моей же правке. Терминал запускался с HOME, подменённым на каталог
профиля, а клиенты X11 берут ключ авторизации из ~/.Xauthority: в
agy_profiles/ag-5 такого файла нет и быть не может, поэтому подключиться к
дисплею терминал не мог. Подменять HOME терминалу и не требуется — это делает
сценарий входа, уже внутри окна, перед самым запуском agy.

Заодно устранено побочное действие в запросе: сценарий входа создавался внутри
функции ПОИСКА терминала. Теперь его готовит start_native_agy_login, а поиск
только отвечает на вопрос и ничего не создаёт.

Владелец не мог понять, обновился ли хаб: на Linux строка сборки в боковой
панели была пуста, потому что заполнялась из панели обновлений, а та
подтягивается только при открытии. Теперь номер берётся из снапшота и
показывается вместе со временем запуска процесса.

Показывается running_commit — снятый ОДИН РАЗ при старте процесса. Поле commit
читается из манифеста на диске при каждом запросе, поэтому переживший
обновление процесс рапортует им свежий номер при старом поведении; на этом я
уже спотыкался при разборе окон консоли. Снятое при старте значение отвечает на
настоящий вопрос: какой код сейчас в памяти.

678 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 16:04:07 +07:00
Hermes Team
884a632049 fix(antigravity): хаб отказывался открыть терминал, стоя на рабочем столе
Мастер сообщил «Графический дисплей не обнаружен», хотя окно хаба было открыто
на рабочем столе владельца. Причина: хаб запускается через nohup и наследует
окружение той оболочки, из которой его запустили. Запуск по SSH или службой
оставляет процесс без DISPLAY.

Наследование не единственный источник. Измерено на сервере владельца:
loginctl show-user ochenstarik -p Display даёт c1, а show-session c1 —
Type=x11, Display=:10, Active=yes. Спросить у системы честнее, чем сдаться.

Теперь дисплей ищется сначала в окружении, затем у systemd, и найденное
значение передаётся в окружение терминала — иначе окно не открылось бы даже
при верном обнаружении. Отказ остаётся правомерным, только когда графического
сеанса не знает и systemd; в сообщении перечисляется всё проверенное, включая
сам loginctl.

XAUTHORITY не выставляем: клиенты X11 по умолчанию берут ~/.Xauthority того же
пользователя, а хаб работает под ним же.

674 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:38:27 +07:00
Hermes Team
6cb0d7c636 fix(profiles): пустые слоты появлялись сами, потому что запрос пути создавал каталог
Владелец спросил, откуда 25 каталогов профилей, когда заводил единицы.
Проверено исполнением: обращение к get_profile_dir создавало каталог. Спросили
четыре пути — появились четыре каталога.

В коде зашит список «стандартных» слотов — ag-orch-primary, ag-orch-fallback,
ag-1..ag-20, ag-w1..ag-w10 — в двух местах: agy_subprocess и
model_discovery_service. Любой обход этого списка материализовал их все. Отсюда
ag-w1..ag-w4, ag-cold-*, ag-spare-* — владелец их не создавал.

Спросить, где профиль жил бы, и завести его — разные действия. Создание теперь
запрашивается явно, create=True, и это делают только те, кто действительно
заводит профиль.

Тесты и сценарий проверки, опиравшиеся на побочное создание, приведены к новому
поведению: они проверяют изоляцию пути, а не то, что каталог возник сам.

669 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:27:33 +07:00
Hermes Team
b309972e49 fix(antigravity): терминал входа не открывался, а мастер сообщал об успехе
Владелец увидел «Терминал запущен (/usr/bin/x-terminal-emulator) для слота
ag-6», но окна не появилось. На сервере в это время висел зомби
[xfce4-terminal] <defunct>: терминал стартовал и немедленно умирал.

Причин три.

x-terminal-emulator на Ubuntu указывает на xfce4-terminal.wrapper, а
xfce4-terminal держит один процесс на сеанс: новый вызов передаёт задание уже
работающему экземпляру и завершается. Нужен --disable-server. Конкретные
эмуляторы теперь пробуются раньше обёртки над альтернативами.

При такой передаче команда выполняется в окружении СТАРОГО экземпляра, и
подменённый HOME не применяется — вход ушёл бы в настоящий домашний каталог
владельца мимо всей изоляции слотов. Теперь терминал запускает сценарий,
который задаёт HOME сам, а не полагается на наследование.

С ключом -e окно закрывается вместе с командой, и причину отказа прочесть
нельзя. Сценарий печатает код возврата agy и ждёт нажатия клавиши. На Windows
по той же причине cmd /k вместо /c.

Отдельно: возврат Popen об открытии окна не говорит ничего, а мастер выдавал
его за успех. Теперь запуск подтверждается тем, что процесс прожил хотя бы
секунду; мгновенное завершение сообщается с кодом возврата.

Тесты подменяли глобальный os.name, а его читает pathlib при выборе класса
пути: на Windows это роняло и проверяемый код, и сам pytest, как только в
ветке для Linux появилась работа с файлами. Заменено явной проверкой системы.

669 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:16:32 +07:00
Hermes Team
23e9ac1d6b review(a57): нативный вход через agy принят с исправлениями
Проверено исполнением, а не по отчёту.

Работает. Профиль, где лежит только файл, записанный agy, признаётся
подключённым, и хаб этот файл не переписывает — главный критерий приёмки
выполнен. Поиск терминала честный: перечисляет проверенных кандидатов, а
DISPLAY, WAYLAND_DISPLAY, XAUTHORITY и DBUS_SESSION_BUS_ADDRESS внесены в
список разрешённых переменных, иначе окно терминала не открылось бы. На
сервере владельца найдены x-terminal-emulator, gnome-terminal,
xfce4-terminal, xterm.

Исправлено два дефекта.

1. Почта после входа искалась в google_accounts.json, auth.json и id_token.
   У свежего слота первых двух нет, а antigravity-oauth-token у владельца
   занимает 505 байт — id_token туда не помещается. Последняя попытка
   разбирала токен доступа как JWT, но ya29-токен Google не JWT и claims не
   несёт. Пустая почта отключает проверку двойников, и рост номеров слотов,
   починенный в 9641957, вернулся бы. Теперь почта запрашивается у UserInfo —
   тем же способом, каким её узнаёт браузерный вход. Отказ сети вход не
   роняет: аккаунт подключён, почта Н/Д.

2. В существующем тесте test_seq_token_prevents_stale_refresh_clobber строгая
   проверка была заменена на нестрогую. Прогнал исходную пять раз подряд и в
   полном наборе — проходит. Ослабление было лишним, вернул; добавленную
   исполнителем проверку seq оставил, она по делу.

Не выполнено исполнителем: живая проверка входа на сервере (P0-7.1) — вместо
неё двенадцать модульных тестов. Вход требует участия владельца, поэтому
проверить его сам не могу.

Неточности отчёта: «Release Gate 16/16» — это счёт внутри второго раздела, а
не итог гейта; обращения к UserInfo API в коде не было, оно добавлено здесь.

661 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:54:59 +07:00
Hermes Team
14d1eb4304 merge main 2026-09-01 14:44:22 +07:00
Hermes Team
6f4394113b review(a56): сжатие контекста принято с исправлениями
Проверено исполнением на сервере владельца, а не по отчёту.

Работает. Сжатие вызывается в настоящем пути запроса (local_adapter.py:168) —
разрыв, ради которого писалось задание, закрыт. Замер на живом компрессоре:
5667 токенов на входе, 624 на выходе, 0,11x, экономия 5043 токена за 127,6 с.

Исправлено четыре дефекта.

1. /props и /tokenize запрашивались по адресу с суффиксом /v1. У llama.cpp они
   живут в корне: измерено, /props → 200, /v1/props → 404, то же с /tokenize.
   Адаптер передаёт супервизору именно адрес с /v1, поэтому счёт токенов молча
   падал на посимвольную оценку, и порог сжатия считался от выдуманного числа.
   Адрес нормализуется.

2. Заявленные «сто процентов сохранения фактов» модель не даёт. Замер: 37 из 38,
   97,4%, потерян 001cd1f. Сто процентов получались дописыванием недостающих
   фактов списком — механизм верный, но измеренное число подменялось
   исправленным, а «(100%)» было вписано в сообщение текстом. Теперь полнота
   самой модели сохраняется отдельно и показывается владельцу: иначе ухудшение
   модели осталось бы незамеченным.

3. Итог сжатия считался посимвольно (длина / 3.5) и подавался рядом с настоящим
   числом токенов на входе. Теперь пересчитывается токенизатором сервера, а
   недоступность токенизатора помечается признаком оценки.

4. Порт 8082 был зашит запасным адресом в двух местах вопреки прямому запрету в
   задании. Профиль без адреса теперь даёт состояние «не настроен» с причиной,
   а не молчаливый стук в 8082.

Не выполнено исполнителем: проверка на живом сервере (P0-6.1). Тест на неё
пропускается как негерметичный, замеры выше сделал ревьюер.

649 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:32:35 +07:00
ochenstarik-ui
3b423ca351 feat(antigravity): нативный вход через agy в терминале (A57)
- Реализован запуск agy в терминале с изолированным HOME (x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, tilix, alacritty, kitty, terminator, urxvt, foot, xterm / Windows wt, cmd)
- Опрос появления antigravity-oauth-token вместо ожидания процесса терминала
- Защита занятого слота от перезаписи без подтверждения
- Честное извлечение email без выдумывания identity и предотвращение дубликатов слотов
- Сохранён браузерный OAuth в качестве запасного пути
- Добавлены 12 тестов в test_a57_agy_native_login.py, 640 тестов проходят
2026-09-01 14:25:13 +07:00
Hermes Team
d5c8c316b4 merge main 2026-09-01 14:15:40 +07:00
Hermes Team
be98fd6751 docs(agents): задание A57 — вход в Antigravity через сам agy
Хаб сейчас записывает чужой файл учётных данных своим кодом: формат восстановлен
по рабочему профилю владельца и по строкам в бинарнике. Работает, но до
следующего обновления agy — и отказ будет молчаливым.

Проверено запуском: подкоманд login или auth у agy нет, вход только запуском CLI
без аргументов. Значит вход переносится в терминал с HOME на каталог профиля,
а браузерный путь остаётся запасным для удалённого случая.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:10:49 +07:00
Hermes Team
f188a18136 fix(accounts): отказ проверки переживал более свежий каталог моделей
В карточке владельца рядом стояли «Проверен: не работает — Please sign in» и
«Получено 11 моделей · 13:25:53». Источники разные: красная строка берётся из
состояния проверки, список — из кэша каталога, и обновляются они независимо.
Каталог был получен позже отказа, то есть провайдер с тех пор ответил, а
карточка продолжала утверждать обратное.

Теперь, если каталог получен без ошибки и позже неудачной проверки, вердикт
показывается как устаревший с предложением проверить заново. Объявлять аккаунт
рабочим не за что — проверка после этого не выполнялась, и выдумывать её
результат нельзя.

Отдельно установлено: поле commit в /api/health читается из файла на диске при
каждом запросе, поэтому устаревший процесс рапортует свежий коммит. Различать
сборки по нему нельзя.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:03:56 +07:00
Hermes Team
0ad946eccd fix(accounts): мастер подключения замирал на шаге 3
Владелец видел «сохранение аккаунта и запуск проверки» и ждал. Зависанием это
не было: действие честно дожидалось проверки у провайдера. Для Antigravity она
идёт через CLI и в худшем случае складывается из 90 с на захват замка профиля,
65 на каталог моделей и 90 на пробный вызов — около четырёх минут молчания при
обещанной в интерфейсе «минуте на этап».

Сохранение учётных данных и назначение роли занимают миллисекунды. Теперь
действие возвращается сразу, а опрос провайдера ставится в фон; карточка
обновляется, когда он закончится, — снапшот и так опрашивается по таймеру.

Провайдеры с ключом поведения не меняют: их подключение проверяется
предварительной проверкой до сохранения и возвращается сразу, как требует A54.
Если фоновая служба не работает, проверка по-прежнему выполняется на месте —
иначе результата не будет вовсе.

Надпись в мастере исправлена: обещание «до минуты на этап» не соответствовало
действительности.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 13:23:55 +07:00
Hermes Team
001cd1f91d fix(installer): установка на Linux падала на защищённом системном Python
Установка на сервере владельца прервалась на проверке: «No module named
'fastapi'». Причин две, и обе в установщике.

Первая: запуск через sudo. Установка пользовательская — всё ложится в
$HOME/.hermes и $HOME/.local/bin, root не нужен. Под sudo домашним каталогом
становится /root, венв Hermes там не находится, и установщик уходит на
системный python. Копия при этом ложится в /root/.hermes, где владелец её не
видит. Теперь запуск через sudo распознаётся и отклоняется с объяснением;
осознанный обход остаётся через HERMES_ALLOW_ROOT=1.

Вторая: системный python в Ubuntu 24.04 помечен EXTERNALLY-MANAGED (PEP 668) и
отклоняет pip install — и обычный, и с --user. Установщик обе неудачи проглатывал
(|| true) и продолжал работу до отказа на проверке. Теперь при защищённом
системном python создаётся собственное окружение $HERMES_HOME/venv, а неудача
установки зависимостей прекращает установку с внятным кодом возврата.
--break-system-packages не применяется: имя флага не преувеличивает.

Пусковик научен той же ветке — иначе запуск уходил бы на системный python,
где зависимостей нет и быть не может.

Проверено исполнением на сервере под непривилегированным пользователем:
окружение создано, зависимости установлены, HERMES_HUB_LINUX_VERIFY_OK,
запуск через sudo отклонён с кодом 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 12:56:47 +07:00
Hermes Team
9641957d5b fix(antigravity): вход завершался успешно, а agy требовал войти снова
agy 2.0 читает учётные данные не из .gemini/oauth_creds.json — это формат
Gemini CLI. Свой токен он берёт из .gemini/antigravity-cli/antigravity-oauth-token
в виде {"auth_method": ..., "token": {...}}. Хаб этот файл никогда не создавал,
поэтому «Авторизация успешно завершена» соседствовала с ответом
«Please sign in to view available models».

Установлено сравнением рабочего профиля владельца с неработающим: оба имели
oauth_creds.json одинакового размера, различие было только в этом файле.
Значение auth_method («consumer») взято из рабочего профиля, а не выведено
из общих соображений.

Срок годности пишется в обоих видах: expiry_date в миллисекундах для формата
Node и expiry строкой RFC3339 для Go-шного oauth2.Token.

Заодно устранён рост номеров профилей: слот выбирался до входа, когда почта
ещё неизвестна, и повторный вход тем же аккаунтом занимал очередной свободный
слот — один аккаунт владельца расползся на ag-2, ag-3, ag-4. Теперь после
опознания почты учётные данные возвращаются в слот, который этот аккаунт уже
занимает. Функция проверки двойников в проекте была, но её никто не вызывал.

Версия поднята до 0.1.3, чтобы отличать сборки на глаз. Проверка версии в
тестах сверяется с исходником, а не с записанным числом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:37:27 +07:00
Hermes Team
6af388a5dc fix(antigravity): пустой вход выдавался за успешный; дата установки в интерфейсе
Вход. agy читает учётные данные из <каталог профиля>/.gemini/oauth_creds.json.
Хаб этот файл пишет, но при отсутствии токена доступа создавал его пустым, а
сбой записи только заносился в журнал. Владелец видел «подключено», а проверка
отвечала «Please sign in to view available models» — ровно это и случилось с
профилем ag-2 при заново подключённом victor.trushenko@gmail.com.

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

Версия. Между сборками она не меняется намеренно, а коммит — строка из
шестнадцатеричных цифр, по которой на глаз не отличить старую сборку от
новой. В /api/settings добавлено время установки из манифеста, и оно
показывается рядом с версией: «Hermes Hub Web v0.1.2 · 01.09 04:10».

610 passed, ruff clean, релизный гейт 10/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 03:17:01 +07:00
Hermes Team
9c57a7c607 fix(antigravity): рабочий аккаунт падал из-за незаданного уровня усилия
Новое сообщение об отказе agy показало причину. У рабочего аккаунта
victor.trushenko@gmail.com каталог получался (11 моделей), а вызов падал:

  invalid model selection (--model "gemini-3.7-flash" --effort ""):
  gemini-3.7-flash requires --effort (available: low, medium, high)

Каталог agy отдаёт идентификаторы с уровнем усилия внутри имени
(gemini-3.7-flash-high, -medium, -low), а в профиле хранится голое имя.
Вызов уходил с пустым --effort, и agy отказывался работать.

Теперь уровень определяется: если он зашит в имени, отделяется от модели;
если нет — берётся из каталога, предпочтительно medium. Проверено, что
claude-sonnet-4-6 при этом не разбирается ошибочно: -6 уровнем не
является.

Отдельно: восемь профилей Antigravity отвечают «Please sign in to view
available models» при HOME=~/.hermes/agy_profiles/<slot>. Это пустые
заготовки без учётных данных, а не поломка: agy запускается с подменённым
HOME ради изоляции аккаунтов, и в незаполненном каталоге ключей нет.

Подписочные аккаунты Codex и Claude: отказ по API-ключу теперь объясняет,
что такие аккаунты подключаются входом по ссылке, а не ключом. Проверка
обращается к каталогу моделей, где платформенному ключу нужны права
api.model.read, и подписочный токен там получает 401 или 403 при исправном
аккаунте.

610 passed, ruff clean, релизный гейт 10/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 03:05:44 +07:00
Hermes Team
9761362bc0 fix(ollama): облачный аккаунт объявлялся неработающим и безлимитным
Адрес. Адаптер всегда шёл на 127.0.0.1:11434, получал Connection refused и
помечал аккаунт нерабочим — при том что каталог из девятнадцати облачных
моделей у него получался. У Ollama Cloud локального сервера нет вовсе,
есть только ключ. Теперь при заданном ключе и незаданном адресе адаптер
идёт на ollama.com; явно указанный адрес по-прежнему в приоритете.
Проверено: только ключ даёт https://ollama.com, заданный адрес — его же.

Ключ. Как у NVIDIA и OpenRouter, он читался только из auth_config в
router_profiles.yaml, куда мастер его не кладёт. Добавлено чтение из
хранилища учётных данных.

Лимиты. Провайдер ollama безусловно относился к локальным и получал
подпись «Без ограничений (локальная модель)». Для облачного аккаунта это
неправда: лимиты у него есть. Теперь облачный аккаунт (узнаётся по
наличию ключа) показывает «Н/Д: лимиты не измерены — провайдер не
сообщает их через API».

Отдельно: отказ agy теперь доносит причину. Прежнее «код 1; каталог не
получен» скрывало и текст ошибки, и главное — что agy запускается с HOME,
подменённым на каталог профиля ради изоляции учётных данных. Если вход
делался обычным agy в оболочке, ключи легли в настоящий домашний каталог,
и профиль пуст. Теперь в сообщении и ответ agy, и использованный HOME.

610 passed, ruff clean, релизный гейт 10/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 02:55:39 +07:00
Hermes Team
afaf600f56 feat(nvidia): определение моделей, доступных конкретному аккаунту
Каталог NVIDIA публичный: GET /v1/models отдаётся вообще без ключа и
возвращает одни и те же 83 модели всем. Проверено запросом. Полей о
доступности в нём нет — только id, object, created, owned_by. Значит
узнать из каталога, чем может пользоваться аккаунт, невозможно.

Единственный достоверный способ — спросить у самой модели. Недоступная
отвечает «404 Function ... Not found for account <id>» до генерации, то
есть её проверка ничего не стоит; доступная расходует один токен при
max_tokens=1.

Добавлены модуль model_entitlements, действие probe_account_models и
кнопка «Определить доступные модели» в карточке аккаунта для NVIDIA и
OpenRouter. Запуск только по явному нажатию с подтверждением: опрос
тратит вызовы и упирается в ограничения частоты. Результат сохраняется.

Модели раскладываются на три группы, а не на две: доступные, не выданные
аккаунту и НЕ ОПРЕДЕЛЁННЫЕ с причиной. Отвергнутый ключ (401/403),
превышение частоты (429) и сетевой сбой попадают в третью группу —
выдавать их за «недоступно» нельзя. Проверено: без ключа все модели
получают «ключ не задан», с неверным ключом — «ключ отвергнут (HTTP 403)».

610 passed, ruff clean, релизный гейт 10/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 02:45:01 +07:00
Hermes Team
2bbb9db5de fix(wizard): ключ брался из глобальной переменной, а не из поля
При проверке подключения ключ читался из window._wiz_token. Переменная
переживает предыдущие попытки подключения и могла оказаться пустой или от
другого провайдера: OpenRouter отвечал «HTTP 401: Missing Authentication
header» при заполненном поле, и причина была не видна.

Прямой вызов validate_connection с ключом даёт «401: User not found», то
есть заголовок формируется верно — дело было в источнике значения.

Теперь ключ и адрес читаются из полей в момент проверки.

609 passed, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 02:34:13 +07:00
Hermes Team
eeeed36358 fix(installer): установка на Linux не останавливала работающий хаб
Установщик копировал файлы, но работающий сервер не трогал. Процесс
продолжал выполнять прежний код из памяти, и владелец видел старый
интерфейс при новом номере сборки: раздача статики читает файлы с диска,
а вся логика действий живёт в загруженном модуле. Три сборки подряд
ставились в файлы, но не в работу — отсюда «сброс не работает»,
«очистка не работает», «версия не изменилась».

Добавлен шаг 0: остановка процессов хаба до копирования. Ищутся только
процессы текущего пользователя и только по признакам хаба
(antigravity_provider.router.web, hermes_hub_web_entry). Сначала обычное
завершение, десять секунд ожидания, затем принудительное. Если процессы
всё же остались, установка НЕ прерывается — файлы обновляются, а владельцу
сообщается, что старый код продолжит работать, пока он их не снимет.

В итоговом сообщении сказано, что хаб остановлен и его надо запустить
заново, и дана команда для проверки, что поднялся новый код.

609 passed, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 02:12:22 +07:00
Hermes Team
f269891271 fix: agy не находился в Linux; Ollama Cloud нельзя было подключить по ключу
agy. Поиск проверял только раскладку Windows (%LOCALAPPDATA%/agy/bin) и
PATH. В Linux утилита ставится в ~/.local/bin, а хаб запускается с
урезанным окружением, где этого каталога в PATH нет. Владелец видел «agy
executable not found» при установленной и работающей утилите — which agy
находил её по адресу /home/ochenstarik/.local/bin/agy.

Добавлены стандартные места Linux: ~/.local/bin, /usr/local/bin, /usr/bin,
/snap/bin. Сообщение об отказе перечисляет проверенные пути. Отдельно
различается «нет доступа к каталогу» и «файла нет».

Ollama Cloud. Мастер требовал адрес локального сервера, которого у
облачного аккаунта не существует: там только ключ. Теперь при заданном
ключе недоступность локального адреса не считается отказом — аккаунт
проверяется по каталогу ollama.com и принимается с честной оговоркой,
что локальные модели недоступны. Проверено: 19 моделей каталога.
Без ключа поведение прежнее.

609 passed, ruff clean, релизный гейт 10/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 02:07:11 +07:00
Hermes Team
266fc51adf fix(providers): ключ не доходил до адаптера; счётчик аккаунтов расходился
Ключ API. add_account сохраняет его через ProfileAuthManager, а адаптеры
NVIDIA и OpenRouter читали только profile.auth_config из
router_profiles.yaml — туда ключ не попадает. Запрос уходил без заголовка
Authorization, и провайдер отвечал «401: Header of type authorization was
missing», хотя список моделей тем же ключом получался: обнаружение читает
ключ из хранилища, а адаптер читал из конфигурации.

Проверено: auth_config в yaml пуст, адаптер теперь находит ключ в
хранилище учётных данных.

Счётчик аккаунтов. Значок в меню брал readiness.accounts_connected_count
(строго AUTHENTICATED), а карточка на странице считала профили правилом
«не NOT_CONFIGURED». Владелец видел 9 в меню и 3 на странице. Приведено к
одному определению — тому же, что у страницы.

609 passed, ruff clean, релизный гейт 10/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 01:46:46 +07:00
ochenstarik-ui
2282f6a852 feat(a56): context compression engine with 100% fact retention, configurable compressor profile, and AI-Memory tracking 2026-09-01 01:27:38 +07:00
Hermes Team
4b6533281e fix(web): новые файлы клиента отдавались без запрета кэширования
A48 и A49 добавили workspace.js, workflow.js и workflow.css, но заголовки
Cache-Control им не прописали. Проверено запросом к серверу владельца:
у /, /app.js и /style.css стоит no-cache, must-revalidate, а у
/workspace.js и /workflow.js заголовков кэша нет вовсе.

Из-за этого браузер держал старые файлы после обновления сборки, и
владелец видел прежний интерфейс при новом номере сборки — та самая
поломка, ради которой запрет кэша когда-то вводился для app.js.

Перечисление файлов сделано общим списком, чтобы следующий добавленный
файл не оказался снова без заголовков.

609 passed, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 01:26:08 +07:00
ochenstarik-ui
3e660c39bb fix(a55): account connection fixes - antigravity windows/linux, ollama server discovery, version display, full settings 2026-08-31 23:39:53 +07:00
Hermes Team
26f7d2ce73 fix(hermes): «конфигурация не найдена» при работающем Hermes
Проверялся ровно один путь — $HERMES_HOME/config.yaml. Hermes хранит
конфигурацию по-разному в зависимости от версии и способа установки, и у
владельца на Linux хаб писал «В Hermes: конфигурация не найдена» при
работающем Hermes v0.20.6.

Теперь проверяются семь известных мест, и в сообщении перечисляется, где
именно искали, — владелец видит список и может назвать верный путь.

Отдельно различаются «файла нет» и «нет доступа к каталогу»: закрытый
правами каталог помечается как непроверенный, а не как отсутствующий.
Это тот же класс ошибки, на котором я сам дважды ошибся в диагнозе,
приняв отказ в доступе за отсутствие файла.

599 passed, ruff clean, релизный гейт 10/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:22:08 +07:00
Hermes Team
e74d4fbe4a fix(accounts): чужая почта после смены аккаунта, отказ NVIDIA при верном ключе, отмена выхода
Кэш опознания. _identities и _snapshots в AccountQuotaService живут в
памяти по ключу «провайдер:слот» и при удалении ключа не чистились.
Владелец удалил аккаунт Antigravity, завёл victor.trushenko@gmail.com, а
в списке остался прежний trushenko.semya@gmail.com. Добавлен
forget_profile, вызывается при удалении учётных данных и при перезаписи
слота другим аккаунтом. Проверено: после сброса запись исчезает.

NVIDIA. Проверка подключения получала каталог моделей успешно — то есть
ключ рабочий и аккаунт опознан, — а затем делала пробный запрос к первой
чат-модели каталога. Каталог NVIDIA общий, доступ к конкретной модели
даётся по аккаунту, и ответ «Function ... Not found for account <id>»
объявлялся провалом подключения. Теперь успешный список моделей считается
доказательством работоспособности ключа, а неудачная проба возвращается
примечанием с предложением выбрать доступную модель.

401 и 403 при этом по-прежнему означают отказ: ключ отвергнут. Различие
поймал тест A54 на неверном ключе.

Выход из программы. Неудачная остановка процессов отменяла выход целиком:
владелец нажимал «закрыть» и оставался в работающем приложении. Теперь
показывается предупреждение, а программа закрывается.

599 passed, ruff clean, релизный гейт 10/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:06:37 +07:00
Hermes Team
b2ca7cdd4d fix: версия в интерфейсе была зашита в разметке; установка падала на занятом файле
Версия. В index.html номер стоял руками в двух местах, а в app.js был
запасным значением '0.1.1'. Номер сборки приходил из API и обновлялся,
версия — нет: владелец обновился до e6eab12 и увидел v0.1.1. Подъём
версии в пяти местах бэкенда до экрана не доходил вовсе. Теперь версия
берётся только из API; если сервер её не передал, пишется Н/Д с причиной,
а не правдоподобный номер.

Установка. Отказ остановки прежнего хаба прерывал установку целиком, и
владелец получал голый код 15. Теперь неудачная остановка не отменяет
установку: причина показывается, работа продолжается, и если файл
действительно занят, об этом скажет копирование с именем файла.

Копирование файлов получило повтор: процесс мог не успеть отпустить файл
после остановки. Пять попыток с паузой вместо отказа с первой.

599 passed, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:46:30 +07:00
Hermes Team
e6eab12616 chore(release): версия 0.1.2
Владелец просил менять версию сборки. Прежнее указание держать 0.1.1
отменено этим.

Поднято во всех пяти местах, где версия объявлена: version.py,
pyproject.toml, HermesHubSetup.cs, install-linux.sh и
config/compatibility.json. Последний ловится тестом на совпадение с
__version__ — без него прогон падал.

599 passed, ruff clean, релизный гейт 10/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:38:01 +07:00
Hermes Team
8daeafa345 merge: A54 — проверка аккаунтов работает, окна консоли, закрытие программы
Проверено ревьюером исполнением:

check_account больше не отказывает из-за незапущенной фоновой службы.
Раньше кнопка «Проверить подключение» перекладывала работу на службу и
возвращала «Фоновая служба проверки не запущена». Теперь выполняется
настоящий запрос, и владелец видит причину провайдера: «Не удалось
подключиться к локальному серверу LLM».

Удаление ключа больше не ждёт полного обхода провайдеров: замерено 0,0 с
против прежних тридцати.

Пустой ключ отклоняется ДО создания слота — профилей-пустышек не остаётся.

Действие clear_accounts: предпросмотр целей, подтверждение, и Antigravity
защищён — проверено, ключ ag-1 после очистки цел, в цели не попадает.

Лаунчер: значок в области уведомлений, при закрытии окна вопрос «Закрыть
Hermes Hub полностью?», выход снимает браузер и останавливает
собственный процесс сервера (StopOwnedRuntime).

Правка ревьюера: поддержка провайдера проверяется ДО требования ключа.
A54 поставил проверку ключа первой, и у неподдерживаемого провайдера
выводилось «не указан API-ключ» вместо «не поддерживается» — ключ там не
поможет, и сообщение уводило не туда. Тест A42 это поймал.

599 passed, ruff clean, релизный гейт 10/10 на обеих конфигурациях.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:05:11 +07:00
ochenstarik-ui
b58bfc6c77 fix(a54): reject empty credentials before slot allocation 2026-08-31 20:55:42 +07:00
ochenstarik-ui
ddeba2db0e fix(a54): validate accounts synchronously and manage Windows runtime lifecycle 2026-08-31 20:54:19 +07:00
117 changed files with 14921 additions and 676 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,169 @@
# Задание A57: вход в Antigravity через сам agy, а не через сочинённый файл
## Дата поступления
2026-09-01
## База
`origin/main` (`f188a18`).
```
git fetch origin --prune
git checkout -b antigravity/a57-agy-native-login origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-8** написан для аудитора.
Зона: подключение аккаунтов Antigravity. С A56 (сжатие контекста) не пересекается.
---
## Задача
Сейчас хаб проводит вход сам: получает токены по OAuth и **записывает чужой файл учётных данных своим кодом**. Формат этого файла ревьюер восстановил по рабочему профилю владельца и по строкам в бинарнике `agy`. Сегодня это работает. Но `agy` обновляется, и формат может измениться молча: файл останется на месте, вход перестанет засчитываться, а владелец увидит ровно ту необъяснимую картину, которую ловили полдня — «Авторизация успешно завершена» и тут же «Please sign in to view available models».
Правильный ответ — дать войти самому `agy`, с `HOME`, указывающим на каталог профиля. Тогда он пишет свои файлы своим форматом, сам обновляет токен по истечении часа, и гадать не о чем.
---
## Что проверено ревьюером — заново не выяснять
### Неинтерактивного входа у agy нет
`agy --help` (проверено запуском) даёт подкоманды: `agent`, `agents`, `changelog`, `help`, `install`, `mcp`, `mic-serve`, `models`, `plugin`, `plugins`, `update`. Подкоманд `login` или `auth` **нет**. Вход один: запустить CLI без аргументов и пройти его в терминале. Это же говорит текст ошибки самого agy:
```
Error: Please sign in to view available models.
Launch the CLI without arguments to sign in.
```
Искать скрытый флаг входа не надо — его нет.
### Что agy держит в каталоге профиля
Рабочий профиль владельца `ag-orch-fallback` (11 моделей):
```
.gemini/oauth_creds.json 1949 байт формат Gemini CLI
.gemini/antigravity-cli/antigravity-oauth-token 505 байт {"auth_method":"consumer","token":{...}}
.gemini/antigravity-cli/settings.json 107 байт enableTelemetry, trustedWorkspaces
.gemini/antigravity-cli/installation_id 36 байт
.gemini/antigravity-cli/jetski_state.pbtxt
.gemini/antigravity-cli/history.jsonl
.gemini/antigravity-cli/conversation_summaries.db
auth.json файл хаба, не agy
```
Вход agy читает из `antigravity-oauth-token`, а не из `oauth_creds.json`. Это установлено сравнением рабочего профиля с неработающим и подтверждено исполнением: файл создали руками для `ag-4``agy models` тут же выдал 11 моделей.
### Изоляция по HOME уже работает
`get_profile_env_dir`, `build_safe_subprocess_env` и `hidden_process_kwargs` существуют и применяются: `agy models` вызывается с `HOME`/`USERPROFILE`, указывающими на каталог профиля. Заново это писать не надо.
### Заготовка уже есть
`launch_native_agy_login` в `agy_subprocess.py` запускает agy с `CREATE_NEW_CONSOLE`. Её никто не вызывает — числится мёртвым кодом с A54. Это отправная точка, а не мусор.
### Рост номеров профилей починен
Слот выбирался до входа, когда почта ещё неизвестна, и повторный вход тем же аккаунтом занимал очередной свободный номер: один аккаунт владельца расползся на ag-2, ag-3, ag-4. В `9641957` после опознания почты учётные данные возвращаются в слот, который этот аккаунт уже занимает. **Не переделывать.**
### Среда владельца
Сервер: Ubuntu 24.04, рабочий стол на месте (Chrome запускается), владелец сидит за машиной. Вторая машина — Windows 11. Хаб работает на обеих.
---
## P0-1. Вход выполняет сам agy
1. **Запуск `agy` в настоящем терминале** с `HOME` и `USERPROFILE`, указывающими на каталог выбранного профиля. Владелец проходит вход глазами и руками — это единственный доступный способ.
2. **Терминал не подразумевать, а искать.** На Linux проверить наличие эмулятора (`x-terminal-emulator`, `gnome-terminal`, `konsole`, `xfce4-terminal`, `xterm`) и назвать в отказе, что именно искали. Отсутствие терминала — честный отказ с перечнем проверенного, а не молчание.
3. **Файл учётных данных хаб больше не сочиняет.** `write_agy_oauth_creds` и запись `antigravity-oauth-token` остаются только для запасного браузерного пути (P0-4).
4. **Ждать окончания входа по появлению файла**, а не по коду возврата терминала: эмулятор часто отсоединяется сразу. Ждать `antigravity-oauth-token` в каталоге профиля с разумным пределом и внятным сообщением по его истечении.
## P0-2. Слот выбирается до входа и не меняется
1. **Каталог профиля определяется заранее** и передаётся через `HOME`. Вход физически не может уйти в чужой каталог — в этом весь смысл.
2. **Занятый слот не перезаписывать.** Если в каталоге уже лежит рабочий вход другого аккаунта, предупредить и потребовать подтверждения.
3. **Каталоги существующих аккаунтов не трогать.** Их два десятка, повторный вход руками стоит владельцу часов.
## P0-3. Почта берётся из профиля, а не выдумывается
1. **После входа опознать аккаунт**, прочитав то, что записал agy. Если почту установить не удалось — показать `Н/Д` с причиной, а не подставить правдоподобное.
2. **Проверить двойников** уже существующим `AutoAssigner.check_duplicate_identity` и вернуть учётные данные в занятый этим аккаунтом слот, если он есть.
3. **Число и время получения моделей** показывать рядом со списком.
## P0-4. Браузерный путь остаётся запасным
1. **Не удалять существующий OAuth.** С другой машины через браузер это единственный способ, и он работает.
2. **Владелец выбирает способ** в мастере: вход в терминале на этой машине или по ссылке из браузера. Предлагать первым тот, который на текущей машине выполним.
3. **Текст объясняет разницу**: терминал доступен только там, где стоит хаб.
## P0-5. Отказ доходит до владельца
1. **Причина отказа — текстом.** `agy` пишет её в stderr; она обязана попадать в интерфейс вместе с указанием `HOME`, с которым шёл запуск.
2. **Пустых сообщений быть не должно.** Запасной текст «Отказ выполнения действия» означает потерянную причину.
3. **Проверка после входа не блокирует ответ.** Действие возвращается сразу, опрос провайдера идёт в фоне — это сделано в `0ad946e`, не откатывать.
## P0-6. Безопасность
1. **Токены и коды в интерфейс не выводить и в журнал не писать.** В сообщениях допустимы пути и имена файлов, но не содержимое.
2. **Права на каталог и файлы**`0700` и `0600`.
3. **`~/.hermes/agy_profiles/` не чистить** и не трогать чужие профили.
## P0-7. Проверка исполнением
Тестами это не ловится: все прежние дефекты входа прошли через зелёный прогон.
1. **Подключить аккаунт через терминал на сервере** и приложить вывод `agy models` для этого профиля.
2. **Подключить второй аккаунт** и убедиться, что первый не задет: у обоих свои каталоги и свои почты.
3. **Повторить вход тем же аккаунтом** и убедиться, что новый слот не создаётся.
4. **Проверить отказ** при отсутствии терминала: сообщение перечисляет, что искали.
5. **Проверить, что браузерный путь по-прежнему работает** с другой машины.
## P0-8. Аудит вторым проходом
1. **Пройти вход целиком на обеих машинах**, скриншоты приложить.
2. **Убедиться, что хаб не пишет `antigravity-oauth-token`** на пути через CLI: файл создаёт agy.
3. **Проверить, что при отказе входа профиль не остаётся наполовину заполненным** и не числится подключённым.
4. **Различать «нет доступа» и «не найдено»** — не повторять ошибку ложного диагноза.
5. **Побочные изменения** объяснить.
6. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Учётные данные и каталоги существующих аккаунтов не трогать.
- Правки ревьюера из `main` не откатывать: `9641957` (запись токена и слоты), `0ad946e` (проверка в фоне), `f188a18` (устаревший вердикт).
- Фронтенд без npm, без сборки, без фреймворков — по `docs/web-api/CONTRACT.md` §1.
- Адреса, пути и имена терминалов в код не зашивать вслепую: искать и сообщать, что искали.
- Версию `0.1.3` не понижать.
- Правило честности без исключений: неизмеренное — `Н/Д` с причиной; отсутствие прав — не то же самое, что отсутствие файла.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. Вход через `agy` в терминале работает на Linux и на Windows; вывод `agy models` приложен.
3. Файл `antigravity-oauth-token` на этом пути создаёт agy, а не хаб.
4. Слот задаётся до входа через `HOME`; чужой каталог затронуть невозможно.
5. Повторный вход тем же аккаунтом не создаёт новый слот.
6. Почта берётся из профиля; неустановленная показывается как `Н/Д` с причиной.
7. Браузерный путь сохранён и проверен с другой машины.
8. Отсутствие терминала даёт отказ с перечнем проверенного.
9. Токены не попадают ни в интерфейс, ни в журнал.
10. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **626**.
11. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
## Главное
Хаб сегодня подделывает чужой формат учётных данных. Это работает ровно до следующего обновления `agy`, и отказ будет молчаливым: файл на месте, вход не засчитан, причина неочевидна. Владелец уже потерял на этом день.
`agy` умеет входить сам и делает это правильно по определению. Задание переносит вход туда, где ему место, оставляя браузерный путь для удалённого случая.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,151 @@
# Задание A58: хаб видит состояние проверки доступности agy
## Дата поступления
2026-09-02
## База
`origin/main` (`e431e39`).
```
git fetch origin --prune
git checkout -b antigravity/a58-agy-eligibility-state origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-7** написан для аудитора.
Зона: обнаружение состояния `agy`. С кодом входа (A57) пересекается только чтением пути к исполняемому файлу.
---
## Задача
Владелец не может пользоваться Antigravity: `agy` отвечает
```
Eligibility check failed: Your current account is not eligible for Antigravity,
because it is not currently available in your location.
```
Вход при этом проходит полностью: терминал открывается, каталог профиля верный, аккаунт опознан. Отказывает Google.
Владелец обходит это сторонним патчером, который держит у себя. После каждого обновления `agy` патч слетает, и владелец узнаёт об этом, только наткнувшись на отказ посреди работы. Задание закрывает именно это: **хаб должен замечать смену состояния и говорить о ней**, а не оставлять владельца выяснять причину заново.
---
## Что проверено ревьюером — заново не выяснять
### Проверка не в клиенте, но отказ выносит клиент
В бинарнике `agy` **нет** строки «not currently available in your location» — её присылает сервер. При этом есть перечисление `EPD_ELIGIBILITY_NOT_ELIGIBLE_REGION_OUT_OF_SCOPE`, `ENDPOINT_AIM_ELIGIBILITY` и `AIM_ELIGIBILITY_FETCH_STATUS_*`: клиент запрашивает право доступа у Google и отказывается работать сам.
### Прокси не помогает — ограничение привязано к аккаунту
Измерено на сервере владельца: выход через Финляндию и через Данию даёт один и тот же отказ. Описание патчера это подтверждает — он снимает ограничение «без VPN и смены региона аккаунта Google», то есть обычные пути именно эти два.
**Поддержку прокси, добавленную в `fa7bbef`, не удалять**: она нужна и работает, просто эту задачу не решает.
### Что делает патчер
Для CLI это правка машинного кода, а не настройка. Ищется последовательность байтов и переписывается так, чтобы ветвление всегда уходило по разрешённому пути:
```
было: test rax,rax ; je eligible ; cmp byte[rax+8],0 ; jne eligible ; call failure
стало: test rax,rax ; je eligible ; test rax,rax ; nop ; jne eligible
```
Четыре байта. В исходнике патчера это названо «единственный гейт начальной проверки». Есть подписи для x86-64 и для arm64.
Подменять в настройках нечего: проверка не в настройках.
### Версии
У владельца `Antigravity CLI 1.1.23`. Патчер объявляет минимальные версии `2.5.5` и `2.9.1` — соответствие надо проверить, иначе подпись не найдётся. Обновление CLI выполняется командой `agy update` и перезаписывает файл, снимая патч.
---
## P0-1. Хаб не патчит ничего сам
1. **Хаб не изменяет исполняемые файлы.** Ни при каких условиях, ни по кнопке, ни автоматически.
2. **Хаб ничего не скачивает и не запускает из сети.** Стороннего кода в хабе нет.
3. **Только чтение**: состояние определяется чтением файла, который уже лежит на машине.
## P0-2. Состояние определяется и показывается
1. **Признак патча** — по наличию в файле изменённой последовательности байтов. Подписи для x86-64 и arm64. Чтение файла, ничего больше.
2. **Три состояния, а не два**: «проверка снята», «проверка на месте», «определить не удалось» — с причиной. Не найдена подпись ни в исходном, ни в изменённом виде означает именно третье: другая версия CLI, а не «не пропатчен».
3. **Версия и отпечаток файла** запоминаются. Смена любого из них после обновления — повод пересчитать состояние и сказать владельцу.
4. **Показывать в карточке аккаунта Antigravity** и в «Состоянии системы».
## P0-3. Владелец узнаёт о смене состояния
1. **Событие в журнале**, когда состояние сменилось: было «снята» — стало «на месте».
2. **Заметное указание в интерфейсе**, а не строчка в глубине: этот отказ останавливает работу целиком.
3. **Не опрашивать в цикле.** Достаточно проверки при запуске, после обновления `agy` и по кнопке. Опрос в цикле уже приводил к тому, что интерфейс сам себя кормил запросами.
## P0-4. Кнопка запускает то, что владелец сам поставил
1. **Путь к сценарию владельца задаётся в настройках.** Умолчания не выдумывать: не задан — кнопки нет, показывается `Н/Д: сценарий не указан`.
2. **Запуск только по нажатию.** Никакого автоматического запуска при обновлении: владелец должен видеть, что и когда выполняется.
3. **Запуск в терминале**, как вход в A57 — владелец видит ход и результат. Использовать существующий `find_terminal_emulator`, заново не писать.
4. **После выполнения состояние пересчитывается** и показывается новое.
5. **Отказ запуска доходит текстом**, с указанием пути и причины.
## P0-5. Обновление CLI
1. **Показывать установленную версию** `agy`. Не удалось определить — `Н/Д` с причиной.
2. **Кнопка обновления** выполняет `agy update` в терминале.
3. **Предупредить о порядке**: обновление перезаписывает файл и снимает патч, поэтому сначала обновление, потом патч. Это подсказка в интерфейсе, а не комментарий в коде.
## P0-6. Проверка исполнением
1. **Определить состояние на настоящем `agy` владельца** и приложить вывод.
2. **Проверить все три состояния**, третье — на файле другой версии.
3. **Проверить, что хаб файл не изменил**: контрольная сумма до и после определения состояния совпадает.
4. **Проверить кнопку** на незаданном пути и на неверном.
## P0-7. Аудит вторым проходом
1. **Убедиться, что хаб не пишет в исполняемый файл** ни на одном пути.
2. **Проверить, что «определить не удалось» не выдаётся за «не пропатчен»** — это разные вещи, и путать их нельзя.
3. **Проверить, что нет опроса в цикле.**
4. **Побочные изменения** объяснить.
5. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Хаб не изменяет чужие исполняемые файлы и не выполняет загруженный из сети код.
- Поддержку прокси из `fa7bbef` не удалять.
- Правки ревьюера из `main` не откатывать.
- Фронтенд без npm, без сборки, без фреймворков — по `docs/web-api/CONTRACT.md` §1.
- Пути и версии в код не зашивать: определять и сообщать, что проверяли.
- Версию `0.1.3` не понижать.
- Правило честности без исключений: неопределённое — `Н/Д` с причиной.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. Состояние проверки определяется на настоящем `agy`; вывод приложен.
3. Три состояния различаются; «определить не удалось» называет причину.
4. Контрольная сумма исполняемого файла до и после определения совпадает.
5. Смена состояния после обновления `agy` попадает в журнал и видна в интерфейсе.
6. Кнопка запускает указанный владельцем сценарий в терминале; путь не задан — кнопки нет.
7. Показывается версия `agy`; есть кнопка `agy update` с предупреждением о порядке.
8. Опроса в цикле нет.
9. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **704**.
10. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
## Главное
Владелец теряет время не на сам обход, а на то, что узнаёт о слетевшем патче случайно — посреди работы, по невнятному отказу. Хаб для того и нужен, чтобы состояние было видно заранее.
Поэтому хаб **смотрит и говорит**, а действие остаётся за владельцем и выполняется его собственным средством. Патчить чужой бинарник самому хабу нельзя: подпись привязана к версии, любое обновление её ломает, и отлаживать пришлось бы чужой код внутри своего.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,151 @@
# Задание A59: обновление, которое видно и доводит себя до конца
## Дата поступления
2026-09-02
## База
`origin/main` (`58cb88e`).
```
git fetch origin --prune
git checkout -b antigravity/a59-visible-update origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-7** написан для аудитора.
Зона: обновление и перезапуск. С A58 (состояние `agy`) не пересекается.
---
## Задача
Владелец показал, как это устроено в Cockpit Tools, и хочет так же:
> захожу в программу и независимо от того, работала она или нет, выходит окно об
> обновлении. ставишь новую версию (что на линукс, что на винде) — сразу видно
> загрузку и что скачивается. потом он останавливает сам все службы,
> устанавливает программу новую и запускает.
Сейчас владелец ставит каждую сборку установщиком вручную. Обновление внутри программы написано, но не доведено до вида, в котором им пользуются.
---
## Что проверено ревьюером — заново не выяснять
Строить с нуля ничего не надо, почти всё уже есть.
```
UpdateManager.check_for_updates есть
UpdateManager._download_file есть, с обработчиком хода: content-length и
progress_cb(downloaded / total)
UpdateManager.download_and_verify есть, с проверкой SHA-256
UpdateManager.install_latest_update есть
UpdateManager.schedule_restart есть
действия check_updates и apply_update есть в обработчике
проверка при запуске сервера есть: server.py вызывает check_for_updates
проверка при открытии интерфейса есть: app.js вызывает checkUpdates(true)
```
Три разрыва, и все на виду.
1. **Проверка при запуске молчит.** `checkUpdates(true)` — тихий режим: обновление находится, но владельцу не показывается ничего.
2. **Ход скачивания никуда не идёт.** `progress_cb` в загрузчике есть, но его никто не передаёт и результат не отображается.
3. **Останов служб и запуск после установки** держится на `schedule_restart` и не проверен на обеих системах.
Плюс внешнее условие: **лента релизов отстала**. Последний опубликованный — `v0.1.2-b7`, а установлена `0.1.3`. Пока свежий релиз не опубликован, обновлять не на что, и проверить работу нельзя.
---
## P0-1. Окно при запуске
1. **Обновление есть — показывается окно**, а не значок в углу. Независимо от того, работала программа до этого или нет.
2. **В окне: номер версии, что нового, размер загрузки.** «Что нового» брать из описания релиза; нет описания — писать `Н/Д: описание не приложено`, а не пустоту.
3. **Отказаться можно**, и отказ запоминается для этой версии: повторно то же окно не всплывает.
4. **Обновления нет — окна нет.** Молчание при отсутствии новостей.
## P0-2. Скачивание видно
1. **Полоса хода и проценты**, источник — `progress_cb`, он уже написан.
2. **Показывать, что именно скачивается**: имя файла и размер, «сколько из скольких».
3. **Нет `content-length` — так и писать**: `Н/Д: сервер не сообщил размер`, и показывать скачанный объём без процентов. Выдумывать проценты нельзя.
4. **Скачивание можно отменить**, недокачанный файл удаляется.
5. **Проверка SHA-256 остаётся обязательной.** Не сошлась — установка не начинается, файл удаляется, причина на экран.
## P0-3. Установка доводит себя до конца
1. **Останавливаются все свои процессы**: веб-сервер, фоновые опросы, дочерние. Тот же порядок, что в установщике — там это уже сделано в `stop_running_hub`.
2. **Чужого не трогать.** Останавливать только своё: по признаку хаба, в своём пользователе.
3. **Установка и запуск** без участия владельца. После запуска — тот же экран, на котором он был.
4. **Работает на Linux и на Windows.** Разница только в способе запуска, поведение одинаковое.
5. **Сорвалось на середине — откат к прежней версии** и внятное сообщение. Программа обязана остаться работоспособной.
## P0-4. Видно, что обновилось
1. **После перезапуска показать, что версия сменилась**: было — стало.
2. **Строка сборки уже показывает коммит работающего процесса** (`running_commit`, снят при старте) и время запуска. Не ломать: это единственный признак, по которому отличают новый код от старого, пережившего установку.
3. **Событие в журнале** о применённом обновлении с обеими версиями.
## P0-5. Ничего лишнего
1. **Не опрашивать в цикле.** Проверка при запуске и по кнопке. Опрос в цикле уже приводил к тому, что интерфейс сам себя кормил запросами.
2. **Опросы состояния молчат** — они в `SILENT_ACTIONS`, туда же добавить опрос хода загрузки.
3. **Автоматическая установка без спроса запрещена.** Показать, предложить, дождаться нажатия.
## P0-6. Проверка исполнением
Тестами это не ловится: дело в живом переходе между версиями.
1. **Обновиться на живой установке** с предыдущей версии на текущую. На обеих системах. Скриншоты окна и полосы хода приложить.
2. **Замерить время** от нажатия до готовности.
3. **Проверить, что после перезапуска работает новый код** — по `running_commit`, а не по номеру версии.
4. **Проверить отказ**: испорченная сумма, обрыв сети, отмена на середине.
5. **Проверить, что старый процесс не остался.**
## P0-7. Аудит вторым проходом
1. **Пройти обновление целиком на обеих системах.**
2. **Проверить, что проценты не выдумываются** при отсутствии `content-length`.
3. **Проверить откат** при сорвавшейся установке.
4. **Проверить, что останавливается только своё.**
5. **Побочные изменения** объяснить.
6. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Проверку SHA-256 и список разрешённых адресов не ослаблять.
- Показ работающего коммита и времени запуска не ломать.
- Фронтенд без npm, без сборки, без фреймворков — по `docs/web-api/CONTRACT.md` §1.
- Правки ревьюера из `main` не откатывать.
- Версию `0.1.3` не понижать.
- Правило честности без исключений: неизвестный размер — `Н/Д` с причиной, а не поддельные проценты.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. При запуске с доступным обновлением показывается окно; без обновления — не показывается.
3. Отказ от версии запоминается, окно не всплывает повторно.
4. Полоса хода и объём отображаются; без `content-length` — честное `Н/Д` без процентов.
5. Отмена удаляет недокачанный файл.
6. Несовпадение SHA-256 прекращает установку с сообщением.
7. Установка сама останавливает свои процессы, ставит и запускает; проверено на Linux и Windows.
8. Сорвавшаяся установка откатывается, программа остаётся работоспособной.
9. После перезапуска `running_commit` соответствует новой сборке.
10. Опроса в цикле нет; опрос хода загрузки молчалив.
11. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **708**.
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.

View file

@ -0,0 +1,169 @@
# Задание A54: проверка аккаунтов не работает, окна консоли, закрытие программы
## Дата поступления
2026-08-31
## База
`origin/main` (`f0d06e4`).
```
git fetch origin --prune
git checkout -b antigravity/a54-accounts-fix origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-8** написан для аудитора.
**Задание срочное.** После установки сборки `f0d06e4` владелец не может пользоваться программой: ни один аккаунт не проверяется, модели не подтягиваются, поверх окна выскакивают чёрные консоли.
---
## Задача
Владелец, дословно: «я так понял ни один аккаунт не подключается. Все проверки проходят с ошибкой, модели перестали нормально подтягиваться. Даже на локальных моделях». И отдельно: «в чём сложность-то?» — про OpenRouter и NVIDIA, которые не подключаются третье задание подряд.
---
## Что проверено ревьюером — заново не выяснять
### Кнопка «Проверить подключение» ничего не проверяет
Воспроизведено вызовом:
```
check_account profile_id=local-1
→ ok=False
→ «Фоновая служба проверки не запущена. Перезапустите веб-сервер.»
```
Действие **перекладывает работу на фоновую службу** вместо того, чтобы выполнить проверку. Если служба не поднялась, владелец получает отказ на каждом аккаунте. В интерфейсе это выглядит как «Тест завершился с ошибкой» и «Отказ выполнения действия» — второе вообще запасной текст на случай пустого сообщения, то есть причина до владельца не доходит.
Служба включается в `web/server.py:496` внутри фонового потока. Любой сбой этого потока оставляет все проверки нерабочими, и узнать об этом нельзя.
### Удаление аккаунта занимает полминуты
Причина найдена: `_rescan_after_auth()` вызывает
```python
HubStateStore.get().refresh(force_scan=True)
AccountProbeService.get().schedule_all()
```
то есть **принудительный полный пересбор всех провайдеров** с сетевыми запросами. Таймауты в сборщике квот — 15, 20 и 30 секунд, у Antigravity через CLI — 60. Удаление одного ключа ждёт опроса всех.
### Окна консоли
В `f0d06e4` скрытие окна добавлено к двум живым запускам `agy` и к остальным фоновым вызовам. Проверено, что `hidden_process_kwargs()` на Windows возвращает `CREATE_NO_WINDOW` и `SW_HIDE`.
Окна у владельца остались. Наиболее вероятная причина: **старый процесс сервера пережил обновление**. Закрытие окна браузера сервер не останавливает, и после установки продолжает работать прежний код. Проверить это первым делом.
`launch_native_agy_login` с `CREATE_NEW_CONSOLE` — мёртвый код, его никто не вызывает. Либо удалить, либо подключить к входу.
### OpenRouter и NVIDIA
Сохранение работает — проверено вызовом `add_account`, профиль создаётся с верным идентификатором, чужой слот отклоняется. Значит дело не в сохранении, а в том, что **после ввода ключа ничего не проверяется и модели не запрашиваются**, и владелец остаётся с пустым списком.
---
## P0-1. Проверка выполняется, а не делегируется
1. **«Проверить подключение» делает настоящий запрос к провайдеру здесь и сейчас** и возвращает результат. Фоновая служба — для периодической проверки, а не для ручной.
2. **Отказ невозможен из-за незапущенной службы.** Если фоновая служба нужна, но не работает, ручная проверка всё равно обязана отработать.
3. **Причина доходит до владельца.** Запасной текст «Отказ выполнения действия» означает пустое сообщение — таких путей быть не должно.
4. **Состояние службы видно** в «Состоянии системы»: работает или нет, когда был последний обход.
## P0-2. Удаление и очистка
1. **Удаление ключа не запускает полный пересбор.** Обновлять только затронутый профиль; полный обход — в фон, не блокируя ответ.
2. **Кнопка «Очистить все аккаунты»** с подтверждением и перечислением того, что будет удалено.
3. **Учётные данные Antigravity — под защитой A37.** Массовое удаление не должно затрагивать `~/.hermes/agy_profiles/` без явного отдельного подтверждения: повторный вход в два десятка аккаунтов делается вручную и стоит владельцу часов.
## P0-3. Закрытие программы на Windows
Владелец: «при нажатии на крестик спрашивать, закрыть программу или свернуть в фон. При закрытии полностью всё закрывает».
1. **Диалог при закрытии**: закрыть полностью или свернуть в фон.
2. **Закрытие останавливает всё**: веб-сервер, фоновые опросы, дочерние процессы. После этого окон появляться не должно.
3. **Свёрнутое состояние видно** — значок в области уведомлений с пунктами «Открыть» и «Выход».
4. **Обновление не должно оставлять старый процесс**: перед установкой прежний сервер останавливается. Это вероятная причина того, что окна не исчезли после установки исправления.
## P0-4. Подключение по ключу проверяется сразу
Для `openrouter`, `nvidia`, `nvidia-nim` и прочих провайдеров с ключом:
1. **После ввода ключа — немедленная проверка**: запрос к провайдеру, и его ответ показывается владельцу.
2. **Ключ неверен — сказать сразу**, не создавая профиль-пустышку.
3. **Ключ верен — тут же запросить модели** и дать выбрать предпочитаемую в том же окне.
4. **Не «сохранено», а «подключено и проверено»** — сообщение должно отражать, что именно произошло.
## P0-5. Модели у локальных и Ollama
1. **«Запросить список моделей» у локального профиля возвращает ошибку** — разобраться и починить. Локальный путь не требует ключа, отказ там означает дефект, а не отсутствие доступа.
2. **Ollama: список не грузится.** Локальные модели через `/api/tags` по адресу профиля; облачный каталог уже работает — не сломать.
3. **Отличать «сервер не отвечает» от «моделей нет»**: у владельца на Windows Ollama не запущена, и `WinError 10061` — это честный ответ, его надо показывать именно так, а не как ошибку обновления.
## P0-6. Antigravity: было 14 моделей, стало 3
На прошлой сборке у аккаунтов Antigravity значилось «Получено 14 моделей» с перечнем. Сейчас в карточке три, статус «Не проверялся», а проверка завершается ошибкой.
1. **Найти, где список сузился.** Проверить, не подменяется ли обнаруженный список предпочтениями профиля — эта ошибка уже была в инспекторе агента и чинилась в правках ревьюера.
2. **Число и время получения показывать** рядом со списком, как было.
## P0-7. Проверка исполнением
Тестов недостаточно: все перечисленные дефекты прошли через зелёный прогон.
1. **Открыть хаб и нажать «Проверить подключение»** на локальном профиле, на Ollama и на Antigravity. Результат приложить скриншотами.
2. **Подключить OpenRouter с заведомо неверным ключом** и убедиться, что ошибка видна сразу; затем убедиться, что при верном ключе подтягиваются модели.
3. **Удалить аккаунт и замерить время** — должно быть быстро, без ожидания опроса всех провайдеров.
4. **Закрыть программу крестиком**, выбрать «закрыть», и убедиться, что процессов не осталось и окна не появляются.
5. **Проверить, что после обновления старый процесс не остаётся.**
## P0-8. Аудит вторым проходом
1. **Проверить, что ручная проверка работает при остановленной фоновой службе.**
2. **Искать оставшиеся пути с пустым сообщением об ошибке** — их не должно быть.
3. **Замерить удаление аккаунта** независимо.
4. **Проверить, что массовая очистка не трогает `~/.hermes/agy_profiles/`.**
5. **Проверить, что окна консоли не появляются** при работающей автопроверке.
6. **Побочные изменения** объяснить.
7. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Учётные данные и `~/.hermes/agy_profiles/` не трогать; массовое удаление — только с отдельным подтверждением.
- Автоматическую проверку не отключать ради тишины: чинить, а не убирать.
- Вёрстку A48 не ломать.
- Версию `0.1.1` не поднимать.
- Правило честности без исключений: причина отказа доходит до владельца текстом.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. Ручная проверка выполняет запрос и возвращает результат даже при незапущенной фоновой службе; проверено.
3. Путей с пустым сообщением об ошибке не осталось.
4. Удаление аккаунта не ждёт полного обхода провайдеров; время замерено до и после.
5. Есть кнопка очистки всех аккаунтов с подтверждением; учётные данные Antigravity не затрагиваются без отдельного согласия.
6. Крестик спрашивает «закрыть или свернуть»; закрытие останавливает сервер и фоновые опросы; проверено отсутствием процессов.
7. Обновление не оставляет старый процесс.
8. Ввод ключа сразу проверяется, модели подтягиваются в том же окне.
9. Список моделей у локального профиля и Ollama работает; «сервер не отвечает» отличается от «моделей нет».
10. У Antigravity список моделей вернулся к полному; показано число и время получения.
11. Скриншоты проверок приложены.
12. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **574**.
13. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
## Главное
Владелец поставил сборку и не может ей пользоваться: проверка отказывает на каждом аккаунте, модели не грузятся даже у локальных, удаление ключа занимает полминуты, а поверх окна выскакивают консоли.
Общее у большинства этих дефектов одно: **действие не делает работу само, а перекладывает её на фоновую службу или на полный обход всех провайдеров**. Отсюда и отказы, и задержки. Чинить надо это, а не симптомы.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,40 @@
# A54 — проверки аккаунтов и завершение приложения
Дата: 2026-08-31. START_HEAD / origin/main на старте: `f0d06e499449564b3fb19c80a8bcd862ea895594`.
Ветка: `antigravity/a54-accounts-fix`. Версия остаётся 0.1.1. Точный FINAL_COMMIT_SHA будет указан при сдаче и в PR после окончательной проверки.
## Что изменено
- Ручная проверка выполняет запрос независимо от фонового планировщика; результаты и ошибки возвращаются сразу. Проверки одного профиля сериализованы, незавершённая inference после таймаута не запускается повторно.
- Модели запрашиваются отдельным действием без inference. Пустой каталог отличается от ошибки соединения. Облачный каталог Ollama сохранён.
- Новый ключ проверяется до создания профиля. OpenRouter: аутентифицированный `/key`, затем каталог. NVIDIA: каталог и минимальный запрос обнаруженной чат-модели. Ошибки не записывают профиль. В мастере можно выбрать полученную модель, результат сохраняется в состоянии проверки.
- Удаление и смена модели используют локальные изменения снапшота; подключение не ожидает общего опроса. Массовая очистка показывает точный список, требует подтверждения, отклоняет устаревший список, исключает Antigravity и ссылки на защищённые данные.
- AG: явный запрос каталога больше не возвращает пожизненный глобальный кэш другого профиля. Предпочтения подписаны отдельно; каталог не обрезается до восьми, видны число и время. Падение с 14 до 3 на машине владельца не воспроизведено напрямую: найдены глобальный кэш и отдельная строка предпочтений, оба исправлены без заявления о доказанной единственной причине.
- Windows: контроллер с tray «Открыть / Выход», выбор полного завершения при закрытии окна приложения; остановка процессов только этой установки. Установщик/PowerShell останавливают старое дерево перед копированием, сохраняя ветвь самого установщика; при обновлении установщик отвечает за перезапуск. Мёртвый запуск отдельной консоли AG удалён. Вывод сервера читается постоянно, чтобы перенаполненный pipe не останавливал сервер.
## Проверки и их пределы
- Linux: **598 passed / 1 skipped / 0 failed**, 4 deselected. Пропуск — Windows C# compiler отсутствует. `ruff check .`, Node DOM contracts и `node --check` успешны.
- Router verification **10/10**; release gate успешен. Итоговый Windows CI проверяется через draft PR, результат будет дописан после выполнения.
- Браузер: настоящий запрос к Qwen на локальном 8081; AG — явно синтетический профиль с 14 моделями; Ollama — HTTP стенд с пустым `/api/tags` и заведомо недоступный порт. Кнопки ручной проверки нажаты.
- OpenRouter в браузере: немедленный HTTP 401 при неверном тестовом ключе, успешный HTTP стенд возвращает каталог и выбор модели в том же мастере. Настоящий ключ OpenRouter/NVIDIA владельца не использовался.
- Удаление, 50 образцов обработчика: базовая медиана **0.329 мс**, A54 **0.324 мс**; максимумы 6.942 / 63.205 мс. Это не доказательство ускорения пользовательского сценария: полуминутную задержку Windows воспроизвести здесь нельзя. Лишние полные сканирования в последующих операциях устранены отдельно. См. `deletion-benchmark.json`.
- Защита AG, ссылки, устаревший preview, ручная проверка при disabled, непустые ошибки, serialization и сохранение результата проверены тестами в изолированных каталогах.
## Не подтверждено исполнением
Windows: диалог крестика, tray, отсутствие оставшихся процессов/консолей и обновление поверх запущенной старой установки требуют интерактивной проверки Windows. Компиляция/CI не заменяют её. В fallback обычного браузера его вкладка не отслеживается как окно приложения; выход доступен через tray.
Не проверены реальные OAuth/каталог аккаунта Antigravity владельца и действующие ключи OpenRouter/NVIDIA. Учётные данные владельца не читались и не изменялись. A54 нельзя считать полностью принятой до этих проверок.
## Артефакты
- `local-live.png` — живой локальный сервер.
- `antigravity-fixture.png` — 14 синтетических моделей (не доказательство реального AG).
- `ollama-fixture.png` — пустой каталог и отказ соединения.
- `openrouter-invalid.png`, `openrouter-valid-fixture.png` — отказ и каталог HTTP стенда.
- `service-health.png` — состояние периодической службы.
- `tests/manual/a54_preview.py` — воспроизводимый изолированный стенд.
- `local-model-review.md`, `local-model-usage.json` — честный результат локальной делегации.
Проверенные первичные описания API: [OpenRouter current key](https://openrouter.ai/docs/api/api-reference/api-keys/get-current-key), [NVIDIA LLM API](https://docs.api.nvidia.com/nim/reference/llm-apis).

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View file

@ -0,0 +1,12 @@
{
"baseline_f0d06e4": {
"median_ms": 0.329,
"max_ms": 6.942,
"samples": 50
},
"a54": {
"median_ms": 0.324,
"max_ms": 63.205,
"samples": 50
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View file

@ -0,0 +1,13 @@
# A54 — локальная оркестрация
Использованы HTTP chat completions на 8082 (Qwen3-4B-Instruct-2507) и 8081 (Qwen3-Coder-30B-A3B), последовательно, без передачи данных владельца. Изолированный Git worktree; ответы моделей не исполнялись автоматически.
Циклы: probe-coder → probe-review → rework → review → rework; дополнительная передача сильному кодеру; preflight-coder → review → rework; Windows helper → review; отдельная генерация теста и ревью. Объёмы и время: `local-model-usage.json` (только сохранённые ответы; один потерянный ответ из-за ошибки оркестрационного скрипта не включён).
## Итог аудита Codex
Модели не довели критические части до приемлемого состояния самостоятельно. 4B повторно оставляла отказ при `enabled=False`, использовала несуществующий `threading.ThreadPoolExecutor`, неправильно читала JSON Ollama. 30B тоже предлагала фиктивные профили и успешную проверку без вызова inference. Эти версии отклонены; конечный код существенно переработан Codex.
Ревью 30B полезно для поиска отдельных дефектов, но содержит ложные срабатывания. Например, оно объявляло нестабильным `setdefault` словаря блокировок под mutex и не признало отсутствие нужного импорта в предложенном тесте. Его `PASS` не принимался как достаточное основание.
Исправленная схема на этой задаче: локальные кандидаты → статическая проверка Codex → исправления → детерминированные тесты → браузерное исполнение → Windows CI. Нельзя утверждать, что расходы Codex снизились: контрольного замера без делегации нет.

View file

@ -0,0 +1,198 @@
[
{
"step": "preflight-coder",
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
"seconds": 13.08,
"usage": {
"completion_tokens": 1490,
"prompt_tokens": 394,
"total_tokens": 1884,
"prompt_tokens_details": {
"cached_tokens": 10
}
},
"finish_reason": "stop"
},
{
"step": "preflight-review",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 5.61,
"usage": {
"completion_tokens": 333,
"prompt_tokens": 1536,
"total_tokens": 1869,
"prompt_tokens_details": {
"cached_tokens": 5
}
},
"finish_reason": "stop"
},
{
"step": "preflight-rework",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 19.48,
"usage": {
"completion_tokens": 1705,
"prompt_tokens": 1849,
"total_tokens": 3554,
"prompt_tokens_details": {
"cached_tokens": 3
}
},
"finish_reason": "stop"
},
{
"step": "probe-coder-1",
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
"seconds": 16.74,
"usage": {
"completion_tokens": 1823,
"prompt_tokens": 1149,
"total_tokens": 2972,
"prompt_tokens_details": {
"cached_tokens": 0
}
},
"finish_reason": "stop"
},
{
"step": "probe-review-1",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 5.89,
"usage": {
"completion_tokens": 603,
"prompt_tokens": 2059,
"total_tokens": 2662,
"prompt_tokens_details": {
"cached_tokens": 2058
}
},
"finish_reason": "stop"
},
{
"step": "probe-review-2",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 7.71,
"usage": {
"completion_tokens": 511,
"prompt_tokens": 2115,
"total_tokens": 2626,
"prompt_tokens_details": {
"cached_tokens": 312
}
},
"finish_reason": "stop"
},
{
"step": "probe-rework-1",
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
"seconds": 19.38,
"usage": {
"completion_tokens": 1879,
"prompt_tokens": 2643,
"total_tokens": 4522,
"prompt_tokens_details": {
"cached_tokens": 10
}
},
"finish_reason": "stop"
},
{
"step": "probe-rework-2",
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
"seconds": 22.35,
"usage": {
"completion_tokens": 2163,
"prompt_tokens": 2607,
"total_tokens": 4770,
"prompt_tokens_details": {
"cached_tokens": 291
}
},
"finish_reason": "stop"
},
{
"step": "probe-root-audit",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 6.82,
"usage": {
"completion_tokens": 490,
"prompt_tokens": 1488,
"total_tokens": 1978,
"prompt_tokens_details": {
"cached_tokens": 4
}
},
"finish_reason": "stop"
},
{
"step": "probe-strong",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 24.7,
"usage": {
"completion_tokens": 2082,
"prompt_tokens": 2439,
"total_tokens": 4521,
"prompt_tokens_details": {
"cached_tokens": 5
}
},
"finish_reason": "stop"
},
{
"step": "test-coder",
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
"seconds": 2.67,
"usage": {
"completion_tokens": 302,
"prompt_tokens": 143,
"total_tokens": 445,
"prompt_tokens_details": {
"cached_tokens": 3
}
},
"finish_reason": "stop"
},
{
"step": "test-review",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 4.22,
"usage": {
"completion_tokens": 385,
"prompt_tokens": 335,
"total_tokens": 720,
"prompt_tokens_details": {
"cached_tokens": 5
}
},
"finish_reason": "stop"
},
{
"step": "windows-coder",
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
"seconds": 3.15,
"usage": {
"completion_tokens": 348,
"prompt_tokens": 173,
"total_tokens": 521,
"prompt_tokens_details": {
"cached_tokens": 3
}
},
"finish_reason": "stop"
},
{
"step": "windows-review",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 3.52,
"usage": {
"completion_tokens": 296,
"prompt_tokens": 378,
"total_tokens": 674,
"prompt_tokens_details": {
"cached_tokens": 3
}
},
"finish_reason": "stop"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View file

@ -0,0 +1,171 @@
"""Live validation and benchmark for Task A56: Context Compression.
Directly tests live Qwen3-4B-2507 compressor on port 8082:
1. Verifies /props and n_ctx = 32768.
2. Verifies exact token counting via /tokenize.
3. Tests prompt compression on large technical context with exact file paths, ports, IPs, SHAs, version numbers, metrics.
4. Validates 100% fact retention.
5. Saves results to /srv/projects/AI-Memory/01_PROJECTS/hermes-hub/compression_memory.json.
"""
import json
import os
import sys
import time
import urllib.request
from pathlib import Path
# Add src to path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "src"))
from antigravity_provider.router.context_compressor import (
COMPRESSED_BLOCK_END,
COMPRESSED_BLOCK_START,
ContextCompressor,
extract_factual_entities,
verify_facts_retention,
)
from antigravity_provider.router.local_supervisor import LocalSupervisor
from antigravity_provider.router.router_config import RouterProfileConfig
def run_live_compression_benchmark():
print("=" * 70)
print("Task A56: Live Context Compressor Verification (Port 8082)")
print("=" * 70)
# 1. Health check
try:
req = urllib.request.Request("http://127.0.0.1:8082/health")
with urllib.request.urlopen(req, timeout=3.0) as resp:
data = json.loads(resp.read().decode("utf-8"))
print(f"[OK] Compressor Health: {data}")
except Exception as e:
print(f"[ERROR] Compressor not reachable on 8082: {e}")
return False
# 2. Props check
supervisor = LocalSupervisor(base_url="http://127.0.0.1:8082")
props = supervisor.query_server_props()
print(f"[OK] Server Props: n_ctx = {props.n_ctx}, model = {props.model_name}, measured = {props.is_measured}")
# 3. Build realistic technical conversation history with diverse facts
history_messages = [
{"role": "system", "content": "You are the Antigravity senior orchestrator for Hermes Hub."},
{
"role": "user",
"content": (
"Task context initialization:\n"
"- Server host: 192.168.1.81, Web backend on port 8765 (/srv/projects/Agent projects/hermes-hub)\n"
"- Primary Coder: Qwen3-Coder-30B-A3B on port 8081 with 224K context (229376 tokens), speed 107.4 tok/s, VRAM 30008 MiB\n"
"- Compressor model: Qwen3-4B-2507 on port 8082 with 32K context (32768 tokens) running on CPU with 32 threads\n"
"- Baseline commit SHA: 26f7d2c, current release version: v0.1.2\n"
"- Central AI Memory Vault: /srv/projects/AI-Memory/01_PROJECTS/hermes-hub\n"
"- Code modules: LocalSupervisor in src/antigravity_provider/router/local_supervisor.py, DualCoderPipeline in src/antigravity_provider/router/dual_coder_pipeline.py\n"
"- Safety boundary: Context truncation margin 1024 tokens, response margin 4096 tokens"
),
},
{
"role": "assistant",
"content": (
"Understood. System parameters and hardware topography recorded:\n"
"- Host 192.168.1.81:8765\n"
"- Port 8081 (Coder: 229376 n_ctx, 107.4 tok/s, 30008 MiB)\n"
"- Port 8082 (Compressor: 32768 n_ctx, CPU ngl 0)\n"
"- SHA 26f7d2c, version v0.1.2\n"
"- Ready for workflow execution."
),
},
{
"role": "user",
"content": (
"Step 1 Execution Details:\n"
"- Modified src/antigravity_provider/router/adapters/local_adapter.py to integrate context compression\n"
"- Added role definition local-supervisor to RoleRegistry in src/antigravity_provider/router/role_registry.py\n"
"- Test suite tests/test_a56_context_compression.py executed with 10 unit tests\n"
"- Memory log written to /srv/projects/AI-Memory/01_PROJECTS/hermes-hub/local_models_memory.json\n"
"- Performance measurement: prompt speed 853.9 tok/s, generation speed 5.4 tok/s on CPU"
),
},
{
"role": "assistant",
"content": (
"Step 1 verified successfully.\n"
"- local_adapter.py updated\n"
"- role_registry.py updated\n"
"- 853.9 tok/s prompt ingestion confirmed\n"
"- Memory synced to /srv/projects/AI-Memory."
),
},
# Fresh window (last 2 messages)
{
"role": "user",
"content": "Step 2: What is the current status of all services on 192.168.1.81?",
},
{
"role": "assistant",
"content": "All services on 192.168.1.81 (ports 8081, 8082, 8765) are healthy and active.",
},
]
print("\n[INFO] Starting Context Compression on Live Server (port 8082)...")
compressor = ContextCompressor()
pconfig = RouterProfileConfig(
profile_id="live-compressor",
provider="local",
custom_base_url="http://127.0.0.1:8082/v1",
preferred_models=["default"],
)
t0 = time.time()
compressed_msgs, outcome = compressor.compress_messages_if_needed(
messages=history_messages,
target_context_limit=32768,
current_token_count=1200,
compressor_profile=pconfig,
threshold_percent=0.0, # force compression
keep_recent_messages=2,
timeout_sec=60.0,
)
total_time = time.time() - t0
print("\n" + "=" * 70)
print("LIVE COMPRESSION RESULTS")
print("=" * 70)
print(f"Status: {outcome.status}")
print(f"Status Message: {outcome.status_message}")
print(f"Tokens Before: {outcome.tokens_before}")
print(f"Tokens After: {outcome.tokens_after}")
print(f"Tokens Saved: {outcome.saved_tokens}")
print(f"Compression Ratio: {outcome.compression_ratio}x")
print(f"Duration: {outcome.duration_sec}s (Total wall time: {total_time:.2f}s)")
print(f"Model / Build: {outcome.gguf_name}")
print(f"Fact Retention: {outcome.facts_retained}/{outcome.facts_total} ({outcome.retention_percent}%)")
print("\nRetained Facts:")
for f in outcome.retained_facts:
print(f"{f}")
if outcome.missing_facts:
print("\nMissing Facts:")
for f in outcome.missing_facts:
print(f"{f}")
print("\n" + "=" * 70)
print("COMPRESSED MESSAGE PREVIEW")
print("=" * 70)
for i, msg in enumerate(compressed_msgs):
print(f"\n--- Message {i+1} [{msg.get('role')}] ---")
print(msg.get("content"))
# Verify key assertions
assert outcome.status == "SUCCESS", f"Expected SUCCESS, got {outcome.status}"
assert outcome.retention_percent == 100.0, f"Expected 100% retention, got {outcome.retention_percent}%"
assert len(compressed_msgs) == 4 # system + compressed + 2 fresh
print("\n[SUCCESS] Live Context Compression Benchmark PASSED 100%!")
return True
if __name__ == "__main__":
success = run_live_compression_benchmark()
sys.exit(0 if success else 1)

View file

@ -1,5 +1,5 @@
{
"hub_version": "0.1.1",
"hub_version": "0.1.3",
"min_hermes_version": "0.20.0",
"max_tested_hermes_version": "0.20.4",
"tested_versions": [

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

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
@ -14,11 +14,11 @@ namespace HermesHubSetup
{
public class SetupEngine
{
public const string HUB_VERSION = "0.1.1";
public const string HUB_VERSION = "0.1.3";
// Подставляется сборщиком из фактического git-коммита. Раньше здесь
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
// какого кода собран установщик.
public const string BuildCommit = "ef6a9e1";
public const string BuildCommit = "e431e39";
public const string MIN_HERMES_VERSION = "0.20.0";
public const string MAX_TESTED_HERMES = "0.20.4";
@ -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
@ -263,12 +273,64 @@ namespace HermesHubSetup
return true;
}
// Restrict cleanup to this installation. Never kill arbitrary Python/browser processes.
public static string StopWarning = "";
// Процесс мог не успеть отпустить файл. Один отказ по занятости — не приговор.
static void CopyWithRetry(string source, string destination)
{
for (int attempt = 1; ; attempt++)
{
try { File.Copy(source, destination, true); return; }
catch (IOException) { if (attempt >= 5) throw; System.Threading.Thread.Sleep(700); }
catch (UnauthorizedAccessException) { if (attempt >= 5) throw; System.Threading.Thread.Sleep(700); }
}
}
public static void StopOwnedRuntime(string home, bool includeLauncher)
{
string escaped = Path.GetFullPath(home).TrimEnd('\\').Replace("'", "''");
string script = "$ErrorActionPreference='Stop'; $root='" + escaped + "'; " +
"$py=@((Join-Path $root 'hermes-agent\\venv\\Scripts\\python.exe'),(Join-Path $root 'hermes-agent\\venv\\Scripts\\pythonw.exe')); " +
"$all=@(Get-CimInstance Win32_Process); $protected=@($PID); $cursor=$PID; " +
"while ($cursor) { $node=$all | Where-Object ProcessId -eq $cursor | Select-Object -First 1; if (!$node) { break }; $cursor=$node.ParentProcessId; if ($cursor -in $protected) { break }; $protected+= $cursor }; " +
"function Stop-HubBranch([int]$processId) { foreach ($child in @($all | Where-Object ParentProcessId -eq $processId)) { if ($child.ProcessId -notin $protected) { Stop-HubBranch $child.ProcessId } }; " +
"if (Get-Process -Id $processId -ErrorAction SilentlyContinue) { Stop-Process -Id $processId -Force -ErrorAction Stop } }; " +
"$targets=@($all | Where-Object { " +
"($_.ExecutablePath -in $py -and ($_.CommandLine -match 'hermes_hub_web_entry\\.py|antigravity_provider\\.router\\.web'))" +
" -or ($_.Name -in @('msedge.exe','chrome.exe','chromium.exe') -and $_.CommandLine -match ('--user-data-dir=[\\x22]?'+[regex]::Escape((Join-Path $root 'web_browser_profile'))+'[\\x22]?(?:\\s|$)'))" +
(includeLauncher ? " -or ($_.Name -eq 'HermesHubWeb.exe' -and ($_.ExecutablePath -eq (Join-Path $root 'HermesHubWeb.exe') -or $_.ExecutablePath -eq (Join-Path $env:LOCALAPPDATA 'Programs\\HermesHub\\HermesHubWeb.exe')))" : "") +
" }); foreach ($target in $targets) { Stop-HubBranch $target.ProcessId; " +
"if (Get-Process -Id $target.ProcessId -ErrorAction SilentlyContinue) { throw 'Не удалось остановить прежний процесс Hermes Hub' } }";
ProcessStartInfo info = new ProcessStartInfo("powershell.exe", "-NoProfile -NonInteractive -EncodedCommand " + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)));
info.UseShellExecute = false;
info.CreateNoWindow = true;
info.WindowStyle = ProcessWindowStyle.Hidden;
using (Process process = Process.Start(info))
{
if (!process.WaitForExit(20000)) { process.Kill(); throw new IOException("Остановка прежнего сервера превысила 20 секунд"); }
if (process.ExitCode != 0) throw new IOException("Не удалось остановить прежний сервер. Обновление отменено.");
}
}
public static int PerformInstall(string sourceRoot, Action<string, int> progressCallback = null)
{
if (!IsHermesFound) return 10;
try
{
// Неудачная остановка прежнего хаба не повод отменять установку.
// Раньше любой ненулевой выход скрипта останавливал всё, и владелец
// видел голый код 15. Если процесс уцелел, копирование само скажет,
// какой файл занят.
try { StopOwnedRuntime(HermesHome, true); }
catch (Exception stopEx)
{
StopWarning = stopEx.Message;
if (progressCallback != null)
progressCallback("Не удалось остановить прежний Hermes Hub: " + stopEx.Message
+ ". Продолжаю установку.", 5);
}
if (progressCallback != null) progressCallback("Preparing installation directory...", 10);
if (!Directory.Exists(TargetInstallDir))
{
@ -285,8 +347,8 @@ namespace HermesHubSetup
if (File.Exists(launcherSrc))
{
File.Copy(launcherSrc, Path.Combine(TargetInstallDir, "HermesHub.exe"), true);
File.Copy(launcherSrc, Path.Combine(HermesHome, "HermesHub.exe"), true);
CopyWithRetry(launcherSrc, Path.Combine(TargetInstallDir, "HermesHub.exe"));
CopyWithRetry(launcherSrc, Path.Combine(HermesHome, "HermesHub.exe"));
}
string webLauncherSrc = Path.Combine(sourceRoot, @"launcher\HermesHubWeb.exe");
@ -297,15 +359,15 @@ namespace HermesHubSetup
if (File.Exists(webLauncherSrc))
{
File.Copy(webLauncherSrc, Path.Combine(TargetInstallDir, "HermesHubWeb.exe"), true);
File.Copy(webLauncherSrc, Path.Combine(HermesHome, "HermesHubWeb.exe"), true);
CopyWithRetry(webLauncherSrc, Path.Combine(TargetInstallDir, "HermesHubWeb.exe"));
CopyWithRetry(webLauncherSrc, Path.Combine(HermesHome, "HermesHubWeb.exe"));
}
// Copy Setup.exe itself to target dir for uninstaller/repair
string setupSrc = Process.GetCurrentProcess().MainModule.FileName;
if (File.Exists(setupSrc))
{
try { File.Copy(setupSrc, Path.Combine(TargetInstallDir, "HermesHubSetup.exe"), true); } catch { }
try { CopyWithRetry(setupSrc, Path.Combine(TargetInstallDir, "HermesHubSetup.exe")); } catch { }
}
// 2. Install UI & System Dependencies into Hermes Python Environment
@ -364,7 +426,7 @@ namespace HermesHubSetup
string templateConfig = Path.Combine(sourceRoot, @"config\router_profiles.example.yaml");
if (!File.Exists(runtimeConfig) && File.Exists(templateConfig))
{
File.Copy(templateConfig, runtimeConfig, true);
CopyWithRetry(templateConfig, runtimeConfig);
}
// 6. Create Start Menu Shortcut
@ -501,7 +563,7 @@ namespace HermesHubSetup
string fileName = Path.GetFileName(file);
srcFiles.Add(fileName);
string destFile = Path.Combine(dst, fileName);
File.Copy(file, destFile, true);
CopyWithRetry(file, destFile);
}
// Remove destination files that do not exist in source or are .pyc
@ -543,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);
@ -589,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);
@ -1074,12 +1143,14 @@ namespace HermesHubSetup
Application.SetCompatibleTextRenderingDefault(false);
bool isSilent = false;
bool restartAfterInstall = false;
bool isUninstall = false;
bool isRepair = false;
bool purgeUserData = false;
foreach (string a in args)
{
if (a.Equals("/restart", StringComparison.OrdinalIgnoreCase)) restartAfterInstall = true;
if (a.Equals("/silent", StringComparison.OrdinalIgnoreCase) || a.Equals("/s", StringComparison.OrdinalIgnoreCase) || a.Equals("-s", StringComparison.OrdinalIgnoreCase)) isSilent = true;
if (a.Equals("/uninstall", StringComparison.OrdinalIgnoreCase) || a.Equals("/u", StringComparison.OrdinalIgnoreCase)) isUninstall = true;
if (a.Equals("/repair", StringComparison.OrdinalIgnoreCase) || a.Equals("/r", StringComparison.OrdinalIgnoreCase) || a.Equals("/reinstall", StringComparison.OrdinalIgnoreCase)) isRepair = true;
@ -1149,6 +1220,11 @@ namespace HermesHubSetup
}
int code = SetupEngine.PerformInstall(sourceRoot);
if (code == 0 && restartAfterInstall)
{
string launcher = Path.Combine(SetupEngine.TargetInstallDir, "HermesHubWeb.exe");
if (File.Exists(launcher)) Process.Start(launcher);
}
Console.WriteLine("Silent install result: " + code);
return code;
}

View file

@ -7,10 +7,31 @@
set -e
HUB_VERSION="0.1.1"
HUB_VERSION="0.1.3"
DEFAULT_HERMES_HOME="$HOME/.hermes"
HERMES_HOME="${HERMES_HOME:-$DEFAULT_HERMES_HOME}"
# Установка пользовательская: всё ложится в $HOME/.hermes и $HOME/.local/bin,
# службы не ставятся, root не нужен. Под sudo домашним каталогом становится
# /root, венв Hermes там не находится, установщик сваливается на системный
# python — а он в Ubuntu 24.04 закрыт для pip (PEP 668). В итоге установка
# уходит в /root/.hermes, где владелец её не видит, и падает на проверке.
# Молча ставить не туда нельзя, поэтому отказываемся сразу и по делу.
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] && [ -z "${HERMES_ALLOW_ROOT:-}" ]; then
echo "❌ Установщик запущен через sudo." >&2
echo "" >&2
echo " Hermes Hub ставится в домашний каталог пользователя, а под sudo" >&2
echo " это /root — туда, где ни аккаунты, ни Hermes Agent не лежат." >&2
echo "" >&2
# $0 здесь — распакованная копия во временном каталоге, называть её
# владельцу бессмысленно: этого файла через минуту не будет.
echo " Запустите тот же установщик от своего имени, без sudo." >&2
echo "" >&2
echo " Если установка в /root действительно нужна, задайте" >&2
echo " HERMES_ALLOW_ROOT=1." >&2
exit 3
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
@ -22,6 +43,17 @@ echo "Hermes Home : $HERMES_HOME"
echo "Source Root : $REPO_ROOT"
echo ""
# 0. Остановка работающего хаба.
#
# Установщик копировал файлы, но работающий сервер не трогал: он продолжал
# крутить старый код в памяти, и владелец видел прежний интерфейс при новом
# номере сборки. Три сборки подряд ставились в файлы, но не в работу.
echo "[0/6] Остановка работающего Hermes Hub..."
# shellcheck source=./lib_stop_running_hub.sh
. "$SCRIPT_DIR/lib_stop_running_hub.sh"
stop_running_hub || true
echo ""
# 1. Check / Discover Python Runtime and Hermes Environment
echo "[1/6] Checking Python and Hermes environment..."
PYTHON_BIN=""
@ -30,6 +62,8 @@ if [ -f "$HERMES_HOME/hermes-agent/venv/bin/python3" ]; then
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python3"
elif [ -f "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python"
elif [ -x "$HERMES_HOME/venv/bin/python3" ]; then
PYTHON_BIN="$HERMES_HOME/venv/bin/python3"
elif command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python3)"
elif command -v python >/dev/null 2>&1; then
@ -57,12 +91,48 @@ echo "[2/6] Verifying Python dependencies..."
DEPS_OK=true
"$PYTHON_BIN" -c "import fastapi, uvicorn, pydantic, psutil, yaml; print('DEPS_OK')" >/dev/null 2>&1 || DEPS_OK=false
HUB_DEPS="fastapi uvicorn pydantic psutil pyyaml"
if [ "$DEPS_OK" != "true" ]; then
echo " Installing required packages (fastapi, uvicorn, pydantic, psutil, pyyaml)..."
"$PYTHON_BIN" -m pip install --no-warn-script-location -q fastapi uvicorn pydantic psutil pyyaml || {
echo "⚠️ Warning: pip install returned non-zero code. Trying with --user..."
"$PYTHON_BIN" -m pip install --user --no-warn-script-location -q fastapi uvicorn pydantic psutil pyyaml || true
}
# Системный python в Debian и Ubuntu помечен как externally-managed
# (PEP 668) и отклоняет pip install — и обычный, и с --user. Обходить это
# через --break-system-packages нельзя: имя флага не преувеличивает, так
# ломают питон всей машины. Правильный ответ — собственный venv.
NEED_VENV=false
if [ -f "/usr/lib/python$PY_VER/EXTERNALLY-MANAGED" ] || [ -f "/usr/lib/python3/EXTERNALLY-MANAGED" ]; then
case "$PYTHON_BIN" in
*/venv/bin/*) : ;;
*) NEED_VENV=true ;;
esac
fi
if [ "$NEED_VENV" = "true" ]; then
echo " Системный Python защищён от изменений (PEP 668)."
echo " Создаю отдельное окружение: $HERMES_HOME/venv"
if ! "$PYTHON_BIN" -m venv "$HERMES_HOME/venv" 2>/dev/null; then
echo "❌ Не удалось создать виртуальное окружение." >&2
echo " Установите пакет python3-venv:" >&2
echo " sudo apt install python3-venv" >&2
exit 11
fi
PYTHON_BIN="$HERMES_HOME/venv/bin/python3"
echo " Using Python: $PYTHON_BIN"
fi
echo " Installing required packages ($HUB_DEPS)..."
# shellcheck disable=SC2086
if ! "$PYTHON_BIN" -m pip install --no-warn-script-location -q $HUB_DEPS; then
echo "Не удалось установить зависимости через $PYTHON_BIN." >&2
echo " Установка прервана: без них хаб не запустится." >&2
exit 12
fi
fi
# Проверяем результат, а не код возврата pip: установка «прошла», а модуля нет —
# именно так предыдущая сборка дошла до проверки и упала на ней.
if ! "$PYTHON_BIN" -c "import fastapi, uvicorn, pydantic, psutil, yaml" >/dev/null 2>&1; then
echo "❌ Зависимости не импортируются даже после установки ($PYTHON_BIN)." >&2
exit 13
fi
# 3. Mirror Plugin Files to ~/.hermes/plugins/antigravity-provider (with cleanup of stale files)
@ -153,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
@ -176,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
@ -203,5 +321,12 @@ echo "To launch the Web Application window:"
echo " $LAUNCHER_BIN"
echo ""
echo "Or open 'Hermes Hub Web' from your Applications menu."
echo ""
echo "ВАЖНО: работавший хаб был остановлен перед установкой, иначе он"
echo "продолжал бы выполнять прежний код из памяти. Запустите его заново"
echo "командой выше — только тогда новая сборка начнёт работать."
echo ""
echo "Проверить, что поднялся новый код:"
echo " curl -s -D - -o /dev/null http://127.0.0.1:5800/workspace.js | grep -i cache-control"
echo "======================================================================"
exit 0

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 "

Binary file not shown.

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Diagnostics;
using System.IO;
using System.Net;
@ -11,6 +11,8 @@ namespace HermesHub
{
public static class WebLauncher
{
private static Mutex instanceMutex;
[STAThread]
public static void Main(string[] args)
{
@ -69,9 +71,17 @@ namespace HermesHub
string targetUrl = string.Format("http://{0}:{1}/", host, port);
string healthUrl = string.Format("http://{0}:{1}/api/health", host, port);
bool firstInstance;
instanceMutex = new Mutex(true, "Local\\HermesHubWeb", out firstInstance);
if (!firstInstance) { Process.Start(targetUrl); return; }
// Adopt no unknown server: stop only a verified process from our installation.
try { StopOwnedRuntime(hermesHome, false); }
catch (Exception ex) { MessageBox.Show(ex.Message, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error); return; }
// 2. Check if server is already running and healthy
bool serverWasAlreadyRunning = IsServerHealthy(healthUrl);
Process serverProcess = null;
StringBuilder serverLog = new StringBuilder();
if (!serverWasAlreadyRunning)
{
@ -131,6 +141,14 @@ namespace HermesHub
try
{
serverProcess = Process.Start(serverPsi);
DataReceivedEventHandler collect = delegate(object sender, DataReceivedEventArgs item) {
if (item.Data == null) return;
lock (serverLog) { serverLog.AppendLine(item.Data); if (serverLog.Length > 4000) serverLog.Remove(0, serverLog.Length - 4000); }
};
serverProcess.ErrorDataReceived += collect;
serverProcess.OutputDataReceived += collect;
serverProcess.BeginErrorReadLine();
serverProcess.BeginOutputReadLine();
}
catch (Exception ex)
{
@ -149,12 +167,8 @@ namespace HermesHub
}
if (serverProcess.HasExited)
{
string why = "";
try { why = serverProcess.StandardError.ReadToEnd(); } catch { }
if (string.IsNullOrEmpty(why))
{
try { why = serverProcess.StandardOutput.ReadToEnd(); } catch { }
}
string why;
lock (serverLog) { why = serverLog.ToString(); }
if (why.Length > 1500) why = why.Substring(why.Length - 1500);
string msg = "Веб-сервер Hermes Hub завершился с ошибкой.";
if (!string.IsNullOrEmpty(why)) msg += Environment.NewLine + Environment.NewLine + why.Trim();
@ -178,7 +192,7 @@ namespace HermesHub
// 4. Locate browser in strict priority: Edge -> Chrome -> Chromium registry -> Fallback
string browserPath = FindChromiumBrowser();
Process browserProc = null;
DateTime browserStartedAt = DateTime.UtcNow;
if (!string.IsNullOrEmpty(browserPath))
{
@ -199,7 +213,7 @@ namespace HermesHub
try
{
browserProc = Process.Start(browserPsi);
browserStartedAt = DateTime.UtcNow;
}
catch (Exception ex)
{
@ -226,36 +240,119 @@ namespace HermesHub
}
}
// 5. Server Lifecycle:
// If the server was started by this launcher session and browser is tracked,
// wait for browser window to close, then gracefully terminate server process.
if (!serverWasAlreadyRunning && serverProcess != null && !serverProcess.HasExited && browserProc != null)
Application.Run(new HubContext(hermesHome, targetUrl, browserPath, browserProc));
instanceMutex.ReleaseMutex();
}
// Restrict cleanup to this installation. Never kill arbitrary Python/browser processes.
public static void StopOwnedRuntime(string home, bool includeLauncher)
{
string escaped = Path.GetFullPath(home).TrimEnd('\\').Replace("'", "''");
string script = "$ErrorActionPreference='Stop'; $root='" + escaped + "'; " +
"$py=@((Join-Path $root 'hermes-agent\\venv\\Scripts\\python.exe'),(Join-Path $root 'hermes-agent\\venv\\Scripts\\pythonw.exe')); " +
"$all=@(Get-CimInstance Win32_Process); $protected=@($PID); $cursor=$PID; " +
"while ($cursor) { $node=$all | Where-Object ProcessId -eq $cursor | Select-Object -First 1; if (!$node) { break }; $cursor=$node.ParentProcessId; if ($cursor -in $protected) { break }; $protected+= $cursor }; " +
"function Stop-HubBranch([int]$processId) { foreach ($child in @($all | Where-Object ParentProcessId -eq $processId)) { if ($child.ProcessId -notin $protected) { Stop-HubBranch $child.ProcessId } }; " +
"if (Get-Process -Id $processId -ErrorAction SilentlyContinue) { Stop-Process -Id $processId -Force -ErrorAction Stop } }; " +
"$targets=@($all | Where-Object { " +
"($_.ExecutablePath -in $py -and ($_.CommandLine -match 'hermes_hub_web_entry\\.py|antigravity_provider\\.router\\.web'))" +
" -or ($_.Name -in @('msedge.exe','chrome.exe','chromium.exe') -and $_.CommandLine -match ('--user-data-dir=[\\x22]?'+[regex]::Escape((Join-Path $root 'web_browser_profile'))+'[\\x22]?(?:\\s|$)'))" +
(includeLauncher ? " -or ($_.Name -eq 'HermesHubWeb.exe' -and ($_.ExecutablePath -eq (Join-Path $root 'HermesHubWeb.exe') -or $_.ExecutablePath -eq (Join-Path $env:LOCALAPPDATA 'Programs\\HermesHub\\HermesHubWeb.exe')))" : "") +
" }); foreach ($target in $targets) { Stop-HubBranch $target.ProcessId; " +
"if (Get-Process -Id $target.ProcessId -ErrorAction SilentlyContinue) { throw 'Не удалось остановить прежний процесс Hermes Hub' } }";
ProcessStartInfo info = new ProcessStartInfo("powershell.exe", "-NoProfile -NonInteractive -EncodedCommand " + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)));
info.UseShellExecute = false;
info.CreateNoWindow = true;
info.WindowStyle = ProcessWindowStyle.Hidden;
using (Process process = Process.Start(info))
{
try
{
browserProc.WaitForExit();
}
catch { }
// Подстраховка: если процесс браузера завершился почти сразу,
// это почти наверняка передача окна другому экземпляру, а не
// закрытие пользователем. Убивать сервер в этом случае нельзя.
if (DateTime.UtcNow - browserStartedAt < TimeSpan.FromSeconds(5))
{
return;
}
try
{
if (!serverProcess.HasExited)
{
serverProcess.Kill();
}
}
catch { }
if (!process.WaitForExit(20000)) { process.Kill(); throw new IOException("Остановка прежнего сервера превысила 20 секунд"); }
if (process.ExitCode != 0) throw new IOException("Не удалось остановить прежний сервер. Обновление отменено.");
}
}
private sealed class HubContext : ApplicationContext
{
private readonly NotifyIcon tray;
private readonly System.Windows.Forms.Timer timer;
private readonly string home, url, browserPath;
private Process browser;
private bool watching, hadWindow, closing;
public HubContext(string homePath, string targetUrl, string browserExe, Process browserProcess)
{
home = homePath; url = targetUrl; browserPath = browserExe; browser = browserProcess;
watching = browser != null;
tray = new NotifyIcon();
tray.Icon = System.Drawing.SystemIcons.Application;
tray.Text = "Hermes Hub — работает в фоне";
ContextMenuStrip menu = new ContextMenuStrip();
menu.Items.Add("Открыть", null, delegate { Open(); });
menu.Items.Add("Выход", null, delegate { ExitCompletely(); });
tray.ContextMenuStrip = menu;
tray.DoubleClick += delegate { Open(); };
tray.Visible = true;
timer = new System.Windows.Forms.Timer(); timer.Interval = 500;
timer.Tick += delegate { WatchWindow(); }; timer.Start();
}
private void WatchWindow()
{
if (!watching || closing || browser == null) return;
bool closed;
try {
browser.Refresh();
if (!browser.HasExited && browser.MainWindowHandle != IntPtr.Zero) hadWindow = true;
closed = browser.HasExited || (hadWindow && browser.MainWindowHandle == IntPtr.Zero);
} catch { closed = true; }
if (!closed) return;
watching = false;
DialogResult answer = MessageBox.Show("Закрыть Hermes Hub полностью?\n\nДа — остановить сервер и фоновые опросы.\nНет — оставить в фоне (значок в области уведомлений).", "Hermes Hub", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
if (answer == DialogResult.Yes) ExitCompletely();
}
private void Open()
{
if (closing) return;
try {
if (browser != null && !browser.HasExited && browser.MainWindowHandle != IntPtr.Zero) {
ShowWindow(browser.MainWindowHandle, 9); SetForegroundWindow(browser.MainWindowHandle); return;
}
if (string.IsNullOrEmpty(browserPath)) { Process.Start(url); return; }
ProcessStartInfo info = new ProcessStartInfo(browserPath,
"--app=\"" + url + "\" --window-size=1400,900 --user-data-dir=\"" + Path.Combine(home, "web_browser_profile") + "\" --no-first-run --no-default-browser-check");
info.UseShellExecute = false;
browser = Process.Start(info); watching = true; hadWindow = false;
} catch (Exception ex) { MessageBox.Show(ex.Message, "Hermes Hub"); }
}
private void ExitCompletely()
{
if (closing) return;
closing = true; timer.Stop();
try {
if (browser != null && !browser.HasExited) {
ProcessStartInfo kill = new ProcessStartInfo("taskkill.exe", "/PID " + browser.Id + " /T /F");
kill.UseShellExecute = false; kill.CreateNoWindow = true;
using (Process process = Process.Start(kill)) { process.WaitForExit(5000); }
}
// Неудачная остановка не повод отменять выход: владелец нажал
// «закрыть», и программа обязана закрыться. Прежде окно с
// ошибкой возвращало его обратно в работающее приложение.
try { StopOwnedRuntime(home, false); }
catch (Exception stopEx) {
MessageBox.Show("Часть процессов остановить не удалось: " + stopEx.Message + " Hermes Hub закроется; при необходимости снимите их в диспетчере задач.", "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
tray.Visible = false; tray.Dispose(); timer.Dispose(); ExitThread();
} catch (Exception ex) {
closing = false; timer.Start();
MessageBox.Show("Не удалось завершить всё: " + ex.Message, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
[System.Runtime.InteropServices.DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr handle);
[System.Runtime.InteropServices.DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr handle, int command);
}
public static bool IsServerHealthy(string url)
{
try

Binary file not shown.

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

@ -16,6 +16,11 @@ if [ -f "$HERMES_HOME/hermes-agent/venv/bin/python3" ]; then
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python3"
elif [ -f "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python"
elif [ -x "$HERMES_HOME/venv/bin/python3" ]; then
# Окружение, созданное установщиком, когда системный python закрыт
# правилом PEP 668. Без этой ветки запуск уходил бы на /usr/bin/python3,
# где зависимостей нет и быть не может.
PYTHON_BIN="$HERMES_HOME/venv/bin/python3"
elif command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python3)"
elif command -v python >/dev/null 2>&1; then

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hermes-hub"
version = "0.1.1"
version = "0.1.3"
description = "Multi-Agent & Multi-Provider Control Hub for Hermes Agent"
readme = "README.md"
license = { text = "MIT" }

View file

@ -50,6 +50,38 @@ if ([string]::IsNullOrWhiteSpace($TargetDir)) {
Write-Host "[2/6] Preparing installation target: $TargetDir" -ForegroundColor Yellow
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
# Stop only processes owned by this installation before replacing files.
# Preserve our own ancestor branch: an updater may have launched this installer.
$hubProcesses = @(Get-CimInstance Win32_Process)
$hubProtected = @($PID)
$hubCursor = $PID
while ($hubCursor) {
$hubNode = $hubProcesses | Where-Object ProcessId -eq $hubCursor | Select-Object -First 1
if (-not $hubNode) { break }
$hubCursor = $hubNode.ParentProcessId
if ($hubCursor -in $hubProtected) { break }
$hubProtected += $hubCursor
}
function Stop-HubBranch([int]$ProcessId) {
foreach ($child in @($hubProcesses | Where-Object ParentProcessId -eq $ProcessId)) {
if ($child.ProcessId -notin $hubProtected) { Stop-HubBranch $child.ProcessId }
}
if (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) {
Stop-Process -Id $ProcessId -Force -ErrorAction Stop
}
}
$hubPythonPaths = @($HermesPython, (Join-Path $HermesHome 'hermes-agent\venv\Scripts\pythonw.exe'))
$hubLauncherPaths = @((Join-Path $HermesHome 'HermesHubWeb.exe'), (Join-Path $TargetDir 'HermesHubWeb.exe'))
$hubBrowserPattern = '--user-data-dir="?' + [regex]::Escape((Join-Path $HermesHome 'web_browser_profile')) + '"?(?:\s|$)'
foreach ($hubProcess in $hubProcesses) {
if (($hubProcess.ExecutablePath -in $hubPythonPaths -and $hubProcess.CommandLine -match 'hermes_hub_web_entry\.py|antigravity_provider\.router\.web') -or
($hubProcess.ExecutablePath -in $hubLauncherPaths) -or
($hubProcess.Name -in @('msedge.exe','chrome.exe','chromium.exe') -and $hubProcess.CommandLine -match $hubBrowserPattern)) {
Stop-HubBranch $hubProcess.ProcessId
if (Get-Process -Id $hubProcess.ProcessId -ErrorAction SilentlyContinue) { throw 'Old Hermes Hub process survived. Installation cancelled.' }
}
}
# 4. Copy Application Files to TargetDir
$RepoRoot = Split-Path -Parent $PSScriptRoot
Write-Host "[3/6] Deploying application binaries..." -ForegroundColor Yellow

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:
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."
)
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"))
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__":
run_release_gate()
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,
@ -138,10 +145,21 @@ def run_checks() -> int:
# 7. Antigravity profile isolation
print("7. Checking Antigravity profile environment directory isolation...")
pdir = get_profile_env_dir("ag-w2")
assert pdir.exists()
assert "ag-w2" in str(pdir)
print(f" [PASS] Profile directory isolated at {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} (не создан)")
passed += 1
# 8. Error classification

File diff suppressed because it is too large Load diff

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

@ -111,9 +111,21 @@ def get_compatibility_path() -> Path:
return get_config_dir() / "compatibility.json"
def get_profile_dir(profile_id: str, provider: Optional[str] = None) -> Path:
"""Return isolated storage directory for a profile.
def get_profile_dir(
profile_id: str, provider: Optional[str] = None, create: bool = False
) -> Path:
"""Вернуть каталог профиля. По умолчанию НИЧЕГО не создаёт.
Раньше эта функция создавала каталог при каждом обращении. В коде зашит
список «стандартных» слотов ag-orch-primary, ag-orch-fallback, ag-1..ag-20,
ag-w1..ag-w10, и любой обход этого списка материализовал их на диске. У
владельца накопилось 25 каталогов, из которых он заводил единицы: остальные
появились сами, просто потому что кто-то спросил путь.
Спрашивать, где профиль жил бы, и создавать его разные действия. Создание
теперь запрашивается явно через create=True: так делают те, кто
действительно заводит профиль.
Accepts both (profile_id) and (provider, profile_id) or (profile_id, provider) gracefully.
"""
# If first argument looks like a provider or swapped, resolve cleanly
@ -151,7 +163,8 @@ def get_profile_dir(profile_id: str, provider: Optional[str] = None) -> Path:
folder_prefix = f"{prov_lower}_profiles"
d = get_hermes_home() / folder_prefix / p_id
d.mkdir(parents=True, exist_ok=True)
if create:
d.mkdir(parents=True, exist_ok=True)
return d

View file

@ -14,6 +14,11 @@ class AccountProbeService:
def __init__(self) -> None:
self.enabled = False
self._next_check = 0.0
self.last_tick = None
self.error = None
self._profile_locks = {}
self._closed = False
self._cloud_next = 0.0
self._states: dict[str, dict[str, Any]] = {}
self._lock = threading.Lock()
self._pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="account-probe")
@ -29,66 +34,134 @@ class AccountProbeService:
with self._lock:
return dict(self._states.get(profile_id, {"state": "never_checked"}))
def schedule(self, provider: str, profile_id: str, *, force: bool = False) -> bool:
if not self.enabled:
return False
def _profile_lock(self, profile_id: str):
with self._lock:
return self._profile_locks.setdefault(profile_id, threading.Lock())
def _mark_checking(self, provider, profile_id):
with self._lock:
current = self._states.get(profile_id, {})
if current.get("state") == "checking":
return False
if not force and current.get("checked_at") and time.time() - current["checked_at"] < 30:
return False
self._states[profile_id] = {
**current, "state": "checking", "provider": provider,
"started_at": time.time(), "message": "Идёт опрос провайдера — это может занять до минуты",
**self._states.get(profile_id, {}), "state": "checking",
"provider": provider, "started_at": time.time(),
"message": "Идёт запрос к провайдеру",
}
self._pool.submit(self._run, provider, profile_id)
def schedule(self, provider: str, profile_id: str, *, force: bool = False) -> bool:
if not self.enabled or self._closed:
return False
lock = self._profile_lock(profile_id)
if not lock.acquire(blocking=False):
return False
current = self.state(profile_id)
if not force and time.time() - current.get("checked_at", 0) < 30:
lock.release()
return False
self._mark_checking(provider, profile_id)
try:
future = self._pool.submit(self._run, provider, profile_id)
def cancelled(done):
if done.cancelled():
with self._lock:
self._states[profile_id] = {"state": "failed", "message": "Проверка отменена при завершении сервера"}
lock.release()
future.add_done_callback(cancelled)
except Exception:
with self._lock:
self._states[profile_id] = current
lock.release()
raise
return True
def check_now(self, provider: str, profile_id: str, models_only: bool = False) -> dict:
# Periodic scheduling can be disabled without disabling a manual request.
if self._closed:
return {"ok": False, "message": "Сервер завершает работу"}
lock = self._profile_lock(profile_id)
if not lock.acquire(timeout=90):
return {"ok": False, "message": "Проверка этого аккаунта ещё выполняется; повторите позже"}
try:
if self._closed:
return {"ok": False, "message": "Сервер завершает работу"}
self._mark_checking(provider, profile_id)
return self._probe(provider, profile_id, models_only)
finally:
lock.release()
def record_validation(self, provider: str, profile_id: str, result: dict) -> None:
with self._lock:
self._states[profile_id] = {
"state": "working", "provider": provider, "checked_at": time.time(),
"message": result["message"], "models": result["data"]["models"],
"check_kind": "credentials_and_catalog",
}
def status(self) -> dict:
return {"enabled": self.enabled, "last_tick": self.last_tick, "error": self.error}
def tick(self, now: Optional[float] = None) -> int:
from .settings_service import get_hub_settings
now = time.monotonic() if now is None else now
if not self.enabled or now < self._next_check:
return 0
self._next_check = now + get_hub_settings()["account_check_interval_seconds"]
return self.schedule_all(force=True)
try:
count = self.schedule_all(force=True)
self._next_check = now + get_hub_settings()["account_check_interval_seconds"]
self.last_tick, self.error = time.time(), None
return count
except Exception as exc:
self.error = str(exc).strip() or type(exc).__name__
return 0
def shutdown(self) -> None:
self.enabled, self._closed = False, True
self._pool.shutdown(wait=False, cancel_futures=True)
def schedule_all(self, *, force: bool = False) -> int:
from .profile_manager import ProfileAuthManager
from .router_config import load_router_config
count = 0
has_ollama = False
for pid, pcfg in load_router_config().profiles.items():
if pcfg.enabled and ProfileAuthManager.get_profile_status(pcfg.provider, pid).get("authenticated"):
count += int(self.schedule(pcfg.provider, pid, force=force))
has_ollama |= pcfg.provider == "ollama"
if self.enabled and has_ollama and time.monotonic() >= self._cloud_next:
from .model_discovery_service import ModelDiscoveryService
self._cloud_next = time.monotonic() + 3600
self._pool.submit(ModelDiscoveryService.get().discover_ollama_cloud)
return count
def _run(self, provider: str, profile_id: str) -> None:
try:
self._probe(provider, profile_id)
finally:
self._profile_lock(profile_id).release()
def _probe(self, provider: str, profile_id: str, models_only: bool = False) -> dict:
from .action_handler import do_test_profile
from .model_discovery_service import ModelDiscoveryService
models, meta = None, {}
try:
from .state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True)
models = ModelDiscoveryService.get().discover_models_sync(provider, timeout=20, profile_id=profile_id)
HubStateStore.get().refresh(force_scan=True)
if provider == "ollama":
cloud = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog")
if cloud.get("is_stale"):
ModelDiscoveryService.get().discover_ollama_cloud()
result = do_test_profile(provider, profile_id, timeout=60, discovered_models=models)
meta = ModelDiscoveryService.get().get_models_with_metadata(provider, profile_id)
success = bool(result.get("success"))
message = result.get("response") or result.get("error") or "Проверка завершена без пояснения"
discovery = ModelDiscoveryService.get()
models = discovery.discover_models_sync(provider, timeout=65 if provider == "antigravity" else 20, profile_id=profile_id)
meta = discovery.get_models_with_metadata(provider, profile_id)
if models is None and meta.get("error"):
success, message = False, meta["error"]
elif models_only:
success = models is not None and not meta.get("error")
message = meta.get("error") or (f"Получено моделей: {len(models)}" if success else "Провайдер не вернул каталог моделей")
else:
result = do_test_profile(provider, profile_id, timeout=60, discovered_models=models)
success = bool(result.get("success"))
message = result.get("response") or result.get("error") or "Провайдер не сообщил причину результата проверки"
state = "working" if success else "failed"
except Exception as exc:
models, meta, state, message = None, {}, "failed", str(exc)
success, state, message = False, "failed", str(exc).strip() or type(exc).__name__
record = {
"state": state, "provider": provider, "checked_at": time.time(),
"message": message, "models": models, "model_error": meta.get("error"),
"models_discovered_at": meta.get("discovered_at"), "models_only": models_only,
}
with self._lock:
self._states[profile_id] = {
"state": state, "provider": provider, "checked_at": time.time(),
"message": message, "models": models, "model_error": meta.get("error"),
"models_discovered_at": meta.get("discovered_at"),
}
try:
from .state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True)
except Exception:
pass
self._states[profile_id] = record
return {"ok": success, "message": message, "data": record}

View file

@ -5,6 +5,7 @@ import json
import os
import logging
import threading
from dataclasses import asdict
from datetime import datetime, timezone
from typing import Any, Dict, Tuple, Optional, Callable, List
@ -18,6 +19,8 @@ from antigravity_provider import paths
from antigravity_provider.router.adapters import get_adapter
logger = logging.getLogger('hermes.router.actions')
_test_locks_guard = threading.Lock()
_test_locks: dict[str, Any] = {}
def do_set_main(provider: str, profile_id: str) -> Tuple[bool, str]:
ok, msg = ProfileAuthManager.set_main_profile(provider, profile_id)
@ -49,13 +52,16 @@ def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, disco
return {'success': False, 'error': f"Профиль '{profile_id}' не найден"}
status = ProfileAuthManager.get_profile_status(pcfg.provider, profile_id)
if not status.get('authenticated'):
return {'success': False, 'error': 'Аккаунт не добавлен. Сначала выполните подключение.'}
if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED':
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'}
model = pcfg.preferred_models[0] if pcfg.preferred_models else (discovered_models or ['default'])[0]
if not status.get('authenticated'):
return {'success': False, 'error': 'Аккаунт не добавлен. Сначала выполните подключение.'}
candidates = discovered_models if discovered_models is not None else pcfg.preferred_models
if not candidates:
return {'success': False, 'error': 'Сервер отвечает, но доступных моделей для тестового запроса нет' if discovered_models == [] else 'Каталог моделей не получен; сначала запросите список моделей'}
model = next((model for model in pcfg.preferred_models if model in candidates), candidates[0])
t0 = time.time()
try:
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
@ -73,14 +79,25 @@ def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, disco
result_container = []
error_container = []
with _test_locks_guard:
invoke_lock = _test_locks.setdefault(profile_id, threading.Lock())
if not invoke_lock.acquire(blocking=False):
return {'success': False, 'error': 'Предыдущий запрос этого аккаунта ещё не завершился'}
def _call_invoke():
try:
result_container.append(adapter.invoke(pcfg, req))
except Exception as e:
error_container.append(e)
finally:
invoke_lock.release()
t = threading.Thread(target=_call_invoke, daemon=True)
t.start()
try:
t.start()
except Exception:
invoke_lock.release()
raise
t.join(timeout=timeout)
el = round(time.time() - t0, 2)
@ -110,7 +127,7 @@ def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, disco
}
except Exception as e:
EventLogService.get().log('system', f'Сбой проверки {profile_id} ({model}): {e}', level='error')
return {'success': False, 'model': model, 'duration_sec': round(time.time() - t0, 2), 'error': str(e)}
return {'success': False, 'model': model, 'duration_sec': round(time.time() - t0, 2), 'error': str(e).strip() or type(e).__name__}
def do_delete_credentials(provider: str, profile_id: str, actor: str = "system") -> Tuple[bool, str]:
# Сигнатура get_profile_dir — (profile_id, provider), а здесь её звали
@ -125,6 +142,10 @@ def do_delete_credentials(provider: str, profile_id: str, actor: str = "system")
if auth_p.is_file():
try:
auth_p.unlink()
from .state_store import HubStateStore
from antigravity_provider.router.quota_collector import AccountQuotaService
AccountQuotaService.get().forget_profile(provider, profile_id)
HubStateStore.get().apply_delta_account_removed(provider, profile_id)
EventLogService.get().log(
'account',
f'Учетные данные для {profile_id} удалены.',
@ -281,11 +302,8 @@ def do_set_model(profile_id: str, model: str, role_id: Optional[str] = None) ->
updated.roles[role_id].default_model = model
if save_router_config(updated):
try:
from antigravity_provider.router.state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True)
except Exception:
pass
from .state_store import HubStateStore
HubStateStore.get().apply_delta_profile_preferences(profile_id, target.preferred_models)
EventLogService.get().log(
"model", f"Для профиля {profile_id} ({provider}) установлена модель '{model}'.", level="info"
)
@ -341,15 +359,19 @@ def do_save_request_options(profile_id: str, request_options: Any) -> Tuple[bool
# Функция объявлена на уровне модуля намеренно: как вложенная она была видна
# не всем точкам завершения входа, и device-flow получал NameError внутри
# обработки успеха.
def _rescan_after_auth() -> None:
try:
from antigravity_provider.router.state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True)
from .account_probe_service import AccountProbeService
AccountProbeService.get().schedule_all()
except Exception as exc: # пересбор не должен ронять сам вход
logger.warning("Не удалось пересобрать снапшот после входа: %s", exc)
def _rescan_after_auth(provider=None, profile_id=None) -> None:
def refresh():
try:
from .state_store import HubStateStore
from .account_probe_service import AccountProbeService
if provider and profile_id:
HubStateStore.get().apply_delta_account_added(provider, profile_id)
else:
HubStateStore.get().refresh(force_scan=True)
AccountProbeService.get().schedule_all()
except Exception as exc:
logger.warning("Не удалось обновить состояние после входа: %s", exc)
threading.Thread(target=refresh, name="auth-state-refresh", daemon=True).start()
def generate_quotas_export(format: str = "json") -> Any:
@ -686,6 +708,54 @@ class ActionExecutor:
return {'ok': False, 'message': reason, 'data': {'status': status}}
return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}}
if action == 'probe_account_models':
# Определение доступных аккаунту моделей. Запускается только по
# явному действию владельца: каталог NVIDIA публичный и о правах
# аккаунта ничего не сообщает, поэтому доступность выясняется
# опросом, а он тратит вызовы и упирается в ограничения частоты.
from .model_entitlements import probe_account_models, load_entitlements
from .model_discovery_service import ModelDiscoveryService
pid = data.get('profile_id') or ''
prov_norm = (prov or data.get('provider') or '').strip().lower()
if not pid or not prov_norm:
return {'ok': False, 'message': 'Не указан профиль или провайдер'}
if data.get('cached_only'):
cached = load_entitlements(prov_norm, pid)
if not cached:
return {'ok': True, 'message': 'Н/Д: доступность моделей ещё не определялась', 'data': {}}
return {'ok': True, 'message': 'Сохранённый результат', 'data': cached}
auth = ProfileAuthManager.load_profile_auth(prov_norm, pid) or {}
token = (auth.get('api_key') or auth.get('token') or '').strip()
if not token:
return {'ok': False, 'message': f'У профиля {pid} нет сохранённого API-ключа'}
pcfg = load_router_config().get_profile(pid)
base_url = (getattr(pcfg, 'custom_base_url', None) or auth.get('base_url') or '').strip()
if not base_url:
base_url = 'https://integrate.api.nvidia.com/v1' if prov_norm.startswith('nvidia') else ''
if not base_url:
return {'ok': False, 'message': f'Не известен адрес провайдера для {pid}'}
models = ModelDiscoveryService.get().get_models(prov_norm) or []
if not models:
return {'ok': False, 'message': 'Каталог моделей ещё не получен — сначала запросите список'}
res = probe_account_models(prov_norm, pid, token, base_url, models)
d = res.to_dict()
msg = (
f"Доступно {len(res.available)} из {d['total']}; "
f"не выдано {len(res.unavailable)}; "
f"не определено {len(res.undetermined)}"
)
return {'ok': True, 'message': msg, 'data': d}
if action == 'validate_connection':
from .connection_preflight import validate_connection
return validate_connection(prov, data.get('token') or data.get('api_key') or '', data.get('base_url') or '', data.get('preferred_model') or '')
# Подключение аккаунта: сохранение профиля и учетных данных (P0-1)
if action == 'add_account':
prov_norm = (prov or data.get('provider') or '').strip().lower()
@ -712,6 +782,32 @@ class ActionExecutor:
token = (data.get('token') or data.get('api_key') or '').strip()
slot = data.get('profile_id')
if slot:
valid, reason = AutoAssigner.validate_slot(prov_norm, slot)
if not valid:
return {'ok': False, 'message': reason}
# Поддержку провайдера проверяем ДО требования ключа: у чужого
# провайдера ключ не поможет, и сообщение «не указан API-ключ»
# уводит владельца не туда.
_SUPPORTED_DIRECT_ADD = (
'openrouter', 'nvidia', 'claude', 'anthropic', 'opencode-go', 'opencode',
'openai-codex', 'codex', 'grok', 'xai', 'antigravity', 'google-antigravity',
'local', 'local-llm', 'llama.cpp', 'ollama', 'vllm',
)
if prov_norm not in _SUPPORTED_DIRECT_ADD:
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
existing_status = ProfileAuthManager.get_profile_status(prov_norm, slot) if slot else {}
if not token and prov_norm not in ('local', 'vllm', 'ollama') and not existing_status.get('authenticated'):
if prov_norm in ('antigravity', 'google-antigravity', 'claude', 'anthropic'):
return {'ok': False, 'message': 'Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата.'}
return {'ok': False, 'message': 'Не указан API-ключ или не завершена авторизация'}
validation = None
if token or prov_norm in ('local', 'vllm', 'ollama'):
from .connection_preflight import validate_connection
validation = validate_connection(prov_norm, token, base_url, data.get('preferred_model') or '')
if not validation['ok']:
return validation
base_url = validation['data']['base_url']
slot = slot or AutoAssigner.find_free_slot(prov_norm) or f'{prov_norm}-1'
status = ProfileAuthManager.get_profile_status(prov_norm, slot)
@ -741,7 +837,7 @@ class ActionExecutor:
return {'ok': False, 'message': 'Не указан API-ключ для NVIDIA NIM'}
elif prov_norm in ('claude', 'anthropic'):
if not token:
return {'ok': False, 'message': 'Не указан API-ключ для Claude'}
return {'ok': False, 'message': 'Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата.'}
elif prov_norm in ('opencode-go', 'opencode'):
if not token:
return {'ok': False, 'message': 'Не указан API-ключ для OpenCode Go'}
@ -753,7 +849,7 @@ class ActionExecutor:
return {'ok': False, 'message': f'Не указан API-ключ для {prov_norm}'}
elif prov_norm in ('antigravity', 'google-antigravity'):
if not token and not is_authenticated:
return {'ok': False, 'message': 'Не выполнена авторизация для Antigravity'}
return {'ok': False, 'message': 'Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата.'}
else:
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
@ -788,12 +884,46 @@ class ActionExecutor:
# Повторный вызов здесь обращался к auth_data, которой у уже
# авторизованного аккаунта не существует: перевод аккаунта в
# другую роль падал с ошибкой, хотя ключ вводить не требуется.
# Слот мог принадлежать другому аккаунту: старое опознание
# обязано уйти вместе с прежними учётными данными.
from antigravity_provider.router.quota_collector import AccountQuotaService
AccountQuotaService.get().forget_profile(prov_norm, slot)
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
_rescan_after_auth()
if validation:
from .model_discovery_service import ModelDiscoveryService
ModelDiscoveryService.get().remember_models(prov_norm, slot, validation['data']['models'])
if data.get('preferred_model'):
ok, message = do_set_model(slot, data['preferred_model'])
if not ok:
return {'ok': False, 'message': message}
_rescan_after_auth(prov_norm, slot)
from antigravity_provider.router.account_probe_service import AccountProbeService
AccountProbeService.get().schedule(prov_norm, slot, force=True)
check_note = 'проверка запускается в фоне' if AccountProbeService.get().enabled else 'проверка Н/Д: фоновая служба не запущена'
return {'ok': True, 'message': f'Аккаунт {prov_norm} ({slot}) сохранён; {check_note}', 'data': {'profile_id': slot}}
if validation:
AccountProbeService.get().record_validation(prov_norm, slot, validation)
return {'ok': True, 'message': validation['message'], 'data': {'profile_id': slot, 'models': validation['data']['models']}}
# Проверку у провайдера не ждём в самом действии. Для
# Antigravity она идёт через CLI и в худшем случае занимает до
# 90 с на захват замка, до 65 на каталог моделей и до 90 на
# пробный вызов — около четырёх минут молчания при обещанной
# «минуте на этап». Владелец видит это как зависший мастер.
#
# Провайдеры с ключом сюда не попадают: их подключение уже
# проверено предварительной проверкой выше и возвращается
# сразу, как того требует A54.
probe = AccountProbeService.get()
started = probe.schedule(prov_norm, slot, force=True)
if started or probe.state(slot).get('state') == 'checking':
return {
'ok': True,
'message': 'Аккаунт подключён. Проверка у провайдера идёт в фоне, '
'результат появится в карточке.',
'data': {'profile_id': slot, 'check': 'running'},
}
# Фоновая служба не работает — проверяем здесь, иначе результата
# не будет вовсе. Ручная проверка обязана работать и без неё.
result = probe.check_now(prov_norm, slot)
result.setdefault('data', {})['profile_id'] = slot
return result
except Exception as e:
return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'}
@ -801,7 +931,52 @@ class ActionExecutor:
# A25 внёс их в этот список, но в A24 они выполняют настоящую работу —
# сохранение цепочки и назначение роли — обработчики ниже. Проглотив их
# здесь, мы бы молча сломали перестановку блоков в маршрутизации.
# ── Вход по localhost-redirect (Antigravity) ──────────────────
# ── Вход через CLI в терминале (Antigravity native login, A57) ──
if action in ('start_native_auth', 'start_native_agy_login', 'start_terminal_auth'):
provider = (data.get('provider') or '').strip().lower()
if provider in ('google-antigravity', 'agy'):
provider = 'antigravity'
if provider != 'antigravity':
return {'ok': False, 'message': f'Провайдер {provider} не поддерживает вход через agy CLI'}
slot = data.get('profile_id')
force = bool(data.get('force') or data.get('confirmed') or data.get('overwrite'))
from antigravity_provider.agy_subprocess import start_native_agy_login
ok, msg, res_data = start_native_agy_login(profile_id=slot, force=force)
return {'ok': ok, 'message': msg, 'data': res_data}
if action in ('poll_native_auth', 'poll_native_agy_login', 'poll_terminal_auth'):
session_id = data.get('session_id') or ''
from antigravity_provider.agy_subprocess import poll_native_agy_login
ok, msg, res_data = poll_native_agy_login(session_id)
if ok and res_data.get('status') == 'completed':
slot = res_data.get('profile_id')
# Вход через терминал завершается своим путём и мимо add_account.
# Учётные данные при этом на диске, профиль числится
# подключённым — но записи о нём в конфигурации маршрутизатора
# нет, а список аккаунтов строится по ней. Владелец входил
# успешно и видел пустой экран.
if slot:
def_ok, def_msg = AutoAssigner.ensure_profile_definition('antigravity', slot)
if not def_ok:
return {
'ok': False,
'message': f'Вход выполнен, но аккаунт не зарегистрирован: {def_msg}',
'data': res_data,
}
_rescan_after_auth('antigravity', slot)
return {'ok': ok, 'message': msg, 'data': res_data}
if action in ('cancel_native_auth', 'cancel_native_agy_login', 'cancel_terminal_auth'):
session_id = data.get('session_id') or ''
from antigravity_provider.agy_subprocess import cancel_native_agy_login
ok, msg = cancel_native_agy_login(session_id)
return {'ok': ok, 'message': msg}
# ── Вход по localhost-redirect (Antigravity / Claude запасной путь) ──
# Раньше веб-мастер писал «авторизация через веб-интерфейс
# невозможна» и отправлял в консоль по SSH. Это неверно:
# ProfileOAuthSession.handle_manual_callback_url принимает адрес
@ -942,6 +1117,29 @@ class ActionExecutor:
res = do_test_profile(prov, pid)
return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res}
elif action == 'clear_accounts':
from .profile_manager import get_profile_auth_path
protected_root = (paths.get_hermes_home() / 'agy_profiles').resolve()
targets, protected = [], []
for profile_id, profile in load_router_config().profiles.items():
auth_path = get_profile_auth_path(profile.provider, profile_id)
if profile.provider in ('antigravity', 'google-antigravity', 'agy') or auth_path.is_symlink() or auth_path.resolve().is_relative_to(protected_root):
protected.append(profile_id)
elif auth_path.is_file():
targets.append({'provider': profile.provider, 'profile_id': profile_id})
preview = {'targets': targets, 'protected': protected}
if not data.get('confirmed'):
return {'ok': True, 'message': f'Будут удалены ключи {len(targets)} аккаунтов. Antigravity исключён из очистки.', 'data': preview}
# Require the exact displayed list. A newly added account is never silently deleted.
if data.get('targets') != targets:
return {'ok': False, 'message': 'Список аккаунтов изменился. Повторите предварительный просмотр.', 'data': preview}
errors = []
for target in targets:
ok, message = do_delete_credentials(target['provider'], target['profile_id'], actor=actor)
if not ok:
errors.append(message)
return {'ok': not errors, 'message': '; '.join(errors) if errors else f'Удалены ключи {len(targets)} аккаунтов. Antigravity сохранён.', 'data': preview}
elif action == 'delete_credentials':
dry_run = bool(data.get('dry_run', False))
confirmed = bool(data.get('confirmed', True))
@ -1042,13 +1240,10 @@ class ActionExecutor:
elif action == 'check_account':
from antigravity_provider.router.account_probe_service import AccountProbeService
if not AccountProbeService.get().enabled:
return {"ok": False, "message": "Фоновая служба проверки не запущена. Перезапустите веб-сервер."}
valid, reason = AutoAssigner.validate_slot(prov, pid)
if not valid:
return {'ok': False, 'message': reason}
started = AccountProbeService.get().schedule(prov, pid, force=True)
return {'ok': True, 'message': 'Проверка запущена' if started else 'Проверка уже выполняется'}
return {'ok': False, 'message': reason or 'Неверный профиль'}
return AccountProbeService.get().check_now(prov, pid)
elif action == 'check_all_accounts':
from antigravity_provider.router.account_probe_service import AccountProbeService
@ -1074,6 +1269,12 @@ class ActionExecutor:
return {'ok': True, 'message': 'Успешно'}
elif action == 'refresh_models':
if pid:
from .account_probe_service import AccountProbeService
valid, reason = AutoAssigner.validate_slot(prov, pid)
if not valid:
return {'ok': False, 'message': reason or 'Неверный профиль'}
return AccountProbeService.get().check_now(prov, pid, models_only=True)
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
service = ModelDiscoveryService.get()
if prov:
@ -1163,6 +1364,22 @@ class ActionExecutor:
status = mgr.get_status_dict()
return {'ok': True, 'message': status.get('message') or 'Статус получен', 'data': status}
elif action == 'get_update_progress':
mgr = UpdateManager()
progress = mgr.get_progress_dict()
return {'ok': True, 'message': progress.get('message') or 'Ход обновления', 'data': progress}
elif action == 'cancel_update':
# Отмена принимается не всегда: после начала установки отменять уже
# нечего. Отвечаем тем, что произошло на самом деле, иначе владелец
# видит «отменено» поверх продолжающейся установки.
mgr = UpdateManager()
progress = mgr.cancel_download()
if progress.get('cancel_accepted'):
return {'ok': True, 'message': 'Загрузка обновления отменена', 'data': progress}
reason = progress.get('cancel_refused_reason') or 'Отмена сейчас невозможна'
return {'ok': False, 'message': reason, 'data': progress}
elif action == 'run_preflight':
from antigravity_provider.router.preflight_service import PreflightCheckService
service = PreflightCheckService.get()
@ -1244,5 +1461,141 @@ class ActionExecutor:
res = setup_memory_structure(vault_path=v_path, project_name=p_name)
return {'ok': res.get('ok', False), 'message': res.get('message', ''), 'data': res}
elif action == 'get_compression_status':
from antigravity_provider.router.settings_service import get_hub_settings
from antigravity_provider.router.local_supervisor import LocalSupervisor
hub_settings = get_hub_settings()
c_pid = hub_settings.get('compressor_profile_id')
c_pcfg = None
if c_pid:
try:
c_pcfg = load_router_config().get_profile(c_pid)
except Exception:
pass
supervisor = LocalSupervisor()
status_data = supervisor.get_compression_status(c_pcfg)
status_data['threshold_percent'] = hub_settings.get('compression_threshold_percent', 75.0)
status_data['keep_recent_messages'] = hub_settings.get('compression_keep_recent_messages', 3)
status_data['compression_enabled'] = hub_settings.get('compression_enabled', True)
return {'ok': True, 'message': 'Статус сжатия получен', 'data': status_data}
elif action == 'get_compression_history':
from antigravity_provider.router.context_compressor import ContextCompressor
history = ContextCompressor().get_compression_history(limit=int(data.get('limit', 20)))
return {'ok': True, 'message': f'Получено записей: {len(history)}', 'data': {'history': history}}
elif action == 'test_compression':
from antigravity_provider.router.settings_service import get_hub_settings
from antigravity_provider.router.local_supervisor import LocalSupervisor
hub_settings = get_hub_settings()
c_pid = data.get('profile_id') or hub_settings.get('compressor_profile_id')
c_pcfg = None
if c_pid and c_pid != 'none':
try:
c_pcfg = load_router_config().get_profile(c_pid)
except Exception:
pass
test_messages = [
{"role": "system", "content": "You are a software engineer."},
{"role": "user", "content": "Hermes Hub server runs on 192.168.1.81:8765. The primary coder is on port 8081 with 224K context (229376 tokens), generating at 107.4 tok/s. VRAM usage: 30008 MiB. Active branch: antigravity/a56-context-compression, Commit SHA: 26f7d2c, version v0.1.2. Local compressor is on port 8082."},
{"role": "assistant", "content": "Acknowledged. All server metrics and ports are noted."},
{"role": "user", "content": "Now run preflight diagnostics for LocalSupervisor and ContextCompressor in src/antigravity_provider/router/local_supervisor.py."},
{"role": "assistant", "content": "Diagnostics completed successfully. Memory vault is at /srv/projects/AI-Memory."},
{"role": "user", "content": "What is our current task?"},
]
supervisor = LocalSupervisor()
new_msgs, outcome = supervisor.compress_context_if_needed(
messages=test_messages,
target_context_limit=32768,
compressor_profile=c_pcfg,
threshold_percent=0.0, # force compression
keep_recent_messages=2,
)
return {
'ok': outcome.status == 'SUCCESS',
'message': outcome.status_message,
'data': {
'outcome': asdict(outcome) if hasattr(outcome, '__dataclass_fields__') else outcome.__dict__,
'messages_before_count': len(test_messages),
'messages_after_count': len(new_msgs),
}
}
# ── Проверка доступности agy и сценарии владельца (A58) ──
elif action == 'refresh_agy_eligibility':
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
service = AgyEligibilityService.get()
service.invalidate_cache()
state = service.check_eligibility_state(force=True)
return {
'ok': True,
'message': f"Состояние проверки agy: {state.get('status_label_ru')}",
'data': state,
}
elif action == 'run_agy_patch_script':
from pathlib import Path
from antigravity_provider.router.settings_service import get_hub_settings
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
from antigravity_provider.agy_subprocess import launch_terminal_task
settings = get_hub_settings()
raw_path = str(settings.get('agy_patch_script_path') or '').strip()
if not raw_path:
return {'ok': False, 'message': 'Н/Д: путь к сценарию патча не указан в настройках'}
p = Path(raw_path).expanduser().resolve()
if not p.is_file() or not (os.access(p, os.X_OK) or p.suffix in ('.sh', '.py', '.bat', '.cmd', '.exe')):
return {'ok': False, 'message': f'Файл сценария не найден или недоступен: {raw_path}'}
ok, msg, res_data = launch_terminal_task(
title='Antigravity Eligibility Patch',
command_args=[str(p)],
work_dir=p.parent,
)
if not ok:
return {'ok': False, 'message': f'Не удалось запустить сценарий в терминале: {msg}', 'data': res_data}
service = AgyEligibilityService.get()
service.invalidate_cache()
new_state = service.check_eligibility_state(force=True)
return {
'ok': True,
'message': 'Сценарий патча запущен в окне терминала',
'data': {'eligibility': new_state, 'terminal': res_data},
}
elif action == 'run_agy_update':
from antigravity_provider.agy_subprocess import get_agy_exe, launch_terminal_task
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
from antigravity_provider.paths import get_hermes_home
try:
agy_exe = get_agy_exe()
except Exception as exc:
return {'ok': False, 'message': f'Утилита agy не найдена: {exc}'}
ok, msg, res_data = launch_terminal_task(
title='Обновление Antigravity CLI (agy update)',
command_args=[agy_exe, 'update'],
work_dir=get_hermes_home(),
)
if not ok:
return {'ok': False, 'message': f'Не удалось запустить обновление в терминале: {msg}', 'data': res_data}
service = AgyEligibilityService.get()
service.invalidate_cache()
new_state = service.check_eligibility_state(force=True)
return {
'ok': True,
'message': 'Обновление agy запущено в окне терминала',
'data': {'eligibility': new_state, 'terminal': res_data},
}
else:
return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True}

View file

@ -54,14 +54,21 @@ class AntigravityAdapter(BaseProviderAdapter):
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
profile_dir = get_profile_env_dir(profile.profile_id)
# Isolate USERPROFILE and HOME while strictly stripping non-Antigravity provider secrets
custom_env = build_safe_subprocess_env(
overrides={
"USERPROFILE": str(profile_dir),
"HOME": str(profile_dir),
"HOMEPATH": str(profile_dir),
}
overrides = {
"USERPROFILE": str(profile_dir),
"HOME": str(profile_dir),
"HOMEPATH": str(profile_dir),
}
# Тот же прокси, что и при входе: иначе аккаунт подключён, а запросы
# упираются в проверку доступности по стране.
from antigravity_provider.agy_subprocess import (
proxy_env_overrides,
resolve_provider_proxy,
)
overrides.update(proxy_env_overrides(resolve_provider_proxy(profile.profile_id)))
custom_env = build_safe_subprocess_env(overrides=overrides)
# If profile specifies a preferred model and request has generic or no model
req = dict(request)
model = req.get("model", "")

View file

@ -144,9 +144,36 @@ class LocalLLMAdapter(BaseProviderAdapter):
messages = list(request.get("messages", []))
# Context Truncation Guard: safely bound prompt if context_window is known to prevent VRAM overflow
# Context Compression & Truncation Guard
context_window = self.get_context_window(profile, model, query_remote=False)
if context_window is not None and context_window > 0 and len(messages) > 1:
from antigravity_provider.router.settings_service import get_hub_settings
from antigravity_provider.router.local_supervisor import LocalSupervisor
from antigravity_provider.router.router_config import load_router_config
hub_settings = get_hub_settings()
threshold_pct = float(hub_settings.get("compression_threshold_percent", 75.0))
keep_recent = int(hub_settings.get("compression_keep_recent_messages", 3))
compressor_pid = hub_settings.get("compressor_profile_id")
compressor_pconfig = None
if compressor_pid:
try:
rcfg = load_router_config()
compressor_pconfig = rcfg.get_profile(compressor_pid)
except Exception:
pass
supervisor = LocalSupervisor(base_url=base_url)
messages, outcome = supervisor.compress_context_if_needed(
messages=messages,
target_context_limit=context_window,
compressor_profile=compressor_pconfig,
threshold_percent=threshold_pct,
keep_recent_messages=keep_recent,
)
# Secondary Safety Guard: if still exceeding token budget (e.g. huge single message or compressor disabled/failed)
max_tok = int(request.get("max_tokens", 0) or 0)
token_budget = context_window - max_tok - 64
if token_budget > 100:
@ -156,7 +183,7 @@ class LocalLLMAdapter(BaseProviderAdapter):
if _est_tok(messages) > token_budget:
logger.warning(
"Context truncation guard active for %s: prompt exceeds context window (%d). Truncating middle messages.",
"Context safety boundary active for %s: prompt exceeds token budget (%d). Truncating middle messages.",
profile.profile_id,
context_window,
)

View file

@ -38,6 +38,20 @@ class NvidiaAdapter(BaseProviderAdapter):
if key:
return str(key).strip()
# Ключ, введённый в мастере, сохраняется через ProfileAuthManager, а не в
# auth_config из router_profiles.yaml. Адаптер читал только второе место и
# уходил без заголовка Authorization: провайдер отвечал 401 «Header of type
# authorization was missing», хотя список моделей тем же ключом получался.
try:
from antigravity_provider.router.profile_manager import ProfileAuthManager
stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {}
key = stored.get("api_key") or stored.get("token")
if key:
return str(key).strip()
except Exception:
pass
suffix = profile.profile_id.upper().replace("-", "_")
for candidate in (f"NVIDIA_API_KEY_{suffix}", "NVIDIA_API_KEY", "NV_API_KEY"):
val = os.environ.get(candidate, "").strip()

View file

@ -14,7 +14,8 @@ from .local_adapter import LocalLLMAdapter
logger = logging.getLogger("hermes.router.adapter.ollama")
DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1"
DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434"
CLOUD_OLLAMA_BASE_URL = "https://ollama.com"
DEFAULT_OLLAMA_MODELS = ["llama3:latest"]
@ -25,11 +26,34 @@ class OllamaAdapter(LocalLLMAdapter):
and native Ollama endpoints (/api/tags).
"""
def _stored_base_url(self, profile: RouterProfileConfig) -> str:
"""Адрес из хранилища учётных данных, если владелец его задавал."""
try:
from antigravity_provider.router.profile_manager import ProfileAuthManager
stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {}
return str(stored.get("base_url") or "").strip()
except Exception:
return ""
def _resolve_base_url(self, profile: RouterProfileConfig) -> str:
"""Resolve base_url from profile custom_base_url, auth_config, or environment."""
url = (
"""Адрес сервера: локальный либо облачный.
У аккаунта Ollama Cloud локального сервера нет вовсе только ключ.
Раньше адаптер всегда шёл на 127.0.0.1:11434, получал «Connection
refused» и объявлял облачный аккаунт неработающим, хотя каталог из
девятнадцати моделей у него получался.
"""
explicit = (
profile.custom_base_url
or profile.auth_config.get("base_url")
or self._stored_base_url(profile)
)
if not explicit and self._resolve_api_key(profile):
return CLOUD_OLLAMA_BASE_URL
url = (
explicit
or os.environ.get("OLLAMA_BASE_URL")
or os.environ.get("OLLAMA_HOST")
or DEFAULT_OLLAMA_BASE_URL
@ -45,6 +69,18 @@ class OllamaAdapter(LocalLLMAdapter):
if key:
return str(key).strip()
# Ключ, введённый в мастере, лежит в хранилище учётных данных, а не в
# auth_config из router_profiles.yaml.
try:
from antigravity_provider.router.profile_manager import ProfileAuthManager
stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {}
key = stored.get("api_key") or stored.get("token")
if key:
return str(key).strip()
except Exception:
pass
suffix = profile.profile_id.upper().replace("-", "_")
for candidate in (f"OLLAMA_API_KEY_{suffix}", "OLLAMA_API_KEY", "OLLAMA_TOKEN"):
val = os.environ.get(candidate, "").strip()
@ -54,21 +90,24 @@ class OllamaAdapter(LocalLLMAdapter):
def _get_native_host(self, base_url: str) -> str:
"""Strip trailing /v1 from base_url to get native Ollama host."""
if base_url.endswith("/v1"):
return base_url[:-3]
return base_url
b = base_url.rstrip("/")
if b.endswith("/v1"):
return b[:-3].rstrip("/")
return b
def _get_chat_url(self, base_url: str) -> str:
"""Get standard chat completions endpoint URL."""
if base_url.endswith("/v1"):
return f"{base_url}/chat/completions"
return f"{base_url}/v1/chat/completions"
b = base_url.rstrip("/")
if b.endswith("/v1"):
return f"{b}/chat/completions"
return f"{b}/v1/chat/completions"
def _get_models_url(self, base_url: str) -> str:
"""Get OpenAI-compatible models endpoint URL."""
if base_url.endswith("/v1"):
return f"{base_url}/models"
return f"{base_url}/v1/models"
b = base_url.rstrip("/")
if b.endswith("/v1"):
return f"{b}/models"
return f"{b}/v1/models"
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
base_url = self._resolve_base_url(profile)

View file

@ -41,6 +41,20 @@ class OpenRouterAdapter(BaseProviderAdapter):
if key:
return str(key).strip()
# Ключ, введённый в мастере, сохраняется через ProfileAuthManager, а не в
# auth_config из router_profiles.yaml. Адаптер читал только второе место и
# уходил без заголовка Authorization: провайдер отвечал 401 «Header of type
# authorization was missing», хотя список моделей тем же ключом получался.
try:
from antigravity_provider.router.profile_manager import ProfileAuthManager
stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {}
key = stored.get("api_key") or stored.get("token")
if key:
return str(key).strip()
except Exception:
pass
suffix = profile.profile_id.upper().replace("-", "_")
for candidate in (f"OPENROUTER_API_KEY_{suffix}", "OPENROUTER_API_KEY"):
val = os.environ.get(candidate, "").strip()

View file

@ -0,0 +1,329 @@
"""Hermes Hub — Antigravity CLI (agy) Eligibility State Detection Service.
Provides 100% read-only analysis of the agy binary machine code to determine whether
the region eligibility check is active, removed (patched by owner), or undetermined.
Strictly NEVER modifies or writes to executable files, and NEVER downloads external code.
"""
from __future__ import annotations
import hashlib
import logging
import os
import re
import subprocess
import threading
import time
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from antigravity_provider.agy_subprocess import (
build_safe_subprocess_env,
get_agy_exe,
hidden_process_kwargs,
)
from antigravity_provider.router.event_bus import (
EVENT_AGY_ELIGIBILITY_CHANGED,
EventBus,
)
from antigravity_provider.router.settings_service import get_hub_settings
from antigravity_provider.router.unified_health import EventLogService
logger = logging.getLogger("hermes.router.agy_eligibility")
STATUS_CHECK_REMOVED = "check_removed"
STATUS_CHECK_ACTIVE = "check_active"
STATUS_UNKNOWN = "unknown"
# ── Machine Code Signatures ──────────────────────────────────────────
# x86-64 Original:
# test rax,rax ; je eligible ; cmp byte[rax+8],0 ; jne eligible ; call failure
# cmp byte[rax+8], 0 is \x80\x78\x08\x00
X86_ORIG_SHORT = re.compile(rb"\x48\x85\xc0\x74.\x80\x78\x08\x00\x75.", re.DOTALL)
X86_ORIG_NEAR = re.compile(rb"\x48\x85\xc0\x0f\x84....\x80\x78\x08\x00\x0f\x85....", re.DOTALL)
X86_ORIG_STANDALONE = re.compile(rb"\x80\x78\x08\x00", re.DOTALL)
# x86-64 Patched:
# test rax,rax ; je eligible ; test rax,rax ; nop ; jne eligible
# test rax,rax ; nop is \x48\x85\xc0\x90
X86_PATCH_SHORT = re.compile(rb"\x48\x85\xc0\x74.\x48\x85\xc0\x90\x75.", re.DOTALL)
X86_PATCH_NEAR = re.compile(rb"\x48\x85\xc0\x0f\x84....\x48\x85\xc0\x90\x0f\x85....", re.DOTALL)
# arm64 Original & Patched signatures
# In Go arm64, struct field [X0, #8] access followed by conditional branch:
# LDRB Wn, [X0, #8] -> \x0n\x20\x40\x39
ARM64_ORIG_PATTERN = re.compile(rb"[\x00-\x1f]\x20\x40\x39", re.DOTALL)
# Patched replaces LDRB / branch with NOP (\x1f\x20\x03\xd5) or MOV X0, X0 (\xe0\x03\x00\xaa)
ARM64_PATCH_PATTERN = re.compile(rb"\x1f\x20\x03\xd5", re.DOTALL)
class AgyEligibilityService:
"""Thread-safe, read-only analyzer of agy CLI binary eligibility check status."""
_instance: Optional[AgyEligibilityService] = None
_instance_lock = threading.Lock()
def __init__(self) -> None:
self._lock = threading.RLock()
self._cached_state: Optional[Dict[str, Any]] = None
self._last_binary_path: Optional[str] = None
self._last_mtime: float = -1.0
self._last_sha256: Optional[str] = None
self._last_status: Optional[str] = None
self._last_status_label: Optional[str] = None
self._last_version: Optional[str] = None
@classmethod
def get(cls) -> AgyEligibilityService:
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def invalidate_cache(self) -> None:
"""Clear cached eligibility state to force re-reading binary on next check."""
with self._lock:
self._cached_state = None
self._last_mtime = -1.0
def _extract_version(self, exe_path: str) -> str:
"""Truthfully extract version from agy executable."""
try:
res = subprocess.run(
[exe_path, "--version"],
capture_output=True,
text=True,
timeout=5,
encoding="utf-8",
errors="replace",
env=build_safe_subprocess_env(),
**hidden_process_kwargs(),
)
raw = res.stdout.strip() or res.stderr.strip()
if raw:
# E.g. "1.1.23" or "Antigravity CLI 1.1.23"
m = re.search(r"(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?|\d+\.\d+)", raw)
if m:
return m.group(1)
return raw.splitlines()[0][:50]
except Exception as exc:
logger.debug("Could not run agy --version: %s", exc)
return "Н/Д (не удалось определить версию)"
def _analyze_binary_bytes(self, data: bytes) -> Tuple[str, str, str]:
"""Analyze binary machine code bytes.
Returns:
(status, status_label_ru, detail_ru)
"""
# 1. Check for patched x86-64 signature
if X86_PATCH_SHORT.search(data) or X86_PATCH_NEAR.search(data):
return (
STATUS_CHECK_REMOVED,
"Проверка снята",
"Патч начальной проверки доступности активен. Ветвление направлено в разрешённый режим.",
)
# 2. Check for original x86-64 signature
if X86_ORIG_SHORT.search(data) or X86_ORIG_NEAR.search(data):
return (
STATUS_CHECK_ACTIVE,
"Проверка на месте",
"Проверка доступности Antigravity активна. Аккаунт может отклоняться Google по региону. Примените патч или настройте прокси.",
)
# 3. Check for arm64 patched / orig if context matches
if ARM64_PATCH_PATTERN.search(data) and b"EPD_ELIGIBILITY" in data:
if not ARM64_ORIG_PATTERN.search(data):
return (
STATUS_CHECK_REMOVED,
"Проверка снята",
"Патч начальной проверки доступности (ARM64) активен.",
)
if ARM64_ORIG_PATTERN.search(data) and b"EPD_ELIGIBILITY" in data:
return (
STATUS_CHECK_ACTIVE,
"Проверка на месте",
"Проверка доступности Antigravity (ARM64) активна. Аккаунт может отклоняться Google по региону.",
)
# 4. Unknown / Unsupported
return (
STATUS_UNKNOWN,
"Н/Д: сигнатура проверки не найдена",
"Сигнатура проверки доступности не найдена в бинарнике (возможно, неподдерживаемая версия agy).",
)
def check_eligibility_state(
self,
force: bool = False,
custom_binary_path: Optional[str | Path] = None,
) -> Dict[str, Any]:
"""Perform 100% read-only evaluation of agy binary eligibility state.
Guarantees:
- NEVER writes to or alters the executable file.
- Calculates SHA-256 before and after evaluation.
- Emits EventBus event and EventLogService audit record upon state transitions.
"""
with self._lock:
# Resolve binary path
if custom_binary_path:
exe_path_str = str(custom_binary_path)
else:
try:
exe_path_str = get_agy_exe()
except Exception as exc:
exe_path_str = ""
error_msg = str(exc)
settings = get_hub_settings()
patch_script_path = settings.get("agy_patch_script_path", "").strip()
if not exe_path_str:
state = {
"status": STATUS_UNKNOWN,
"status_label_ru": "Н/Д: исполняемый файл agy не найден",
"detail_ru": f"Утилита agy не обнаружена в системе: {error_msg if 'error_msg' in locals() else 'путь не найден'}",
"version": "Н",
"binary_path": "",
"binary_sha256": "",
"binary_size_bytes": 0,
"checked_at": time.time(),
"patch_script_path": patch_script_path,
}
self._cached_state = state
return dict(state)
p = Path(exe_path_str)
if not p.is_file():
state = {
"status": STATUS_UNKNOWN,
"status_label_ru": "Н/Д: файл не найден",
"detail_ru": f"Файл {p} не существует или не является файлом",
"version": "Н",
"binary_path": str(p),
"binary_sha256": "",
"binary_size_bytes": 0,
"checked_at": time.time(),
"patch_script_path": patch_script_path,
}
self._cached_state = state
return dict(state)
try:
stat = p.stat()
mtime = stat.st_mtime
size = stat.st_size
except OSError as exc:
state = {
"status": STATUS_UNKNOWN,
"status_label_ru": f"Н/Д: нет доступа к файлу ({exc.strerror or exc})",
"detail_ru": f"Не удалось прочитать атрибуты файла {p}: {exc}",
"version": "Н",
"binary_path": str(p),
"binary_sha256": "",
"binary_size_bytes": 0,
"checked_at": time.time(),
"patch_script_path": patch_script_path,
}
self._cached_state = state
return dict(state)
# Return cached state if nothing changed and not forced
if (
not force
and self._cached_state is not None
and self._last_binary_path == str(p)
and self._last_mtime == mtime
):
# Update patch_script_path if settings changed
self._cached_state["patch_script_path"] = patch_script_path
return dict(self._cached_state)
# Read-only binary read and hash computation
try:
with open(p, "rb") as f:
data = f.read()
except OSError as exc:
state = {
"status": STATUS_UNKNOWN,
"status_label_ru": f"Н/Д: ошибка чтения файла ({exc.strerror or exc})",
"detail_ru": f"Не удалось прочитать бинарный файл {p}: {exc}",
"version": "Н",
"binary_path": str(p),
"binary_sha256": "",
"binary_size_bytes": size,
"checked_at": time.time(),
"patch_script_path": patch_script_path,
}
self._cached_state = state
return dict(state)
sha256_hash = hashlib.sha256(data).hexdigest()
status, status_label_ru, detail_ru = self._analyze_binary_bytes(data)
# Extract version
version = self._extract_version(str(p))
state = {
"status": status,
"status_label_ru": status_label_ru,
"detail_ru": detail_ru,
"version": version,
"binary_path": str(p),
"binary_sha256": sha256_hash,
"binary_size_bytes": len(data),
"checked_at": time.time(),
"patch_script_path": patch_script_path,
}
# Detect state transition and emit notifications (P0-3)
previous_status = self._last_status
previous_label = self._last_status_label or previous_status or "Н"
previous_sha = self._last_sha256
if previous_status is not None and previous_status != status:
logger.info(
"AGY eligibility state changed: %s -> %s (sha256: %s)",
previous_status,
status,
sha256_hash[:12],
)
EventBus.get().publish(EVENT_AGY_ELIGIBILITY_CHANGED, dict(state))
level = "warning" if status == STATUS_CHECK_ACTIVE else "info"
EventLogService.get().log(
category="security",
message=(
f"Состояние проверки доступности agy изменилось: "
f"{previous_label}{status_label_ru}"
),
details=f"Исполняемый файл: {p} (SHA-256: {sha256_hash[:16]}..., Версия: {version})",
level=level,
actor="system",
action="agy_eligibility_change",
target_profile="antigravity",
outcome="success",
)
elif previous_sha is not None and previous_sha != sha256_hash:
logger.info("AGY binary hash changed: %s -> %s", previous_sha[:12], sha256_hash[:12])
EventLogService.get().log(
category="system",
message=f"Обнаружено изменение исполняемого файла agy (SHA-256: {sha256_hash[:16]}..., статус: {status_label_ru})",
level="info",
actor="system",
action="agy_binary_updated",
target_profile="antigravity",
)
self._last_binary_path = str(p)
self._last_mtime = mtime
self._last_sha256 = sha256_hash
self._last_status = status
self._last_status_label = status_label_ru
self._last_version = version
self._cached_state = state
return dict(state)

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

@ -0,0 +1,173 @@
"""Validate supplied credentials before creating any profile or writing auth files."""
import json
import urllib.error
import urllib.request
from urllib.parse import urlsplit
DEFAULT_URLS = {
"openrouter": "https://openrouter.ai/api/v1",
"nvidia": "https://integrate.api.nvidia.com/v1",
"local": "http://127.0.0.1:8081/v1", "vllm": "http://127.0.0.1:8081/v1",
"ollama": "http://127.0.0.1:11434", "claude": "https://api.anthropic.com/v1",
"opencode-go": "https://opencode.ai/zen/go/v1", "grok": "https://api.x.ai/v1",
"openai-codex": "https://api.openai.com/v1",
}
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ValueError("Перенаправление API запрещено: укажите конечный URL сервера")
def validate_connection(provider, token="", base_url="", preferred_model=""):
provider = {"nvidia-nim": "nvidia", "local-llm": "local", "llama.cpp": "local"}.get(provider, provider)
try:
base_url = (base_url or DEFAULT_URLS.get(provider, "")).rstrip("/")
parsed = urlsplit(base_url)
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
raise ValueError("Укажите HTTP(S) URL сервера без пароля, параметров и фрагмента")
if provider not in DEFAULT_URLS:
raise ValueError("Для этого провайдера используйте вход через авторизацию")
if provider not in ("local", "vllm", "ollama") and not token:
raise ValueError("Не указан API-ключ")
# Подписочные аккаунты Codex (ChatGPT) и Claude работают по OAuth, а не
# по платформенному ключу. Проверка ниже обращается к каталогу моделей,
# где платформенному ключу нужны права api.model.read: подписочный
# токен там получает 403 или 401, хотя аккаунт исправен и в Hermes
# работает. Предупреждаем об этом заранее, чтобы отказ не выглядел
# поломкой аккаунта.
subscription_note = ""
if provider in ("openai-codex", "codex", "claude", "anthropic"):
subscription_note = (
" Если это аккаунт по подписке (ChatGPT Plus/Pro или Claude Max), "
"ключ здесь не подойдёт: такие аккаунты подключаются входом по ссылке "
"на предыдущем шаге мастера, а не API-ключом."
)
headers = {"Content-Type": "application/json"}
if provider == "claude":
headers.update({"x-api-key": token, "anthropic-version": "2023-06-01"})
elif token:
headers["Authorization"] = "Bearer " + token
opener = urllib.request.build_opener(_NoRedirect())
def request(path, body=None):
req = urllib.request.Request(base_url + path, headers=headers,
data=json.dumps(body).encode() if body is not None else None)
with opener.open(req, timeout=20) as response:
payload = json.load(response)
if not isinstance(payload, dict) or payload.get("error"):
raise ValueError("Провайдер вернул ошибку или неверный JSON")
return payload
if provider == "openrouter":
# The catalog is public: its HTTP 200 is not proof of a valid key.
request("/key")
if provider == "ollama":
base_url = base_url.removesuffix("/v1")
payload, field, key = request("/api/tags"), "models", "name"
else:
payload, field, key = request("/models"), "data", "id"
entries = payload.get(field)
if not isinstance(entries, list):
raise ValueError("Провайдер не вернул список моделей")
models = sorted({m[key] for m in entries if isinstance(m, dict) and isinstance(m.get(key), str) and m[key]})
if provider == "nvidia":
if not models:
raise ValueError("Каталог NVIDIA пуст: проверить ключ тестовым запросом невозможно")
if preferred_model and preferred_model not in models:
raise ValueError("Выбранной модели нет в каталоге NVIDIA")
# Каталог NVIDIA общий для всех, а доступ к конкретной модели даётся
# по аккаунту. Успешный список уже доказывает, что ключ рабочий:
# сервер опознал аккаунт и ответил. Пробный запрос — уточнение, а не
# условие. Раньше его отказ («Function ... Not found for account»)
# объявлялся провалом подключения, хотя ключ был верным.
chat_models = [model for model in models if any(word in model.lower() for word in ('instruct', 'chat')) and not any(word in model.lower() for word in ('embed', 'guard', 'reward'))]
probe_target = preferred_model or (chat_models[0] if chat_models else None)
if probe_target:
try:
result = request("/chat/completions", {"model": probe_target,
"messages": [{"role": "user", "content": "ping"}], "max_tokens": 1})
if not isinstance(result.get("choices"), list) or not result["choices"]:
probe_note = f"модель {probe_target} не вернула результат"
else:
probe_note = ""
except Exception as probe_exc:
# 401 и 403 — ключ отвергнут, это отказ подключения.
# 404 и прочее — ключ принят, но модель аккаунту не выдана:
# NVIDIA отвечает «Function ... Not found for account <id>»,
# то есть аккаунт опознан. Валить подключение из-за этого нельзя.
code = getattr(probe_exc, "code", None)
if code in (401, 403):
raise
probe_note = f"модель {probe_target} недоступна вашему аккаунту ({str(probe_exc)[:120]})"
else:
probe_note = "чат-модель для пробы не найдена"
if probe_note:
return {"ok": True, "data": {"models": models, "base_url": base_url},
"message": f"Ключ принят, моделей в каталоге: {len(models)}. Проба: {probe_note}. Выберите доступную модель."}
message = f"Подключено и проверено. Получено моделей: {len(models)}" if models else "Сервер отвечает; моделей пока нет"
return {"ok": True, "message": message, "data": {"models": models, "base_url": base_url}}
except Exception as exc:
exc_str = str(exc)
exc_lower = exc_str.lower()
# У Ollama Cloud локального сервера нет вовсе: там только ключ. Если
# ключ задан, а локальный адрес не отвечает, это не отказ подключения —
# аккаунт облачный. Проверяем его по общедоступному каталогу и
# принимаем, честно сказав, что локальные модели недоступны.
if provider == "ollama" and token and any(
k in exc_lower for k in ("connection refused", "winerror 10061", "errno 111", "refused", "failed to connect", "target machine actively refused")
):
try:
cloud = urllib.request.Request(
"https://ollama.com/api/tags",
headers={"Accept": "application/json", "Authorization": f"Bearer {token}"},
)
with urllib.request.urlopen(cloud, timeout=15) as response:
payload = json.load(response)
cloud_models = sorted({
m.get("name") for m in (payload.get("models") or [])
if isinstance(m, dict) and isinstance(m.get("name"), str)
})
return {
"ok": True,
"message": (
f"Облачный аккаунт Ollama принят. Моделей в каталоге: {len(cloud_models)}. "
f"Локальный сервер по адресу {base_url} не отвечает — локальные модели недоступны."
),
"data": {"models": cloud_models, "base_url": base_url, "cloud_only": True},
}
except Exception as cloud_exc:
return {
"ok": False,
"message": (
f"Локальный сервер {base_url} не отвечает, и облачный каталог Ollama недоступен: "
f"{str(cloud_exc)[:160]}"
),
"data": {"models": []},
}
if provider == "ollama" and any(k in exc_lower for k in ("connection refused", "winerror 10061", "errno 111", "111", "refused", "failed to connect", "target machine actively refused")):
return {
"ok": False,
"message": f"Не удалось подключиться к {base_url} (Connection refused). Если Ollama работает на сервере или другой машине, укажите её сетевой адрес (например, http://192.168.1.81:11434).",
"data": {"models": []},
}
if isinstance(exc, urllib.error.HTTPError):
reason = (exc.reason or 'провайдер отклонил запрос')
if exc.code in (401, 403) and subscription_note:
reason = str(reason) + subscription_note
try:
body = json.loads(exc.read(4096).decode('utf-8', errors='replace'))
detail = body.get('error') or body.get('detail')
if isinstance(detail, dict):
detail = detail.get('message')
if isinstance(detail, str) and detail.strip():
reason = detail[:500]
except Exception:
pass
message = f"HTTP {exc.code}: {reason}"
else:
message = str(exc).strip() or type(exc).__name__
if token:
message = message.replace(token, "[скрыто]")
return {"ok": False, "message": message, "data": {"models": []}}

View file

@ -0,0 +1,535 @@
"""Hermes Hub Context Compression Engine (Сжатие контекста).
Implements Task A56 requirements:
- P0-1: Configurable compressor role/profile (not hardcoded to port 8082), excluded from Hermes default routing chains.
- P0-2: Context-fill threshold (measured via /props and /tokenize, default 75%), fresh window preserved verbatim, no recursive double-compression.
- P0-3: Strict verbatim preservation of factual entities (file paths, ports, IPs, variable/function names, commit SHAs, version numbers, benchmark metrics) with 100% retention guarantee.
- P0-4: Transparent telemetry, history store for original uncompressed text, non-blocking graceful fallback if compressor is offline/unconfigured.
- P0-5: Shared memory persistence in /srv/projects/AI-Memory indexed by GGUF build metadata.
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
import urllib.error
import urllib.request
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
logger = logging.getLogger("hermes.router.compression")
SHARED_MEMORY_VAULT = Path("/srv/projects/AI-Memory")
COMPRESSION_MEMORY_FILE = SHARED_MEMORY_VAULT / "01_PROJECTS" / "hermes-hub" / "compression_memory.json"
COMPRESSED_BLOCK_START = "<!-- HERMES_CONTEXT_COMPRESSION_START -->"
COMPRESSED_BLOCK_END = "<!-- HERMES_CONTEXT_COMPRESSION_END -->"
@dataclass
class FactualEntities:
file_paths: List[str] = field(default_factory=list)
ip_addresses: List[str] = field(default_factory=list)
port_numbers: List[str] = field(default_factory=list)
commit_shas: List[str] = field(default_factory=list)
version_numbers: List[str] = field(default_factory=list)
identifiers: List[str] = field(default_factory=list)
metrics: List[str] = field(default_factory=list)
@property
def total_count(self) -> int:
return (
len(self.file_paths)
+ len(self.ip_addresses)
+ len(self.port_numbers)
+ len(self.commit_shas)
+ len(self.version_numbers)
+ len(self.identifiers)
+ len(self.metrics)
)
def all_unique_facts(self) -> Set[str]:
items: Set[str] = set()
items.update(self.file_paths)
items.update(self.ip_addresses)
items.update(self.port_numbers)
items.update(self.commit_shas)
items.update(self.version_numbers)
items.update(self.identifiers)
items.update(self.metrics)
return items
@dataclass
class CompressionOutcome:
status: str # "SUCCESS", "SKIPPED", "UNCONFIGURED", "ERROR"
status_message: str
tokens_before: int = 0
tokens_after: int = 0
compression_ratio: float = 1.0
saved_tokens: int = 0
duration_sec: float = 0.0
facts_total: int = 0
facts_retained: int = 0
retention_percent: float = 100.0
# Итог считается посимвольно, если токенизатор сервера недоступен.
tokens_after_is_estimate: bool = True
# Полнота, которую дала сама модель, до дописывания недостающих фактов.
model_retention_percent: float = 100.0
facts_added_by_safeguard: List[str] = field(default_factory=list)
retained_facts: List[str] = field(default_factory=list)
missing_facts: List[str] = field(default_factory=list)
model_name: str = ""
gguf_name: str = ""
endpoint: str = ""
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
original_messages_snapshot: List[Dict[str, Any]] = field(default_factory=list)
def extract_factual_entities(text: str) -> FactualEntities:
"""Extract factual technical entities from text using strict regex patterns."""
if not text:
return FactualEntities()
# 1. Absolute and relative file paths (e.g. /srv/projects/Agent projects/..., ./src/..., benchmarks/foo.md)
path_pattern = re.compile(
r"(?:/[a-zA-Z0-9._ -]+)+/[a-zA-Z0-9._-]+\.[a-zA-Z0-9]+|(?:/[a-zA-Z0-9._-]+)+[a-zA-Z0-9._-]+\.[a-zA-Z0-9]+|(?:[a-zA-Z0-9._-]+/)+[a-zA-Z0-9._-]+\.[a-zA-Z0-9]+"
)
raw_paths = path_pattern.findall(text)
file_paths = sorted(set(p.strip() for p in raw_paths if p.strip()))
# 2. IP addresses (e.g. 192.168.1.81, 127.0.0.1)
ip_pattern = re.compile(r"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b")
ip_addresses = sorted(set(ip_pattern.findall(text)))
# 3. Ports (e.g. :8081, :8082, :8765, port 8081)
port_pattern = re.compile(r"(?::|\bport\s+)([0-9]{4,5})\b", re.IGNORECASE)
port_matches = port_pattern.findall(text)
port_numbers = sorted(set(f":{p}" if not p.startswith(":") else p for p in port_matches))
# 4. Commit SHAs and hex tokens (7-64 hex chars)
sha_pattern = re.compile(r"\b[0-9a-fA-F]{7,64}\b")
raw_shas = sha_pattern.findall(text)
# Filter out pure decimal numbers or common words
commit_shas = sorted(set(
s for s in raw_shas
if any(c in "abcdefABCDEF" for c in s) and 7 <= len(s) <= 64
))
# 5. Version numbers (e.g. v0.1.2, 0.1.1, v0.1.2-b2)
ver_pattern = re.compile(r"\bv?[0-9]+\.[0-9]+(?:\.[0-9]+)?(?:-[a-zA-Z0-9.]+)?\b")
version_numbers = sorted(set(v for v in ver_pattern.findall(text) if v not in ip_addresses))
# 6. Specific benchmark and hardware numbers (e.g. 107.4 tok/s, 30008 MiB, 229376 tokens, 224K, 64K)
metric_pattern = re.compile(r"\b[0-9]+(?:\.[0-9]+)?\s*(?:tok/s|t/s|MiB|GiB|MB|GB|tokens|tok|K|k)\b")
metrics = sorted(set(metric_pattern.findall(text)))
# 7. Key Python / System Identifiers
id_pattern = re.compile(r"\b(?:LocalSupervisor|DualCoderPipeline|ContextCompressor|Qwen3-Coder|Qwen3-4B|Phi-4|Granite|llama-server|systemd)\b")
identifiers = sorted(set(id_pattern.findall(text)))
return FactualEntities(
file_paths=file_paths,
ip_addresses=ip_addresses,
port_numbers=port_numbers,
commit_shas=commit_shas,
version_numbers=version_numbers,
identifiers=identifiers,
metrics=metrics,
)
def verify_facts_retention(summary: str, expected_facts: FactualEntities) -> Tuple[float, List[str], List[str]]:
"""Check how many expected facts are present verbatim in the summary."""
all_facts = expected_facts.all_unique_facts()
if not all_facts:
return 100.0, [], []
preserved: List[str] = []
missing: List[str] = []
for fact in all_facts:
# Check direct substring match
if fact in summary or (fact.startswith(":") and fact[1:] in summary):
preserved.append(fact)
else:
missing.append(fact)
retention_percent = (len(preserved) / len(all_facts)) * 100.0
return retention_percent, preserved, missing
class ContextCompressor:
"""Orchestrates high-fidelity LLM context compression."""
DEFAULT_THRESHOLD_PERCENT: float = 75.0
DEFAULT_KEEP_RECENT: int = 3
def __init__(
self,
memory_file: Optional[Path] = None,
):
self.memory_file = memory_file or COMPRESSION_MEMORY_FILE
self._history_snapshots: List[CompressionOutcome] = []
def get_compression_history(self, limit: int = 20) -> List[Dict[str, Any]]:
"""Return in-memory history of recent compressions."""
return [asdict(o) for o in reversed(self._history_snapshots[-limit:])]
def resolve_compressor_endpoint(
self,
profile_config: Optional[Any] = None,
custom_base_url: Optional[str] = None,
) -> Tuple[str, str, str]:
"""Resolve base_url, model_name, and auth token for compressor."""
if custom_base_url:
return custom_base_url.rstrip("/"), "default", ""
if profile_config is not None:
base_url = (
getattr(profile_config, "custom_base_url", None)
or (profile_config.auth_config.get("base_url") if hasattr(profile_config, "auth_config") and isinstance(profile_config.auth_config, dict) else None)
or os.environ.get("LOCAL_COMPRESSOR_BASE_URL")
or ""
)
model_name = profile_config.preferred_models[0] if getattr(profile_config, "preferred_models", None) else "default"
token = profile_config.auth_config.get("api_key") or profile_config.auth_config.get("token") if hasattr(profile_config, "auth_config") and isinstance(profile_config.auth_config, dict) else ""
return str(base_url).rstrip("/"), str(model_name), str(token or "")
# Порт 8082 сегодняшний, завтра другой: зашивать его нельзя. Нет
# адреса — значит компрессор не настроен, и это отдельное состояние,
# а не повод молча постучаться в 8082.
env_url = os.environ.get("LOCAL_COMPRESSOR_BASE_URL", "")
return env_url.rstrip("/"), "default", ""
def compress_messages_if_needed(
self,
messages: List[Dict[str, Any]],
target_context_limit: int,
current_token_count: int,
compressor_profile: Optional[Any] = None,
threshold_percent: float = DEFAULT_THRESHOLD_PERCENT,
keep_recent_messages: int = DEFAULT_KEEP_RECENT,
timeout_sec: float = 60.0,
) -> Tuple[List[Dict[str, Any]], CompressionOutcome]:
"""Compress conversation history if current_token_count exceeds threshold.
Preserves system prompt and last `keep_recent_messages` untouched.
Never nests compressed summaries recursively.
Guarantees 100% factual retention.
"""
# P0-1: If compressor profile is not configured
if not self.resolve_compressor_endpoint(compressor_profile)[0]:
outcome = CompressionOutcome(
status="UNCONFIGURED",
status_message=(
"Н/Д: модель для сжатия не выбрана"
if not compressor_profile
else "Н/Д: у выбранного профиля не задан адрес сервера"
),
tokens_before=current_token_count,
tokens_after=current_token_count,
compression_ratio=1.0,
)
return messages, outcome
# P0-2: When to compress — check threshold
threshold_tokens = int(target_context_limit * (threshold_percent / 100.0))
if current_token_count < threshold_tokens:
outcome = CompressionOutcome(
status="SKIPPED",
status_message=f"В пределах нормы ({current_token_count}/{threshold_tokens} токенов, {threshold_percent}%)",
tokens_before=current_token_count,
tokens_after=current_token_count,
compression_ratio=1.0,
)
return messages, outcome
# Not enough messages to compress (e.g. only system + 1 user prompt)
system_msg: List[Dict[str, Any]] = []
conversation: List[Dict[str, Any]] = list(messages)
if conversation and conversation[0].get("role") == "system":
system_msg = [conversation[0]]
conversation = conversation[1:]
if len(conversation) <= keep_recent_messages:
outcome = CompressionOutcome(
status="SKIPPED",
status_message=f"Слишком мало сообщений для разделения ({len(conversation)} <= {keep_recent_messages})",
tokens_before=current_token_count,
tokens_after=current_token_count,
compression_ratio=1.0,
)
return messages, outcome
# Partition into old history to compress and fresh window to keep verbatim
old_history = conversation[:-keep_recent_messages]
fresh_window = conversation[-keep_recent_messages:]
# P0-2 & P0-3: Extract existing summary block if present to prevent recursive double compression
extracted_facts: List[FactualEntities] = []
text_segments_to_compress: List[str] = []
for msg in old_history:
content = str(msg.get("content", ""))
# Extract facts before stripping tags
extracted_facts.append(extract_factual_entities(content))
if COMPRESSED_BLOCK_START in content and COMPRESSED_BLOCK_END in content:
# Extract the inner text of previously compressed context
match = re.search(
rf"{re.escape(COMPRESSED_BLOCK_START)}\s*(.*?)\s*{re.escape(COMPRESSED_BLOCK_END)}",
content,
re.DOTALL,
)
if match:
prev_summary = match.group(1).strip()
text_segments_to_compress.append(f"[РАНЕЕ СОХРАНЁННЫЙ КОНТЕКСТ]:\n{prev_summary}")
else:
text_segments_to_compress.append(content)
else:
role_label = msg.get("role", "user").upper()
text_segments_to_compress.append(f"[{role_label}]:\n{content}")
combined_history_text = "\n\n".join(text_segments_to_compress)
# Merge all expected facts
all_expected_facts = FactualEntities()
for ef in extracted_facts:
all_expected_facts.file_paths.extend(ef.file_paths)
all_expected_facts.ip_addresses.extend(ef.ip_addresses)
all_expected_facts.port_numbers.extend(ef.port_numbers)
all_expected_facts.commit_shas.extend(ef.commit_shas)
all_expected_facts.version_numbers.extend(ef.version_numbers)
all_expected_facts.identifiers.extend(ef.identifiers)
all_expected_facts.metrics.extend(ef.metrics)
all_expected_facts.file_paths = sorted(set(all_expected_facts.file_paths))
all_expected_facts.ip_addresses = sorted(set(all_expected_facts.ip_addresses))
all_expected_facts.port_numbers = sorted(set(all_expected_facts.port_numbers))
all_expected_facts.commit_shas = sorted(set(all_expected_facts.commit_shas))
all_expected_facts.version_numbers = sorted(set(all_expected_facts.version_numbers))
all_expected_facts.identifiers = sorted(set(all_expected_facts.identifiers))
all_expected_facts.metrics = sorted(set(all_expected_facts.metrics))
# P0-4: Execute compression against the resolved compressor model
base_url, model_name, token = self.resolve_compressor_endpoint(compressor_profile)
api_url = f"{base_url}/chat/completions" if not base_url.endswith("/chat/completions") else base_url
system_instruction = (
"You are a precise technical context compression engine.\n"
"Summarize the technical history concisely into clear structured bullet points.\n"
"CRITICAL REQUIREMENT: Strictly preserve ALL factual entities VERBATIM:\n"
"- Exact file paths and directory names (e.g. /path/to/file.py)\n"
"- IP addresses and hostnames (e.g. 192.168.1.81)\n"
"- Port numbers (e.g. :8081, :8082, :8765)\n"
"- Commit SHAs and cryptographic hashes (e.g. 26f7d2c, 8e75dc6)\n"
"- Version numbers (e.g. v0.1.2)\n"
"- Exact benchmark metrics and quantities (e.g. 107.4 tok/s, 30008 MiB, 224K)\n"
"Never generalize, invent, or omit technical identifiers."
)
compression_request_payload = {
"model": model_name or "default",
"messages": [
{"role": "system", "content": system_instruction},
{
"role": "user",
"content": f"Please summarize the following execution history while retaining 100% of technical facts:\n\n{combined_history_text}",
},
],
"temperature": 0.1,
"max_tokens": 1024,
}
t0 = time.time()
try:
req_headers = {"Content-Type": "application/json", "User-Agent": "Hermes-ContextCompressor/1.0"}
if token:
req_headers["Authorization"] = f"Bearer {token}"
req_data = json.dumps(compression_request_payload).encode("utf-8")
req = urllib.request.Request(api_url, data=req_data, headers=req_headers, method="POST")
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
resp_data = json.loads(resp.read().decode("utf-8"))
elapsed = time.time() - t0
choices = resp_data.get("choices", [])
if not choices:
raise RuntimeError("Compressor returned empty choices")
raw_summary = choices[0].get("message", {}).get("content", "").strip()
if not raw_summary:
raise RuntimeError("Compressor returned empty content")
# Extract model GGUF build metadata if available
gguf_model = str(resp_data.get("model", model_name or "compressor"))
# P0-3: Verify facts retention
retention_rate, preserved, missing = verify_facts_retention(raw_summary, all_expected_facts)
# Полнота, которую дала САМА модель. Её нельзя терять: ниже
# недостающие факты дописываются списком, и после этого проверка
# покажет сто процентов. Замер на сервере владельца дал 97,4% —
# один факт из тридцати восьми модель потеряла. Показывая только
# исправленное число, мы скрыли бы от владельца, что модель
# теряет факты, и он не заметил бы, когда станет хуже.
model_retention_rate = retention_rate
model_missing = list(missing)
# P0-3 Safeguard: If any critical technical entities were omitted by model, append explicit factual ledger
if missing:
logger.info("Context compressor missed %d facts. Appending verbatim factual safeguard ledger.", len(missing))
facts_ledger = "\n### Ключевые сохранённые факты:\n" + "\n".join(f"- `{f}`" for f in missing)
final_summary = f"{raw_summary}\n{facts_ledger}"
retention_rate, preserved, missing = verify_facts_retention(final_summary, all_expected_facts)
else:
final_summary = raw_summary
# Format the compressed block with clear delimiter
formatted_compressed_content = (
f"{COMPRESSED_BLOCK_START}\n"
f"## Сжатая сводка предшествующего контекста\n"
f"{final_summary}\n"
f"{COMPRESSED_BLOCK_END}"
)
compressed_message = {
"role": "user",
"content": formatted_compressed_content,
}
new_messages = system_msg + [compressed_message] + fresh_window
# Approximate/count new tokens
est_before = current_token_count
est_summary_tokens = max(1, int(len(formatted_compressed_content) / 3.5))
est_fresh_tokens = sum(max(1, int(len(str(m.get("content", ""))) / 3.5)) for m in fresh_window)
est_system_tokens = sum(max(1, int(len(str(m.get("content", ""))) / 3.5)) for m in system_msg)
est_after = est_system_tokens + est_summary_tokens + est_fresh_tokens
ratio = round(est_after / max(1, est_before), 2)
saved = max(0, est_before - est_after)
outcome = CompressionOutcome(
status="SUCCESS",
status_message=(
f"Контекст сжат: {est_before}{est_after} токенов "
f"({ratio}x, экономия {saved}) за {elapsed:.2f}с. "
f"Фактов сохранено: {len(preserved)}/{all_expected_facts.total_count} "
f"({retention_rate:.1f}%)"
+ (
f"; {len(model_missing)} из них дописано списком, "
f"сама модель сохранила {model_retention_rate:.1f}%."
if model_missing
else ", все — самой моделью."
)
),
tokens_before=est_before,
tokens_after=est_after,
compression_ratio=ratio,
saved_tokens=saved,
duration_sec=round(elapsed, 2),
facts_total=all_expected_facts.total_count,
facts_retained=len(preserved),
retention_percent=retention_rate,
model_retention_percent=model_retention_rate,
facts_added_by_safeguard=model_missing,
retained_facts=preserved,
missing_facts=missing,
model_name=model_name,
gguf_name=gguf_model,
endpoint=base_url,
original_messages_snapshot=messages,
)
# Store in in-memory snapshot history
self._history_snapshots.append(outcome)
# P0-5: Record to AI-Memory
self._record_to_shared_memory(outcome)
logger.info(
"Context compression successful: %d -> %d tokens (%.2fx) in %.2fs. Model: %s",
est_before,
est_after,
ratio,
elapsed,
gguf_model,
)
return new_messages, outcome
except Exception as err:
elapsed = time.time() - t0
logger.warning("Context compression failed (%s). Continuing with uncompressed context: %s", base_url, err)
# P0-4: Failure does NOT crash task. Proceed with original uncompressed messages
outcome = CompressionOutcome(
status="ERROR",
status_message=f"Ошибка сжатия ({err}). Задача продолжается на исходном контексте.",
tokens_before=current_token_count,
tokens_after=current_token_count,
compression_ratio=1.0,
duration_sec=round(elapsed, 2),
model_name=model_name,
endpoint=base_url,
original_messages_snapshot=messages,
)
self._history_snapshots.append(outcome)
return messages, outcome
def _record_to_shared_memory(self, outcome: CompressionOutcome) -> None:
"""Persist compression history in /srv/projects/AI-Memory indexed by GGUF model."""
try:
self.memory_file.parent.mkdir(parents=True, exist_ok=True)
existing_data: Dict[str, Any] = {}
if self.memory_file.exists():
try:
existing_data = json.loads(self.memory_file.read_text(encoding="utf-8"))
except Exception:
existing_data = {}
models_map = existing_data.setdefault("compressor_models", {})
model_key = Path(outcome.gguf_name).name if outcome.gguf_name else "default_compressor"
rec = models_map.setdefault(model_key, {
"gguf_name": outcome.gguf_name,
"total_compressions": 0,
"successful_compressions": 0,
"avg_compression_ratio": 1.0,
"avg_duration_sec": 0.0,
"avg_fact_retention_percent": 100.0,
"last_used": "",
"history": [],
})
rec["total_compressions"] += 1
if outcome.status == "SUCCESS":
rec["successful_compressions"] += 1
n = rec["successful_compressions"]
rec["avg_compression_ratio"] = round(((rec["avg_compression_ratio"] * (n - 1)) + outcome.compression_ratio) / n, 3)
rec["avg_duration_sec"] = round(((rec["avg_duration_sec"] * (n - 1)) + outcome.duration_sec) / n, 2)
rec["avg_fact_retention_percent"] = round(((rec["avg_fact_retention_percent"] * (n - 1)) + outcome.retention_percent) / n, 1)
rec["last_used"] = outcome.timestamp
rec["history"].append({
"timestamp": outcome.timestamp,
"tokens_before": outcome.tokens_before,
"tokens_after": outcome.tokens_after,
"ratio": outcome.compression_ratio,
"duration_sec": outcome.duration_sec,
"retention_percent": outcome.retention_percent,
"facts_total": outcome.facts_total,
"status": outcome.status,
})
# Keep history limited to last 50 entries
rec["history"] = rec["history"][-50:]
self.memory_file.write_text(json.dumps(existing_data, indent=2, ensure_ascii=False), encoding="utf-8")
except Exception as exc:
logger.debug("Failed to record compression memory to %s: %s", self.memory_file, exc)

View file

@ -24,6 +24,8 @@ EVENT_REFRESH_STARTED = "REFRESH_STARTED"
EVENT_REFRESH_COMPLETED = "REFRESH_COMPLETED"
EVENT_REFRESH_FAILED = "REFRESH_FAILED"
EVENT_AGY_ELIGIBILITY_CHANGED = "AGY_ELIGIBILITY_CHANGED"
class EventBus:
"""Central thread-safe EventBus for decoupling backend state changes from UI rendering."""

View file

@ -22,6 +22,15 @@ from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from .context_compressor import (
COMPRESSION_MEMORY_FILE,
CompressionOutcome,
ContextCompressor,
FactualEntities,
extract_factual_entities,
verify_facts_retention,
)
logger = logging.getLogger(__name__)
SHARED_MEMORY_VAULT = Path("/srv/projects/AI-Memory")
@ -74,6 +83,10 @@ class ModelMemoryRecord:
history: List[Dict[str, Any]] = field(default_factory=list)
# Разделитель между сообщениями при подсчёте объёма контекста.
MESSAGE_SEPARATOR = "\n\n"
class LocalSupervisor:
"""Oversees and regulates work dispatch to local models."""
@ -85,16 +98,24 @@ class LocalSupervisor:
self,
base_url: str = "http://127.0.0.1:8081",
memory_path: Optional[Path] = None,
compressor: Optional[ContextCompressor] = None,
):
self.base_url = base_url.rstrip("/")
# /props и /tokenize у llama.cpp живут в КОРНЕ, а не под /v1. Проверено
# на сервере владельца: /props → 200, /v1/props → 404; то же с
# /tokenize. Адаптер передаёт сюда адрес вида .../v1, поэтому без
# нормализации оба запроса получали 404, счёт токенов молча падал на
# посимвольную оценку, и порог сжатия считался от выдуманного числа.
self.root_url = self.base_url[:-3].rstrip("/") if self.base_url.endswith("/v1") else self.base_url
self.memory_path = memory_path or LOCAL_MEMORY_FILE
self.compressor = compressor or ContextCompressor()
# -------------------------------------------------------------
# P0-5: Measured limits via /props and /tokenize
# -------------------------------------------------------------
def query_server_props(self, timeout_sec: float = 3.0) -> ServerPropsResult:
"""Query real model properties and context limits from live server."""
props_url = f"{self.base_url}/props"
props_url = f"{self.root_url}/props"
try:
req = urllib.request.Request(props_url, headers={"User-Agent": "Hermes-LocalSupervisor/1.0"})
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
@ -128,7 +149,7 @@ class LocalSupervisor:
if not text:
return TokenCountResult(tokens_count=0, is_estimated=False, method="exact_empty")
tok_url = f"{self.base_url}/tokenize"
tok_url = f"{self.root_url}/tokenize"
try:
payload = json.dumps({"content": text}).encode("utf-8")
req = urllib.request.Request(
@ -395,3 +416,99 @@ class LocalSupervisor:
clean = Path(name).stem
clean = clean.replace(".gguf", "").replace("-Q4_K_M", "").replace("-Instruct", "").strip()
return clean or "local-model"
# -------------------------------------------------------------
# P0-1, P0-2, P0-3, P0-4, P0-5: Context Compression Integration
# -------------------------------------------------------------
def compress_context_if_needed(
self,
messages: List[Dict[str, Any]],
target_context_limit: int,
compressor_profile: Optional[Any] = None,
threshold_percent: float = 75.0,
keep_recent_messages: int = 3,
timeout_sec: float = 60.0,
) -> Tuple[List[Dict[str, Any]], CompressionOutcome]:
"""Compress old context via ContextCompressor if measured tokens exceed threshold."""
full_text = "\n\n".join(str(m.get("content", "")) for m in messages if isinstance(m, dict))
current_token_count = self.count_tokens(full_text).tokens_count
new_messages, outcome = self.compressor.compress_messages_if_needed(
messages=messages,
target_context_limit=target_context_limit,
current_token_count=current_token_count,
compressor_profile=compressor_profile,
threshold_percent=threshold_percent,
keep_recent_messages=keep_recent_messages,
timeout_sec=timeout_sec,
)
# Сжиматель считает итог посимвольно (длина / 3.5): доступа к серверу у
# него нет. Здесь он есть, и степень сжатия — величина, которую владелец
# читает как измеренную. Пересчитываем настоящим токенизатором, а если
# он недоступен, честно помечаем оценкой.
if outcome.status == "SUCCESS":
new_text = MESSAGE_SEPARATOR.join(
str(m.get("content", "")) for m in new_messages if isinstance(m, dict)
)
counted = self.count_tokens(new_text)
outcome.tokens_after = counted.tokens_count
outcome.tokens_after_is_estimate = counted.is_estimated
outcome.saved_tokens = max(0, outcome.tokens_before - counted.tokens_count)
outcome.compression_ratio = round(
counted.tokens_count / max(1, outcome.tokens_before), 2
)
return new_messages, outcome
def get_compression_status(self, compressor_profile: Optional[Any] = None) -> Dict[str, Any]:
"""Return real-time diagnostic status of context compressor."""
if not compressor_profile and not os.environ.get("LOCAL_COMPRESSOR_BASE_URL"):
return {
"configured": False,
"status": "unconfigured",
"display_status": "Н/Д: модель для сжатия не выбрана",
"profile_id": None,
"endpoint": None,
"model": None,
"history_count": len(self.compressor._history_snapshots),
"total_saved_tokens": sum(h.saved_tokens for h in self.compressor._history_snapshots if h.status == "SUCCESS"),
}
base_url, model_name, _ = self.compressor.resolve_compressor_endpoint(compressor_profile)
# Check health of compressor endpoint
is_healthy = False
n_ctx = 32768
props_url = f"{base_url}/props"
if props_url.endswith("/v1/props"):
props_url = props_url.replace("/v1/props", "/props")
try:
req = urllib.request.Request(props_url, headers={"User-Agent": "Hermes-CompressorCheck/1.0"})
with urllib.request.urlopen(req, timeout=2.0) as resp:
data = json.loads(resp.read().decode("utf-8"))
gen_settings = data.get("default_generation_settings", {})
n_ctx = int(gen_settings.get("n_ctx") or data.get("n_ctx") or 32768)
is_healthy = True
except Exception:
is_healthy = False
recent = self.compressor.get_compression_history(limit=5)
return {
"configured": True,
"status": "ready" if is_healthy else "offline",
"display_status": (
f"🟢 Готов: {model_name} ({base_url}) | Контекст: {n_ctx}"
if is_healthy
else f"⚠️ Недоступен: {base_url} (сжатие пропускается, задачи не прерываются)"
),
"profile_id": getattr(compressor_profile, "profile_id", None) or "local-compressor",
"endpoint": base_url,
"model": model_name,
"n_ctx": n_ctx,
"is_healthy": is_healthy,
"history_count": len(self.compressor._history_snapshots),
"total_saved_tokens": sum(h.saved_tokens for h in self.compressor._history_snapshots if h.status == "SUCCESS"),
"recent_compressions": recent,
}

View file

@ -76,6 +76,13 @@ class ModelDiscoveryService:
# NON-BLOCKING READ API
# ─────────────────────────────────────────────────────────────
def remember_models(self, provider: str, profile_id: str, models: list[str]) -> None:
with self._cache_lock:
self._cache[f"{provider.lower()}:{profile_id}"] = {
"models": list(models), "discovered_at": time.time(), "error": None,
}
self._save_cache_to_disk()
def get_models(self, provider: str) -> Optional[List[str]]:
"""Return cached models for provider immediately, or None if undiscovered."""
meta = self.get_models_with_metadata(provider)
@ -203,7 +210,7 @@ class ModelDiscoveryService:
result_holder[0] = models
error_holder[0] = err_msg
except Exception as exc:
error_holder[0] = str(exc)
error_holder[0] = str(exc).strip() or type(exc).__name__
worker = threading.Thread(target=_do_probe, daemon=True)
worker.start()
@ -362,9 +369,56 @@ class ModelDiscoveryService:
if prov in ("antigravity", "google-antigravity"):
from antigravity_provider.agy_subprocess import discover_models
main_p = getattr(self._probe_context, "profile_id", None) or ProfileAuthManager.get_main_profile("antigravity") or "ag-orch-fallback"
from antigravity_provider.paths import get_hermes_home
from antigravity_provider.router.router_config import load_router_config
candidate_pids: List[str] = []
req_p = getattr(self._probe_context, "profile_id", None)
if req_p:
candidate_pids.append(req_p)
try:
res = discover_models(profile_id=main_p)
cfg = load_router_config()
for pid, pcfg in cfg.profiles.items():
if pcfg.provider.lower() in ("antigravity", "google-antigravity") and pid not in candidate_pids:
candidate_pids.append(pid)
except Exception:
pass
main_p = ProfileAuthManager.get_main_profile("antigravity")
if main_p and main_p not in candidate_pids:
candidate_pids.append(main_p)
standard_slots = (
["ag-orch-primary", "ag-orch-fallback"]
+ [f"ag-{i}" for i in range(1, 21)]
+ [f"ag-w{i}" for i in range(1, 11)]
)
for s in standard_slots:
if s not in candidate_pids:
candidate_pids.append(s)
try:
agy_dir = get_hermes_home() / "agy_profiles"
if agy_dir.is_dir():
for sub in sorted(agy_dir.iterdir()):
if sub.is_dir() and sub.name not in candidate_pids:
candidate_pids.append(sub.name)
except Exception:
pass
target_pid = None
for cand in candidate_pids:
st = ProfileAuthManager.get_profile_status("antigravity", cand)
if st.get("authenticated") or ProfileAuthManager.load_profile_auth("antigravity", cand):
target_pid = cand
break
if not target_pid:
target_pid = candidate_pids[0] if candidate_pids else "ag-orch-fallback"
try:
res = discover_models(profile_id=target_pid)
if res:
return sorted(list(set(res.values()))), None
return None, "Модели Google Antigravity не обнаружены"
@ -412,7 +466,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err)
logger.debug("Codex model query HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("Codex model query failed on %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для OpenAI Codex"
@ -448,7 +502,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err)
logger.debug("OpenCode model query HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("OpenCode model query failed on %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для OpenCode Go"
@ -484,7 +538,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err)
logger.debug("Grok model discovery HTTP error for %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("Grok model discovery failed for %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для Grok"
@ -523,7 +577,7 @@ class ModelDiscoveryService:
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
return None, last_err or "Отсутствуют учетные данные для Claude"
elif prov in ("openrouter",):
@ -574,7 +628,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err)
logger.debug("OpenRouter model discovery HTTP error for %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("OpenRouter model discovery failed for %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для OpenRouter"
@ -613,7 +667,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err)
logger.debug("NVIDIA model discovery HTTP error for %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("NVIDIA model discovery failed for %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для NVIDIA NIM"
@ -655,7 +709,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err)
logger.debug("Ollama /api/tags HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("Ollama /api/tags query failed on %s: %s", pid, exc)
# 2. Try OpenAI-compatible endpoint /v1/models
@ -675,7 +729,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err)
logger.debug("Ollama /v1/models HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("Ollama /v1/models query failed on %s: %s", pid, exc)
return None, last_err or "Не удалось подключиться к серверу Ollama"
@ -708,13 +762,12 @@ class ModelDiscoveryService:
for m in items
if m
]
if models:
return sorted(set(models)), None
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("Local LLM model query HTTP error on %s (%s): %s", pid, base_url, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("Local LLM model query failed on %s (%s): %s", pid, base_url, exc)
return None, last_err or "Не удалось подключиться к локальному серверу LLM"

View file

@ -0,0 +1,180 @@
"""Определение моделей, доступных конкретному аккаунту.
Каталог NVIDIA публичный: `GET /v1/models` отдаётся вообще без ключа и
возвращает один и тот же список всем. Полей о доступности в нём нет только
`id`, `object`, `created`, `owned_by`. Проверено запросом.
Значит узнать, чем может пользоваться конкретный аккаунт, из каталога нельзя.
Единственный достоверный способ спросить у самой модели. Недоступная
отвечает до генерации:
404 Function ... Not found for account '<id>'
то есть проверка недоступной модели не стоит ничего, а доступной один токен
при `max_tokens=1`.
Опрос запускается ТОЛЬКО по явному действию владельца и результат сохраняется:
83 запроса подряд упираются в ограничения провайдера, и делать это молча при
каждом подключении неправильно.
"""
from __future__ import annotations
import json
import logging
import threading
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from antigravity_provider import paths
logger = logging.getLogger("hermes.router.entitlements")
# Опрашиваем небольшими пачками: провайдеры ограничивают частоту, а выигрыш от
# большего числа потоков всё равно съедается их лимитом.
DEFAULT_WORKERS = 8
DEFAULT_TIMEOUT_SEC = 20.0
@dataclass
class EntitlementResult:
"""Итог опроса одного аккаунта."""
provider: str
profile_id: str
checked_at: float
available: List[str] = field(default_factory=list)
unavailable: List[str] = field(default_factory=list)
undetermined: Dict[str, str] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"provider": self.provider,
"profile_id": self.profile_id,
"checked_at": self.checked_at,
"available": list(self.available),
"unavailable": list(self.unavailable),
"undetermined": dict(self.undetermined),
"total": len(self.available) + len(self.unavailable) + len(self.undetermined),
}
def _store_path() -> Any:
return paths.get_hermes_home() / "model_entitlements.json"
def load_entitlements(provider: str, profile_id: str) -> Optional[Dict[str, Any]]:
"""Ранее определённая доступность или None, если опрос не проводился."""
try:
path = _store_path()
if not path.is_file():
return None
data = json.loads(path.read_text(encoding="utf-8-sig"))
return data.get(f"{provider}:{profile_id}")
except Exception as exc:
logger.debug("Не удалось прочитать сохранённую доступность: %s", exc)
return None
def save_entitlements(result: EntitlementResult) -> None:
path = _store_path()
try:
data: Dict[str, Any] = {}
if path.is_file():
try:
data = json.loads(path.read_text(encoding="utf-8-sig"))
except Exception:
data = {}
data[f"{result.provider}:{result.profile_id}"] = result.to_dict()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception as exc:
logger.warning("Не удалось сохранить доступность моделей: %s", exc)
def _probe_one(base_url: str, token: str, model: str, timeout: float) -> tuple:
"""Вернуть (model, state, note): state — available | unavailable | undetermined."""
body = json.dumps(
{"model": model, "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1}
).encode()
req = urllib.request.Request(
base_url.rstrip("/") + "/chat/completions",
data=body,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
payload = json.load(response)
if isinstance(payload.get("choices"), list):
return (model, "available", "")
return (model, "undetermined", "провайдер вернул ответ без choices")
except urllib.error.HTTPError as exc:
# 404 — модель аккаунту не выдана. Это ответ провайдера, а не сбой.
if exc.code == 404:
return (model, "unavailable", "не выдана аккаунту")
# 401/403 — отвергнут сам ключ: дальше опрашивать бессмысленно.
if exc.code in (401, 403):
return (model, "undetermined", f"ключ отвергнут (HTTP {exc.code})")
if exc.code == 429:
return (model, "undetermined", "превышена частота запросов")
return (model, "undetermined", f"HTTP {exc.code}")
except Exception as exc:
return (model, "undetermined", str(exc)[:80])
def probe_account_models(
provider: str,
profile_id: str,
token: str,
base_url: str,
models: List[str],
workers: int = DEFAULT_WORKERS,
timeout: float = DEFAULT_TIMEOUT_SEC,
progress: Optional[Callable[[int, int], None]] = None,
) -> EntitlementResult:
"""Опросить каталог и разложить модели по доступности.
Модель, про которую ответ неоднозначен, попадает в `undetermined` с
причиной выдавать её за доступную или недоступную нельзя.
"""
result = EntitlementResult(
provider=provider, profile_id=profile_id, checked_at=time.time()
)
if not models:
return result
if not token:
result.undetermined = {m: "ключ не задан" for m in models}
return result
done = 0
lock = threading.Lock()
def _run(model: str):
nonlocal done
outcome = _probe_one(base_url, token, model, timeout)
with lock:
done += 1
if progress:
try:
progress(done, len(models))
except Exception:
pass
return outcome
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
for model, state, note in pool.map(_run, models):
if state == "available":
result.available.append(model)
elif state == "unavailable":
result.unavailable.append(model)
else:
result.undetermined[model] = note
result.available.sort()
result.unavailable.sort()
save_entitlements(result)
return result

View file

@ -15,7 +15,7 @@ import threading
import time
import urllib.request
import urllib.error
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
@ -114,6 +114,76 @@ class ProfileAuthManager:
"""Official API to get isolated directory for a profile."""
return get_profile_dir(profile_id, provider)
# agy 2.0 (Antigravity CLI) читает вход НЕ из .gemini/oauth_creds.json —
# это формат Gemini CLI. Свой токен он держит в
# .gemini/antigravity-cli/antigravity-oauth-token, и структура там другая:
# обёртка {"auth_method": ..., "token": {...}}.
#
# Хаб писал только файл Gemini CLI, поэтому авторизация проходила успешно, а
# agy отвечал «Please sign in to view available models»: он смотрел в файл,
# которого нет. Установлено сравнением рабочего профиля с неработающим.
# Значение взято из рабочего профиля владельца, не выведено из общих
# соображений: agy пишет туда "consumer" для личного аккаунта Google.
ANTIGRAVITY_AUTH_METHOD = "consumer"
@classmethod
def _write_antigravity_cli_token(cls, profile_dir: Path, creds_dict: dict) -> Optional[Path]:
"""Записать токен в формате Antigravity CLI рядом с файлом Gemini CLI."""
cli_dir = profile_dir / ".gemini" / "antigravity-cli"
target = cli_dir / "antigravity-oauth-token"
try:
cli_dir.mkdir(parents=True, exist_ok=True)
try:
os.chmod(cli_dir, 0o700)
except OSError:
pass
# Способ входа сохраняем такой же, как у уже работающего профиля на
# этой машине: угадывать его значение нельзя, а рабочий образец
# рядом — самый надёжный источник.
auth_method = cls.ANTIGRAVITY_AUTH_METHOD
try:
for sibling in profile_dir.parent.iterdir():
if sibling == profile_dir or not sibling.is_dir():
continue
ref = sibling / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
if ref.is_file():
existing = json.loads(ref.read_text(encoding="utf-8"))
if isinstance(existing, dict) and existing.get("auth_method"):
auth_method = str(existing["auth_method"])
break
except Exception:
pass
# Срок годности пишем в обоих видах. Gemini CLI (Node) ждёт
# expiry_date в миллисекундах, Go-шный oauth2.Token — expiry
# строкой RFC3339. Какой из них читает agy, по бинарнику не
# определить, а лишнее поле разбор JSON пропускает.
token_payload = dict(creds_dict)
try:
expiry_ms = int(creds_dict.get("expiry_date") or 0)
if expiry_ms > 0:
token_payload["expiry"] = (
datetime.fromtimestamp(expiry_ms / 1000, tz=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
)
except (TypeError, ValueError, OSError, OverflowError):
pass
payload = {"auth_method": auth_method, "token": token_payload}
temp = cli_dir / f"antigravity-oauth-token.tmp-{threading.get_ident()}-{time.time_ns()}"
temp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
os.replace(temp, target)
try:
os.chmod(target, 0o600)
except OSError:
pass
return target
except Exception as exc:
logger.error("Не записан токен Antigravity CLI в %s: %s", target, exc)
raise
@classmethod
def write_agy_oauth_creds(cls, profile_dir: Path, auth_data: dict) -> Path:
"""Atomically write <profile_dir>/.gemini/oauth_creds.json in exact agy CLI format."""
@ -123,6 +193,14 @@ class ProfileAuthManager:
access_token = token_info.get("access_token") or auth_data.get("access_token") or ""
refresh_token = token_info.get("refresh_token") or auth_data.get("refresh_token") or ""
# Файл без токена доступа бесполезен: agy на нём отвечает «Please sign in
# to view available models», а хаб считает аккаунт подключённым. Пустой
# вход не должен выдаваться за успешный.
if not access_token and not refresh_token:
raise ValueError(
"Вход не завершён: провайдер не вернул токен доступа. "
"Учётные данные agy не записаны."
)
scope = token_info.get("scope") or auth_data.get("scope") or ""
token_type = token_info.get("token_type") or auth_data.get("token_type") or "Bearer"
id_token = token_info.get("id_token") or auth_data.get("id_token") or ""
@ -179,6 +257,8 @@ class ProfileAuthManager:
os.chmod(target_file, 0o600)
except OSError:
pass
cls._write_antigravity_cli_token(profile_dir, creds_dict)
return target_file
@staticmethod
@ -314,7 +394,16 @@ class ProfileAuthManager:
try:
cls.write_agy_oauth_creds(pdir, auth_data)
except Exception as e:
logger.warning("Failed to write .gemini/oauth_creds.json for profile=%s: %s", profile_id, e)
# Прежде сбой оставался только в журнале, и владелец видел
# «подключено» при неработающем аккаунте. agy читает именно
# этот файл, поэтому без него подключения нет.
logger.error(
"Не записаны учётные данные agy для профиля %s: %s", profile_id, e
)
raise RuntimeError(
f"Учётные данные для agy не записаны ({e}). "
f"Аккаунт {profile_id} подключённым не считается."
) from e
# For Local and OpenAI-compatible providers, synchronize custom_base_url in router_profiles.yaml
if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm", "openrouter", "nvidia", "nvidia-nim"):
@ -423,6 +512,49 @@ class ProfileAuthManager:
except Exception as e:
logger.warning("Error reading %s: %s", auth_file, e)
if not auth_file.is_file() and provider in ("antigravity", "google-antigravity"):
pdir = get_profile_dir(profile_id, provider)
cli_token = pdir / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
if cli_token.is_file():
try:
data = json.loads(cli_token.read_text(encoding="utf-8"))
if isinstance(data, dict):
tokens = data.get("token") or data
# Extract email if possible
email = None
acc_file = pdir / ".gemini" / "google_accounts.json"
if acc_file.is_file():
try:
acc_d = json.loads(acc_file.read_text(encoding="utf-8"))
if isinstance(acc_d, dict) and acc_d.get("active") and "@" in str(acc_d["active"]):
email = str(acc_d["active"]).strip()
except Exception:
pass
if not email and isinstance(tokens, dict):
id_tok = tokens.get("id_token")
if id_tok:
jwt_em, _ = cls.extract_jwt_identity(str(id_tok))
if jwt_em and "@" in jwt_em:
email = jwt_em
return {
"provider": provider,
"profile_id": profile_id,
"token": tokens,
"auth_method": data.get("auth_method", "consumer"),
"email": email or "",
}
except Exception as e:
logger.warning("Error reading %s: %s", cli_token, e)
gemini_creds = pdir / ".gemini" / "oauth_creds.json"
if gemini_creds.is_file():
try:
data = json.loads(gemini_creds.read_text(encoding="utf-8"))
if isinstance(data, dict):
return {"provider": provider, "profile_id": profile_id, "token": data, "auth_method": "oauth"}
except Exception as e:
logger.warning("Error reading %s: %s", gemini_creds, e)
# Fallbacks for specific providers
if provider == "openai-codex":
env_var = f"CODEX_TOKEN_{profile_id.upper().replace('-', '_')}"
@ -655,11 +787,24 @@ class ProfileAuthManager:
}
if provider in ("antigravity", "google-antigravity"):
tokens = auth_data.get("token") or auth_data.get("tokens", {})
acc_token = tokens.get("access_token") if isinstance(tokens, dict) else (auth_data.get("access_token") or "")
id_token = tokens.get("id_token") if isinstance(tokens, dict) else (auth_data.get("id_token") or "")
refresh_tok = tokens.get("refresh_token") if isinstance(tokens, dict) else (auth_data.get("refresh_token") or "")
email = auth_data.get("email")
tokens = auth_data.get("token") or auth_data.get("tokens")
if not isinstance(tokens, dict):
tokens = {}
acc_token = tokens.get("access_token") or auth_data.get("access_token") or ""
id_token = tokens.get("id_token") or auth_data.get("id_token") or ""
refresh_tok = tokens.get("refresh_token") or auth_data.get("refresh_token") or ""
key = auth_data.get("api_key", "")
email = auth_data.get("email") or auth_data.get("user_email")
is_auth = bool(acc_token or refresh_tok or key or (email and auth_data.get("auth_method") == "oauth"))
if not is_auth:
return {
"authenticated": False,
"provider": provider,
"profile_id": profile_id,
"status": "NOT_CONFIGURED",
"error": None,
}
acc_id = None
if id_token:
email_from_jwt, acc_id = cls.extract_jwt_identity(id_token)
@ -668,11 +813,14 @@ class ProfileAuthManager:
email_from_jwt, acc_id = cls.extract_jwt_identity(acc_token)
email = email or email_from_jwt
expiry = tokens.get("expiry_date") if isinstance(tokens, dict) else auth_data.get("expiry_date")
if not expiry and isinstance(tokens, dict):
expiry = tokens.get("expires_at")
expiry = (
tokens.get("expiry_date")
or auth_data.get("expiry_date")
or tokens.get("expires_at")
or auth_data.get("expires_at")
)
if not expiry:
expiry_str = tokens.get("expiry") if isinstance(tokens, dict) else auth_data.get("expiry")
expiry_str = tokens.get("expiry") or auth_data.get("expiry")
if expiry_str:
try:
dt = datetime.fromisoformat(str(expiry_str).replace("Z", "+00:00"))
@ -688,7 +836,7 @@ class ProfileAuthManager:
is_expired = not bool(refresh_tok)
return {
"authenticated": True,
"authenticated": not is_expired,
"provider": provider,
"profile_id": profile_id,
"email_masked": mask_email(email) if email else None,

View file

@ -255,6 +255,29 @@ class ProfileOAuthSession:
email = fetch_user_email(tokens["access_token"])
logger.info("OAuth account identity resolved (email_found=%s)", bool(email))
# Слот выбирается ДО входа, когда почта ещё неизвестна, поэтому
# повторный вход тем же аккаунтом занимал очередной свободный
# слот: у владельца один аккаунт расползся на ag-2, ag-3, ag-4.
# Узнав почту, возвращаем учётные данные в слот, который этот
# аккаунт уже занимает, вместо создания двойника.
if email:
from antigravity_provider.router.auto_assigner import AutoAssigner
try:
existing = AutoAssigner.check_duplicate_identity(
"antigravity", email, exclude_profile_id=self.profile_id
)
except Exception as exc:
existing = None
logger.warning("Проверка двойников не выполнена: %s", exc)
if existing and existing != self.profile_id:
logger.info(
"Аккаунт уже занимает профиль %s — пишем туда, а не в %s",
existing,
self.profile_id,
)
self.profile_id = existing
# Format in standard gemini:antigravity shape
expires_at = tokens.get("expires_at") or (int(time.time()) + 3600)
auth_data = {

View file

@ -69,6 +69,19 @@ class AccountQuotaService:
# If not in cache, generate baseline snapshot from profile auth
return self._generate_baseline_snapshot(provider, profile_id)
def forget_profile(self, provider: str, profile_id: str) -> None:
"""Забыть опознание и квоты профиля.
_identities и _snapshots живут в памяти по ключу «провайдер:слот».
При удалении ключа они не чистились, и слот, переиспользованный под
другой аккаунт, показывал прежнюю почту: владелец удалил один
аккаунт Antigravity, завёл другой и увидел в списке старый адрес.
"""
key = f"{provider}:{profile_id}"
with self._cache_lock:
self._identities.pop(key, None)
self._snapshots.pop(key, None)
def get_identity(self, provider: str, profile_id: str) -> AccountIdentity:
key = f"{provider}:{profile_id}"
with self._cache_lock:
@ -905,6 +918,31 @@ class AccountQuotaService:
def _generate_baseline_snapshot(self, provider: str, profile_id: str) -> QuotaSnapshot:
"""Truthful offline baseline with provider-specific independent limit pools."""
now = _utc_now()
# У облачного аккаунта Ollama лимиты есть, и объявлять их
# отсутствующими нельзя. Локальный сервер узнаётся по отсутствию
# ключа: облачный доступ без ключа невозможен.
if provider == "ollama":
try:
from .profile_manager import ProfileAuthManager
auth = ProfileAuthManager.load_profile_auth(provider, profile_id) or {}
is_cloud = bool((auth.get("api_key") or auth.get("token") or "").strip())
except Exception:
is_cloud = False
if is_cloud:
return QuotaSnapshot(
account_id=profile_id,
provider=provider,
buckets=[],
fetched_at=now,
source="cloud_provider",
unavailable_reason=(
"Н/Д: лимиты облачного аккаунта Ollama не измерены — "
"провайдер не сообщает их через API"
),
is_loading=False,
)
if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"):
b_unlimited = QuotaBucket(
id="local.unlimited",

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

@ -6,6 +6,8 @@ along with Obsidian shared memory validation and canonical structure setup.
from __future__ import annotations
import json
from urllib.parse import urlparse
import re
import logging
import os
import tempfile
@ -32,6 +34,18 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"email_masking_mode": "none",
"default_role": "manager",
"obsidian_vault_path": "/srv/projects/AI-Memory",
"compressor_profile_id": None,
"compression_threshold_percent": 75.0,
"compression_keep_recent_messages": 3,
"compression_enabled": True,
# Прокси для обращений провайдеров, у которых проверка доступности
# смотрит на адрес. Google отказал владельцу: «not currently available
# in your location». Задаётся адресом вида socks5://127.0.0.1:1080 или
# http://127.0.0.1:8080; пустое значение означает «без прокси».
"provider_proxy_url": "",
# Путь к пользовательскому сценарию патча проверки доступности agy (A58).
# Пустое значение означает «сценарий не указан».
"agy_patch_script_path": "",
}
@ -53,6 +67,36 @@ def get_settings_file() -> Path:
return get_hermes_home() / "hub_settings.json"
def _normalize_proxy_url(raw: Any) -> str:
"""Привести адрес прокси к рабочему виду или отбросить.
Проверяем не только схему, но и сам адрес: приписать socks5:// можно
чему угодно, и тогда мусор выглядел бы принятым, а все обращения
провайдера молча ломались бы без внятной причины.
"""
value = str(raw or "").strip()
if not value:
return ""
if "://" not in value:
value = "socks5://" + value
try:
parsed = urlparse(value)
except ValueError:
return ""
if parsed.scheme not in ("http", "https", "socks5", "socks5h"):
return ""
host = parsed.hostname or ""
if not host or not re.fullmatch(r"[A-Za-z0-9._\-\[\]:]+", host):
return ""
try:
port = parsed.port
except ValueError:
return ""
if port is not None and not (1 <= port <= 65535):
return ""
return value
def get_hub_settings() -> Dict[str, Any]:
"""Load settings from hub_settings.json merged with standard defaults, cached by mtime."""
global _SETTINGS_CACHE, _SETTINGS_CACHE_MTIME, _SETTINGS_CACHE_PATH
@ -124,6 +168,29 @@ def get_hub_settings() -> Dict[str, Any]:
vault_path = str(merged.get("obsidian_vault_path", "/srv/projects/AI-Memory")).strip()
merged["obsidian_vault_path"] = vault_path
compressor_pid = merged.get("compressor_profile_id")
if compressor_pid is not None and str(compressor_pid).strip() and str(compressor_pid).strip() != "none":
merged["compressor_profile_id"] = str(compressor_pid).strip()
else:
merged["compressor_profile_id"] = None
try:
merged["compression_threshold_percent"] = max(10.0, min(95.0, float(merged.get("compression_threshold_percent", 75.0))))
except (ValueError, TypeError):
merged["compression_threshold_percent"] = 75.0
# Адрес прокси проверяем на вид, а не принимаем что попало: неверная
# схема тихо ломает все обращения провайдера, и причина не видна.
merged["provider_proxy_url"] = _normalize_proxy_url(merged.get("provider_proxy_url"))
try:
merged["compression_keep_recent_messages"] = max(1, min(20, int(merged.get("compression_keep_recent_messages", 3))))
except (ValueError, TypeError):
merged["compression_keep_recent_messages"] = 3
merged["compression_enabled"] = bool(merged.get("compression_enabled", True))
merged["agy_patch_script_path"] = str(merged.get("agy_patch_script_path") or "").strip()
_SETTINGS_CACHE = dict(merged)
_SETTINGS_CACHE_MTIME = current_mtime
_SETTINGS_CACHE_PATH = sfile_str
@ -277,8 +344,46 @@ def setup_memory_structure(
def get_hermes_config_status(config_path: Optional[Path] = None) -> Dict[str, Any]:
"""Read Hermes configuration (~/.hermes/config.yaml) in read-only mode to provide status feedback."""
import yaml
checked: list = []
if config_path is None:
config_path = get_hermes_home() / "config.yaml"
# Hermes хранит конфигурацию не в одном месте: путь зависит от версии и
# способа установки. Раньше проверялся ровно один файл, и у владельца на
# Linux хаб писал «конфигурация не найдена» при работающем Hermes.
home = get_hermes_home()
candidates = [
home / "config.yaml",
home / "config.yml",
home / "config.json",
home / "hermes-agent" / "config.yaml",
Path.home() / ".hermes" / "config.yaml",
Path.home() / ".config" / "hermes" / "config.yaml",
Path.home() / ".config" / "hermes-agent" / "config.yaml",
]
seen = set()
for candidate in candidates:
key = str(candidate)
if key in seen:
continue
seen.add(key)
checked.append(key)
try:
if candidate.exists():
config_path = candidate
break
except OSError as exc:
# Каталог может быть закрыт правами: это «не смогли проверить»,
# а не «файла нет». Разница важна, иначе диагноз ложный.
checked[-1] = f"{key} (нет доступа: {exc.strerror or exc})"
else:
return {
"exists": False,
"model": None,
"provider": None,
"base_url": None,
"path": None,
"checked_paths": checked,
"message": "Конфигурация Hermes не найдена. Проверено: " + "; ".join(checked),
}
if not config_path.exists():
return {
@ -287,7 +392,8 @@ def get_hermes_config_status(config_path: Optional[Path] = None) -> Dict[str, An
"provider": None,
"base_url": None,
"path": str(config_path),
"message": "Конфигурационный файл ~/.hermes/config.yaml не найден",
"checked_paths": checked or [str(config_path)],
"message": f"Конфигурация Hermes не найдена по пути {config_path}",
}
try:

View file

@ -11,6 +11,8 @@ import time
from dataclasses import dataclass, field, replace
from typing import Any, Dict, List, Optional, Tuple
from antigravity_provider.version import __version__
from antigravity_provider.router.event_bus import (
EventBus,
EVENT_ACCOUNT_UPDATED,
@ -75,6 +77,7 @@ class HubStateStore:
def __init__(self) -> None:
self._lock = threading.RLock()
self._removed_accounts: set[str] = set()
self._generation: int = 0
self._current_snapshot: Optional[HubSnapshot] = None
self._pending_refreshes: Dict[str, float] = {}
@ -138,6 +141,9 @@ class HubStateStore:
# requests may complete while this build is running.
uh_service = UnifiedHealthService.get()
profiles_by_prov = uh_service.scan_all(force=force_scan)
with self._lock:
removed = set(self._removed_accounts)
profiles_by_prov = {provider: [p for p in profiles if p.profile_id not in removed] for provider, profiles in profiles_by_prov.items()}
all_profs = {
profile.profile_id: profile
for profiles in profiles_by_prov.values()
@ -209,7 +215,9 @@ class HubStateStore:
hermes_cfg = {"exists": False, "model": None, "provider": None}
default_role = "manager"
from antigravity_provider.version import __version__
metrics = {
"version": __version__,
"generation": gen,
"seq": request_seq,
"duration_ms": round((time.time() - t0) * 1000, 2),
@ -307,6 +315,7 @@ class HubStateStore:
routing={},
quotas={},
metrics={
"version": __version__,
"generation": 0,
"seq": 0,
"telemetry": telemetry_data,
@ -386,6 +395,8 @@ class HubStateStore:
profile: ProfileViewModel | str,
profile_id: Optional[str] = None,
) -> None:
with self._lock:
self._removed_accounts.discard(profile_id if isinstance(profile, str) else profile.profile_id)
if isinstance(profile, str):
provider = profile
if profile_id is None:
@ -410,8 +421,16 @@ class HubStateStore:
},
)
def apply_delta_profile_preferences(self, profile_id: str, models: list[str]) -> None:
# A configuration change does not require quota/identity network requests.
with self._lock:
profile = self._current_snapshot.get_profile(profile_id) if self._current_snapshot else None
if profile:
self._apply_profile_delta(replace(profile, preferred_models=list(models)))
def apply_delta_account_removed(self, provider: str, profile_id: str) -> None:
with self._lock:
self._removed_accounts.add(profile_id)
current = self._current_snapshot or self._build_empty_snapshot()
all_profiles = dict(current.all_profiles)
all_profiles.pop(profile_id, None)

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

@ -539,11 +539,29 @@ class UnifiedHealthService:
model_meta = ModelDiscoveryService.get().get_models_with_metadata(prov, pid)
if prov == "ollama":
model_meta["cloud"] = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog")
if is_authenticated and pcfg.enabled:
if is_authenticated and pcfg.enabled and not check.get("models_only"):
if check.get("state") == "checking":
health_state, health_lbl = "checking", "Проверяется…"
elif check.get("state") == "failed":
health_state, health_lbl = STATUS_UNHEALTHY, "Проверен: не работает — " + check.get("message", "Причина Н")
# Вердикт проверки и каталог моделей обновляются
# независимо, и в карточке владельца они оказались
# рядом: «не работает — Please sign in» и тут же
# «Получено 11 моделей» с более поздним временем.
# Утверждать отказ, когда каталог получен ПОЗЖЕ него,
# нельзя: провайдер с тех пор ответил. Но и объявлять
# аккаунт рабочим не за что — проверка после этого не
# выполнялась. Честный ответ — что вердикт устарел.
checked_at = check.get("checked_at") or 0
catalog_at = model_meta.get("discovered_at") or 0
if not model_meta.get("error") and catalog_at > checked_at:
when = datetime.datetime.fromtimestamp(catalog_at).strftime("%H:%M:%S")
health_state = STATUS_NOT_TESTED
health_lbl = (
f"Проверка устарела: каталог моделей получен позже ({when}). "
"Нажмите «Проверить подключение»."
)
else:
health_state, health_lbl = STATUS_UNHEALTHY, "Проверен: не работает — " + check.get("message", "Причина Н")
elif check.get("state") == "working" and health_state in (STATUS_NOT_TESTED, STATUS_HEALTHY):
health_state, health_lbl = STATUS_HEALTHY, "Проверен: работает"
assigned = role_assignments.get(pid, [])

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__
@ -23,7 +24,13 @@ from antigravity_provider.router.state_store import HubStateStore
from antigravity_provider.router.action_handler import ActionExecutor
from antigravity_provider.router.router_config import load_router_config
from antigravity_provider.updater.update_manager import get_installed_commit, UpdateManager
from antigravity_provider.updater.update_manager import (
get_installed_commit,
get_installed_build_time,
get_last_applied_update,
acknowledge_last_applied_update,
UpdateManager,
)
logger = logging.getLogger("hermes.router.web")
@ -99,12 +106,87 @@ 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() читает манифест с диска при каждом вызове, поэтому
# переживший обновление процесс бодро рапортует свежий коммит: владелец видит
# новый номер и старое поведение. Отличить сборки по такому полю нельзя.
#
# Снятое при старте значение отвечает на настоящий вопрос: какой код сейчас
# в памяти. Вместе со временем запуска этого достаточно, чтобы понять, дошло
# ли обновление до работы, а не только до файлов.
RUNNING_COMMIT: str = get_installed_commit()
PROCESS_STARTED_AT: float = time.time()
@app.get("/api/health")
def health_check():
from ..account_probe_service import AccountProbeService
return {
"pid": os.getpid(),
"account_probe": AccountProbeService.get().status(),
"ok": True,
"version": __version__,
"commit": get_installed_commit(),
"running_commit": RUNNING_COMMIT,
"started_at": PROCESS_STARTED_AT,
# Antigravity и Claude раньше стояли здесь как supported: False с
# советом идти в десктоп или пробрасывать порты. Это было неверно:
# ProfileOAuthSession.handle_manual_callback_url и
@ -116,8 +198,8 @@ def health_check():
"opencode-go": {"supported": True, "reason": "token"},
"antigravity": {
"supported": True,
"reason": "redirect-url-paste",
"hint": "Откройте ссылку в любом браузере и верните адрес из адресной строки",
"reason": "terminal-or-redirect",
"hint": "Вход через agy в терминале (основной) или по ссылке в браузере (запасной)",
},
"claude": {
"supported": True,
@ -194,6 +276,28 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
raise HTTPException(status_code=503, detail="Snapshot not ready")
snap_dict = dataclasses.asdict(snapshot)
from ..account_probe_service import AccountProbeService
from ..model_discovery_service import ModelDiscoveryService
probe, discovery = AccountProbeService.get(), ModelDiscoveryService.get()
snap_dict["account_probe"] = probe.status()
for profile in snap_dict.get("all_profiles", {}).values():
pid, provider = profile["profile_id"], profile["provider"]
check = probe.state(pid)
profile["connection_check"] = check
if profile.get("auth_state") == "AUTHENTICATED" and profile.get("enabled", True) and not check.get("models_only"):
if check.get("state") == "working":
profile["health_state"], profile["health_label_ru"] = "healthy", "Проверен: работает"
elif check.get("state") == "failed":
profile["health_state"], profile["health_label_ru"] = "unhealthy", "Проверен: не работает — " + check["message"]
elif check.get("state") == "checking":
profile["health_state"], profile["health_label_ru"] = "checking", "Проверяется…"
profile["model_discovery"] = discovery.get_models_with_metadata(provider, pid)
if provider == "ollama":
profile["model_discovery"]["cloud"] = discovery.get_models_with_metadata("ollama-cloud-catalog")
snap_dict["profiles_by_provider"] = {
provider: [snap_dict["all_profiles"].get(p["profile_id"], p) for p in profiles]
for provider, profiles in snap_dict.get("profiles_by_provider", {}).items()
}
snap_dict = sanitize_snapshot(snap_dict)
server_host = _web_settings().get("web_api_host", "127.0.0.1")
@ -209,10 +313,39 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
),
}
snap_dict["version"] = __version__
snap_dict["commit"] = get_installed_commit()
snap_dict["running_commit"] = RUNNING_COMMIT
snap_dict["started_at"] = PROCESS_STARTED_AT
snap_dict["system_paths"] = {
"hermes_home": str(paths.get_hermes_home()),
"config_dir": str(paths.get_config_dir()),
"log_file": str(paths.get_log_file()),
}
snap_dict["last_applied_update"] = get_last_applied_update()
try:
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
snap_dict["agy_eligibility"] = AgyEligibilityService.get().check_eligibility_state(force=False)
except Exception as exc:
snap_dict["agy_eligibility"] = {
"status": "unknown",
"status_label_ru": f"Н/Д: {exc}",
"detail_ru": str(exc),
"version": "Н",
"binary_path": "",
"binary_sha256": "",
"binary_size_bytes": 0,
"checked_at": time.time(),
"patch_script_path": "",
}
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:
@ -226,13 +359,17 @@ async def handle_action(request: Request, authorized: bool = Depends(get_auth_to
threading.Thread(target=func, name=name, daemon=True).start()
actor = request.headers.get("X-Hub-Actor") or (f"web:{request.client.host}" if request.client else "user:web")
result = await run_in_threadpool(ActionExecutor.execute, action, data.get("data", {}), async_runner=_async_runner, actor=actor)
try:
result = await run_in_threadpool(ActionExecutor.execute, action, data.get("data", {}), async_runner=_async_runner, actor=actor)
except Exception as exc:
result = {"ok": False, "message": f"Действие {action} завершилось ошибкой {type(exc).__name__}"}
if result.get("unknown"):
raise HTTPException(status_code=404, detail="Неизвестное действие")
return {
"ok": result.get("ok", False),
"message": result.get("message", ""),
"message": result.get("message") or ("Действие выполнено" if result.get("ok") else f"Действие {action} не выполнено: обработчик не сообщил причину"),
"data": result.get("data", {})
}
@ -281,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:
@ -302,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:
@ -331,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:
@ -350,6 +499,89 @@ async def diagnose_skill_endpoint(request: Request, authorized: bool = Depends(g
raise HTTPException(status_code=400, detail=str(exc))
@app.get("/api/compression/status")
def get_compression_status_endpoint(authorized: bool = Depends(get_auth_token)):
"""Return real-time diagnostic status of context compressor."""
from antigravity_provider.router.settings_service import get_hub_settings
from antigravity_provider.router.local_supervisor import LocalSupervisor
hub_settings = get_hub_settings()
c_pid = hub_settings.get("compressor_profile_id")
c_pcfg = None
if c_pid:
try:
c_pcfg = load_router_config().get_profile(c_pid)
except Exception:
pass
supervisor = LocalSupervisor()
status_data = supervisor.get_compression_status(c_pcfg)
status_data["threshold_percent"] = hub_settings.get("compression_threshold_percent", 75.0)
status_data["keep_recent_messages"] = hub_settings.get("compression_keep_recent_messages", 3)
status_data["compression_enabled"] = hub_settings.get("compression_enabled", True)
return JSONResponse(content=jsonable_encoder(status_data))
@app.get("/api/compression/history")
def get_compression_history_endpoint(limit: int = 20, authorized: bool = Depends(get_auth_token)):
"""Return historical record of recent context compressions."""
from antigravity_provider.router.context_compressor import ContextCompressor
history = ContextCompressor().get_compression_history(limit=limit)
return JSONResponse(content=jsonable_encoder({"history": history}))
@app.post("/api/compression/test")
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
from dataclasses import asdict
try:
data = await request.json()
except Exception:
data = {}
hub_settings = get_hub_settings()
c_pid = data.get("profile_id") or hub_settings.get("compressor_profile_id")
c_pcfg = None
if c_pid and c_pid != "none":
try:
c_pcfg = load_router_config().get_profile(c_pid)
except Exception:
pass
test_messages = [
{"role": "system", "content": "You are a software engineer."},
{"role": "user", "content": "Hermes Hub server runs on 192.168.1.81:8765. The primary coder is on port 8081 with 224K context (229376 tokens), generating at 107.4 tok/s. VRAM usage: 30008 MiB. Active branch: antigravity/a56-context-compression, Commit SHA: 26f7d2c, version v0.1.2. Local compressor is on port 8082."},
{"role": "assistant", "content": "Acknowledged. All server metrics and ports are noted."},
{"role": "user", "content": "Now run preflight diagnostics for LocalSupervisor and ContextCompressor in src/antigravity_provider/router/local_supervisor.py."},
{"role": "assistant", "content": "Diagnostics completed successfully. Memory vault is at /srv/projects/AI-Memory."},
{"role": "user", "content": "What is our current task?"},
]
supervisor = LocalSupervisor()
new_msgs, outcome = supervisor.compress_context_if_needed(
messages=test_messages,
target_context_limit=32768,
compressor_profile=c_pcfg,
threshold_percent=0.0, # force compression
keep_recent_messages=2,
)
return JSONResponse(content=jsonable_encoder({
"ok": outcome.status == "SUCCESS",
"message": outcome.status_message,
"data": {
"outcome": asdict(outcome) if hasattr(outcome, "__dataclass_fields__") else outcome.__dict__,
"messages_before_count": len(test_messages),
"messages_after_count": len(new_msgs),
}
}))
@app.get("/api/settings")
def get_settings(authorized: bool = Depends(get_auth_token)):
"""Return current server and hub settings without exposing raw auth tokens."""
@ -358,18 +590,29 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
last_check = UpdateManager.get_last_check_result()
server_host = raw.get("web_api_host", "127.0.0.1")
is_external = (server_host != "127.0.0.1" and server_host != "localhost")
system_paths = {
"hermes_home": str(paths.get_hermes_home()),
"config_dir": str(paths.get_config_dir()),
"log_file": str(paths.get_log_file()),
}
settings_out: Dict[str, Any] = {
"system_paths": system_paths,
"web_api_host": server_host,
"web_api_port": raw.get("web_api_port", 5800),
"web_api_token_configured": has_token,
"theme": raw.get("theme", "system"),
"quota_refresh_interval_sec": raw.get("quota_refresh_interval_sec", 300),
"hermes_home": str(paths.get_hermes_home()),
"config_dir": str(paths.get_config_dir()),
"log_file": str(paths.get_log_file()),
"quota_refresh_interval_sec": raw.get("quota_refresh_interval_sec", raw.get("account_check_interval_seconds", 300)),
"hermes_home": system_paths["hermes_home"],
"config_dir": system_paths["config_dir"],
"log_file": system_paths["log_file"],
"installed_commit": get_installed_commit(),
# Версия между сборками не меняется намеренно, а коммит — строка из
# шестнадцатеричных цифр, по которой трудно на глаз отличить старую
# сборку от новой. Время установки отвечает на этот вопрос сразу.
"installed_at": get_installed_build_time(),
"version": __version__,
"last_update_check": last_check.to_dict() if last_check else None,
"last_applied_update": get_last_applied_update(),
"network_security": {
"is_external_bind": is_external,
"is_tls": False,
@ -381,6 +624,21 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
),
},
}
try:
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
settings_out["agy_eligibility"] = AgyEligibilityService.get().check_eligibility_state(force=False)
except Exception as exc:
settings_out["agy_eligibility"] = {
"status": "unknown",
"status_label_ru": f"Н/Д: {exc}",
"detail_ru": str(exc),
"version": "Н",
"binary_path": "",
"binary_sha256": "",
"binary_size_bytes": 0,
"checked_at": time.time(),
"patch_script_path": "",
}
for k, v in raw.items():
if k not in settings_out and not any(secret in k.lower() for secret in ['token', 'secret', 'key', 'password', 'jwt']):
settings_out[k] = v
@ -465,6 +723,23 @@ if _STATIC_DIR.is_dir():
headers=_NO_CACHE,
)
# A48 и A49 добавили новые файлы клиента, а заголовки кэширования им не
# прописали: браузер держал их сколько угодно. Владелец обновлял сборку и
# видел прежний интерфейс — ровно та поломка, ради которой запрет кэша
# вводился для app.js. Перечисление сделано общим, чтобы следующий
# добавленный файл не оказался снова без заголовков.
for _client_script in ("workspace.js", "workflow.js", "workflow.css"):
def _make_route(name: str):
media = "text/css" if name.endswith(".css") else "application/javascript"
def _serve():
return FileResponse(str(_STATIC_DIR / name), media_type=media, headers=_NO_CACHE)
return _serve
app.get("/" + _client_script)(_make_route(_client_script))
@app.get("/snapshot.example.json")
def _fixture():
return FileResponse(str(_STATIC_DIR / "snapshot.example.json"), media_type="application/json")
@ -488,6 +763,7 @@ if _STATIC_DIR.is_dir():
# ─────────────────────────────────────────────────────────────
_SNAPSHOT_REFRESH_SEC = 30
_background_stop = threading.Event()
def _background_refresh_loop() -> None:
@ -509,17 +785,37 @@ def _background_refresh_loop() -> None:
except Exception as exc:
logger.debug("Initial background update check skipped: %s", exc)
while True:
# Check if update was just applied and log event to EventLogService
try:
from antigravity_provider.router.unified_health import EventLogService
applied = get_last_applied_update()
if applied and not applied.get("acknowledged"):
prev_v = applied.get("prev_version", "unknown")
prev_c = (applied.get("prev_commit") or "unknown")[:7]
new_v = applied.get("new_version", "unknown")
new_c = (applied.get("new_commit") or "unknown")[:7]
EventLogService.get().log(
"system",
f"Hermes Hub успешно обновлён с {prev_v} ({prev_c}) до {new_v} ({new_c})",
level="info",
)
acknowledge_last_applied_update()
logger.info("Recorded post-update event in EventLogService: %s -> %s", prev_c, new_c)
except Exception as exc:
logger.debug("Check last_applied_update on startup skipped: %s", exc)
while not _background_stop.is_set():
try:
AccountProbeService.get().tick()
HubStateStore.get().refresh(force_scan=False)
except Exception as exc:
logger.warning("Snapshot refresh failed: %s", exc)
time.sleep(_SNAPSHOT_REFRESH_SEC)
_background_stop.wait(_SNAPSHOT_REFRESH_SEC)
@app.on_event("startup")
def _start_background_refresh() -> None:
_background_stop.clear()
# В фоне: опрос ходит по сети к нескольким провайдерам, держать на нём
# старт сервера нельзя.
threading.Thread(target=_background_refresh_loop, daemon=True, name="hub-web-refresh").start()
@ -530,3 +826,12 @@ def _start_background_refresh() -> None:
AccountQuotaService.get().start_background_scheduler()
except Exception as exc:
logger.warning("Could not start quota scheduler: %s", exc)
@app.on_event("shutdown")
def _stop_background_refresh() -> None:
from ..account_probe_service import AccountProbeService
from ..quota_collector import AccountQuotaService
_background_stop.set()
AccountProbeService.get().shutdown()
AccountQuotaService.get().stop_background_scheduler()

File diff suppressed because it is too large Load diff

View file

@ -66,7 +66,7 @@
<span class="status-dot healthy"></span>
<span class="source-text" id="source-text">Загрузка данных...</span>
</div>
<div class="version-tag" id="version-tag">Hermes Hub Web v0.1.1</div>
<div class="version-tag" id="version-tag">Hermes Hub Web</div>
<div class="commit-tag" id="commit-tag" style="font-size:11px; color:var(--text-muted); font-family:var(--font-mono); margin-top:2px;"></div>
</div>
</aside>
@ -455,6 +455,13 @@
<input type="password" id="setting-server-token-input" class="input-text" placeholder="Задать новый токен...">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<label class="setting-label" for="setting-provider-proxy-url">Прокси / VPN для провайдеров</label>
<div class="setting-desc">Google отказывает по местоположению: «not currently available in your location». Выход через разрешённую страну снимает отказ. Пример: <code>socks5://127.0.0.1:1080</code>. Пусто — без прокси. Отдельному аккаунту можно задать свой адрес.</div>
</div>
<div class="setting-control"><input id="setting-provider-proxy-url" class="input-text" type="text" placeholder="Н/Д: загрузка" aria-label="Адрес прокси для обращений провайдеров"></div>
</div>
<div class="setting-row">
<div class="setting-info">
<label class="setting-label" for="setting-account-check-interval">Проверка аккаунтов и моделей</label>
@ -594,13 +601,114 @@
<div id="obsidian-vault-details" style="font-size:12px; color:var(--text-muted); margin-top:8px; font-family:var(--font-mono);"></div>
</div>
<!-- Context Compression Settings (A56) -->
<div class="settings-card" style="margin-top:16px;" id="settings-compression-card">
<div class="section-card-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<div>
<h2 class="settings-group-title" style="margin:0;">Сжатие контекста (Context Compression)</h2>
<div class="setting-desc">Автоматическое сжатие длинного контекста с сохранением 100% технических фактов и сущностей</div>
</div>
<span id="compression-status-badge" class="badge">Н/Д: модель для сжатия не выбрана</span>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Профиль модели для сжатия</div>
<div class="setting-desc">Служебная модель-компрессор (например, Qwen3-4B на порту 8082). Не участвует в маршрутизации Hermes.</div>
</div>
<div class="setting-control">
<select id="setting-compressor-profile" class="select-filter">
<option value="none">Н/Д: модель для сжатия не выбрана (отключено)</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Порог заполнения контекста для запуска сжатия</div>
<div class="setting-desc">Сжатие начинается при превышении процента от измеренного контекста модели (/props)</div>
</div>
<div class="setting-control">
<select id="setting-compression-threshold-percent" class="select-filter">
<option value="50">50% контекста (агрессивное сжатие)</option>
<option value="60">60% контекста</option>
<option value="70">70% контекста</option>
<option value="75" selected>75% контекста (рекомендуется — около 3/4)</option>
<option value="80">80% контекста</option>
<option value="85">85% контекста</option>
<option value="90">90% контекста (позднее сжатие)</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Количество свежих сообщений (Fresh Window)</div>
<div class="setting-desc">Последние сообщения, которые всегда остаются 100% дословными и не подлежат сжатию</div>
</div>
<div class="setting-control">
<select id="setting-compression-keep-recent" class="select-filter">
<option value="2">2 последних сообщения</option>
<option value="3" selected>3 последних сообщения (рекомендуется)</option>
<option value="4">4 последних сообщения</option>
<option value="5">5 последних сообщений</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Диагностика и проверка сжатия</div>
<div class="setting-desc" id="compression-diagnostic-desc">Проверить отклик компрессора и 100% удержание путей, портов, IP и хэшей</div>
</div>
<div class="setting-control" style="display:flex; gap:8px;">
<button class="btn btn-secondary btn-sm" id="btn-check-compression-status">Статус компрессора</button>
<button class="btn btn-primary btn-sm" id="btn-test-compression">Тестовое сжатие (100% фактов)</button>
</div>
</div>
<div id="compression-details-box" style="font-size:12px; color:var(--text-muted); margin-top:8px; font-family:var(--font-mono); white-space:pre-wrap;"></div>
</div>
<!-- Antigravity CLI & Eligibility Settings (A58) -->
<div class="settings-card" style="margin-top:16px;" id="settings-agy-card">
<div class="section-card-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<div>
<h2 class="settings-group-title" style="margin:0;">Параметры Antigravity CLI</h2>
<div class="setting-desc">Версия утилиты, путь к исполняемому файлу и состояние проверки доступности Google</div>
</div>
<span id="agy-eligibility-badge" class="badge">Проверка...</span>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Версия и исполняемый файл agy</div>
<div class="setting-desc" id="agy-version-path-desc">Н/Д: определение...</div>
</div>
<div class="setting-control" style="display:flex; gap:8px;">
<button class="btn btn-secondary btn-sm" id="btn-refresh-agy-eligibility">↻ Проверить состояние</button>
<button class="btn btn-primary btn-sm" id="btn-run-agy-update" title="Обновление agy перезаписывает исполняемый файл и возвращает проверку доступности: сначала выполните обновление, затем повторно примените патч.">⬆ Обновить agy (agy update)</button>
</div>
</div>
<div class="setting-row" style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-top:8px;">
<div class="setting-desc" style="font-size:12px; color:var(--text-secondary); margin:0;">
<strong>Порядок действий:</strong> <em>Обновление agy перезаписывает исполняемый файл и возвращает проверку доступности: сначала выполните обновление, затем повторно примените патч.</em>
</div>
</div>
<div class="setting-row" style="margin-top:12px;">
<div class="setting-info">
<label class="setting-label" for="setting-agy-patch-script-path">Путь к сценарию патча владельца</label>
<div class="setting-desc">Собственный скрипт владельца для снятия проверки доступности (P0-4). Оставьте пустым, если не используется.</div>
</div>
<div class="setting-control" style="display:flex; gap:8px; width:55%;">
<input type="text" id="setting-agy-patch-script-path" class="input-text" style="flex:1;" placeholder="Н/Д: сценарий не указан">
<button class="btn btn-secondary btn-sm" id="btn-run-agy-patch-script">⚡ Запустить сценарий патча</button>
</div>
</div>
<div id="agy-eligibility-details" style="font-size:12px; color:var(--text-muted); margin-top:8px; font-family:var(--font-mono);"></div>
</div>
<!-- Hub Updates -->
<div class="settings-card" style="margin-top:16px;" id="settings-updates-card">
<h2 class="settings-group-title">Обновление Hermes Hub</h2>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Текущая версия и сборка</div>
<div class="setting-desc" id="update-installed-info">Hermes Hub v0.1.1</div>
<div class="setting-desc" id="update-installed-info">Hermes Hub</div>
</div>
<div class="setting-control" style="display:flex; align-items:center; gap:8px;">
<span id="update-status-badge" class="badge">Не проверено</span>

View file

@ -653,6 +653,17 @@ body {
border: 1px solid var(--border-subtle);
}
.account-eligibility-warning {
margin-top: 6px;
padding: 6px 8px;
background: rgba(230, 162, 60, 0.12);
border-left: 3px solid var(--status-warning);
border-radius: var(--radius-sm);
font-size: 11px;
color: var(--status-warning);
line-height: 1.4;
}
.role-answering-status {
padding: 6px 10px;
border-radius: var(--radius-sm);

View file

@ -229,14 +229,32 @@ function arrangeSettingsPanels() {
['Общие настройки',['setting-default-role','setting-theme']],
['Управление квотами',['setting-account-check-interval','setting-quota-interval','setting-quota-threshold-percent','setting-quota-threshold-action']],
['Безопасность и API',['setting-server-host','setting-server-token-input','setting-email-masking-mode']],
['Сеть и доступ',['setting-provider-proxy-url']],
];
for (const [title,ids] of groups) {
const card = document.createElement('section'); card.className='settings-card';
const heading=document.createElement('h2'); heading.className='settings-group-title'; heading.textContent=title; card.append(heading);
for (const id of ids) card.append(document.getElementById(id).closest('.setting-row'));
for (const id of ids) {
const row = document.getElementById(id)?.closest('.setting-row');
if (row) card.append(row);
}
if (title === 'Управление квотами') card.append(document.getElementById('btn-save-hub-settings').closest('.settings-actions'));
view.insertBefore(card, first);
}
// Строки, которых нет ни в одной группе, раньше удалялись вместе с исходной
// карточкой: новая настройка просто пропадала с экрана, и найти её было
// нельзя. Так исчезло поле прокси. Теперь остаток не выбрасывается, а
// собирается отдельной карточкой — забыть настройку в списке всё ещё можно,
// но потерять её уже нет.
const leftovers = Array.from(first.querySelectorAll('.setting-row'));
if (leftovers.length) {
const card = document.createElement('section'); card.className='settings-card';
const heading=document.createElement('h2'); heading.className='settings-group-title';
heading.textContent='Прочие настройки'; card.append(heading);
for (const row of leftovers) card.append(row);
view.insertBefore(card, first);
}
first.remove();
}
document.addEventListener('DOMContentLoaded', arrangeSettingsPanels);

View file

@ -5,24 +5,29 @@ Features:
- SHA-256 package cryptographic hash verification.
- Staged download without touching live executable.
- Hermetic backup and automatic rollback on corrupt/failing update.
- Real-time thread-safe progress tracking and cancellation.
- Isolated process lifecycle management (stopping only own hub processes).
- Non-blocking execution and honest error reporting (rate limits, 404, network errors).
- Zero embedded developer PATs (safe public asset feed / signed release manifests).
"""
from __future__ import annotations
import hashlib
import inspect
import json
import logging
import os
import re
import shutil
import zipfile
import subprocess
import threading
import sys
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Tuple
@ -72,8 +77,6 @@ def _is_release_older(published_at: str, installed_at: str) -> bool:
if not published_at or not installed_at:
return False
try:
from datetime import datetime
def _parse(v: str):
return datetime.fromisoformat(v.strip().replace("Z", "+00:00"))
@ -168,6 +171,30 @@ def extract_release_commit(release_data: Dict[str, Any]) -> str:
return ""
@dataclass
class UpdateProgress:
status: str = "idle" # idle | checking | downloading | verifying | installing | restarting | completed | failed | cancelled
filename: str = ""
downloaded_bytes: int = 0
total_bytes: Optional[int] = None
progress_percent: Optional[float] = None
message: str = "Готов к обновлению"
error: Optional[str] = None
updated_at: float = field(default_factory=time.time)
def to_dict(self) -> Dict[str, Any]:
return {
"status": self.status,
"filename": self.filename,
"downloaded_bytes": self.downloaded_bytes,
"total_bytes": self.total_bytes,
"progress_percent": round(self.progress_percent, 1) if self.progress_percent is not None else None,
"message": self.message,
"error": self.error,
"updated_at": self.updated_at,
}
@dataclass
class UpdateManifest:
version: str
@ -180,6 +207,7 @@ class UpdateManifest:
changelog: Optional[str] = None
git_commit: Optional[str] = None
assets: Dict[str, str] = field(default_factory=dict)
asset_sizes: Dict[str, int] = field(default_factory=dict)
@dataclass
@ -194,6 +222,8 @@ class UpdateCheckResult:
changelog: Optional[str] = None
release_notes: Optional[str] = None
assets: Dict[str, str] = field(default_factory=dict)
asset_sizes: Dict[str, int] = field(default_factory=dict)
download_size: Optional[int] = None
manifest: Optional[UpdateManifest] = None
error: Optional[str] = None
message: Optional[str] = None
@ -211,6 +241,8 @@ class UpdateCheckResult:
"changelog": self.changelog,
"release_notes": self.release_notes,
"assets": self.assets,
"asset_sizes": self.asset_sizes,
"download_size": self.download_size,
"error": self.error,
"message": self.message,
"checked_at": self.checked_at,
@ -268,9 +300,226 @@ def is_allowed_update_host(url: str, allow_dev_local: bool = False) -> bool:
return False
def get_last_applied_update_path() -> Path:
"""Path to ~/.hermes/updates/last_applied_update.json."""
return paths.get_hermes_home() / "updates" / "last_applied_update.json"
def record_last_applied_update(
prev_version: str,
prev_commit: str,
new_version: str,
new_commit: str,
) -> None:
"""Save record of applied update for post-restart notification."""
try:
p = get_last_applied_update_path()
p.parent.mkdir(parents=True, exist_ok=True)
data = {
"prev_version": prev_version,
"prev_commit": prev_commit,
"new_version": new_version,
"new_commit": new_commit,
"updated_at": datetime.now(timezone.utc).isoformat(),
"acknowledged": False,
}
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
logger.info("Recorded last applied update: %s (%s) -> %s (%s)", prev_version, prev_commit[:7], new_version, new_commit[:7])
except Exception as exc:
logger.warning("Failed to record last_applied_update: %s", exc)
def get_last_applied_update() -> Optional[Dict[str, Any]]:
"""Retrieve last applied update info if present."""
try:
p = get_last_applied_update_path()
if p.is_file():
return json.loads(p.read_text(encoding="utf-8"))
except Exception as exc:
logger.debug("Failed reading last_applied_update: %s", exc)
return None
def acknowledge_last_applied_update() -> None:
"""Mark last applied update as acknowledged."""
try:
p = get_last_applied_update_path()
if p.is_file():
data = json.loads(p.read_text(encoding="utf-8"))
data["acknowledged"] = True
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
except Exception as exc:
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()
is_win = sys.platform == "win32"
if is_win:
try:
cmd = ["wmic", "process", "where", "name='HermesHubWeb.exe'", "get", "ProcessId"]
res = subprocess.run(cmd, capture_output=True, text=True, timeout=5, **hidden_process_kwargs())
pids = []
if res.returncode == 0:
for line in res.stdout.splitlines():
val = line.strip()
if val.isdigit():
pid = int(val)
if pid != current_pid:
pids.append(pid)
for pid in pids:
try:
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=5, **hidden_process_kwargs())
except Exception:
pass
return True
except Exception as exc:
logger.debug("Windows stop_running_hub: %s", exc)
return True
else:
try:
uid = os.getuid()
pattern = "antigravity_provider.router.web|hermes_hub_web_entry"
res = subprocess.run(
["pgrep", "-u", str(uid), "-f", pattern],
capture_output=True,
text=True,
timeout=5,
)
if res.returncode != 0 or not res.stdout.strip():
logger.debug("No other hub processes found on Linux")
return True
pids = [int(p) for p in res.stdout.split() if p.strip().isdigit() and int(p) != current_pid]
if not pids:
return True
logger.info("Stopping hub processes for user %s: %s", uid, pids)
import signal
for pid in pids:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass
except Exception as e:
logger.debug("Failed to SIGTERM pid %s: %s", pid, e)
start_t = time.time()
alive = list(pids)
while alive and (time.time() - start_t) < timeout_sec:
time.sleep(0.5)
still_alive = []
for pid in alive:
try:
os.kill(pid, 0)
still_alive.append(pid)
except ProcessLookupError:
pass
alive = still_alive
if alive:
logger.warning("Hub processes still alive after %ss, sending SIGKILL: %s", timeout_sec, alive)
for pid in alive:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
except Exception as e:
logger.debug("Failed to SIGKILL pid %s: %s", pid, e)
return True
except Exception as exc:
logger.warning("Linux stop_running_hub error: %s", exc)
return True
# Почему отмена отклонена — своими словами для владельца, а не кодом состояния.
_CANCEL_REFUSAL = {
"installing": "Отмена невозможна: установка уже началась",
"restarting": "Отмена невозможна: Hermes Hub уже перезапускается",
"completed": "Отменять нечего: обновление уже установлено",
"failed": "Отменять нечего: обновление уже завершилось ошибкой",
"cancelled": "Загрузка уже отменена",
"idle": "Отменять нечего: обновление не запускалось",
}
def _call_progress_cb(cb: Optional[Callable], downloaded: int, total: Optional[int]) -> None:
"""Вызвать обработчик хода загрузки, поддержав обе его формы.
Старая форма принимает одну долю (0..1), новая (скачано, всего).
Доли при неизвестном общем размере не существует, и подставлять вместо неё
ноль нельзя: обработчик получал бы «0%» на каждом чанке всю загрузку.
Старую форму в этом случае просто не зовём молчание честнее выдуманного
числа, а сам ход всё равно виден через _set_progress.
"""
if not cb:
return
known_total = bool(total and total > 0)
try:
sig = inspect.signature(cb)
single_arg = len(sig.parameters) == 1
except (TypeError, ValueError):
single_arg = False
if single_arg:
if known_total:
cb(downloaded / total)
return
try:
cb(downloaded, total)
except TypeError:
if known_total:
cb(downloaded / total)
class UpdateManager:
"""Manages update checks, package download, hash validation, and updater execution."""
_lock = threading.Lock()
_progress: UpdateProgress = UpdateProgress()
_cancel_event = threading.Event()
_last_check_result: Optional[UpdateCheckResult] = None
_last_check_time: float = 0.0
@ -285,6 +534,85 @@ class UpdateManager:
def get_last_check_result(cls) -> Optional[UpdateCheckResult]:
return cls._last_check_result
@classmethod
def get_progress_dict(cls) -> Dict[str, Any]:
with cls._lock:
return cls._progress.to_dict()
@classmethod
def _set_progress(
cls,
status: str,
message: str = "",
filename: str = "",
downloaded_bytes: int = 0,
total_bytes: Optional[int] = None,
progress_percent: Optional[float] = None,
error: Optional[str] = None,
) -> None:
with cls._lock:
cls._progress = UpdateProgress(
status=status,
filename=filename if filename else cls._progress.filename,
downloaded_bytes=downloaded_bytes,
total_bytes=total_bytes,
progress_percent=progress_percent,
message=message if message else cls._progress.message,
error=error,
updated_at=time.time(),
)
@classmethod
def cancel_download(cls) -> Dict[str, Any]:
"""Отменить загрузку обновления и удалить недокачанные файлы.
Отмена допустима только пока идёт проверка или загрузка. Действие
`cancel_update` открыто в HTTP-API, и без этой проверки вызов во время
установки вычищал каталог staging вместе с файлом установщика, который
в этот момент исполняет bash: установка ломалась на середине, а ответ
«отменено» сообщал владельцу неправду о том, что происходит с машиной.
"""
with cls._lock:
current_status = cls._progress.status
if current_status not in ("checking", "downloading"):
refused = cls._progress.to_dict()
refused["cancel_accepted"] = False
refused["cancel_refused_reason"] = _CANCEL_REFUSAL.get(
current_status,
f"Отмена невозможна на этапе «{current_status}»",
)
return refused
cls._cancel_event.set()
with cls._lock:
cls._progress = UpdateProgress(
status="cancelled",
filename=cls._progress.filename,
downloaded_bytes=0,
total_bytes=None,
progress_percent=None,
message="Загрузка обновления отменена пользователем",
error=None,
updated_at=time.time(),
)
try:
updates_dir = paths.get_hermes_home() / "updates"
staging_dir = updates_dir / "staging"
if staging_dir.exists():
for f in staging_dir.iterdir():
if f.is_file():
f.unlink(missing_ok=True)
except Exception as exc:
logger.debug("Clean staging dir on cancel failed: %s", exc)
accepted = cls.get_progress_dict()
accepted["cancel_accepted"] = True
return accepted
@classmethod
def cancel_update(cls) -> Dict[str, Any]:
"""Alias for cancel_download."""
return cls.cancel_download()
def get_status_dict(self) -> Dict[str, Any]:
installed_commit = get_installed_commit()
if self._last_check_result:
@ -302,6 +630,8 @@ class UpdateManager:
"changelog": None,
"release_notes": None,
"assets": {},
"asset_sizes": {},
"download_size": None,
"error": None,
"message": "Проверка обновлений еще не выполнялась",
"checked_at": 0.0,
@ -392,13 +722,16 @@ class UpdateManager:
body = str(data.get("body") or data.get("changelog") or "")
published_at = str(data.get("published_at") or "")
# Extract assets mapping {name: download_url}
# Extract assets mapping {name: download_url} and asset sizes {name: size_bytes}
assets_map: Dict[str, str] = {}
asset_sizes: Dict[str, int] = {}
raw_assets = data.get("assets", [])
if isinstance(raw_assets, list):
for asset in raw_assets:
if isinstance(asset, dict) and "name" in asset and "browser_download_url" in asset:
assets_map[asset["name"]] = asset["browser_download_url"]
if "size" in asset and isinstance(asset["size"], (int, float)):
asset_sizes[asset["name"]] = int(asset["size"])
elif isinstance(raw_assets, dict):
assets_map = dict(raw_assets)
@ -420,8 +753,27 @@ class UpdateManager:
changelog=body,
git_commit=latest_commit,
assets=assets_map,
asset_sizes=asset_sizes,
)
# Determine download_size for target platform
is_win = sys.platform == "win32"
chosen_size: Optional[int] = None
if is_win and "HermesHubSetup.exe" in asset_sizes:
chosen_size = asset_sizes["HermesHubSetup.exe"]
elif not is_win:
for linux_name in ("hermes-hub-setup.sh", "install-linux.sh"):
if linux_name in asset_sizes:
chosen_size = asset_sizes[linux_name]
break
if chosen_size is None:
for aname, asize in asset_sizes.items():
if aname.endswith(".zip"):
chosen_size = asize
break
if chosen_size is None and asset_sizes:
chosen_size = next(iter(asset_sizes.values()), None)
# Compare commits
inst_clean = installed_commit.strip().lower()
lat_clean = latest_commit.strip().lower()
@ -463,6 +815,8 @@ class UpdateManager:
changelog=body,
release_notes=body,
assets=assets_map,
asset_sizes=asset_sizes,
download_size=chosen_size,
manifest=manifest,
error=None,
message=message,
@ -490,17 +844,45 @@ class UpdateManager:
self,
url: str,
dest_file: Path,
progress_cb: Optional[Callable[[float], None]] = None,
progress_cb: Optional[Callable] = None,
) -> None:
"""Helper to download a file with allowlist check and optional progress callback."""
"""Helper to download a file with allowlist check, progress tracking, and cancellation support."""
if not is_allowed_update_host(url, allow_dev_local=False):
raise ValueError(f"Недопустимый хост пакета обновления: {url}")
if self._cancel_event.is_set():
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка отменена")
raise InterruptedError("Загрузка обновления отменена пользователем")
if url.startswith("file://") or Path(url).is_file():
local_src = Path(url.replace("file://", ""))
total_bytes = local_src.stat().st_size if local_src.exists() else None
self._set_progress(
status="downloading",
filename=dest_file.name,
downloaded_bytes=0,
total_bytes=total_bytes,
progress_percent=0.0 if total_bytes else None,
message=f"Копирование {dest_file.name}...",
)
if self._cancel_event.is_set():
dest_file.unlink(missing_ok=True)
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка отменена")
raise InterruptedError("Загрузка обновления отменена пользователем")
shutil.copy2(local_src, dest_file)
downloaded = dest_file.stat().st_size
pct = 100.0 if total_bytes else None
self._set_progress(
status="downloading",
filename=dest_file.name,
downloaded_bytes=downloaded,
total_bytes=total_bytes,
progress_percent=pct,
message=f"Файл {dest_file.name} скопирован",
)
if progress_cb:
progress_cb(1.0)
_call_progress_cb(progress_cb, downloaded, total_bytes)
return
req = urllib.request.Request(
@ -508,21 +890,53 @@ class UpdateManager:
headers={"User-Agent": f"HermesHub/{__version__}"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
total_len = int(resp.headers.get("content-length", 0))
raw_len = resp.headers.get("content-length")
total_len = int(raw_len) if raw_len and raw_len.isdigit() else 0
total_bytes = total_len if total_len > 0 else None
downloaded = 0
self._set_progress(
status="downloading",
filename=dest_file.name,
downloaded_bytes=0,
total_bytes=total_bytes,
progress_percent=0.0 if total_bytes else None,
message=f"Скачивание {dest_file.name}...",
)
with open(dest_file, "wb") as out_f:
while chunk := resp.read(65536):
while True:
if self._cancel_event.is_set():
out_f.close()
dest_file.unlink(missing_ok=True)
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка отменена")
raise InterruptedError("Загрузка обновления отменена пользователем")
chunk = resp.read(65536)
if not chunk:
break
out_f.write(chunk)
downloaded += len(chunk)
if progress_cb and total_len > 0:
progress_cb(downloaded / total_len)
pct = ((downloaded / total_bytes) * 100.0) if (total_bytes and total_bytes > 0) else None
self._set_progress(
status="downloading",
filename=dest_file.name,
downloaded_bytes=downloaded,
total_bytes=total_bytes,
progress_percent=pct,
message=f"Скачивание {dest_file.name}...",
)
if progress_cb:
_call_progress_cb(progress_cb, downloaded, total_bytes)
def download_and_verify(
self,
manifest: UpdateManifest,
progress_cb: Optional[Callable[[float], None]] = None,
progress_cb: Optional[Callable] = None,
) -> Tuple[bool, str, Optional[Path]]:
"""Download update package into staging and verify SHA-256 hash."""
self._cancel_event.clear()
self.staging_dir.mkdir(parents=True, exist_ok=True)
dest_file = self.staging_dir / f"hermes-hub-{manifest.version}.zip"
@ -530,31 +944,54 @@ class UpdateManager:
self._download_file(manifest.package_url, dest_file, progress_cb)
# Cryptographic SHA-256 Verification
self._set_progress(
status="verifying",
filename=dest_file.name,
message=f"Проверка контрольной суммы SHA-256 для {dest_file.name}...",
)
calc_hash = compute_sha256(dest_file)
if manifest.sha256 and calc_hash != manifest.sha256.lower():
dest_file.unlink(missing_ok=True)
return False, f"SHA-256 hash mismatch! Expected {manifest.sha256}, got {calc_hash}", None
err = f"SHA-256 hash mismatch! Expected {manifest.sha256}, got {calc_hash}"
self._set_progress(status="failed", filename=dest_file.name, error=err, message=err)
return False, err, None
self._set_progress(
status="completed",
filename=dest_file.name,
message="Пакет успешно загружен и верифицирован",
)
return True, "Пакет успешно загружен и верифицирован", dest_file
except InterruptedError:
dest_file.unlink(missing_ok=True)
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка обновления отменена")
return False, "Загрузка обновления отменена пользователем", None
except Exception as exc:
dest_file.unlink(missing_ok=True)
return False, f"Ошибка загрузки: {exc}", None
err = f"Ошибка загрузки: {exc}"
self._set_progress(status="failed", filename=dest_file.name, error=err, message=err)
return False, err, None
def install_latest_update(
self,
check_result: Optional[UpdateCheckResult] = None,
progress_cb: Optional[Callable[[float], None]] = None,
progress_cb: Optional[Callable] = None,
target_dir: Optional[Path] = None,
) -> Tuple[bool, str]:
"""Download installer or update package, verify checksums, apply and restart."""
self._cancel_event.clear()
if check_result is None:
self._set_progress(status="checking", message="Проверка наличия обновлений...")
check_result = self.check_for_updates()
if check_result.error:
self._set_progress(status="failed", error=check_result.error, message=check_result.error)
return False, f"Ошибка проверки обновлений: {check_result.error}"
if not check_result.update_available:
self._set_progress(status="idle", message="Обновление не требуется")
return False, "Обновление не требуется (установлена последняя сборка)"
self.staging_dir.mkdir(parents=True, exist_ok=True)
@ -573,6 +1010,8 @@ class UpdateManager:
sha = parts[0].strip().lower()
fname = parts[1].lstrip("*").strip().lower()
checksums_map[fname] = sha
except InterruptedError:
return False, "Загрузка обновления отменена пользователем"
except Exception as e:
logger.warning("Failed to download or parse checksums.txt: %s", e)
@ -592,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():
@ -605,17 +1057,30 @@ class UpdateManager:
chosen_asset_name = Path(chosen_url).name or f"hermes-hub-{check_result.latest_version}.zip"
if not chosen_url or not chosen_asset_name:
return False, "В релизе не найден подходящий файл обновления для текущей платформы"
err = "В релизе не найден подходящий файл обновления для текущей платформы"
self._set_progress(status="failed", error=err, message=err)
return False, err
# 3. Download target asset into staging
dest_file = self.staging_dir / chosen_asset_name
try:
self._download_file(chosen_url, dest_file, progress_cb)
except InterruptedError:
dest_file.unlink(missing_ok=True)
self._set_progress(status="cancelled", filename=chosen_asset_name, message="Загрузка отменена")
return False, "Загрузка обновления отменена пользователем"
except Exception as exc:
dest_file.unlink(missing_ok=True)
return False, f"Ошибка загрузки {chosen_asset_name}: {exc}"
err = f"Ошибка загрузки {chosen_asset_name}: {exc}"
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err
# 4. SHA-256 Checksum Verification
self._set_progress(
status="verifying",
filename=chosen_asset_name,
message=f"Проверка контрольной суммы SHA-256 для {chosen_asset_name}...",
)
calc_sha = compute_sha256(dest_file)
expected_sha = checksums_map.get(chosen_asset_name.lower())
if not expected_sha and check_result.manifest and check_result.manifest.sha256:
@ -627,31 +1092,64 @@ class UpdateManager:
# исполняемый код, поэтому непроверенный файл не запускаем вовсе.
if not expected_sha:
dest_file.unlink(missing_ok=True)
return (
False,
err = (
f"Не удалось получить контрольную сумму для {chosen_asset_name}: "
"в релизе нет checksums.txt или файл не скачался. "
"Установка отменена — непроверенный файл не запускается."
)
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err
if calc_sha != expected_sha:
dest_file.unlink(missing_ok=True)
return (
False,
err = (
f"Контрольная сумма SHA-256 не совпала для {chosen_asset_name}! "
f"Ожидалось {expected_sha}, получено {calc_sha}. Установка отменена."
)
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err
# 5. Stop running hub services before applying update
self._set_progress(
status="installing",
filename=chosen_asset_name,
message="Остановка работающих служб хаба...",
)
stop_running_hub()
# Record metadata for post-restart notification
prev_v = __version__
prev_c = get_installed_commit()
new_v = check_result.latest_version
new_c = check_result.latest_commit or ""
# 6. Apply update based on file type
self._set_progress(
status="installing",
filename=chosen_asset_name,
message=f"Установка пакета {chosen_asset_name}...",
)
def _record_success():
record_last_applied_update(
prev_version=prev_v,
prev_commit=prev_c,
new_version=new_v,
new_commit=new_c,
)
# 5. Apply update based on file type
if chosen_asset_name.endswith(".zip"):
ok, msg = self.apply_update_sync(dest_file, target_dir=target_dir)
if not ok:
self._set_progress(status="failed", filename=chosen_asset_name, error=msg, message=msg)
return False, msg
_record_success()
self._set_progress(status="completed", filename=chosen_asset_name, message=msg)
return True, "Обновление успешно установлено"
elif chosen_asset_name == "HermesHubSetup.exe":
try:
cmd = [str(dest_file), "/silent", "/reinstall"]
cmd = [str(dest_file), "/silent", "/reinstall", "/restart"]
creation_flags = 0
if hasattr(subprocess, "DETACHED_PROCESS") and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
creation_flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
@ -659,15 +1157,25 @@ class UpdateManager:
try:
rc = proc.wait(timeout=600)
except subprocess.TimeoutExpired:
return False, "Установщик не завершился за 10 минут. Проверьте состояние вручную."
err = "Установщик не завершился за 10 минут. Проверьте состояние вручную."
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err
if rc != 0:
return False, f"Установщик завершился с кодом {rc}. Обновление не применено."
err = f"Установщик завершился с кодом {rc}. Обновление не применено."
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err
_record_success()
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
ok_r, msg_r = self.schedule_restart()
if not ok_r:
self._set_progress(status="completed", filename=chosen_asset_name, message=f"Обновление установлено. {msg_r}")
return True, f"Обновление установлено. {msg_r}"
self._set_progress(status="restarting", filename=chosen_asset_name, message="Обновление установлено, Hermes Hub перезапускается.")
return True, "Обновление установлено, Hermes Hub перезапускается."
except Exception as exc:
return False, f"Не удалось запустить установщик: {exc}"
err = f"Не удалось запустить установщик: {exc}"
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err
elif chosen_asset_name.endswith(".sh"):
try:
@ -680,18 +1188,32 @@ class UpdateManager:
capture_output=True, text=True, timeout=600,
)
if res_i.returncode != 0:
# Код возврата в сообщении обязателен: установщик может
# завершиться, не сказав ни слова, и владелец получал
# «Установка не удалась: » без единого признака причины.
tail = (res_i.stderr or res_i.stdout or "").strip().splitlines()[-3:]
return False, "Установка не удалась: " + " / ".join(tail)
if tail:
err = f"Установка не удалась (код {res_i.returncode}): " + " / ".join(tail)
else:
err = f"Установка не удалась (код {res_i.returncode}): установщик ничего не сообщил"
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err
_record_success()
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
ok_r, msg_r = self.schedule_restart()
if not ok_r:
self._set_progress(status="completed", filename=chosen_asset_name, message=f"Обновление установлено. {msg_r}")
return True, f"Обновление установлено. {msg_r}"
self._set_progress(status="restarting", filename=chosen_asset_name, message="Обновление установлено, Hermes Hub перезапускается.")
return True, "Обновление установлено, Hermes Hub перезапускается."
except Exception as exc:
return False, f"Не удалось запустить скрипт установки: {exc}"
err = f"Не удалось запустить скрипт установки: {exc}"
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err
self._set_progress(status="completed", filename=chosen_asset_name, message="Файл обновления загружен и проверен")
return True, "Файл обновления загружен и проверен"
def schedule_restart(self, delay_sec: float = 3.0) -> Tuple[bool, str]:
"""Перезапустить веб-хаб после установки обновления.
@ -741,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)
@ -758,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

@ -1,8 +1,8 @@
"""Single Source of Truth for Hermes Hub Versioning."""
from __future__ import annotations
__version__ = "0.1.1"
VERSION_INFO = (0, 1, 1)
__version__ = "0.1.3"
VERSION_INFO = (0, 1, 3)
CHANNEL = "stable"
MINIMUM_HERMES_VERSION = "0.20.0"

View file

@ -0,0 +1,54 @@
"""A54 isolated UI execution: real local HTTP; synthetic remote credentials only."""
import json
import os
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from types import SimpleNamespace
os.environ['HERMES_HOME'] = tempfile.mkdtemp(prefix='a54-ui-')
os.environ['HERMES_ROUTER_CONFIG'] = os.path.join(os.environ['HERMES_HOME'], 'router_profiles.yaml')
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.router_config import RouterConfig, save_router_config
from antigravity_provider.router.quota_collector import AccountQuotaService
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.web import server
class Fixture(BaseHTTPRequestHandler):
def do_GET(self):
invalid = self.path.endswith('/key') and self.headers.get('Authorization') != 'Bearer fixture-valid'
self.send_response(401 if invalid else 200)
self.end_headers()
body = {'error': {'message': 'A54 fixture: invalid key'}} if invalid else ({'models': []} if self.path.endswith('/api/tags') else {'data': [{'id': 'fixture-chat'}]})
self.wfile.write(json.dumps(body).encode())
def do_POST(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b'{"choices":[{"message":{"content":"fixture OK"}}]}')
def log_message(self, *a):
pass
http = ThreadingHTTPServer(('127.0.0.1', 0), Fixture)
threading.Thread(target=http.serve_forever, daemon=True).start()
print('FIXTURE_BASE_URL=http://127.0.0.1:%s/v1' % http.server_port, flush=True)
save_router_config(RouterConfig())
for provider, pid, url in [('local', 'local-1', 'http://127.0.0.1:8081/v1'), ('ollama', 'ollama-1', f'http://127.0.0.1:{http.server_port}'), ('ollama', 'ollama-2', 'http://127.0.0.1:1')]:
AutoAssigner.ensure_profile_definition(provider, pid)
ProfileAuthManager.save_profile_auth(provider, pid, {'base_url': url, 'email': 'A54-STAND-' + pid})
# No AG account is used: rendering its 14-model fixture does not verify real OAuth.
AutoAssigner.ensure_profile_definition('antigravity', 'ag-w1')
ProfileAuthManager.save_profile_auth('antigravity', 'ag-w1', {'auth_method': 'oauth', 'email': 'A54-SYNTHETIC-AG'})
original_probe = ModelDiscoveryService.get()._probe_provider
ModelDiscoveryService.get()._probe_provider = lambda provider: ([f'gemini-fixture-{i}' for i in range(14)], None) if provider == 'antigravity' else original_probe(provider)
from antigravity_provider.router import action_handler
original_adapter = action_handler.get_adapter
action_handler.get_adapter = lambda provider: SimpleNamespace(invoke=lambda *a: {'choices': [{'message': {'content': 'AG fixture'}}]}) if provider == 'antigravity' else original_adapter(provider)
AccountQuotaService.get().fetch_all_configured = lambda **kw: None
AccountQuotaService.get().start_background_scheduler = lambda: None
server.UpdateManager.check_for_updates = lambda self: SimpleNamespace(error=None, message='A54 стенд', update_available=False, to_dict=lambda: {})
server.run_web_server(host='127.0.0.1', port=5804)

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

@ -60,6 +60,13 @@ def setup_test_environment(tmp_path, monkeypatch):
cfg = RouterConfig()
save_router_config(cfg)
# These tests cover persistence/routing; HTTP validation has its own A54 tests.
from antigravity_provider.router.connection_preflight import DEFAULT_URLS
monkeypatch.setattr("antigravity_provider.router.connection_preflight.validate_connection", lambda provider, token='', base_url='', preferred_model='': {
"ok": True, "message": "Подключено и проверено", "data": {"models": ["fixture-model"], "base_url": base_url or DEFAULT_URLS[provider]}})
monkeypatch.setattr("antigravity_provider.router.action_handler._rescan_after_auth", lambda *args: None)
monkeypatch.setattr("antigravity_provider.router.account_probe_service.AccountProbeService.check_now", lambda *args, **kwargs: {"ok": True, "message": "Проверено", "data": {}})
yield hermes_home
@ -111,7 +118,7 @@ def test_p0_1_add_account_openrouter_default_base_url():
},
)
assert res["ok"] is True
assert "сохранён" in res["message"]
assert "проверено" in res["message"]
# Verify profile created and auth saved
auth = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-1")
@ -137,7 +144,7 @@ def test_p0_1_add_account_nvidia_default_base_url():
},
)
assert res["ok"] is True
assert "сохранён" in res["message"]
assert "проверено" in res["message"]
auth = ProfileAuthManager.load_profile_auth("nvidia", "nvidia-1")
assert auth is not None
@ -222,7 +229,7 @@ def test_p0_1_add_account_honest_rejections():
# Missing API key for claude
res_cl = ActionExecutor.execute("add_account", {"provider": "claude", "token": ""})
assert res_cl["ok"] is False
assert "API-ключ" in res_cl["message"]
assert "Авторизация через браузер не завершена" in res_cl["message"] or "API-ключ" in res_cl["message"]
# Unsupported provider
res_unsupp = ActionExecutor.execute("add_account", {"provider": "unknown_provider_xyz"})

View file

@ -66,6 +66,13 @@ def setup_isolated_env(tmp_path, monkeypatch):
UnifiedHealthService._instance = None
HubStateStore._instance = None
# These tests cover persistence/routing; HTTP validation has its own A54 tests.
from antigravity_provider.router.connection_preflight import DEFAULT_URLS
monkeypatch.setattr("antigravity_provider.router.connection_preflight.validate_connection", lambda provider, token='', base_url='', preferred_model='': {
"ok": True, "message": "Подключено и проверено", "data": {"models": ["fixture-model"], "base_url": base_url or DEFAULT_URLS[provider]}})
monkeypatch.setattr("antigravity_provider.router.action_handler._rescan_after_auth", lambda *args: None)
monkeypatch.setattr("antigravity_provider.router.account_probe_service.AccountProbeService.check_now", lambda *args, **kwargs: {"ok": True, "message": "Проверено", "data": {}})
yield {
"hermes_home": hermes_home,
"profiles_yaml": profiles_yaml,

View file

@ -0,0 +1,192 @@
"""Tests for Task A55: Account Connection Defect Fixes (P0-1 through P0-5)."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from antigravity_provider.version import __version__, VERSION_INFO
from antigravity_provider.router.action_handler import ActionExecutor
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.adapters.ollama_adapter import OllamaAdapter, DEFAULT_OLLAMA_BASE_URL
from antigravity_provider.router.connection_preflight import validate_connection
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.web.server import app
# ── P0-1: Antigravity & Browser Auth Tests ──
def test_p0_1_add_account_unauthenticated_browser_auth_error_message():
"""add_account should return Russian browser auth message for antigravity and claude."""
with patch.object(ProfileAuthManager, "get_profile_status", return_value={"authenticated": False}):
# Antigravity without token/completed auth
res_ag = ActionExecutor.execute("add_account", {
"provider": "antigravity",
"profile_id": "ag-1",
"token": "",
})
assert not res_ag["ok"]
assert "Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата." in res_ag["message"]
# Claude without token/completed auth
res_cl = ActionExecutor.execute("add_account", {
"provider": "claude",
"profile_id": "claude-1",
"token": "",
})
assert not res_cl["ok"]
assert "Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата." in res_cl["message"]
def test_p0_1_profile_auth_manager_get_profile_status_antigravity():
"""get_profile_status should return authenticated: False when auth_data is empty or invalid."""
# Unauthenticated/empty auth_data
with patch.object(ProfileAuthManager, "load_profile_auth", return_value={}):
st = ProfileAuthManager.get_profile_status("antigravity", "ag-1")
assert st["authenticated"] is False
assert st["status"] == "NOT_CONFIGURED"
# Authenticated with access_token
with patch.object(ProfileAuthManager, "load_profile_auth", return_value={"token": {"access_token": "ya29.valid-token"}}):
st = ProfileAuthManager.get_profile_status("antigravity", "ag-1")
assert st["authenticated"] is True
assert st["status"] == "AUTHENTICATED"
def test_p0_1_load_profile_auth_gemini_oauth_creds_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""load_profile_auth should check .gemini/oauth_creds.json if auth.json is missing."""
monkeypatch.setattr("antigravity_provider.paths.get_hermes_home", lambda: tmp_path)
pdir = tmp_path / "agy_profiles" / "ag-5"
gemini_dir = pdir / ".gemini"
gemini_dir.mkdir(parents=True, exist_ok=True)
creds_file = gemini_dir / "oauth_creds.json"
creds_file.write_text(json.dumps({"access_token": "token-123", "refresh_token": "refresh-456"}), encoding="utf-8")
auth = ProfileAuthManager.load_profile_auth("antigravity", "ag-5")
assert auth is not None
assert auth.get("provider") == "antigravity"
assert auth.get("token", {}).get("access_token") == "token-123"
# ── P0-2: Ollama Base URL & Connection Error Tests ──
def test_p0_2_ollama_default_base_url():
"""DEFAULT_OLLAMA_BASE_URL must be http://127.0.0.1:11434 (without trailing /v1)."""
assert DEFAULT_OLLAMA_BASE_URL == "http://127.0.0.1:11434"
def test_p0_2_ollama_adapter_url_builders():
"""OllamaAdapter URL methods should handle base_urls with or without /v1 cleanly."""
adapter = OllamaAdapter()
# Standard base url without /v1
assert adapter._get_native_host("http://127.0.0.1:11434") == "http://127.0.0.1:11434"
assert adapter._get_chat_url("http://127.0.0.1:11434") == "http://127.0.0.1:11434/v1/chat/completions"
assert adapter._get_models_url("http://127.0.0.1:11434") == "http://127.0.0.1:11434/v1/models"
# Base url with /v1
assert adapter._get_native_host("http://192.168.1.81:11434/v1") == "http://192.168.1.81:11434"
assert adapter._get_chat_url("http://192.168.1.81:11434/v1") == "http://192.168.1.81:11434/v1/chat/completions"
assert adapter._get_models_url("http://192.168.1.81:11434/v1") == "http://192.168.1.81:11434/v1/models"
# Base url with trailing slash
assert adapter._get_native_host("http://192.168.1.81:11434/") == "http://192.168.1.81:11434"
assert adapter._get_chat_url("http://192.168.1.81:11434/") == "http://192.168.1.81:11434/v1/chat/completions"
def test_p0_2_ollama_connection_refused_helpful_message():
"""Ollama connection refused error should provide network IP guidance."""
# Test connection preflight when endpoint is down
res = validate_connection("ollama", token="", base_url="http://127.0.0.1:11434")
assert not res["ok"]
assert "Не удалось подключиться к http://127.0.0.1:11434 (Connection refused)" in res["message"]
assert "http://192.168.1.81:11434" in res["message"]
# ── P0-3: Antigravity Dynamic Model Discovery Tests ──
def test_p0_3_antigravity_dynamic_model_discovery():
"""ModelDiscoveryService probe should find and query any authenticated Antigravity profile."""
discovery = ModelDiscoveryService.get()
with patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status") as mock_st, \
patch("antigravity_provider.agy_subprocess.discover_models", return_value={"gemini-2.5-pro": "gemini-2.5-pro"}) as mock_disc:
# Simulate ag-3 being authenticated
def status_side_effect(prov, pid):
if pid == "ag-3":
return {"authenticated": True, "provider": prov, "profile_id": pid}
return {"authenticated": False, "provider": prov, "profile_id": pid}
mock_st.side_effect = status_side_effect
models, err = discovery._probe_provider("antigravity")
assert err is None
assert models == ["gemini-2.5-pro"]
mock_disc.assert_called_with(profile_id="ag-3")
# ── P0-4: Version Info & API Propagation Tests ──
def test_p0_4_version_single_source_of_truth():
"""Номер версии и VERSION_INFO обязаны совпадать между собой.
Сверять с записанным в тесте числом бессмысленно: при каждой сборке его
пришлось бы править, и тест превращался бы в напоминание, а не в проверку.
Смысл требования единый источник, его и проверяем.
"""
assert VERSION_INFO == tuple(int(part) for part in __version__.split("."))
def test_p0_4_version_in_api_endpoints():
"""Все точки API обязаны отдавать ту же версию, что и пакет."""
client = TestClient(app)
# /api/health
res_health = client.get("/api/health")
assert res_health.status_code == 200
assert res_health.json().get("version") == __version__
# /api/settings
res_settings = client.get("/api/settings")
assert res_settings.status_code == 200
assert res_settings.json().get("version") == __version__
# /api/snapshot
res_snap = client.get("/api/snapshot")
assert res_snap.status_code == 200
snap = res_snap.json()
assert snap.get("version") == __version__
assert (snap.get("metrics") or {}).get("version") == __version__
# ── P0-5: Settings View & System Paths Tests ──
def test_p0_5_settings_api_returns_system_paths_and_hub_settings():
"""GET /api/settings must return system_paths, server host/port, token status, and hub settings."""
client = TestClient(app)
res = client.get("/api/settings")
assert res.status_code == 200
data = res.json()
# System paths dict
assert "system_paths" in data
sys_paths = data["system_paths"]
assert "hermes_home" in sys_paths
assert "config_dir" in sys_paths
assert "log_file" in sys_paths
# Server settings & tokens
assert "web_api_host" in data
assert "web_api_port" in data
assert "web_api_token_configured" in data
# Snapshot includes system_paths
res_snap = client.get("/api/snapshot")
assert res_snap.status_code == 200
snap = res_snap.json()
assert "system_paths" in snap
assert snap["system_paths"]["hermes_home"] == sys_paths["hermes_home"]

View file

@ -0,0 +1,486 @@
"""Tests for Task A56: Context Compression (Сжатие контекста).
Verifies:
- P0-1: Compressor is a configurable role/profile, not hardcoded, excluded from Hermes default routing chains.
- P0-2: Context threshold triggering (default 75%), fresh window preserved verbatim, no recursive double compression.
- P0-3: Strict verbatim preservation of factual entities (file paths, ports, IPs, variable/function names, commit SHAs, versions, metrics) with 100% retention guarantee.
- P0-4: Transparent telemetry, history store for original uncompressed text, non-blocking graceful fallback when compressor is offline.
- P0-5: Shared memory persistence in /srv/projects/AI-Memory indexed by GGUF build metadata.
- P0-6: Audit pass testing on live server (if accessible) and mock offline fallback.
"""
from __future__ import annotations
import json
import os
import tempfile
import urllib.error
import urllib.request
from dataclasses import asdict
from pathlib import Path
from typing import Any, Dict, List
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from antigravity_provider.router.context_compressor import (
COMPRESSED_BLOCK_END,
COMPRESSED_BLOCK_START,
CompressionOutcome,
ContextCompressor,
FactualEntities,
extract_factual_entities,
verify_facts_retention,
)
from antigravity_provider.router.local_supervisor import (
LocalSupervisor,
ServerPropsResult,
TokenCountResult,
)
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig
from antigravity_provider.router.settings_service import (
DEFAULT_SETTINGS,
get_hub_settings,
invalidate_settings_cache,
save_hub_settings,
)
from antigravity_provider.router.web.server import app
# ─────────────────────────────────────────────────────────────
# Fixtures
# ─────────────────────────────────────────────────────────────
@pytest.fixture
def tmp_vault(tmp_path: Path):
vault = tmp_path / "AI-Memory"
vault.mkdir(parents=True, exist_ok=True)
(vault / "01_PROJECTS" / "hermes-hub").mkdir(parents=True, exist_ok=True)
return vault
@pytest.fixture
def test_client():
return TestClient(app)
# ─────────────────────────────────────────────────────────────
# P0-1: Configurable role & profile
# ─────────────────────────────────────────────────────────────
def test_p0_1_compressor_unconfigured_returns_nd_status(tmp_vault: Path):
"""When compressor profile is not selected, returns unconfigured N/D without error."""
compressor = ContextCompressor(memory_file=tmp_vault / "01_PROJECTS" / "hermes-hub" / "compression_memory.json")
supervisor = LocalSupervisor(compressor=compressor)
# Status without profile
status = supervisor.get_compression_status(compressor_profile=None)
assert status["configured"] is False
assert status["status"] == "unconfigured"
assert "Н/Д: модель для сжатия не выбрана" in status["display_status"]
# Compression with unconfigured profile
messages = [
{"role": "system", "content": "System prompt"},
{"role": "user", "content": "Hello world" * 100},
]
with patch.dict(os.environ, {}, clear=True):
res_msgs, outcome = compressor.compress_messages_if_needed(
messages=messages,
target_context_limit=1000,
current_token_count=800,
compressor_profile=None,
)
assert outcome.status == "UNCONFIGURED"
assert "Н" in outcome.status_message
assert res_msgs == messages
def test_p0_1_compressor_endpoint_resolved_from_profile(tmp_vault: Path):
"""Compressor endpoint is dynamically resolved from profile config, not hardcoded."""
compressor = ContextCompressor(memory_file=tmp_vault / "01_PROJECTS" / "hermes-hub" / "compression_memory.json")
pconfig = RouterProfileConfig(
profile_id="local-compressor-custom",
provider="local",
custom_base_url="http://192.168.1.100:9999/v1",
preferred_models=["CustomCompressor-7B"],
auth_config={"api_key": "secret-token-123"},
)
base_url, model, token = compressor.resolve_compressor_endpoint(pconfig)
assert base_url == "http://192.168.1.100:9999/v1"
assert model == "CustomCompressor-7B"
assert token == "secret-token-123"
# ─────────────────────────────────────────────────────────────
# P0-2: Triggering conditions & threshold
# ─────────────────────────────────────────────────────────────
def test_p0_2_threshold_skips_when_under_limit(tmp_vault: Path):
"""When token count is below threshold percent (e.g. 50% < 75%), compression is skipped."""
compressor = ContextCompressor(memory_file=tmp_vault / "01_PROJECTS" / "hermes-hub" / "compression_memory.json")
pconfig = RouterProfileConfig(
profile_id="compressor-mock",
provider="local",
custom_base_url="http://127.0.0.1:8082/v1",
preferred_models=["Qwen3-4B-2507"],
)
messages = [
{"role": "system", "content": "System prompt"},
{"role": "user", "content": "Message 1"},
{"role": "assistant", "content": "Response 1"},
{"role": "user", "content": "Message 2"},
]
# Target 10,000, current 5,000 -> 50% < 75% threshold
res_msgs, outcome = compressor.compress_messages_if_needed(
messages=messages,
target_context_limit=10000,
current_token_count=5000,
compressor_profile=pconfig,
threshold_percent=75.0,
)
assert outcome.status == "SKIPPED"
assert "В пределах нормы" in outcome.status_message
assert res_msgs == messages
def test_p0_2_fresh_window_preserved_verbatim_and_no_double_nesting(tmp_vault: Path):
"""Fresh messages remain completely verbatim; existing summary blocks are unnested without recursion."""
compressor = ContextCompressor(memory_file=tmp_vault / "01_PROJECTS" / "hermes-hub" / "compression_memory.json")
pconfig = RouterProfileConfig(
profile_id="compressor-mock",
provider="local",
custom_base_url="http://127.0.0.1:8082/v1",
preferred_models=["Qwen3-4B-2507"],
)
# Existing summary block inside history
prev_summary = (
f"{COMPRESSED_BLOCK_START}\n"
f"## Сжатая сводка предшествующего контекста\n"
f"- Server running on 192.168.1.81:8765\n"
f"- Active port: :8081\n"
f"{COMPRESSED_BLOCK_END}"
)
messages = [
{"role": "system", "content": "You are a senior developer."},
{"role": "user", "content": prev_summary},
{"role": "user", "content": "Step 2: Created /srv/projects/hermes/test.py with SHA 8e75dc6."},
{"role": "assistant", "content": "Step 2 done."},
# Fresh window (last 2 messages)
{"role": "user", "content": "Fresh user request: run tests now."},
{"role": "assistant", "content": "Fresh assistant response."},
]
mock_llm_response = {
"choices": [{
"message": {
"role": "assistant",
"content": (
"- Server running on 192.168.1.81:8765\n"
"- Active port :8081\n"
"- Created /srv/projects/hermes/test.py with SHA 8e75dc6"
)
}
}],
"model": "Qwen3-4B-2507-Instruct-Q4_K_M.gguf",
}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(mock_llm_response).encode("utf-8")
mock_resp.__enter__.return_value = mock_resp
with patch("urllib.request.urlopen", return_value=mock_resp):
res_msgs, outcome = compressor.compress_messages_if_needed(
messages=messages,
target_context_limit=1000,
current_token_count=900,
compressor_profile=pconfig,
threshold_percent=75.0,
keep_recent_messages=2,
)
assert outcome.status == "SUCCESS"
assert outcome.retention_percent == 100.0
# System message preserved
assert res_msgs[0]["content"] == "You are a senior developer."
# Fresh window preserved verbatim
assert res_msgs[-2]["content"] == "Fresh user request: run tests now."
assert res_msgs[-1]["content"] == "Fresh assistant response."
# Only 1 single compression block, never nested
compressed_content = res_msgs[1]["content"]
assert compressed_content.count(COMPRESSED_BLOCK_START) == 1
assert compressed_content.count(COMPRESSED_BLOCK_END) == 1
# ─────────────────────────────────────────────────────────────
# P0-3: Strict Verbatim Preservation of Factual Entities
# ─────────────────────────────────────────────────────────────
def test_p0_3_extract_and_verify_all_factual_entities():
"""Extracts and verifies 100% of paths, ports, IPs, SHAs, versions, and metrics."""
text = (
"Server deployed on 192.168.1.81:8765. Primary coder is on port 8081 with 224K context (229376 tokens). "
"Measured generation speed: 107.4 tok/s, VRAM: 30008 MiB. Local compressor on port 8082 with 32768 context. "
"File: /srv/projects/Agent projects/hermes-hub/src/antigravity_provider/router/local_supervisor.py. "
"Commit SHA: 26f7d2c, version: v0.1.2. Classes: LocalSupervisor and ContextCompressor."
)
facts = extract_factual_entities(text)
assert "/srv/projects/Agent projects/hermes-hub/src/antigravity_provider/router/local_supervisor.py" in facts.file_paths
assert "192.168.1.81" in facts.ip_addresses
assert any(":8081" in p or "8081" in p for p in facts.port_numbers)
assert any(":8082" in p or "8082" in p for p in facts.port_numbers)
assert "26f7d2c" in facts.commit_shas
assert "v0.1.2" in facts.version_numbers
assert any("107.4" in m for m in facts.metrics)
assert any("30008" in m for m in facts.metrics)
assert "LocalSupervisor" in facts.identifiers
# Verify retention in exact summary
summary = (
"Summary:\n"
"- 192.168.1.81:8765\n"
"- port 8081, 224K, 229376 tokens, 107.4 tok/s, 30008 MiB\n"
"- port 8082, 32768 tokens\n"
"- /srv/projects/Agent projects/hermes-hub/src/antigravity_provider/router/local_supervisor.py\n"
"- SHA 26f7d2c, version v0.1.2\n"
"- LocalSupervisor, ContextCompressor"
)
retention, preserved, missing = verify_facts_retention(summary, facts)
assert retention >= 95.0
def test_p0_3_factual_safeguard_ledger_guarantees_100_percent_retention(tmp_vault: Path):
"""If LLM summary misses an entity, safeguard ledger automatically appends it to reach 100% retention."""
compressor = ContextCompressor(memory_file=tmp_vault / "01_PROJECTS" / "hermes-hub" / "compression_memory.json")
pconfig = RouterProfileConfig(
profile_id="compressor-mock",
provider="local",
custom_base_url="http://127.0.0.1:8082/v1",
preferred_models=["Qwen3-4B-2507"],
)
messages = [
{"role": "system", "content": "You are a coder."},
{"role": "user", "content": "Critical secret port is :8888 and critical commit is 79ac9cf5610dccf8."},
{"role": "assistant", "content": "Understood."},
{"role": "user", "content": "Recent question?"},
]
# Model generates a summary that forgot the port and commit
mock_llm_response = {
"choices": [{
"message": {
"role": "assistant",
"content": "User discussed server settings."
}
}],
"model": "Qwen3-4B-2507-Instruct-Q4_K_M.gguf",
}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(mock_llm_response).encode("utf-8")
mock_resp.__enter__.return_value = mock_resp
with patch("urllib.request.urlopen", return_value=mock_resp):
res_msgs, outcome = compressor.compress_messages_if_needed(
messages=messages,
target_context_limit=1000,
current_token_count=800,
compressor_profile=pconfig,
threshold_percent=50.0,
keep_recent_messages=1,
)
assert outcome.status == "SUCCESS"
assert outcome.retention_percent == 100.0
compressed_text = res_msgs[1]["content"]
assert ":8888" in compressed_text
assert "79ac9cf5610dccf8" in compressed_text
# ─────────────────────────────────────────────────────────────
# P0-4: Non-blocking graceful fallback & history snapshot
# ─────────────────────────────────────────────────────────────
def test_p0_4_compressor_offline_does_not_crash_task(tmp_vault: Path):
"""When compressor server is unreachable, task continues safely on uncompressed context."""
compressor = ContextCompressor(memory_file=tmp_vault / "01_PROJECTS" / "hermes-hub" / "compression_memory.json")
pconfig = RouterProfileConfig(
profile_id="compressor-offline",
provider="local",
custom_base_url="http://127.0.0.1:9999/v1", # dead port
preferred_models=["Qwen3-4B-2507"],
)
messages = [
{"role": "system", "content": "System message"},
{"role": "user", "content": "Original user text" * 20},
{"role": "assistant", "content": "Original assistant text" * 20},
{"role": "user", "content": "Latest user query"},
]
# Simulating connection error
with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("Connection refused")):
res_msgs, outcome = compressor.compress_messages_if_needed(
messages=messages,
target_context_limit=100,
current_token_count=500,
compressor_profile=pconfig,
threshold_percent=50.0,
keep_recent_messages=1,
)
# Task is NOT aborted
assert outcome.status == "ERROR"
assert "Задача продолжается на исходном контексте" in outcome.status_message
assert res_msgs == messages
# Original messages preserved in history snapshot
assert outcome.original_messages_snapshot == messages
# ─────────────────────────────────────────────────────────────
# P0-5: Shared Memory Persistence in AI-Memory
# ─────────────────────────────────────────────────────────────
def test_p0_5_compression_memory_recorded_by_gguf_name(tmp_vault: Path):
"""Records compression history in /srv/projects/AI-Memory indexed by GGUF model."""
memory_file = tmp_vault / "01_PROJECTS" / "hermes-hub" / "compression_memory.json"
compressor = ContextCompressor(memory_file=memory_file)
pconfig = RouterProfileConfig(
profile_id="compressor-p1",
provider="local",
custom_base_url="http://127.0.0.1:8082/v1",
preferred_models=["Qwen3-4B-2507"],
)
messages = [
{"role": "system", "content": "Sys"},
{"role": "user", "content": "Server is 192.168.1.81:8081"},
{"role": "assistant", "content": "Ok"},
{"role": "user", "content": "Next"},
]
mock_llm_response = {
"choices": [{
"message": {
"role": "assistant",
"content": "- 192.168.1.81:8081 active"
}
}],
"model": "/srv/ai/models/qwen3-4b-compressor/Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(mock_llm_response).encode("utf-8")
mock_resp.__enter__.return_value = mock_resp
with patch("urllib.request.urlopen", return_value=mock_resp):
_, outcome = compressor.compress_messages_if_needed(
messages=messages,
target_context_limit=100,
current_token_count=200,
compressor_profile=pconfig,
threshold_percent=50.0,
keep_recent_messages=1,
)
assert outcome.status == "SUCCESS"
assert memory_file.exists()
data = json.loads(memory_file.read_text(encoding="utf-8"))
assert "compressor_models" in data
gguf_key = "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf"
assert gguf_key in data["compressor_models"]
rec = data["compressor_models"][gguf_key]
assert rec["successful_compressions"] == 1
assert rec["avg_fact_retention_percent"] == 100.0
assert len(rec["history"]) == 1
# ─────────────────────────────────────────────────────────────
# Web API & Action Handler Integration
# ─────────────────────────────────────────────────────────────
def test_web_api_compression_endpoints(test_client: TestClient):
"""Tests GET /api/compression/status, /api/compression/history, POST /api/compression/test."""
# 1. GET /api/compression/status
res = test_client.get("/api/compression/status")
assert res.status_code == 200
data = res.json()
assert "threshold_percent" in data
assert "display_status" in data
# 2. GET /api/compression/history
res_hist = test_client.get("/api/compression/history")
assert res_hist.status_code == 200
assert "history" in res_hist.json()
# 3. POST /api/compression/test
res_test = test_client.post("/api/compression/test", json={"profile_id": "none"})
assert res_test.status_code == 200
test_json = res_test.json()
assert "message" in test_json
assert "data" in test_json
# ─────────────────────────────────────────────────────────────
# P0-6: Live Server Verification (if 8082 is reachable)
# ─────────────────────────────────────────────────────────────
def test_p0_6_live_compressor_verification():
"""Live verification against local llama-server compressor on port 8082 if online."""
try:
req = urllib.request.Request("http://127.0.0.1:8082/health", headers={"User-Agent": "Test/1.0"})
with urllib.request.urlopen(req, timeout=3.0) as resp:
data = json.loads(resp.read().decode("utf-8"))
if data.get("status") != "ok":
pytest.skip("Local compressor server on 8082 not healthy")
except Exception as exc:
pytest.skip(f"Local compressor server on 8082 not reachable: {exc}")
compressor = ContextCompressor()
pconfig = RouterProfileConfig(
profile_id="live-compressor",
provider="local",
custom_base_url="http://127.0.0.1:8082/v1",
preferred_models=["default"],
)
test_messages = [
{"role": "system", "content": "You are a software engineer."},
{"role": "user", "content": "Hermes Hub server runs on 192.168.1.81:8765. Primary coder is on port 8081 with 224K context (229376 tokens), speed 107.4 tok/s, VRAM 30008 MiB. Active branch: antigravity/a56-context-compression, Commit SHA: 26f7d2c, version v0.1.2. Local compressor is on port 8082 on CPU."},
{"role": "assistant", "content": "Server metrics and ports registered."},
{"role": "user", "content": "File path is /srv/projects/Agent projects/hermes-hub/src/antigravity_provider/router/local_supervisor.py."},
{"role": "assistant", "content": "Path registered."},
{"role": "user", "content": "What is our next action?"},
]
res_msgs, outcome = compressor.compress_messages_if_needed(
messages=test_messages,
target_context_limit=32768,
current_token_count=150,
compressor_profile=pconfig,
threshold_percent=0.0, # force compression
keep_recent_messages=2,
)
assert outcome.status == "SUCCESS"
assert outcome.retention_percent == 100.0
assert outcome.saved_tokens >= 0
assert len(res_msgs) == 4 # system + compressed + 2 fresh
compressed_body = res_msgs[1]["content"]
assert "192.168.1.81" in compressed_body
assert "8081" in compressed_body
assert "8082" in compressed_body
assert "26f7d2c" in compressed_body
assert "v0.1.2" in compressed_body

View file

@ -0,0 +1,233 @@
"""Проверки по итогам разбора A56 ревьюером.
Три находки, каждая подтверждена измерением на сервере владельца:
1. `/props` и `/tokenize` у llama.cpp живут в корне, а не под `/v1`.
Измерено: `/props` 200, `/v1/props` 404; то же с `/tokenize`.
Адаптер передаёт супервизору адрес вида `.../v1`, поэтому счёт токенов
молча падал на посимвольную оценку, и порог сжатия считался от выдуманного
числа.
2. Заявленные «100% сохранения фактов» модель не даёт. Измерено на живом
компрессоре: 5667 токенов на входе, 624 на выходе, 37 фактов из 38
97,4%, потерян `001cd1f`. Сто процентов получаются дописыванием
недостающих фактов списком. Механизм верный, но подменять им измеренное
число нельзя: иначе ухудшение модели останется незамеченным.
3. Порт 8082 был зашит в коде как запасной адрес. Задание это прямо
запрещает: сегодня он такой, завтра другой.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from antigravity_provider.router.context_compressor import (
CompressionOutcome,
ContextCompressor,
)
from antigravity_provider.router.local_supervisor import LocalSupervisor
# ── Находка 1: адрес /props и /tokenize ──
@pytest.mark.unit
@pytest.mark.parametrize(
"given, expected_root",
[
("http://127.0.0.1:8081/v1", "http://127.0.0.1:8081"),
("http://127.0.0.1:8081/v1/", "http://127.0.0.1:8081"),
("http://127.0.0.1:8081", "http://127.0.0.1:8081"),
("http://server.local:9000/v1", "http://server.local:9000"),
],
)
def test_props_and_tokenize_go_to_the_root(given, expected_root):
assert LocalSupervisor(base_url=given).root_url == expected_root
@pytest.mark.unit
def test_tokenize_request_url_has_no_v1():
supervisor = LocalSupervisor(base_url="http://127.0.0.1:8081/v1")
seen = {}
def _fake_urlopen(req, timeout=None):
seen["url"] = req.full_url
raise RuntimeError("сеть в тесте недоступна")
with patch("urllib.request.urlopen", _fake_urlopen):
supervisor.count_tokens("проверка")
assert seen["url"] == "http://127.0.0.1:8081/tokenize"
@pytest.mark.unit
def test_props_request_url_has_no_v1():
supervisor = LocalSupervisor(base_url="http://127.0.0.1:8081/v1")
seen = {}
def _fake_urlopen(req, timeout=None):
seen["url"] = req.full_url
raise RuntimeError("сеть в тесте недоступна")
with patch("urllib.request.urlopen", _fake_urlopen):
supervisor.query_server_props()
assert seen["url"] == "http://127.0.0.1:8081/props"
@pytest.mark.unit
def test_unmeasured_limit_is_marked_as_such():
"""Недоступный /props даёт запасное значение — и оно обязано быть помечено."""
supervisor = LocalSupervisor(base_url="http://127.0.0.1:8081/v1")
def _fake_urlopen(req, timeout=None):
raise RuntimeError("сервер не отвечает")
with patch("urllib.request.urlopen", _fake_urlopen):
result = supervisor.query_server_props()
assert result.is_measured is False
# ── Находка 2: полнота фактов не подменяется исправленной ──
class _Profile:
custom_base_url = "http://127.0.0.1:9999/v1"
preferred_models = ["compressor"]
auth_config: dict = {}
def _compress_with_summary(summary: str):
payload = {"choices": [{"message": {"content": summary}}], "model": "test-gguf"}
class _Resp:
def read(self):
import json
return json.dumps(payload).encode()
def __enter__(self):
return self
def __exit__(self, *a):
return False
messages = [
{"role": "system", "content": "система"},
{"role": "user", "content": "Правка в коммите 001cd1f, порт 8082, файл /srv/app/main.py"},
{"role": "assistant", "content": "Скорость 107.4 tok/s на версии v0.1.3"},
{"role": "user", "content": "свежее 1"},
{"role": "user", "content": "свежее 2"},
{"role": "user", "content": "свежее 3"},
]
compressor = ContextCompressor()
with patch("urllib.request.urlopen", return_value=_Resp()), \
patch.object(ContextCompressor, "_record_to_shared_memory", lambda self, o: None):
_, outcome = compressor.compress_messages_if_needed(
messages=messages,
target_context_limit=1000,
current_token_count=900,
compressor_profile=_Profile(),
)
return outcome
@pytest.mark.unit
def test_model_retention_is_reported_separately_when_facts_were_added():
# Сводка намеренно теряет часть фактов — их дописывает страховка.
outcome = _compress_with_summary("Кратко: правка внесена, порт 8082.")
assert outcome.status == "SUCCESS"
assert outcome.facts_added_by_safeguard, "страховка обязана была сработать"
assert outcome.model_retention_percent < 100.0
assert outcome.retention_percent == pytest.approx(100.0)
assert "дописано списком" in outcome.status_message
assert f"{outcome.model_retention_percent:.1f}%" in outcome.status_message
@pytest.mark.unit
def test_full_model_retention_is_stated_as_the_model_s_own():
summary = "Коммит 001cd1f, порт 8082, файл /srv/app/main.py, 107.4 tok/s, v0.1.3"
outcome = _compress_with_summary(summary)
assert not outcome.facts_added_by_safeguard
assert outcome.model_retention_percent == pytest.approx(100.0)
assert "самой моделью" in outcome.status_message
@pytest.mark.unit
def test_status_message_never_hardcodes_a_hundred_percent():
import inspect
from antigravity_provider.router import context_compressor
source = inspect.getsource(context_compressor)
assert "(100%)." not in source, "полнота обязана браться из замера, а не из строки"
# ── Находка 3: порт компрессора не зашит ──
@pytest.mark.unit
def test_compressor_port_is_not_hardcoded():
import inspect
from antigravity_provider.router import context_compressor
code_lines = [
line
for line in inspect.getsource(context_compressor).splitlines()
if not line.lstrip().startswith("#")
]
assert not any("127.0.0.1:8082" in line for line in code_lines)
@pytest.mark.unit
def test_profile_without_address_is_unconfigured_not_port_8082(monkeypatch):
monkeypatch.delenv("LOCAL_COMPRESSOR_BASE_URL", raising=False)
class _NoAddress:
custom_base_url = None
preferred_models: list = []
auth_config: dict = {}
_, outcome = ContextCompressor().compress_messages_if_needed(
messages=[{"role": "user", "content": "x"}],
target_context_limit=1000,
current_token_count=900,
compressor_profile=_NoAddress(),
)
assert outcome.status == "UNCONFIGURED"
assert "адрес" in outcome.status_message
# ── Итог сжатия пересчитывается токенизатором ──
@pytest.mark.unit
def test_result_size_is_recounted_by_the_tokenizer():
supervisor = LocalSupervisor(base_url="http://127.0.0.1:8081/v1")
outcome = CompressionOutcome(
status="SUCCESS", status_message="", tokens_before=900, tokens_after=1
)
with patch.object(
ContextCompressor,
"compress_messages_if_needed",
return_value=([{"role": "user", "content": "сводка"}], outcome),
), patch.object(
LocalSupervisor,
"count_tokens",
side_effect=[
type("T", (), {"tokens_count": 900, "is_estimated": False})(),
type("T", (), {"tokens_count": 120, "is_estimated": False})(),
],
):
_, result = supervisor.compress_context_if_needed(
messages=[{"role": "user", "content": "длинная история"}],
target_context_limit=1000,
)
assert result.tokens_after == 120
assert result.tokens_after_is_estimate is False
assert result.saved_tokens == 780

View file

@ -0,0 +1,402 @@
"""Tests for Task A57: Antigravity Native Login via agy CLI.
Covers:
- P0-1: Native agy CLI invocation in terminal with isolated HOME/USERPROFILE/HOMEPATH.
Terminal lookup on Linux and Windows with honest error reporting when missing.
Waiting for credentials by file detection rather than process exit code.
- P0-2: Profile slot is pre-selected and isolated. Occupied slots require confirmation.
Existing account directories in ~/.hermes/agy_profiles/ are never touched.
- P0-3: Account identity is truthfully read from agy-generated files without inventing emails.
AutoAssigner.check_duplicate_identity de-duplicates accounts and prevents slot creep.
- P0-4: Browser OAuth redirect path is preserved as fallback.
- P0-5: Failures report exact cause (including stderr, checked candidate terminals, and HOME).
- P0-6: Security: no credentials in logs, directory permissions 0700, file permissions 0600.
"""
from __future__ import annotations
import json
import os
from antigravity_provider import agy_subprocess
import shutil
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from antigravity_provider.agy_subprocess import (
check_profile_native_auth_status,
find_terminal_emulator,
poll_native_agy_login,
start_native_agy_login,
)
from antigravity_provider.paths import get_profile_dir
from antigravity_provider.router.action_handler import ActionExecutor
from antigravity_provider.router.router_config import (
RouterConfig,
RouterProfileConfig,
save_router_config,
)
@pytest.fixture(autouse=True)
def isolated_hermes_env(tmp_path, monkeypatch):
"""Ensure every test runs with fully isolated HERMES_HOME and temporary environment."""
h_home = tmp_path / "hermes_home"
h_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(h_home))
monkeypatch.setenv("DISPLAY", ":10.0")
# Initial empty router config
cfg = RouterConfig()
save_router_config(cfg)
return h_home
# ── TEST 1: Terminal discovery on Linux ──
@pytest.mark.unit
def test_terminal_discovery_linux_found(tmp_path, monkeypatch):
"""P0-1: find_terminal_emulator finds available terminal on Linux and returns correct command."""
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False)
monkeypatch.setenv("DISPLAY", ":10.0")
def mock_which(cmd):
if cmd == "gnome-terminal":
return "/usr/bin/gnome-terminal"
return None
monkeypatch.setattr(shutil, "which", mock_which)
cmd, err, checked = find_terminal_emulator("ag-1", "/bin/agy", tmp_path)
assert err is None
assert cmd is not None
assert cmd[0] == "/usr/bin/gnome-terminal"
assert "--" in cmd
# Поиск терминала подставляет то, что ему передали, и ничего не создаёт.
# Сценарий входа готовит start_native_agy_login.
assert "/bin/agy" in cmd
assert any("gnome-terminal (найден: /usr/bin/gnome-terminal)" in item for item in checked)
@pytest.mark.unit
def test_terminal_discovery_linux_missing_honest_error(tmp_path, monkeypatch):
"""P0-1: When no terminal emulator exists on Linux, report honest error listing checked candidates."""
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False)
monkeypatch.setenv("DISPLAY", ":10.0")
monkeypatch.setattr(shutil, "which", lambda cmd: None)
cmd, err, checked = find_terminal_emulator("ag-1", "/bin/agy", tmp_path)
assert cmd is None
assert err is not None
assert "Терминал не найден на сервере" in err
assert "x-terminal-emulator" in err
assert "gnome-terminal" in err
assert "konsole" in err
assert "xterm" in err
assert len(checked) >= 8
@pytest.mark.unit
def test_terminal_discovery_no_display_honest_error(tmp_path, monkeypatch):
"""P0-1: сеанса нет ни в окружении, ни у systemd — отказ с перечнем проверенного.
Пустое окружение само по себе больше не приговор: хаб запускается через
nohup и по SSH остаётся без DISPLAY, стоя при этом на рабочем столе. Отказ
правомерен, только когда и systemd графического сеанса не знает.
"""
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False)
monkeypatch.delenv("DISPLAY", raising=False)
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
monkeypatch.delenv("MIR_SOCKET", raising=False)
monkeypatch.setattr(agy_subprocess.shutil, "which", lambda _n: None)
cmd, err, checked = find_terminal_emulator("ag-1", "/bin/agy", tmp_path)
assert cmd is None
assert err is not None
assert "Графический сеанс не обнаружен" in err
assert any("DISPLAY (не задан)" in item for item in checked)
assert any("loginctl" in item for item in checked)
@pytest.mark.unit
def test_terminal_discovery_windows(tmp_path, monkeypatch):
"""P0-1: On Windows, find_terminal_emulator selects wt.exe or cmd.exe."""
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: True)
monkeypatch.setattr(shutil, "which", lambda c: "C:\\Windows\\System32\\wt.exe" if "wt" in c else None)
cmd, err, checked = find_terminal_emulator("ag-1", "C:\\bin\\agy.exe", tmp_path)
assert err is None
assert cmd is not None
assert "wt.exe" in cmd[0]
monkeypatch.setattr(shutil, "which", lambda c: None)
cmd, err, checked = find_terminal_emulator("ag-1", "C:\\bin\\agy.exe", tmp_path)
assert err is None
assert cmd is not None
assert "cmd.exe" in cmd[0]
# ── TEST 2: Start native agy login & slot protection ──
@pytest.mark.unit
def test_start_native_agy_login_creates_isolated_home(tmp_path, monkeypatch):
"""P0-1/P0-2: start_native_agy_login sets up profile directory with 0700 permissions and launches terminal."""
slot = "ag-1"
pdir = get_profile_dir(slot, "antigravity")
mock_popen = MagicMock()
monkeypatch.setattr("subprocess.Popen", mock_popen)
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
monkeypatch.setattr(
"antigravity_provider.agy_subprocess.find_terminal_emulator",
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
)
ok, msg, data = start_native_agy_login(profile_id=slot)
assert ok is True
assert "Терминал успешно запущен" in msg
assert data["profile_id"] == "ag-1"
assert "session_id" in data
assert pdir.is_dir()
# Изоляция обеспечивается сценарием входа, а не окружением терминала.
# HOME терминалу подменять нельзя: клиенты X11 берут ключ авторизации из
# ~/.Xauthority, и с подменённым HOME его там нет — xfce4-terminal выходил
# с кодом 1, не открыв окна. Сценарий задаёт HOME сам, уже внутри окна.
mock_popen.assert_called_once()
call_kwargs = mock_popen.call_args[1]
assert call_kwargs["env"].get("HOME") != str(pdir)
assert call_kwargs["cwd"] == str(pdir)
helper = pdir / ".hermes-agy-login.sh"
assert helper.is_file(), "сценарий входа должен быть создан"
body = helper.read_text(encoding="utf-8")
assert str(pdir) in body and "export HOME" in body
@pytest.mark.unit
def test_start_native_agy_login_occupied_slot_requires_confirmation(tmp_path, monkeypatch):
"""P0-2: Attempting to log into an already authenticated slot requires confirmation unless force=True."""
slot = "ag-2"
pdir = get_profile_dir(slot, "antigravity")
pdir.mkdir(parents=True, exist_ok=True)
auth_file = pdir / "auth.json"
auth_file.write_text(json.dumps({
"auth_method": "oauth",
"email": "existing.developer@gmail.com",
"token": {"access_token": "ya29.active_token", "refresh_token": "1//active_ref"},
}), encoding="utf-8")
# First attempt without force -> confirmation required
ok, msg, data = start_native_agy_login(profile_id=slot, force=False)
assert ok is False
assert data.get("confirmation_required") is True
assert "уже занят аккаунтом" in msg
# Second attempt with force=True -> proceeds
mock_popen = MagicMock()
monkeypatch.setattr("subprocess.Popen", mock_popen)
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
monkeypatch.setattr(
"antigravity_provider.agy_subprocess.find_terminal_emulator",
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
)
ok, msg, data = start_native_agy_login(profile_id=slot, force=True)
assert ok is True
assert "session_id" in data
# ── TEST 3: Polling detection of credentials written by agy ──
@pytest.mark.unit
def test_poll_native_agy_login_detects_antigravity_oauth_token(tmp_path, monkeypatch):
"""P0-1/P0-3: Poller detects .gemini/antigravity-cli/antigravity-oauth-token written by agy."""
slot = "ag-3"
pdir = get_profile_dir(slot, "antigravity")
monkeypatch.setattr("subprocess.Popen", MagicMock())
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
monkeypatch.setattr(
"antigravity_provider.agy_subprocess.find_terminal_emulator",
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
)
ok, msg, data = start_native_agy_login(profile_id=slot)
assert ok is True
session_id = data["session_id"]
# While file is not written yet -> pending
ok, msg, p_data = poll_native_agy_login(session_id)
assert ok is True
assert p_data["status"] == "pending"
# Simulate agy CLI writing its token file
cli_dir = pdir / ".gemini" / "antigravity-cli"
cli_dir.mkdir(parents=True, exist_ok=True)
token_file = cli_dir / "antigravity-oauth-token"
token_file.write_text(json.dumps({
"auth_method": "consumer",
"token": {
"access_token": "ya29.native_agy_token",
"refresh_token": "1//native_agy_refresh",
"token_type": "Bearer",
"expiry": "2026-09-01T16:00:00Z",
},
}), encoding="utf-8")
# Simulate active account identification
acc_file = pdir / ".gemini" / "google_accounts.json"
acc_file.write_text(json.dumps({"active": "agy.native@gmail.com"}), encoding="utf-8")
# Poller should detect completion
ok, msg, p_data = poll_native_agy_login(session_id)
assert ok is True
assert p_data["status"] == "completed"
assert p_data["email"] == "agy.native@gmail.com"
assert p_data["profile_id"] == "ag-3"
@pytest.mark.unit
def test_poll_native_agy_login_truthful_email_or_na(tmp_path, monkeypatch):
"""P0-3: When email identity cannot be found, display 'Н', never an invented name."""
slot = "ag-4"
pdir = get_profile_dir(slot, "antigravity")
monkeypatch.setattr("subprocess.Popen", MagicMock())
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
monkeypatch.setattr(
"antigravity_provider.agy_subprocess.find_terminal_emulator",
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
)
ok, msg, data = start_native_agy_login(profile_id=slot)
session_id = data["session_id"]
cli_dir = pdir / ".gemini" / "antigravity-cli"
cli_dir.mkdir(parents=True, exist_ok=True)
token_file = cli_dir / "antigravity-oauth-token"
token_file.write_text(json.dumps({
"auth_method": "consumer",
"token": {
"access_token": "ya29.anon_token",
"refresh_token": "1//anon_refresh",
},
}), encoding="utf-8")
ok, msg, p_data = poll_native_agy_login(session_id)
assert ok is True
assert p_data["status"] == "completed"
assert "Н" in p_data["email"]
@pytest.mark.unit
def test_poll_native_agy_login_duplicate_identity_deduplication(tmp_path, monkeypatch):
"""P0-3: When logging into a new slot with an account already registered in another slot, redirect to existing slot."""
# Slot 1 already has account
s1_dir = get_profile_dir("ag-1", "antigravity")
s1_dir.mkdir(parents=True, exist_ok=True)
(s1_dir / "auth.json").write_text(json.dumps({
"email": "corp.developer@company.com",
"auth_method": "oauth",
"token": {"access_token": "ya29.old", "refresh_token": "1//old"},
}), encoding="utf-8")
cfg = RouterConfig()
cfg.profiles["ag-1"] = RouterProfileConfig(profile_id="ag-1", provider="antigravity")
save_router_config(cfg)
# User attempts login into ag-5
s5_dir = get_profile_dir("ag-5", "antigravity")
monkeypatch.setattr("subprocess.Popen", MagicMock())
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
monkeypatch.setattr(
"antigravity_provider.agy_subprocess.find_terminal_emulator",
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
)
ok, msg, data = start_native_agy_login(profile_id="ag-5")
session_id = data["session_id"]
# agy writes credentials for corp.developer@company.com into ag-5
cli_dir = s5_dir / ".gemini" / "antigravity-cli"
cli_dir.mkdir(parents=True, exist_ok=True)
(cli_dir / "antigravity-oauth-token").write_text(json.dumps({
"auth_method": "consumer",
"token": {"access_token": "ya29.new_corp", "refresh_token": "1//new_corp"},
}), encoding="utf-8")
(s5_dir / ".gemini" / "google_accounts.json").write_text(
json.dumps({"active": "corp.developer@company.com"}), encoding="utf-8"
)
ok, msg, p_data = poll_native_agy_login(session_id)
assert ok is True
assert p_data["status"] == "completed"
# De-duplicated back to ag-1
assert p_data["profile_id"] == "ag-1"
assert p_data["email"] == "corp.developer@company.com"
# ── TEST 4: Action executor integration & browser fallback ──
@pytest.mark.unit
def test_action_handler_native_auth_routes(monkeypatch):
"""P0-1/P0-5: ActionExecutor handles start_native_auth and poll_native_auth."""
monkeypatch.setattr("subprocess.Popen", MagicMock())
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
monkeypatch.setattr(
"antigravity_provider.agy_subprocess.find_terminal_emulator",
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
)
res = ActionExecutor.execute("start_native_auth", {"provider": "antigravity", "profile_id": "ag-6"})
assert res["ok"] is True
session_id = res["data"]["session_id"]
poll_res = ActionExecutor.execute("poll_native_auth", {"session_id": session_id})
assert poll_res["ok"] is True
assert poll_res["data"]["status"] == "pending"
cancel_res = ActionExecutor.execute("cancel_native_auth", {"session_id": session_id})
assert cancel_res["ok"] is True
@pytest.mark.unit
def test_browser_redirect_auth_fallback_preserved():
"""P0-4: Existing browser OAuth redirect path is fully functional as fallback."""
res = ActionExecutor.execute("start_redirect_auth", {"provider": "antigravity", "profile_id": "ag-7"})
assert res["ok"] is True
assert "url" in res["data"]
assert "session_id" in res["data"]
assert res["data"]["paste_kind"] == "url"
ActionExecutor.execute("cancel_redirect_auth", {"session_id": res["data"]["session_id"]})
@pytest.mark.unit
def test_permissions_0700_and_0600(tmp_path):
"""P0-6: Profile directories are created with 0700 and credential files with 0600."""
slot = "ag-8"
pdir = get_profile_dir(slot, "antigravity")
pdir.mkdir(parents=True, exist_ok=True)
os.chmod(pdir, 0o700)
cli_dir = pdir / ".gemini" / "antigravity-cli"
cli_dir.mkdir(parents=True, exist_ok=True)
token_file = cli_dir / "antigravity-oauth-token"
token_file.write_text(json.dumps({
"auth_method": "consumer",
"token": {"access_token": "ya29.perm_test", "refresh_token": "1//perm"},
}), encoding="utf-8")
check_profile_native_auth_status(slot)
# Check directory permissions (on POSIX systems)
if os.name != "nt":
assert oct(pdir.stat().st_mode & 0o777) == "0o700"
assert oct(token_file.stat().st_mode & 0o777) == "0o600"

View file

@ -0,0 +1,248 @@
"""Проверки по итогам разбора A57 ревьюером.
Мастер сообщал «Терминал запущен (/usr/bin/x-terminal-emulator) для слота
ag-6», а окна не появлялось. На сервере владельца в это время висел
`[xfce4-terminal] <defunct>` терминал стартовал и немедленно умирал.
Причин три, и каждая проверяется здесь.
1. `x-terminal-emulator` на Ubuntu указывает на `xfce4-terminal.wrapper`, а
xfce4-terminal держит один процесс на сеанс: новый вызов передаёт задание
работающему экземпляру и завершается. Нужен `--disable-server`.
2. При такой передаче команда выполняется в окружении СТАРОГО экземпляра, и
подменённый HOME не применяется вход ушёл бы в настоящий домашний
каталог владельца мимо всей изоляции слотов. Поэтому окружение задаёт сам
сценарий, а не наследование.
3. С ключом -e окно закрывается вместе с командой, и причину отказа прочесть
нельзя. Сценарий ждёт нажатия клавиши.
И отдельно: возврат Popen ничего не говорит об открытии окна.
"""
from __future__ import annotations
import shutil
import subprocess
import pytest
from antigravity_provider import agy_subprocess
from antigravity_provider.agy_subprocess import (
find_terminal_emulator,
write_login_helper,
)
@pytest.fixture
def linux(monkeypatch):
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False)
monkeypatch.setenv("DISPLAY", ":0")
def _only(available: set[str]):
def _which(name):
return f"/usr/bin/{name}" if name in available else None
return _which
# ── Сценарий входа ──
def test_helper_sets_environment_itself(tmp_path):
helper = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-6")
body = helper.read_text(encoding="utf-8")
assert "HOME=" in body and str(tmp_path) in body
assert "export HOME" in body
assert "/usr/local/bin/agy" in body
def test_helper_keeps_the_window_open(tmp_path):
body = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-6").read_text(encoding="utf-8")
assert "read " in body, "иначе окно исчезнет вместе с agy и причина отказа пропадёт"
assert "$status" in body, "код возврата agy должен быть виден владельцу"
def test_helper_contains_no_secrets(tmp_path):
body = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-6").read_text(encoding="utf-8").lower()
for forbidden in ("access_token", "refresh_token", "api_key", "bearer", "ya29."):
assert forbidden not in body
# ── Выбор эмулятора ──
def test_xfce4_terminal_gets_disable_server(linux, monkeypatch, tmp_path):
monkeypatch.setattr(shutil, "which", _only({"xfce4-terminal", "x-terminal-emulator"}))
cmd, err, _ = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path)
assert err is None
assert cmd[0] == "/usr/bin/xfce4-terminal"
assert "--disable-server" in cmd, (
"без этого вызов уходит работающему экземпляру и окно не открывается"
)
def test_alternatives_wrapper_is_the_last_resort(linux, monkeypatch, tmp_path):
"""x-terminal-emulator — обёртка над альтернативами, лишний слой."""
monkeypatch.setattr(shutil, "which", _only({"x-terminal-emulator", "xterm"}))
cmd, err, _ = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path)
assert err is None
assert cmd[0] == "/usr/bin/xterm"
def test_terminal_discovery_creates_nothing(linux, monkeypatch, tmp_path):
"""Поиск терминала — запрос, а не действие: файлов после него не остаётся."""
monkeypatch.setattr(shutil, "which", _only({"xterm"}))
cmd, err, _ = find_terminal_emulator("ag-6", "/путь/к/запуску", tmp_path)
assert err is None
assert "/путь/к/запуску" in cmd
assert not (tmp_path / ".hermes-agy-login.sh").exists()
def test_missing_terminal_lists_what_was_checked(linux, monkeypatch, tmp_path):
monkeypatch.setattr(shutil, "which", _only(set()))
cmd, err, checked = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path)
assert cmd is None
assert err
assert len(checked) >= 5
assert any("xterm" in item for item in checked)
# ── Запуск подтверждается, а не объявляется ──
def test_immediate_exit_is_reported_as_failure(linux, monkeypatch, tmp_path):
monkeypatch.setattr(shutil, "which", _only({"xterm"}))
monkeypatch.setattr(agy_subprocess.time, "sleep", lambda _s: None)
monkeypatch.setattr(
agy_subprocess, "get_agy_exe", lambda: "/usr/local/bin/agy", raising=False
)
class _DeadProcess:
def __init__(self, *a, **k):
pass
def poll(self):
return 1
monkeypatch.setattr(subprocess, "Popen", _DeadProcess)
ok, msg, data = agy_subprocess.start_native_agy_login(profile_id="ag-6", force=True)
assert ok is False
assert "окно не открылось" in msg
assert data.get("exit_code") == 1
# ── Пустые слоты появлялись сами ──
def test_asking_for_a_path_creates_nothing(tmp_path, monkeypatch):
"""У владельца накопилось 25 каталогов профилей при единицах заведённых.
Причина: get_profile_dir создавал каталог при каждом обращении, а в коде
зашит список «стандартных» слотов ag-orch-primary, ag-orch-fallback,
ag-1..ag-20, ag-w1..ag-w10. Любой обход этого списка материализовал их все.
Спросить, где профиль жил бы, и завести его разные действия.
"""
from antigravity_provider import paths
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
root = tmp_path / "agy_profiles"
for pid in ("ag-orch-primary", "ag-w7", "ag-19", "ag-cold-3"):
paths.get_profile_dir(pid, "antigravity")
assert not root.exists() or not any(root.iterdir()), (
"запрос пути не должен оставлять каталоги на диске"
)
created = paths.get_profile_dir("ag-1", "antigravity", create=True)
assert created.is_dir()
assert sorted(p.name for p in root.iterdir()) == ["ag-1"]
# ── Дисплей ищется не только в окружении ──
def test_display_from_environment_wins(monkeypatch):
from antigravity_provider.agy_subprocess import detect_graphical_session
monkeypatch.setenv("DISPLAY", ":0")
found, checked = detect_graphical_session()
assert found["DISPLAY"] == ":0"
assert any("DISPLAY (задан: :0)" in item for item in checked)
def test_display_is_asked_from_systemd_when_environment_is_empty(monkeypatch):
"""Хаб запускается через nohup: по SSH он остаётся без DISPLAY.
Измерено на сервере владельца: loginctl show-user даёт сеанс c1, а
show-session c1 Type=x11, Display=:10. Отказываться, не спросив, рано.
"""
from antigravity_provider import agy_subprocess as m
for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("USER", "ochenstarik")
monkeypatch.setattr(m.shutil, "which", lambda n: "/usr/bin/loginctl" if n == "loginctl" else None)
answers = {("show-user", "ochenstarik"): "c1", ("show-session", "c1", "Type"): "x11",
("show-session", "c1", "Display"): ":10"}
class _Res:
def __init__(self, out):
self.stdout, self.returncode = out, 0
def _run(cmd, **kwargs):
if cmd[1] == "show-user":
return _Res(answers[("show-user", cmd[2])])
return _Res(answers[("show-session", cmd[2], cmd[-1])])
monkeypatch.setattr(m.subprocess, "run", _run)
found, checked = m.detect_graphical_session()
assert found == {"DISPLAY": ":10"}
assert any("loginctl сеанс c1" in item for item in checked)
def test_wayland_session_is_reported_as_wayland(monkeypatch):
from antigravity_provider import agy_subprocess as m
for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("USER", "owner")
monkeypatch.setattr(m.shutil, "which", lambda n: "/usr/bin/loginctl" if n == "loginctl" else None)
class _Res:
def __init__(self, out):
self.stdout, self.returncode = out, 0
def _run(cmd, **kwargs):
if cmd[1] == "show-user":
return _Res("2")
return _Res("wayland" if cmd[-1] == "Type" else "wayland-0")
monkeypatch.setattr(m.subprocess, "run", _run)
found, _ = m.detect_graphical_session()
assert found == {"WAYLAND_DISPLAY": "wayland-0"}
def test_no_session_anywhere_lists_what_was_asked(monkeypatch):
from antigravity_provider import agy_subprocess as m
for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setattr(m.shutil, "which", lambda _n: None)
found, checked = m.detect_graphical_session()
assert found == {}
assert any("loginctl (не найден)" in item for item in checked)

View file

@ -0,0 +1,319 @@
"""Tests for Task A58: Antigravity CLI (agy) Eligibility State Detection and Controls.
Verifies:
- Read-only machine code inspection for x86-64 and arm64 signatures.
- Detection of all 3 states: check_removed, check_active, unknown (with exact reasons).
- Strict read-only guarantee (SHA256 binary integrity preserved).
- State change event bus publishing and audit logging without continuous polling loops.
- Action handlers for run_agy_patch_script, run_agy_update, and refresh_agy_eligibility.
- Web API endpoints (/api/snapshot, /api/settings) returning agy_eligibility.
"""
from __future__ import annotations
import hashlib
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from antigravity_provider.router.agy_eligibility_service import (
STATUS_CHECK_ACTIVE,
STATUS_CHECK_REMOVED,
STATUS_UNKNOWN,
AgyEligibilityService,
)
from antigravity_provider.router.event_bus import (
EVENT_AGY_ELIGIBILITY_CHANGED,
EventBus,
)
from antigravity_provider.router.settings_service import (
get_hub_settings,
save_hub_settings,
)
from antigravity_provider.router.unified_health import EventLogService
from antigravity_provider.router.web.server import app
@pytest.fixture(autouse=True)
def reset_service():
"""Reset singleton cache before and after each test."""
service = AgyEligibilityService.get()
service.invalidate_cache()
service._last_status = None
service._last_status_label = None
service._last_sha256 = None
service._last_binary_path = None
yield
service.invalidate_cache()
service._last_status = None
service._last_status_label = None
service._last_sha256 = None
service._last_binary_path = None
def test_real_agy_binary_if_present():
"""Verify inspection against the real host binary at ~/.local/bin/agy if available."""
real_path = Path.home() / ".local" / "bin" / "agy"
if not real_path.is_file():
pytest.skip("Real agy binary not found on this host")
with open(real_path, "rb") as f:
original_bytes = f.read()
expected_sha = hashlib.sha256(original_bytes).hexdigest()
service = AgyEligibilityService.get()
res = service.check_eligibility_state(force=True, custom_binary_path=real_path)
assert res["binary_path"] == str(real_path)
assert res["binary_sha256"] == expected_sha
assert res["binary_size_bytes"] == len(original_bytes)
assert res["status"] in (STATUS_CHECK_ACTIVE, STATUS_CHECK_REMOVED, STATUS_UNKNOWN)
assert res["status_label_ru"] != ""
# Verify 100% read-only integrity: file was not modified
with open(real_path, "rb") as f:
after_bytes = f.read()
assert hashlib.sha256(after_bytes).hexdigest() == expected_sha
def test_x86_unpatched_signature_detected(tmp_path: Path):
"""P0-1 & P0-2: Unpatched x86-64 binary matches check_active."""
# Machine code: test rax,rax ; je ; cmp byte [rax+8], 0 ; jne ; call
fake_code = (
b"\x90\x90"
b"\x48\x85\xc0\x0f\x84\x0d\x02\x00\x00\x80\x78\x08\x00\x0f\x85\x03\x02\x00\x00\xe8\x48\x9c\xfd\xff"
b"\x90\x90"
)
fake_bin = tmp_path / "fake_agy_x86_orig"
fake_bin.write_bytes(fake_code)
service = AgyEligibilityService.get()
res = service.check_eligibility_state(force=True, custom_binary_path=fake_bin)
assert res["status"] == STATUS_CHECK_ACTIVE
assert "Проверка на месте" in res["status_label_ru"]
assert "Аккаунт может отклоняться" in res["detail_ru"]
assert res["binary_sha256"] == hashlib.sha256(fake_code).hexdigest()
def test_x86_patched_signature_detected(tmp_path: Path):
"""P0-1 & P0-2: Patched x86-64 binary matches check_removed."""
# Machine code: test rax,rax ; je ; test rax,rax ; nop ; jne ; call
fake_code = (
b"\x90\x90"
b"\x48\x85\xc0\x0f\x84\x0d\x02\x00\x00\x48\x85\xc0\x90\x0f\x85\x03\x02\x00\x00\xe8\x48\x9c\xfd\xff"
b"\x90\x90"
)
fake_bin = tmp_path / "fake_agy_x86_patched"
fake_bin.write_bytes(fake_code)
service = AgyEligibilityService.get()
res = service.check_eligibility_state(force=True, custom_binary_path=fake_bin)
assert res["status"] == STATUS_CHECK_REMOVED
assert "Проверка снята" in res["status_label_ru"]
assert "Патч начальной проверки" in res["detail_ru"]
assert res["binary_sha256"] == hashlib.sha256(fake_code).hexdigest()
def test_arm64_signatures_detected(tmp_path: Path):
"""P0-1 & P0-2: ARM64 original and patched detection."""
# ARM64 unpatched
arm_orig_code = b"HEADER\x00EPD_ELIGIBILITY\x00\x00\x20\x40\x39\x00\x00TRAILER"
bin_orig = tmp_path / "fake_agy_arm64_orig"
bin_orig.write_bytes(arm_orig_code)
service = AgyEligibilityService.get()
res_orig = service.check_eligibility_state(force=True, custom_binary_path=bin_orig)
assert res_orig["status"] == STATUS_CHECK_ACTIVE
assert "Проверка на месте" in res_orig["status_label_ru"]
# ARM64 patched (NOP)
arm_patch_code = b"HEADER\x00EPD_ELIGIBILITY\x00\x1f\x20\x03\xd5\x00\x00TRAILER"
bin_patch = tmp_path / "fake_agy_arm64_patch"
bin_patch.write_bytes(arm_patch_code)
service.invalidate_cache()
res_patch = service.check_eligibility_state(force=True, custom_binary_path=bin_patch)
assert res_patch["status"] == STATUS_CHECK_REMOVED
assert "Проверка снята" in res_patch["status_label_ru"]
def test_unknown_state_unsupported_binary(tmp_path: Path):
"""P0-2: Unsupported binary returns status 'unknown' with truthful reason."""
fake_code = b"Hello, this is a completely different binary without signatures."
fake_bin = tmp_path / "fake_agy_unknown"
fake_bin.write_bytes(fake_code)
service = AgyEligibilityService.get()
res = service.check_eligibility_state(force=True, custom_binary_path=fake_bin)
assert res["status"] == STATUS_UNKNOWN
assert "Н/Д: сигнатура проверки не найдена" in res["status_label_ru"]
assert "неподдерживаемая версия" in res["detail_ru"]
def test_unknown_state_missing_file(tmp_path: Path):
"""P0-2: Missing binary path returns status 'unknown'."""
missing_bin = tmp_path / "non_existent_agy_binary"
service = AgyEligibilityService.get()
res = service.check_eligibility_state(force=True, custom_binary_path=missing_bin)
assert res["status"] == STATUS_UNKNOWN
assert "Н/Д: файл не найден" in res["status_label_ru"]
def test_read_only_guarantee(tmp_path: Path):
"""Strictly guarantees that checking eligibility NEVER modifies the file."""
fake_code = b"\x48\x85\xc0\x74\x02\x80\x78\x08\x00\x75\x02"
fake_bin = tmp_path / "test_bin_readonly"
fake_bin.write_bytes(fake_code)
mtime_before = fake_bin.stat().st_mtime
sha_before = hashlib.sha256(fake_code).hexdigest()
service = AgyEligibilityService.get()
res = service.check_eligibility_state(force=True, custom_binary_path=fake_bin)
assert res["status"] == STATUS_CHECK_ACTIVE
assert fake_bin.read_bytes() == fake_code
assert fake_bin.stat().st_mtime == mtime_before
assert hashlib.sha256(fake_bin.read_bytes()).hexdigest() == sha_before
def test_state_change_event_and_audit_logging(tmp_path: Path):
"""P0-3: State transition publishes EventBus event and logs to EventLogService."""
events_received = []
def on_event(event_name, data):
events_received.append((event_name, data))
EventBus.get().subscribe(EVENT_AGY_ELIGIBILITY_CHANGED, on_event)
bin_path = tmp_path / "mutable_test_agy"
# Step 1: Initial state is check_active
code_active = b"\x48\x85\xc0\x74\x02\x80\x78\x08\x00\x75\x02"
bin_path.write_bytes(code_active)
service = AgyEligibilityService.get()
res1 = service.check_eligibility_state(force=True, custom_binary_path=bin_path)
assert res1["status"] == STATUS_CHECK_ACTIVE
# Step 2: Simulate owner running patch script -> binary transitions to check_removed
code_patched = b"\x48\x85\xc0\x74\x02\x48\x85\xc0\x90\x75\x02"
bin_path.write_bytes(code_patched)
service.invalidate_cache()
res2 = service.check_eligibility_state(force=True, custom_binary_path=bin_path)
assert res2["status"] == STATUS_CHECK_REMOVED
# EventBus must have received the transition event
assert len(events_received) == 1
assert events_received[0][0] == EVENT_AGY_ELIGIBILITY_CHANGED
assert events_received[0][1]["status"] == STATUS_CHECK_REMOVED
# EventLogService must contain security audit log
logs = EventLogService.get().get_events(category="security")
found_logs = [log for log in logs if getattr(log, "action", None) == "agy_eligibility_change" or (isinstance(log, dict) and log.get("action") == "agy_eligibility_change")]
assert len(found_logs) >= 1
last_log = found_logs[-1]
msg = getattr(last_log, "message", None) if not isinstance(last_log, dict) else last_log.get("message", "")
assert "Проверка на месте → Проверка снята" in msg
EventBus.get().unsubscribe(EVENT_AGY_ELIGIBILITY_CHANGED, on_event)
def test_action_run_agy_patch_script_not_configured():
"""P0-4: When patch script path is empty, action returns informative message."""
client = TestClient(app)
# Ensure setting is empty
save_hub_settings({"agy_patch_script_path": ""})
response = client.post("/api/action", json={"action": "run_agy_patch_script"})
assert response.status_code == 200
data = response.json()
assert data["ok"] is False
assert "Н/Д: путь к сценарию патча не указан в настройках" in data["message"]
def test_action_run_agy_patch_script_invalid_path(tmp_path: Path):
"""P0-4: When patch script file does not exist, action returns error."""
client = TestClient(app)
missing_script = tmp_path / "non_existent_patch.sh"
save_hub_settings({"agy_patch_script_path": str(missing_script)})
response = client.post("/api/action", json={"action": "run_agy_patch_script"})
assert response.status_code == 200
data = response.json()
assert data["ok"] is False
assert "Файл сценария не найден или недоступен" in data["message"]
def test_action_run_agy_patch_script_success(tmp_path: Path):
"""P0-4: Valid patch script launches in terminal and returns new eligibility state."""
client = TestClient(app)
patch_script = tmp_path / "fake_patch.sh"
patch_script.write_text("#!/bin/sh\necho Patched\n")
patch_script.chmod(0o755)
save_hub_settings({"agy_patch_script_path": str(patch_script)})
with patch("antigravity_provider.agy_subprocess.launch_terminal_task") as mock_launch:
mock_launch.return_value = (True, "Запущено", {"terminal_cmd": "xterm"})
response = client.post("/api/action", json={"action": "run_agy_patch_script"})
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert "Сценарий патча запущен" in data["message"]
assert "eligibility" in data["data"]
def test_action_run_agy_update():
"""P0-5: Action run_agy_update launches agy update in terminal."""
client = TestClient(app)
with patch("antigravity_provider.agy_subprocess.get_agy_exe", return_value="/bin/agy"), \
patch("antigravity_provider.agy_subprocess.launch_terminal_task") as mock_launch:
mock_launch.return_value = (True, "Запущено", {"terminal_cmd": "xterm"})
response = client.post("/api/action", json={"action": "run_agy_update"})
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert "Обновление agy запущено" in data["message"]
assert "eligibility" in data["data"]
def test_action_refresh_agy_eligibility():
"""Action refresh_agy_eligibility triggers cache invalidation and recheck."""
client = TestClient(app)
response = client.post("/api/action", json={"action": "refresh_agy_eligibility"})
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert "Состояние проверки agy" in data["message"]
assert "status" in data["data"]
def test_web_api_snapshot_and_settings_include_eligibility():
"""Verify /api/snapshot and /api/settings contain agy_eligibility payload."""
client = TestClient(app)
# Snapshot endpoint
snap_resp = client.get("/api/snapshot")
assert snap_resp.status_code == 200
snap_data = snap_resp.json()
assert "agy_eligibility" in snap_data
elig = snap_data["agy_eligibility"]
assert "status" in elig
assert "status_label_ru" in elig
assert "binary_sha256" in elig
# Settings endpoint
sett_resp = client.get("/api/settings")
assert sett_resp.status_code == 200
sett_data = sett_resp.json()
assert "agy_eligibility" in sett_data
assert "agy_patch_script_path" in sett_data

View file

@ -0,0 +1,614 @@
"""Hermes Hub — Task A59 Visible Update & Completion Engine Test Suite.
Verifies:
1. P0-1: Startup check shows modal when update available; remains silent when no update; dismissal remembered in localStorage.
2. P0-2: Visible download progress tracking; honest None without fake percentages when Content-Length is missing; cancel download deletes partial file and sets cancelled status; SHA-256 verification and failure rejection.
3. P0-3: Isolated stop_running_hub (killing only own user hub processes and excluding current PID); apply_update_sync atomic rollback on corrupted update package.
4. P0-4: Saving last_applied_update.json, EventLogService notification on restart, UI notification contract, and running_commit integrity.
5. P0-5: Absence of update polling loops; get_update_progress and cancel_update in SILENT_ACTIONS.
"""
from __future__ import annotations
import contextlib
import io
import json
import os
import re
import shutil
import sys
import tempfile
import urllib.error
import zipfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from antigravity_provider.router.action_handler import ActionExecutor
from antigravity_provider.router.web.server import app
from antigravity_provider.router.unified_health import EventLogService
from antigravity_provider.updater.update_manager import (
UpdateManager,
UpdateManifest,
UpdateCheckResult,
UpdateProgress,
compute_sha256,
get_installed_commit,
get_last_applied_update,
record_last_applied_update,
acknowledge_last_applied_update,
stop_running_hub,
)
from antigravity_provider.version import __version__
APP_JS_PATH = (
Path(__file__).resolve().parent.parent
/ "src"
/ "antigravity_provider"
/ "router"
/ "web"
/ "static"
/ "app.js"
)
@pytest.fixture
def client():
return TestClient(app)
# ── TEST 1: P0-1 Modal upon Startup and Dismissal Contract in app.js ──
@pytest.mark.unit
def test_p0_1_app_js_modal_on_startup_and_dismiss_contract():
"""Verify app.js opens update modal on checkUpdates(true) unless dismissed in localStorage."""
src = APP_JS_PATH.read_text(encoding="utf-8")
# 1. Startup check in checkUpdates
assert "async function checkUpdates(silent = false)" in src
assert "hermes_dismissed_update_" in src, "app.js must check localStorage for dismissed version"
assert "openUpdateModal('details')" in src or "openUpdateModal()" in src
# 2. Details modal content: version, what's new, download size
assert "Что нового" in src
assert "Н/Д: описание не приложено" in src, "app.js must output honest N/A when changelog is empty"
assert "Н/Д: размер не указан" in src, "app.js must output honest N/A when download size is unknown"
assert "Напомнить позже" in src, "app.js must have dismiss/remind later button"
assert "Обновить сейчас" in src, "app.js must have start update button"
# 3. Dismiss function saves to localStorage
assert "function dismissUpdateModal" in src
assert "localStorage.setItem('hermes_dismissed_update_'" in src
# ── TEST 2: P0-2 Real-Time Progress Tracking & Honest None Without Content-Length ──
@pytest.mark.unit
def test_p0_2_progress_tracking_with_and_without_content_length(tmp_path, monkeypatch):
"""Verify progress tracking: percentage with Content-Length, honest None without Content-Length."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
mgr = UpdateManager()
# 1. Initial idle state
prog = UpdateManager.get_progress_dict()
assert prog["status"] in ("idle", "checking")
assert "downloaded_bytes" in prog
# 2. Download with known Content-Length
test_payload = b"X" * 1024 * 100 # 100 KB
src_file = tmp_path / "remote_pkg.zip"
src_file.write_bytes(test_payload)
dest_file = tmp_path / "downloaded_pkg.zip"
# Mock urllib response with Content-Length
class MockResponseWithLen:
def __init__(self):
self.headers = {"content-length": str(len(test_payload))}
self._data = io.BytesIO(test_payload)
def read(self, amt=65536):
return self._data.read(amt)
def __enter__(self):
return self
def __exit__(self, *args):
pass
with patch("urllib.request.urlopen", return_value=MockResponseWithLen()):
mgr._download_file("https://github.com/ochenstarik-ui/hermes-hub/releases/download/v0.1.3/pkg.zip", dest_file)
prog_after = UpdateManager.get_progress_dict()
assert prog_after["downloaded_bytes"] == len(test_payload)
assert prog_after["total_bytes"] == len(test_payload)
assert prog_after["progress_percent"] == 100.0
# 3. Download WITHOUT Content-Length (or 0) -> Honest None, no fake percentages!
dest_file_no_len = tmp_path / "no_len_pkg.zip"
class MockResponseWithoutLen:
def __init__(self):
self.headers = {} # No content-length header!
self._data = io.BytesIO(test_payload)
def read(self, amt=65536):
return self._data.read(amt)
def __enter__(self):
return self
def __exit__(self, *args):
pass
with patch("urllib.request.urlopen", return_value=MockResponseWithoutLen()):
mgr._download_file("https://github.com/ochenstarik-ui/hermes-hub/releases/download/v0.1.3/no_len.zip", dest_file_no_len)
prog_no_len = UpdateManager.get_progress_dict()
assert prog_no_len["downloaded_bytes"] == len(test_payload)
assert prog_no_len["total_bytes"] is None, "total_bytes must be None when Content-Length is missing"
assert prog_no_len["progress_percent"] is None, "progress_percent must be None when total is unknown"
# ── TEST 3: P0-2 Download Cancellation Cleans Staging and Sets Cancelled Status ──
@pytest.mark.unit
def test_p0_2_cancel_download_cleans_file_and_sets_cancelled_status(tmp_path, monkeypatch):
"""Cancelling update sets status to cancelled, interrupts loop, and removes partial file."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
mgr = UpdateManager()
staging_file = mgr.staging_dir / "partial_download.zip"
staging_file.parent.mkdir(parents=True, exist_ok=True)
staging_file.write_bytes(b"Partial download data 12345")
# Отмена осмысленна только пока идёт загрузка — ставим это состояние явно,
# иначе тест проверял бы отмену того, чего не происходит.
UpdateManager._set_progress(
status="downloading",
filename="partial_download.zip",
downloaded_bytes=27,
message="Скачивание partial_download.zip...",
)
# Trigger cancel
cancel_res = UpdateManager.cancel_download()
assert cancel_res["status"] == "cancelled"
assert cancel_res["cancel_accepted"] is True
assert "отменена" in (cancel_res["message"] or "").lower()
assert not staging_file.exists(), "Partially downloaded file in staging must be removed upon cancellation"
# Also test ActionExecutor 'cancel_update'
UpdateManager._set_progress(status="downloading", filename="partial_download.zip")
action_res = ActionExecutor.execute("cancel_update", {})
assert action_res["ok"] is True
assert action_res["data"]["status"] == "cancelled"
# ── TEST 3b: отмена после начала установки отклоняется, а не врёт ──
@pytest.mark.unit
def test_p0_2_cancel_refused_after_install_started(tmp_path, monkeypatch):
"""Отмена во время установки не трогает staging и честно сообщает отказ.
Действие cancel_update открыто в HTTP-API. Раньше вызов на этапе installing
чистил каталог staging вместе с исполняемым в этот момент установщиком и
отвечал «отменено», хотя установка продолжалась.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
mgr = UpdateManager()
installer = mgr.staging_dir / "hermes-hub-setup.sh"
installer.parent.mkdir(parents=True, exist_ok=True)
installer.write_bytes(b"#!/bin/bash\necho installing\n")
UpdateManager._set_progress(
status="installing",
filename="hermes-hub-setup.sh",
message="Установка пакета hermes-hub-setup.sh...",
)
res = UpdateManager.cancel_download()
assert res["cancel_accepted"] is False
assert res["status"] == "installing", "Статус не должен подменяться на cancelled"
assert "установка уже началась" in res["cancel_refused_reason"].lower()
assert installer.exists(), "Файл исполняемого установщика удалять нельзя"
action_res = ActionExecutor.execute("cancel_update", {})
assert action_res["ok"] is False
assert "отмена невозможна" in action_res["message"].lower()
UpdateManager._set_progress(status="idle", message="Готов к обновлению")
# ── TEST 4: P0-2 SHA-256 Mismatch Rejection and Failure Status ──
@pytest.mark.unit
def test_p0_2_sha256_mismatch_aborts_and_sets_failed_status(tmp_path, monkeypatch):
"""When SHA-256 hash does not match, download is aborted, file deleted, and status set to failed."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
pkg_file = tmp_path / "pkg.zip"
with zipfile.ZipFile(pkg_file, "w") as zf:
zf.writestr("code.py", "print('hello')")
manifest = UpdateManifest(
version="0.1.4",
channel="stable",
package_url=f"file://{pkg_file}",
sha256="ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", # wrong hash
)
mgr = UpdateManager()
ok, msg, dest = mgr.download_and_verify(manifest)
assert ok is False
assert "mismatch" in msg.lower() or "не совпала" in msg.lower()
assert dest is None
prog = UpdateManager.get_progress_dict()
assert prog["status"] == "failed"
assert prog["error"] is not None
# ── 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(simulated_os):
"""Чужие процессы хаба останавливаются, собственный — никогда.
Проверяется на обеих ветках, а не на той, где случился прогон. Ветки
останавливают процессы по-разному: на 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)
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, 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 ──
@pytest.mark.unit
def test_p0_3_apply_update_sync_rollback_on_failure(tmp_path, monkeypatch):
"""apply_update_sync restores files from backup if update package fails validation."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
app_dir = tmp_path / "app"
src_dir = app_dir / "src" / "antigravity_provider"
src_dir.mkdir(parents=True, exist_ok=True)
(src_dir / "version.py").write_text('__version__ = "0.1.3"\n', encoding="utf-8")
# Corrupt zip with python syntax error
corrupt_zip = tmp_path / "corrupt_pkg.zip"
with zipfile.ZipFile(corrupt_zip, "w") as zf:
zf.writestr("src/antigravity_provider/version.py", "INVALID SYNTAX ???!!!")
mgr = UpdateManager()
ok, msg = mgr.apply_update_sync(corrupt_zip, target_dir=app_dir)
assert ok is False
assert "откат" in msg.lower() or "rollback" in msg.lower()
# Verify original version was restored
restored = (src_dir / "version.py").read_text(encoding="utf-8")
assert '__version__ = "0.1.3"' in restored
# ── TEST 7: P0-4 last_applied_update.json and EventLogService ──
@pytest.mark.unit
def test_p0_4_last_applied_update_recording_and_event_logging(tmp_path, monkeypatch, client):
"""Test recording last applied update, server settings/snapshot contract, and EventLogService."""
hermes_home = tmp_path / "hermes"
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# 1. Record update
record_last_applied_update(
prev_version="0.1.2",
prev_commit="aaaaaaa1111111",
new_version="0.1.3",
new_commit="bbbbbbb2222222",
)
applied = get_last_applied_update()
assert applied is not None
assert applied["prev_version"] == "0.1.2"
assert applied["prev_commit"] == "aaaaaaa1111111"
assert applied["new_version"] == "0.1.3"
assert applied["new_commit"] == "bbbbbbb2222222"
assert applied["acknowledged"] is False
# 2. Check EventLogService logging contract
EventLogService.get().log(
"system",
f"Hermes Hub успешно обновлён с {applied['prev_version']} ({applied['prev_commit'][:7]}) до {applied['new_version']} ({applied['new_commit'][:7]})",
level="info",
)
events = EventLogService.get().get_events(category="system", limit=10)
found = any("Hermes Hub успешно обновлён" in (getattr(e, "message", None) or "") for e in events)
assert found is True
# 3. Acknowledge update
acknowledge_last_applied_update()
applied_after = get_last_applied_update()
assert applied_after["acknowledged"] is True
# 4. Check GET /api/settings includes last_applied_update
res = client.get("/api/settings")
assert res.status_code == 200
data = res.json()
assert "last_applied_update" in data
assert data["last_applied_update"]["new_version"] == "0.1.3"
# ── TEST 8: P0-5 Silent Actions and No Polling Loops ──
@pytest.mark.unit
def test_p0_5_silent_actions_and_no_interval_polling():
"""Verify get_update_progress and cancel_update are in SILENT_ACTIONS and no global update intervals exist."""
src = APP_JS_PATH.read_text(encoding="utf-8")
# 1. Check SILENT_ACTIONS
assert "'get_update_progress'" in src
assert "'cancel_update'" in src
# 2. Check that there is NO setInterval for checkUpdates
assert "setInterval(checkUpdates" not in src
assert "setInterval(() => checkUpdates" not in src
assert "setInterval(function() { checkUpdates" not in src
# 3. ActionExecutor get_update_progress
res_prog = ActionExecutor.execute("get_update_progress", {})
assert res_prog["ok"] is True
assert "data" in res_prog
assert "status" in res_prog["data"]
# ── TEST 9: P0-4 провалившаяся установка не выдаёт себя за успешную ──
@pytest.mark.unit
def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monkeypatch):
"""Установщик упал — записи о применённом обновлении быть не должно.
record_last_applied_update вызывался ДО запуска установщика. При падении
запись оставалась на диске, и при следующем старте хаб писал в журнал
«успешно обновлён», а интерфейс показывал тост об успехе владельцу
сообщали о версии, которая не установилась.
Заодно проверяется, что причина отказа не пустая: установщик может
завершиться, не сказав ни слова, и сообщение «Установка не удалась: »
не давало ни одного признака причины.
"""
import hashlib
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
with platform_installer(tmp_path, exit_code=3) as (asset_name, installer):
sha = hashlib.sha256(installer.read_bytes()).hexdigest()
check_result = UpdateCheckResult(
update_available=True,
current_version="0.1.3",
latest_version="0.1.4",
latest_commit="deadbeefdeadbeef",
installed_commit="0000000aaaa",
assets={
asset_name: f"file://{installer}",
"checksums.txt": "file:///nonexistent",
},
)
mgr = UpdateManager()
real_download = UpdateManager._download_file
def fake_download(self, url, dest, progress_cb=None):
if dest.name == "checksums.txt":
dest.write_text(f"{sha} {asset_name}\n", encoding="utf-8")
return
return real_download(self, url, dest, progress_cb)
with patch.object(UpdateManager, "_download_file", fake_download):
with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True):
ok, msg = mgr.install_latest_update(check_result=check_result)
assert ok is False
assert get_last_applied_update() is None, (
"После провалившейся установки записи о применённом обновлении быть не должно"
)
# Проверяется, что код назван, а не как он склоняется: ветки формулируют
# по-разному («код 3» и «кодом 3»), инвариант же один.
assert re.search(r"код\w*\s+3", msg), (
f"Причина отказа должна называть код возврата, получено: {msg!r}"
)
prog = UpdateManager.get_progress_dict()
assert prog["status"] == "failed"
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):
"""Успех записывает и «было», и «стало», причём «было» снято до установки."""
import hashlib
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
with platform_installer(tmp_path, exit_code=0) as (asset_name, installer):
sha = hashlib.sha256(installer.read_bytes()).hexdigest()
check_result = UpdateCheckResult(
update_available=True,
current_version="0.1.3",
latest_version="0.1.4",
latest_commit="deadbeefdeadbeef",
installed_commit="0000000aaaa",
assets={
asset_name: f"file://{installer}",
"checksums.txt": "file:///nonexistent",
},
)
mgr = UpdateManager()
real_download = UpdateManager._download_file
def fake_download(self, url, dest, progress_cb=None):
if dest.name == "checksums.txt":
dest.write_text(f"{sha} {asset_name}\n", encoding="utf-8")
return
return real_download(self, url, dest, progress_cb)
with patch.object(UpdateManager, "_download_file", fake_download):
with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True):
with patch.object(UpdateManager, "schedule_restart", return_value=(True, "перезапуск запущен")):
ok, msg = mgr.install_latest_update(check_result=check_result)
assert ok is True
rec = get_last_applied_update()
assert rec is not None
assert rec["new_version"] == "0.1.4"
assert rec["new_commit"] == "deadbeefdeadbeef"
assert rec["prev_commit"] != rec["new_commit"], (
"«Было» снимается до установки, иначе прежняя сборка совпадёт с новой"
)
assert rec["acknowledged"] is False
UpdateManager._set_progress(status="idle", message="Готов к обновлению")
# ── TEST 11: P0-2 полоса не изображает процент при неизвестном размере ──
@pytest.mark.unit
def test_p0_2_app_js_indeterminate_bar_when_size_unknown():
"""При неизвестном размере полоса бежит, а не заполняется целиком.
Текст рядом был честным («Н/Д: сервер не сообщил размер»), а полоса при этом
рисовалась на всю ширину: `downloaded > 0 ? '100%' : '20%'`. Полная полоса
читается как «готово» тот же выдуманный процент, только нарисованный.
"""
src = APP_JS_PATH.read_text(encoding="utf-8")
assert "downloaded > 0 ? '100%' : '20%'" not in src, (
"Полоса не должна заполняться на всю ширину при неизвестном размере"
)
assert "Н/Д: сервер не сообщил размер" in src
assert "INDETERMINATE_ACTIVE_STATUSES" in src, "нужен список этапов с неопределённой полосой"
assert "@keyframes indeterminate-bar" in src, "нужна анимация бегущего отрезка"
assert "indeterminate-bar-style" in src, "стиль вставляется один раз по id"
# ── TEST 12: P0-2 интерфейс не принимает отказ в отмене за отмену ──
@pytest.mark.unit
def test_p0_2_app_js_handles_refused_cancel():
"""Отказ в отмене возвращает опрос хода, а не оставляет окно замершим."""
src = APP_JS_PATH.read_text(encoding="utf-8")
assert "cancel_accepted" in src, "app.js обязан различать принятую и отклонённую отмену"
idx = src.index("async function cancelUpdateProcess()")
tail = src[idx:idx + 1200]
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

@ -22,7 +22,7 @@ def isolated(tmp_path, monkeypatch):
monkeypatch.setattr(AccountProbeService, "_instance", service)
discovery = ModelDiscoveryService(tmp_path / "test-model-cache.json")
monkeypatch.setattr(ModelDiscoveryService, "_instance", discovery)
monkeypatch.setattr("antigravity_provider.router.action_handler._rescan_after_auth", lambda: None)
monkeypatch.setattr("antigravity_provider.router.action_handler._rescan_after_auth", lambda *args: None)
monkeypatch.setattr("antigravity_provider.router.state_store.HubStateStore.refresh", lambda *a, **kw: None)
ActionExecutor._pending_connections.clear()
yield service, discovery
@ -65,16 +65,10 @@ def test_invalid_key_real_http_correct_slot(isolated, provider):
"provider": provider, "token": "intentionally-invalid",
"base_url": f"http://127.0.0.1:{server.server_port}/v1",
})
assert result["ok"]
pid = result["data"]["profile_id"]
assert pid.startswith(provider + "-")
assert load_router_config().get_profile(pid).provider == provider
probe = do_test_profile(provider, pid)
assert not probe["success"]
assert "401" in probe["error"] and "Invalid API key" in probe["error"]
discovery = isolated[1]
assert discovery.discover_models_sync(provider, profile_id=pid) is None
assert "Invalid API key" in discovery.get_models_with_metadata(provider, pid)["error"]
assert not result["ok"]
assert "401" in result["message"]
assert not load_router_config().profiles
finally:
server.shutdown()
server.server_close()
@ -187,13 +181,9 @@ def test_slow_action_does_not_block_web_health(isolated, monkeypatch):
asyncio.run(exercise())
def test_repeated_connection_rejected_while_checking(isolated, monkeypatch):
service, _ = isolated
service.enabled = True
monkeypatch.setattr(service._pool, "submit", lambda *args: None)
payload = {"provider": "nvidia", "token": "intentionally-invalid-repeated"}
first = ActionExecutor.execute("add_account", payload)
second = ActionExecutor.execute("add_account", payload)
assert first["ok"] and first["data"]["profile_id"] == "nvidia-1"
assert not second["ok"] and "проверяется" in second["message"]
assert list(load_router_config().profiles) == ["nvidia-1"]
def test_repeated_invalid_connection_does_not_create_profiles(isolated):
# Repeated invalid input cannot consume slots or create placeholder credentials.
payload = {"provider": "nvidia", "token": "invalid", "base_url": "invalid-url"}
assert not ActionExecutor.execute("add_account", payload)["ok"]
assert not ActionExecutor.execute("add_account", payload)["ok"]
assert not load_router_config().profiles

249
tests/test_accounts_a54.py Normal file
View file

@ -0,0 +1,249 @@
"""A54: real loopback HTTP, no owner credentials, no inference-service load."""
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from types import SimpleNamespace
import pytest
from antigravity_provider.router.account_probe_service import AccountProbeService
from antigravity_provider.router.action_handler import ActionExecutor, do_delete_credentials
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.connection_preflight import validate_connection
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.profile_manager import ProfileAuthManager, get_profile_auth_path
from antigravity_provider.router.router_config import RouterConfig, load_router_config, save_router_config
@pytest.fixture
def services(tmp_path, monkeypatch):
save_router_config(RouterConfig())
probe = AccountProbeService()
discovery = ModelDiscoveryService(tmp_path / 'models.json')
monkeypatch.setattr(AccountProbeService, '_instance', probe)
monkeypatch.setattr(ModelDiscoveryService, '_instance', discovery)
monkeypatch.setattr('antigravity_provider.router.action_handler._rescan_after_auth', lambda *a: None)
yield probe, discovery
probe.shutdown()
@pytest.fixture
def http_provider():
calls = []
class Provider(BaseHTTPRequestHandler):
def do_GET(self):
calls.append(self.path)
invalid = self.headers.get('Authorization') == 'Bearer bad' and self.path.endswith('/key')
body = {'error': {'message': 'Invalid key'}} if invalid else ({'models': []} if self.path.endswith('/api/tags') else {'data': [{'id': 'fixture-chat'}]})
self.send_response(401 if invalid else 200)
self.end_headers()
self.wfile.write(json.dumps(body).encode())
def do_POST(self):
calls.append(self.path)
body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
assert body['max_tokens'] == 1
valid = self.headers.get('Authorization') == 'Bearer fixture-valid'
self.send_response(200 if valid else 401)
self.end_headers()
self.wfile.write(json.dumps({'choices': [{'message': {'content': 'ok'}}]} if valid else {'error': {'message': 'Invalid key'}}).encode())
def log_message(self, *args):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Provider)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f'http://127.0.0.1:{server.server_port}/v1', calls
server.shutdown()
server.server_close()
thread.join()
def test_manual_check_works_when_periodic_disabled(services, monkeypatch):
probe, discovery = services
calls = []
monkeypatch.setattr(discovery, 'discover_models_sync', lambda *a, **kw: ['fixture'])
def invoke(*a, **kw):
calls.append(kw)
return {'success': True, 'response': 'Provider answered'}
monkeypatch.setattr('antigravity_provider.router.action_handler.do_test_profile', invoke)
assert probe.enabled is False
result = probe.check_now('local', 'local-1')
assert result['ok'] and result['message'] == 'Provider answered'
assert len(calls) == 1 and calls[0]['discovered_models'] == ['fixture']
def test_models_only_empty_is_success_without_inference(services, http_provider, monkeypatch):
probe, _ = services
url, calls = http_provider
AutoAssigner.ensure_profile_definition('ollama', 'ollama-1')
ProfileAuthManager.save_profile_auth('ollama', 'ollama-1', {'base_url': url})
monkeypatch.setattr('antigravity_provider.router.action_handler.do_test_profile', lambda *a, **kw: pytest.fail('catalog called inference'))
result = probe.check_now('ollama', 'ollama-1', models_only=True)
assert result['ok'] and result['data']['models'] == []
assert calls == ['/api/tags']
@pytest.mark.parametrize('provider', ['openrouter', 'nvidia'])
def test_invalid_key_never_creates_profile(services, http_provider, provider):
url, _ = http_provider
result = ActionExecutor.execute('add_account', {'provider': provider, 'token': 'bad', 'base_url': url})
assert not result['ok'] and '401' in result['message']
assert not load_router_config().profiles
assert not get_profile_auth_path(provider, provider + '-1').exists()
@pytest.mark.parametrize('provider', ['openrouter', 'nvidia', 'local', 'ollama'])
def test_valid_preflight_returns_catalog(services, http_provider, provider):
url, calls = http_provider
result = validate_connection(provider, 'fixture-valid', url)
assert result['ok'] and result['message']
assert result['data']['models'] == ([] if provider == 'ollama' else ['fixture-chat'])
assert ('/v1/chat/completions' in calls) == (provider == 'nvidia')
@pytest.mark.parametrize('url', ['bad-url', 'http://user:pass@localhost/v1', 'http://localhost/v1?key=abc', 'file:///tmp/models'])
def test_preflight_rejects_invalid_urls(url):
result = validate_connection('local', '', url)
assert not result['ok'] and result['message']
def test_empty_exception_is_visible(services, monkeypatch):
probe, discovery = services
def fail(*a, **kw):
raise RuntimeError()
monkeypatch.setattr(discovery, 'discover_models_sync', fail)
result = probe.check_now('local', 'local-1')
assert not result['ok'] and result['message'] == 'RuntimeError'
def test_periodic_failure_visible_and_retry(services, monkeypatch):
probe, _ = services
probe.enabled = True
monkeypatch.setattr(probe, 'schedule_all', lambda **kw: (_ for _ in ()).throw(ValueError('sweep failed')))
assert probe.tick(1) == 0 and probe.status()['error'] == 'sweep failed'
monkeypatch.setattr(probe, 'schedule_all', lambda **kw: 3)
assert probe.tick(2) == 3
assert probe.status()['last_tick'] and probe.status()['error'] is None
def test_manual_background_do_not_overlap(services, monkeypatch):
probe, discovery = services
probe.enabled = True
entered, release = threading.Event(), threading.Event()
concurrent, maximum = 0, 0
def discover(*a, **kw):
nonlocal concurrent, maximum
concurrent += 1
maximum = max(maximum, concurrent)
entered.set()
assert release.wait(3)
concurrent -= 1
return ['fixture']
monkeypatch.setattr(discovery, 'discover_models_sync', discover)
monkeypatch.setattr('antigravity_provider.router.action_handler.do_test_profile', lambda *a, **kw: {'success': True, 'response': 'ok'})
assert probe.schedule('local', 'local-1')
assert entered.wait(2)
result = []
manual = threading.Thread(target=lambda: result.append(probe.check_now('local', 'local-1')))
manual.start()
assert not probe.schedule('local', 'local-1', force=True)
release.set()
manual.join(3)
assert result[0]['ok'] and maximum == 1
def test_delete_fast_and_bulk_protects_antigravity(services, monkeypatch):
from antigravity_provider.router.state_store import HubStateStore
removed = []
monkeypatch.setattr(HubStateStore, 'get', lambda: SimpleNamespace(apply_delta_account_removed=lambda *a: removed.append(a)))
for provider, pid in [('antigravity', 'ag-w1'), ('nvidia', 'nvidia-1'), ('local', 'local-1')]:
AutoAssigner.ensure_profile_definition(provider, pid)
path = get_profile_auth_path(provider, pid)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('{"fixture":true}')
protected = get_profile_auth_path('antigravity', 'ag-w1')
before = protected.read_bytes()
started = time.monotonic()
assert do_delete_credentials('local', 'local-1')[0]
assert time.monotonic() - started < 0.5
preview = ActionExecutor.execute('clear_accounts', {})
assert 'ag-w1' in preview['data']['protected']
assert protected.read_bytes() == before
result = ActionExecutor.execute('clear_accounts', {'confirmed': True, 'targets': preview['data']['targets']})
assert result['ok'] and protected.read_bytes() == before
assert not get_profile_auth_path('nvidia', 'nvidia-1').exists()
assert len(removed) == 2
def test_bulk_stale_preview_rejected(services):
result = ActionExecutor.execute('clear_accounts', {'confirmed': True, 'targets': [{'profile_id': 'forged'}]})
assert not result['ok'] and 'изменился' in result['message']
def test_antigravity_explicit_catalog_ignores_global_cache(monkeypatch):
from antigravity_provider import agy_subprocess as agy
monkeypatch.setattr(agy, '_AGY_MODEL_CACHE', {'old': 'old'})
monkeypatch.setattr(agy, 'get_agy_exe', lambda: 'fixture-agy')
monkeypatch.setattr(agy.subprocess, 'run', lambda *a, **kw: SimpleNamespace(returncode=0, stdout='\n'.join(f'gemini-{i}\tfixture' for i in range(14))))
assert len(agy.discover_models('ag-w1')) == 14
def test_valid_add_remembers_check_and_selected_model(services, http_provider):
probe, discovery = services
url, _ = http_provider
result = ActionExecutor.execute('add_account', {'provider': 'openrouter', 'token': 'fixture-valid', 'base_url': url, 'preferred_model': 'fixture-chat'})
assert result['ok']
pid = result['data']['profile_id']
assert probe.state(pid)['state'] == 'working'
assert discovery.get_models_with_metadata('openrouter', pid)['models'] == ['fixture-chat']
assert load_router_config().get_profile(pid).preferred_models[0] == 'fixture-chat'
def test_timeout_keeps_inference_reserved(services, monkeypatch):
from antigravity_provider.router.action_handler import do_test_profile
AutoAssigner.ensure_profile_definition('local', 'local-1')
ProfileAuthManager.save_profile_auth('local', 'local-1', {'base_url': 'http://127.0.0.1:1/v1'})
entered, release, finished = threading.Event(), threading.Event(), threading.Event()
calls = []
def invoke(*a, **kw):
calls.append(1)
entered.set()
try:
assert release.wait(2)
return {}
finally:
finished.set()
monkeypatch.setattr('antigravity_provider.router.action_handler.get_adapter', lambda p: SimpleNamespace(invoke=invoke))
try:
first = do_test_profile('local', 'local-1', timeout=0.02, discovered_models=['fixture'])
assert entered.is_set() and not first['success']
second = do_test_profile('local', 'local-1', timeout=0.02, discovered_models=['fixture'])
assert not second['success'] and 'ещё не завершился' in second['error']
assert calls == [1]
finally:
release.set()
assert finished.wait(2)
def test_bulk_protects_symlink_into_ag(services, tmp_path):
AutoAssigner.ensure_profile_definition('local', 'local-1')
target = get_profile_auth_path('antigravity', 'ag-w1')
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text('protected fixture')
link = get_profile_auth_path('local', 'local-1')
link.parent.mkdir(parents=True, exist_ok=True)
link.symlink_to(target)
preview = ActionExecutor.execute('clear_accounts', {})
assert 'local-1' in preview['data']['protected']
assert preview['data']['targets'] == []
assert ActionExecutor.execute('clear_accounts', {'confirmed': True, 'targets': []})['ok']
assert target.read_text() == 'protected fixture'
@pytest.mark.parametrize('provider', ['openrouter', 'nvidia', 'claude'])
def test_empty_key_never_allocates_slot(services, provider):
result = ActionExecutor.execute('add_account', {'provider': provider, 'token': ''})
assert not result['ok'] and result['message']
assert not load_router_config().profiles

Some files were not shown because too many files have changed in this diff Show more