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>
Аудит 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
Мастер честно сообщил: «Терминал /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>
- Реализован запуск 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 тестов проходят
Вход. 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>
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>
Проверено ревьюером исполнением, все восемь замечаний владельца закрыты:
Чужой слот. Раньше add_account с profile_id=ag-w1 для nvidia возвращал
ok=True и клал аккаунт в слот Antigravity. Теперь отказ с причиной:
«Слот ag-w1 не принадлежит провайдеру nvidia». Свой слот принимается,
идентификатор выдаётся верный.
Группировка по провайдерам восстановлена: скрытие заголовков групп,
добавленное в A48 (display:contents + display:none), убрано.
Автоматическая проверка запускается при старте веб-сервера
(_start_background_refresh), состояние и списки моделей больше не ждут
ручного нажатия.
Облачные модели Ollama: эндпоинт не выдуман — ревьюер проверил запросом,
https://ollama.com/api/tags отвечает 200 и отдаёт 19 моделей
(gpt-oss:20b, kimi-k2.6, glm-5.1 и другие).
Локальный провайдер подписан llama.cpp вместо «Локальный сервер».
Показ хода при долгом опросе честный: «может занять до минуты на этап».
Конфликты с A49 разрешены сложением: правки дополняют друг друга —
поле пути к хранилищу Obsidian и поле периода проверки аккаунтов,
стили вкладки скиллов и стили групп провайдеров. В тесте свежести памяти
взят вариант A50: коммит извлекается из файла, а не зашит.
545 passed, 1 skipped, ruff clean, релизный гейт 10/10 и на конфигурации
владельца, и на пустой.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. OllamaAdapter: поддержка локального инстанса по умолчанию и удаленного Ollama API
с кастомным base_url и опциональным Bearer токеном, discovery по /v1/models и /api/tags,
статус квоты «Без ограничений».
2. ClaudeAdapter: реальный health_check API probe и динамический discover_models.
3. OpenRouterAdapter: обязательные заголовки HTTP-Referer и X-OpenRouter-Title,
сбор метаданных моделей (context_length, display_name).
4. NvidiaAdapter: парсинг заголовка Retry-After и динамическая задержка при 429.
5. Экспорт лимитов: эндпоинт GET /api/quotas/export (JSON / CSV), экшен export_quotas
и кнопка выгрузки в веб-интерфейсе с маскированием секретов.
6. tests/test_api_providers_a32.py: 15 тестов, 434 passed, ruff чисто.
1. P0-1: Восстановлены все веб-обработчики в app.js (openAddAccountWizard,
handleNodeAccountChange, handleNodeModelChange, handleRefreshProviderModels,
checkUpdates), связаны с потоками startDeviceAuth и startRedirectAuth,
выбор слота обязателен и понятен пользователю.
2. P0-2: Добавлены адаптеры OpenRouter и NVIDIA NIM с поддержкой динамического
base_url, множественных аккаунтов без ограничений, GET /models и честным
отображением квот/«Н/Д».
3. P0-3: В мастере подключения локального провайдера реализована кнопка автопоиска
(discover_local_models), отображение серверов, ошибок портов и автозаполнение.
4. P0-4: Полностью удален устаревший десктопный интерфейс CustomTkinter
(router/ui/** 20 файлов, hermes_hub_app.py), зависимости customtkinter и pillow
убраны из pyproject.toml и инсталляторов, оставлен единый ярлык «Hermes Hub».
5. 418 passed, 1 skipped, 4 deselected, ruff чисто.
CORS был настроен как allow_origins=["*"] вместе с allow_credentials=True.
FastAPI в таком сочетании не отдаёт звёздочку, а ОТРАЖАЕТ присланный Origin
обратно. Проверено запросом к работающему хабу:
Origin: https://evil.example.com
-> HTTP 200
access-control-allow-origin: https://evil.example.com
access-control-allow-credentials: true
Опаснее всего это на localhost. get_auth_token требует токен только при
небlocalhost-привязке, то есть на 127.0.0.1 проверки нет вовсе. Значит любая
открытая рядом веб-страница могла прочитать /api/snapshot со всеми
аккаунтами, почтами и квотами и вызвать /api/action — удалить учётные
данные, переписать маршрутизацию, запустить вход OAuth. Ровно так на обеих
машинах владельца хаб и работает.
Собственному интерфейсу CORS не нужен: он отдаётся тем же сервером.
Межсайтовые запросы запрещены по умолчанию, список разрешённых источников
вынесен в настройку web_api_allowed_origins — он понадобится, когда одна
панель будет смотреть на несколько хабов.
Проверено после правки: заголовков access-control в ответе нет, браузер
такой запрос заблокирует; собственный интерфейс работает, снапшот приходит
(24 профиля, 6 ролей, индикатор «Live API»). 451 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Раздел 3 контракта требует secrets.compare_digest, но в коде стояло
x_hub_token != required_token, а compare_digest не встречался в router/
вообще. Обычное сравнение строк выходит на первом несовпавшем символе и даёт
утечку по времени. Проверка стала уместной сейчас, когда хаб собираются
открыть по сети.
Сравнение идёт в БАЙТАХ, а не в строках: compare_digest со строками
запрещает не-ASCII и падает TypeError — токен с кириллицей давал бы 500
вместо честного отказа. Выяснено исполнением, а не чтением документации.
Проверено на живом приложении при web_api_host=0.0.0.0: без токена 401,
неверный 401, отличающийся одним символом 401, верный 200. Страница отдаётся
без токена (иначе его негде было бы ввести), в /api/settings токен не
попадает, в снапшоте нет access_token/refresh_token/client_secret.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Мастер подключения писал, что на сервере без экрана вход «через веб-интерфейс
невозможен», и отправлял в консоль по SSH либо переносить каталог
agy_profiles руками. GET /api/health отдавал для этих провайдеров жёстко
вписанное supported: false.
Утверждение оказалось ложным. В коде уже были
ProfileOAuthSession.handle_manual_callback_url и
ClaudeOAuthSession.handle_auth_code — оба принимают вставленное вручную
значение и доводят обмен кода на токены. Наружу их просто не вывели. Браузер
нужен где угодно, а не на машине с Hub: владелец открывает ссылку у себя и
возвращает адрес из адресной строки.
Добавлены действия start_redirect_auth, submit_redirect_callback,
poll_redirect_auth, cancel_redirect_auth. auth_flows теперь отражает
настоящие возможности, а не литерал. Обе заглушки в мастере заменены живым
потоком; мёртвая ветка Claude с полем API Key удалена.
Три дефекта, найденных при проверке исполнением:
1. Одна опечатка при вставке убивала сессию: handle_callback ставил
status="failed" при отсутствии кода или чужом state, и вход приходилось
начинать заново, хотя ссылка оставалась годной. Для ручного ввода такие
ошибки больше не конечные; отказ провайдера конечен по-прежнему.
2. Окно слушателя в 5 минут рассчитано на браузер той же машины. При входе с
другого ПК его не хватает: 20 минут — значение, проверенное на практике.
3. find_free_slot всегда возвращал ag-orch-fallback: занятость определяется по
файлу учётных данных, а agy на Windows держит их в keyring, поэтому все
десять слотов выглядят свободными. Вход затёр бы работающий аккаунт. Слот
теперь выбирает владелец из списка, построенного по снапшоту, с пометкой,
какие заняты и кем.
Два теста закрепляли снятую заглушку: test_headless_server_auth_matrix требовал
слов «Headless» и «agy» в интерфейсе, test_c_state_mismatch требовал
status == "failed". Первый переведён на проверку настоящего потока, второй
усилен: свойство безопасности (отказ без обмена кода) проверяется по-прежнему,
и дополнительно проверено, что после промаха верная вставка доходит до обмена.
Проверено вживую в браузере: список из 10 слотов с пометкой занятости,
выбранный слот доходит до сервера, ссылка настоящая от accounts.google.com,
поле вставки на месте. 431 passed, 2 skipped; ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец сообщил, что в маршрутизации не переставляются блоки, нигде нет
выбора аккаунта для роли и на «Обзоре» ничего нельзя изменить. На скриншоте
при этом видна кнопка «Изменить цепочку», которой в коде уже нет: A24 её
удалил вместе с renderTeam и renderProviders.
Причина не в логике. FileResponse отдавал app.js и style.css без заголовка
Cache-Control, поэтому браузер применял эвристическое кэширование и держал
скрипт от прошлой сборки. index.html при навигации перепроверялся и был
свежим — отсюда смесь нового текста подсказки со старыми кнопками, а
перетаскивание и выбор модели просто отсутствовали в загруженном коде.
Действие reorder_chain при этом исправно: проверено вызовом, порядок
цепочки меняется и сохраняется в router_profiles.yaml.
Заодно подключённые аккаунты выводятся первыми, а пустые слоты
(«не подключён») — в конце группы: рабочие карточки были разбросаны
между пустыми и их приходилось выискивать.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/api/snapshot отдавал квоты с source="baseline" и нулём измеренных
корзин всегда. Две независимые причины, и лечение одной из них ничего
не давало.
1. state_store наполняет квоты через quota_service.get_snapshot, который
читает кэш и при промахе отдаёт пустую заглушку, живой опрос НЕ
запуская. Кэш никто не грел: в десктопе это делал
_refresh_quotas_on_startup, в вебе аналога не было. Штатный
планировщик службы не спасает — его цикл сначала спит интервал
(300 с по умолчанию) и только потом опрашивает.
2. HubStateStore.get_snapshot() возвращает КЭШИРОВАННЫЙ снапшот и
пересобирает его только при первом вызове. Даже после прогрева квот
ответ оставался прежним. В десктопе пересбор делал _refresh_data.
Добавлен фоновый цикл: прогрев квот при старте, затем пересбор снапшота
каждые 30 секунд. Порядок важен — снапшот, собранный до прогрева,
зафиксировал бы пустые корзины.
Проверено исполнением: квоты появляются через ~10 секунд после старта,
24 измеренных корзины, source=provider_api, ag-w2 Gemini неделя 80.5% —
совпадает с прямым опросом провайдера.
Регрессия закрыта tests/test_web_snapshot_freshness.py, включая проверку
порядка «прогрев перед пересбором».
Тесты: 327 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A15 и A16 сошлись в пустоту: API отвечал, файлы клиента лежали в
репозитории, но server.py не монтировал static — в браузере был 404 и до
интерфейса было не добраться.
Причина организационная и она на ревьюере: контракт описал каталог
static/ в структуре пакета, но в разделе об эндпоинтах не назвал, кто его
отдаёт. Обе стороны выполнили написанное и всё равно не собрались.
Подключены StaticFiles и корневой маршрут. Проверено исполнением:
/ -> 200 (13.5 КБ), /app.js -> 200 (51 КБ), /style.css -> 200 (21 КБ),
/api/health -> 200, /api/snapshot -> 200 (98 КБ).
Тесты: 325 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Правки при приёмке A15. Веб-API и вынесение действий приняты, но
в сданном виде не работали ни то, ни другое.
1. Десктоп был уничтожен. При выносе действий из hermes_hub_app.py
пропало объявление class HermesHubApp вместе с 13 методами каркаса:
__init__, _build_layout, _create_view, _show_view, _refresh_data и
другими. Оставшиеся 14 методов оказались вложены внутрь функции
_load_saved_theme после её return — синтаксически валидный
недостижимый код, поэтому модуль импортировался и дефект выглядел
безобидно. launch_hub() при этом падал бы с NameError.
hermes_hub_app.py восстановлен из main; задание прямо требовало
десктоп не ломать.
2. Дублирование убрано правильным способом: десктоп импортирует пять
do_* из action_handler, второй реализации в проекте нет.
3. Веб-API падал с 500 на обоих значимых эндпоинтах: get_auth_token и
run_server читали config.hub, а такого атрибута у RouterConfig нет.
Настройки живут в hub_settings.json. Работал только /api/health, у
которого нет проверки авторизации, — из-за чего сервер и выглядел
поднявшимся.
4. do_save_settings при переносе потеряла атомарную запись через
os.replace, ensure_ascii=False и вызов set_refresh_interval, то есть
интервал обновления квот из настроек перестал применяться.
Восстановлено.
5. Импорт адаптера был убран внутрь do_test_profile, что делало функцию
неподменяемой в тестах. Поднят на уровень модуля.
6. Версия в /api/health была зашита как "1.0.0" вместо настоящей.
Проверено исполнением: /api/snapshot отдаёт 200 и 12 ключей, полностью
совпадающих с docs/web-api/snapshot.example.json; секретов в ответе нет;
неизвестное действие даёт 404. Тесты: 319 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Работа A15 выполнена, но не закоммичена: git в его окружении был
недоступен. Восстановлена ревьюером из рабочего каталога.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>