1. P0-1: get_default_router_config() возвращает чистую конфигурацию (0 профилей,
13 канонических ролей с пустыми цепочками). Миграция не внедряет фиктивные профили.
2. P0-2: Учетные данные (~/.hermes/*_profiles/, hub_settings.json) изолированы и
никогда не затрагиваются при сбросе или установке.
3. P0-3: Профили создаются динамически при подключении аккаунтов (ag-1, codex-1, etc.).
Пустые цепочки ролей являются нормальным рабочим состоянием.
4. P0-4: Добавлен экшен reset_router_config и кнопка «Начать настройку заново»
в настройках с подтверждением и созданием бэкапа router_profiles.yaml.bak_<timestamp>.
5. P0-5: scripts/verify_multi_provider_router.py адаптирован и проходит 10/10 PASS
как на пустой конфигурации, так и на заполненной.
6. tests/test_a41_clean_install.py: 6 тестов, 490 passed, ruff чисто.
Брендбук требует три темы, причём Medium — отдельная, а не осветлённая Dark:
тёмно-зелёный холст #1A2A1F со светлыми кремовыми карточками #F7F1E3.
В style.css она описана целиком и отрисовывается верно — проверено
подстановкой data-theme вручную. Но в список выбора не попала, а applyTheme
знал только light и dark, поэтому любое другое значение сбрасывало тему в
системную. Выбрать Medium было невозможно.
После правки все три переключаются:
dark фон #101510 карточки #1A2A1F
medium фон #1A2A1F карточки #F7F1E3
light фон #F7F1E3 карточки белые
Цвета совпадают с палитрой брендбука. 486 passed, ruff чисто.
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 чисто.
Реализована поддержка произвольных параметров запроса (request_options) для
локальных профилей:
1. RouterProfileConfig и ProfileViewModel дополнены полем request_options
с полной поддержкой вложенных словарей и сериализацией в YAML.
2. LocalLLMAdapter подмешивает request_options в тело POST /chat/completions.
Явные поля запроса имеют приоритет, при расхождениях пишется warning.
Изоляция провайдеров сохранена: другие адаптеры не трогают request_options.
3. Неизвестные/некорректные параметры обрабатываются чисто и классифицируются
как INVALID_REQUEST с извлечением текста ошибки сервера.
4. В веб-интерфейсе реализована карточка профиля с редактором JSON параметров,
живой валидацией синтаксиса, предпросмотром тела запроса и проверкой
подключения.
5. Отсутствие зашитых параметров enable_thinking и reasoning_effort в логике.
6. 526 passed, 3 skipped, 4 deselected, ruff чисто.
Проверка A37 исполнением. Защита границы рабочей области работает, обходы
через ../ и ~ в validate_path отсекаются, каталоги учётных данных закрыты.
Но в validate_command нашлась дыра ровно в том месте, ради которого guard и
делался.
Тильда и переменные окружения не раскрывались перед проверкой. Путь
"~/.hermes/agy_profiles" не считался абсолютным, склеивался с каталогом
проекта в путь с буквальным "~" внутри и признавался допустимым. Измерено:
rm -rf ~/.hermes/agy_profiles РАЗРЕШЕНО
rm -rf ~/.ssh РАЗРЕШЕНО
rm -rf $HOME/.hermes РАЗРЕШЕНО
тот же путь абсолютным отказ
тот же путь через validate_path отказ
То есть самый естественный способ написать опасную команду обходил защиту, а
абсолютный путь — нет. После правки все три отклоняются, штатное удаление
внутри проекта по-прежнему разрешено.
Добавлен тест, удерживающий это свойство.
Проверено отдельно, что защита не ломает продукт: страница, app.js, health и
snapshot отдают 200, в снапшоте 13 ролей, секретов в ответе нет, проверка
обновлений работает. 508 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Проверка кандидата A28–A31 исполнением.
1. Дублирование ролей. RoleRegistry.migrate_legacy_roles написана верно, но
НЕ ВЫЗЫВАЛАСЬ НИОТКУДА — проверено поиском по всему коду. Вместо неё
работала «идемпотентная миграция» в router_config, дописывавшая недостающие
умолчания и не убиравшая старые роли. На конфигурации владельца интерфейс
показывал 19 агентов, причём шесть пар были неотличимы по названию:
orchestrator и manager — оба «Менеджер проекта», reviewer и code-reviewer —
оба «Ревьюер кода». Разложить аккаунты по такому списку невозможно.
Миграция подключена. Внутри неё нашлась вторая ошибка: при обходе одним
проходом пустая каноническая роль затирала цепочку, перенесённую из старой.
У владельца сработало бы именно так — в manager попал бы codex-orch вместо
выставленного им ag-orch-fallback. Старые роли теперь обрабатываются
первыми: в них и лежит настроенный порядок аккаунтов.
Проверено на живой конфигурации владельца: было 19 ролей, стало 13, все
цепочки совпадают с исходными.
2. Сохранённый workflow мигрируется вместе с ролями. Идентификаторы агентов
повторяют идентификаторы ролей, и без переименования рёбра ссылались бы на
исчезнувших агентов — «Ребро ссылается на отсутствующего агента».
3. Поиск локальных серверов моделей. Раньше адрес вводился руками. Теперь
опрашиваются известные порты на петле — Ollama 11434, LM Studio 1234,
llama.cpp 8080-8082, vLLM 8000, Jan, GPT4All, Text Generation WebUI, —
параллельно, девять портов за 1.5 с. Наружу идёт только то, что ответило;
список моделей берётся у сервера. Порт, занятый чужим сервисом, показывается
с причиной, закрытые не показываются вовсе. Действие discover_local_models.
Роль в фикстурах test_workflow_service_a30 переименована в test-developer:
«developer» — псевдоним канонической developer-1, и тест проверял бы работу
псевдонимов вместо механики workflow.
475 passed, 2 skipped; ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
В adapters/antigravity_adapter.py жил модульный _AGY_INVOCATION_LOCK, общий
для ВСЕХ профилей Antigravity. Он брался при каждом вызове, у которого есть
учётные данные, то есть при каждом рабочем. Ветка без мьютекса срабатывала
только у профиля без учётки — у вызова, который и так упадёт.
Измерено на живом адаптере: три параллельных вызова по одной секунде
занимали 3.01 с. При десяти подключённых аккаунтах одновременно работал ровно
один, чем обесценивалась вся мультиаккаунтность — то, ради чего хаб и делался.
Мьютекс охранял пустоту. Он был введён в 50fde5f со словами «guarded global
gemini:antigravity credential swap ... to eliminate concurrent subprocess
race», когда подмена учётных данных была ГЛОБАЛЬНОЙ. С тех пор она стала
попрофильной: agy_subprocess пишет в profile_dir/.gemini/oauth_creds.json, а
HOME, USERPROFILE, HOMEPATH и HOMEDRIVE подменяются на каталог профиля.
Общего состояния между профилями не осталось — проверено поиском глобальных
путей и обращений к keyring, их нет.
После снятия: те же три вызова занимают 1.00 с, и каждый идёт со своим HOME
(ag-w1, ag-w2, ag-w3) — изоляция не пострадала.
Ограничение одновременности остаётся за LeaseManager: он считает лизы по
профилю и настраивается через max_concurrency, в том числе значением 1 для
локальных моделей с --parallel 1.
Добавлен тест, удерживающий это свойство: он падает, если вызовы разных
профилей снова начнут сериализоваться. Существующий тест изоляции учётных
данных проходит без изменений.
Найдено при разборе анализа, который Antigravity провёл на сервере владельца
(hermes-muliacount). 459 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Preflight and updater tests no longer probe 8081/8082 or GitHub.
Hermetic runs fail-fast on non-loopback sockets. GUI helpers are
importable without customtkinter. CI installs the web extra, and
pytest-timeout plus a wall-clock wrapper bound the suite.
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>
Проверка A27 исполнением. Основа сделана верно — сравнение по коммитам,
чтение релизов основного репозитория, честный показ отказа проверки. Три
дефекта закрыты.
1. В вебе функция была нерабочей целиком. Веб-сервер передаёт async_runner
всегда, а check_updates в этой ветке отвечал «Проверка обновлений
запущена» без data. Результат до клиента не доходил, кнопка обновления не
могла появиться никогда. Проверка — один HTTP-запрос с таймаутом 10
секунд, поэтому выполняется синхронно и всегда возвращает данные; в фон
уходит только установка.
2. Контрольная сумма пропускалась молча. При недоступном checksums.txt
expected_sha оставался пустым, проверка не выполнялась, и скачанный
установщик запускался. Здесь исполняется загруженный из сети код —
непроверенный файл теперь не запускается вовсе, с внятной причиной.
3. Перезапуска не было, но он обещался. Ни install-linux.sh, ни виндовый
установщик в тихом режиме приложение не поднимают, а сообщение гласило
«Hermes Hub будет перезапущен»: владелец остался бы со старым процессом и
решил, что обновление не сработало. Добавлен schedule_restart —
отсоединённый помощник ждёт освобождения порта, текущий процесс выходит
раньше. Установка теперь дожидается завершения установщика и проверяет код
возврата, вместо того чтобы обещать успех сразу после запуска.
Тест test_action_executor_update_actions требовал, чтобы check_updates уходил
в фон, то есть закреплял дефект как требование — переведён на желаемое
поведение.
Проверено: сумма отсутствует — отказ; сумма не совпала — отказ; совпала —
запуск и перезапуск. Через HTTP приходят installed_commit, latest_commit,
release_tag и время публикации. В интерфейсе видно «Доступно обновление
(a1e1db7)», «Сборка: d7ad3e3» и время последней проверки; при отказе сети —
«Ошибка проверки» с причиной, а не «Актуально». 450 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Проверка A26 исполнением. Признак подключённости был записан как
p.authenticated === true || (p.health_state && p.health_state !== 'not_configured')
и содержит два дефекта. Поля authenticated в ProfileViewModel нет вовсе —
первая половина условия мертва. Вторая пропускает всё, кроме not_configured,
то есть холодный резерв (health_state "disabled") и непроверенные пустые
слоты.
Измерено на живом снапшоте: из 24 профилей фильтр пропускал 5, при том что
по-настоящему подключён 1. При нуле настоящих аккаунтов страница показывала
три карточки «Холодный резерв» вместо пустого состояния — ровно тот мусор,
который владелец просил убрать.
Тот же предикат используется для выбора аккаунта на «Обзоре», поэтому там
предлагалось назначать роли на пустые слоты: мышление слотами, ради отмены
которого задание и делалось.
Authoritative признак — auth_state: у подключённого AUTHENTICATED, у пустого
слота и у холодного резерва NOT_CONFIGURED. AUTH_REQUIRED и AUTH_EXPIRED
означают подключённый аккаунт, которому нужен повторный вход, — показываем.
Заодно в выборе аккаунта показывается почта, а не имя профиля: жалоба из A24
про «Кодер 1 — назначенный аккаунт Кодер 2» иначе возвращалась.
Проверено в браузере: было 5 «подключённых» из 24, стало 2 — оба с
auth_state AUTHENTICATED; холодный резерв из выбора на «Обзоре» исчез.
442 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец подключил аккаунт, увидел его в «Аккаунтах» — и не нашёл ни в
«Обзоре», ни в «Маршрутизации».
Поведение верное: эти два экрана показывают цепочки ролей, а в умолчаниях в
цепочках участвуют только ag-orch-fallback и ag-w1..w4. Слоты ag-cold-* и
ag-spare-2 не входят никуда, поэтому подключённый в них аккаунт там и не
появится. Но мастер показывал все десять слотов одинаково, как «свободен», и
выбор был вслепую — а результат выглядел как пропажа аккаунта.
Теперь в списке слотов видна роль: «ag-w1 — свободен · coder-primary
(primary)» против «ag-spare-2 — свободен · не участвует в маршрутизации».
Участвующие идут первыми. После подключения слота вне цепочек показывается
пояснение, где его добавить.
Признак участия берётся из состава самих цепочек, а не из assigned_roles:
холодный резерв и ag-spare-2 значатся с ролью "spare", которой среди шести
маршрутизируемых ролей нет, так что проверка по названию роли давала бы
неверный ответ.
Проверено в браузере: шесть слотов показаны с настоящими ролями, четыре — с
пометкой о неучастии; состав совпадает с разбором снапшота по цепочкам.
432 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец подключил первый аккаунт Antigravity на сервере, получил
«Авторизация успешно завершена» — и не увидел его в разделе «Аккаунты».
Учётные данные сохранялись правильно: save_profile_auth и load_profile_auth
симметричны, get_profile_status после записи отдаёт authenticated=True —
проверено исполнением. Не совпадало другое: состояние профилей берётся из
кэша UnifiedHealthService, а фоновый цикл веб-сервера обновляет снапшот с
force_scan=False и кэш не трогает.
Измерено на изолированном каталоге:
до входа ag-w1 -> not_configured
сразу после входа ag-w1 -> not_configured
после refresh(force=False) ag-w1 -> not_configured <- цикл делает это
после refresh(force=True) ag-w1 -> not_tested
То есть аккаунт не появился бы никогда, пока хаб не перезапустят.
Теперь после успешного входа выполняется пересбор с force_scan=True — во всех
четырёх точках завершения: device-flow, ручная вставка адреса, опрос
redirect-потока и код Claude. Ошибка пересбора логируется и сам вход не
роняет.
Функция объявлена на уровне модуля: вложенной она была видна не всем точкам,
и device-flow получал NameError внутри обработки успеха. Мой тест этого не
поймал, потому что проверял только redirect-путь — нашла проверка ruff.
Проверено: not_configured -> not_tested сразу после входа, без ручного
обновления; путь device-flow исполняется без NameError. 432 passed, ruff
чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец открыл хаб по сети и не смог войти в Antigravity: кнопка
«Копировать» не давала ничего, а после подтверждения доступа браузер уходил
на 127.0.0.1:51121 его собственной машины и показывал «страница недоступна».
Два дефекта.
1. navigator.clipboard существует только в защищённом контексте — HTTPS или
localhost. По http://192.168.1.81:5800 его нет вовсе, и три кнопки
копирования не работали. Хуже: промис никто не проверял, поэтому они
показывали «Ссылка скопирована», ничего не скопировав. Подтверждено
измерением на живой странице по сетевому адресу: isSecureContext=false,
navigator.clipboard отсутствует. Добавлен общий помощник с запасным
execCommand('copy') и честным сообщением при неудаче.
2. Адрес возврата вёл в тупик, из которого код ещё надо было выковырять со
страницы ошибки, где он часто обрезан. Теперь, когда хаб открыт не с этой
машины, показывается готовая команда проброса порта возврата: тогда
слушатель хаба принимает возврат сам и вставлять ничего не нужно. Порт и
адрес берутся из ответа сервера и window.location, не зашиты.
Запасным путём принимается и голый код: не только полный адрес. Признак
адреса — "?" или "://", но НЕ слэш: коды Google сами содержат его и
начинаются с "4/0A...". Первая версия условия их отсекала — поймано
тестом. Добавлена проверка правдоподобия, иначе произвольный текст уходил
на обмен и давал невнятную ошибку провайдера вместо подсказки.
Проверено исполнением: голый код, полный адрес и вставка без протокола
принимаются; русский текст, короткая строка и строка с пробелом отвергаются
без обращения к провайдеру. Подсказка о пробросе отрисована на живой
странице, открытой по сетевому адресу. 432 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец открыл хаб по сети и получил пустую панель с повторяющимся тостом
«Ошибка сервера: 401». Кода 401 клиент не знал вовсе: ответ падал в общую
ветку ошибок, тост повторялся на каждом опросе, и ни одной подсказки о том,
что нужен токен и где его взять, не было.
Попытка ввести токен в «Настройках» тоже провалилась: рядом с полем токена
стоит кнопка сохранения настроек СЕРВЕРА, а она сама шлёт save_settings и
требует токен — получался замкнутый круг, 401 на попытке ввести токен от 401.
Теперь 401 перехватывается и в fetchSnapshot, и в executeAction: опрос
останавливается, открывается окно с полем ввода и объяснением, откуда взять
значение. Токен проверяется настоящим запросом до сохранения, поэтому
неверный не попадает в localStorage. После принятия окно закрывается, поле в
настройках заполняется, опрос возобновляется.
Дефект, найденный при проверке в браузере: непустой токен с не-ASCII
символами роняет fetch на TypeError, и обработчик умирает молча, ничего не
показав. Заголовки HTTP переносят только ASCII. Добавлена проверка до
отправки и перехват сетевой ошибки — так бывает, когда вместе с токеном
скопирован текст вокруг.
Проверено в браузере на изолированном экземпляре: окно появляется само,
кириллица даёт внятное сообщение, неверный токен отвергается и не
сохраняется, верный принимается — панель оживает, индикатор Live API,
опрос возобновлён. 432 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>
Если /api/snapshot не отвечал, клиент молча загружал snapshot.example.json —
63 профиля с почтами user@example.test — и ставил индикатор источника в
зелёное через setSourceIndicator(true, ...). На экране появлялась полная
здоровая панель, не имеющая отношения к этому серверу.
Дефект становится опасным ровно в том сценарии, ради которого делался вход с
другой машины: при работе через SSH-туннель достаточно промахнуться портом
или потерять туннель, чтобы принять пример за собственные данные и,
например, решать по нему, у какого аккаунта кончилась квота.
Осознанная работа с фикстурой сохранена: ?fixture=1 и открытие страницы
файлом. Подстановка вместо ответа сервера убрана — отсутствие данных теперь
читается как отсутствие данных.
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>
Владелец открыл карточку Grok и увидел «Grok 2h — Н/Д» и статус «Не
проверялся» — хотя сервер в этот момент уже отдавал настоящие данные:
подписка 14%, GrokChat 13%, GrokBuild 1%.
Проверено на живом сервере: и /api/snapshot, и quota_snapshot внутри
профиля содержали правильные корзины. Дефект целиком в клиенте — окно
рисовалось ОДИН РАЗ при открытии и на опрос не реагировало. Открытое до
завершения прогрева квот, оно навсегда оставалось с заглушкой
_generate_baseline_snapshot, а после успешной проверки подключения
по-прежнему показывало «Не проверялся».
Теперь открытый профиль отслеживается и перерисовывается при каждом
применении снапшота. Объявление переменной поднято выше первого
использования: let не поднимается, и обращение раньше объявления
роняло бы обработчик снапшота целиком.
При закрытии окна сбрасывается отслеживание и останавливается опрос кода
устройства — раньше он продолжал работать после закрытия мастера.
Тесты: 431 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец был прав: «апи не нужен». Подтверждено на его аккаунте GROK PRO —
api.x.ai/v1/chat/completions вернул 200 и ответ модели grok-4.3 БЕЗ
покупки кредитов. Прежний 402 был целиком из-за того, что подключён был
другой аккаунт, без подписки. Моё утверждение про «разные кошельки»
окончательно снято.
1. Квота показывала «Н/Д» при живой подписке. Читались только
prepaidBalance и onDemandCap — у подписчика оба нулевые. А расход
подписки лежит в creditUsagePercent, и разбивка по продуктам в
productUsage. Теперь оттуда и берётся: на живом аккаунте выходит
14% за неделю, GrokChat 13%, GrokBuild 1% — ровно то, что владелец
видит на grok.com. Корзина кредитов создаётся только когда они реально
заведены: нули у подписчика — норма, а не повод рисовать пустое.
2. Выбор модели отвергал настоящие имена: «кэш моделей для grok пуст, а
модель grok-4.5 не найдена». Причина — в _probe_provider грока не было
вовсе, обнаружение знало только antigravity, codex, opencode и local.
При этом api.x.ai/v1/models принимает тот же OAuth-токен и отдаёт 12
моделей. Зонд добавлен; grok-4.5 теперь принимается, выдуманная
grok-99-turbo — отклоняется.
Тесты: 428 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец: «удалить так и не могу ненужный». Backend был починен в
d755a07, но кнопка по-прежнему не работала.
Причина в клиенте: он отправлял только profile_id, без provider. Сервер
не знал, в каком каталоге искать auth.json, строил неверный путь и снова
отвечал «удалять нечего».
Идентификатор профиля однозначен, поэтому провайдер теперь берётся из
конфигурации, когда его не передали. Действие работает независимо от
того, кто его вызвал.
Заодно в клиенте: подтверждение перед необратимым удалением и обновление
экрана после — раньше карточка оставалась в прежнем виде, и было
непонятно, сработало ли.
Проверено через веб-API без параметра provider: до — авторизован,
после — нет.
Тесты: 426 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец прислал анонс x.ai/news/grok-hermes: доступ к Grok даётся по
подписке SuperGrok через OAuth, покупать кредиты API не требуется. Моя
формулировка в интерфейсе утверждала обратное — что подписка кредиты не
пополняет и это отдельный кошелёк. Это неверно, и оно уже попадало
пользователю на экран.
Установлено проверками:
- cli-chat-proxy.grok.com/v1/models принимает наш OAuth-токен -> 200,
отдаёт grok-4.6;
- тот же прокси /chat/completions -> 426, требует версию Grok CLI
не ниже 0.1.202, и она читается не из заголовков, которые я перебрал;
- api.x.ai/v1/responses (путь из документации Hermes) -> 402
personal-team-blocked:spending-limit.
При этом подключён ochenstarik@gmail.com, а подписка SuperGrok — на
victor.trushenko@gmail.com. То есть отказ объясняется аккаунтом, а не
природой подписки, и утверждать иное я не мог.
Сообщение переписано на проверяемое: у аккаунта нет ни баланса, ни
лимита трат, а доступ даёт подписка на ЭТОМ же аккаунте либо купленные
кредиты. Само чтение биллинга остаётся — оно работает и показывает
настоящие числа.
Тесты: 422 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец спросил, где кончились лимиты, и показал экран: еженедельный
лимит SuperGrok израсходован на 14%, запас есть. А вызовы через Hub
падали с 402 «out of credits».
Разгадка — два разных кошелька. Подписка SuperGrok покрывает чат
grok.com, а программный доступ списывается с кредитов API. Проверено на
живом аккаунте: prepaidBalance 0, onDemandCap 0. Именно поэтому 402, и
подписка тут не помогает.
Адрес биллинга взят из плагина hermes-grok-usage, который владелец уже
использует: cli-chat-proxy.grok.com/v1/billing принимает ТОТ ЖЕ
OAuth-токен, что и авторизация. Проверено запросом — 200 и данные.
_collect_grok_quota раньше строила пустые корзины и никуда не ходила,
поэтому Hub показывал «Н/Д» и объяснить ничего не мог. Теперь читает
предоплаченный баланс и лимит по мере использования, а при нулевом
балансе пишет причину прямым текстом, включая различие кошельков.
Процент считается только когда есть от чего считать: при нулевом лимите
доля не определена, и подставлять ноль процентов нельзя — показываются
абсолютные значения.
Тесты: 422 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец подключил не тот аккаунт Grok и не смог его убрать: «кнопка не
функционирует». Хуже: действие возвращало ok=True с сообщением об успехе,
а профиль оставался авторизованным.
Причина: сигнатура get_profile_dir — (profile_id, provider), а
do_delete_credentials звала её наоборот. Внутри функции есть костыль,
молча исправляющий перестановку, но только для трёх провайдеров:
antigravity, openai-codex, opencode-go. Для grok, claude и local путь
получался неверным (grok_profiles/grok вместо grok_profiles/grok-worker-1),
файл «не находился», и срабатывала ветка «учетные данные отсутствовали»
с ok=True.
То есть удаление работало у трёх провайдеров из шести, а у остальных
молча лгало.
Теперь используется get_profile_auth_path, который берёт аргументы в
правильном порядке. Отсутствие файла больше не считается успехом
удаления: возвращается честный отказ «удалять нечего».
Проверено на живом профиле: до — авторизован, после удаления — нет,
повторная попытка сообщает, что удалять нечего. Тест покрывает все
четыре провайдера, у которых костыль не срабатывал.
Тесты: 419 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец упёрся в заглушку «подключение через веб-интерфейс пока не
реализовано» и не смог подключить Grok. Backend был готов давно —
start_grok_oauth и start_codex_oauth возвращают настоящие адрес и код, —
но наружу не выведен: подключить эти провайдеры можно было только из
десктопа.
Добавлены действия start_device_auth и poll_device_auth. Адрес и код
выдаёт ПРОВАЙДЕР, интерфейс их только отображает — никаких подставленных
значений, как было с выдуманными GRK-7842 и CDX-9104.
Клиент показывает ссылку с кнопками «Открыть» и «Копировать», код
крупно и моноширинным, и опрашивает состояние каждые три секунды. Отказ
и просроченный код показываются как окончательные, опрос прекращается —
это работает вместе с правкой 50e4de5, научившей опрос различать
authorization_pending, access_denied и expired_token.
Проверено через веб-API: start отдаёт настоящий адрес accounts.x.ai и
код, poll возвращает pending, несуществующая сессия — честный отказ.
Контракт поднят до 1.4, действий стало двадцать одно.
Это снимает главное препятствие к удалению десктопа: он был
единственным путём подключить Grok и Codex.
Тесты: 414 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец: «не даёт зайти в грок через аутх». Backend при этом исправен —
проверено: провайдер возвращает настоящую сессию, адрес
accounts.x.ai/oauth2/device и код.
Дефект в цикле опроса: коды 400, 403 и 404 скопом считались
«авторизация ещё не подтверждена» и опрос молча продолжался. Но в
device-flow сервер сообщает РАЗНЫЕ вещи одним кодом 400, различая их
полем error в теле: authorization_pending, slow_down, access_denied,
expired_token.
Следствие: отказ пользователя и просроченный код выглядели как ожидание.
Мастер показывал «Ожидание подтверждения...» до самого таймаута и не
говорил, что подтверждение уже отклонено или код давно истёк.
Теперь каждый исход обрабатывается по существу: ожидание продолжает
опрос, slow_down увеличивает интервал, отказ и просрочка прекращают его
с внятным сообщением.
Тесты: 414 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Найдено при проверке Grok на живых данных владельца. Вызов падал с
AttributeError: 'str' object has no attribute 'get' — grok_adapter.py:103.
Поле error провайдеры отдают то объектом {"message": ...}, то строкой.
Код безусловно звал .get у результата, и на строковой форме ОБРАБОТЧИК
ОШИБОК ПАДАЛ САМ: сбой происходил ровно там, где обрабатывался другой
сбой, маршрутизация обрывалась вместо перехода к резерву, а настоящая
причина терялась.
После правки причина видна: Grok API Error (403): The OAuth2 access token
could not be validated. То есть у Grok просто протух токен, а выглядело
как поломка кода.
Та же конструкция стояла ещё в пяти адаптерах: claude, codex, opencode,
deepseek, local. Разбор вынесен в base_adapter.extract_api_error_message,
все шесть переведены на него.
Тест проверяет обе формы ответа и отдельно следит, чтобы копии хрупкой
конструкции не вернулись.
Тесты: 411 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Обе работы приняты, проверено исполнением.
A24: ровно семь разделов, renderTeam и renderProviders удалены, кнопки
«Изменить цепочку» нет, перетаскивание блоков есть. Перестановка цепочки
проверена вживую: сохраняется в router_profiles.yaml и откатывается.
A25: провайдер local подключён к настоящему серверу владельца через
SSH-туннель к 127.0.0.1:8081. health_check проходит, /v1/models отдаёт
модель, реальный вызов возвращает «ОК» за 3.1 с. Профили local-1 и
local-2 добавлены. Квота отдаётся отдельным состоянием
(source=local_provider, «Без ограничений»), а не как отсутствие данных.
Адрес сервера нигде не зашит.
Разрешение конфликта в action_handler: A25 внёс edit_route и assign_role
в список «просто навигация», где они возвращают заглушку. В A24 это
работающие обработчики — сохранение цепочки и назначение роли. Приняв
версию A25 целиком, мы бы молча сломали перестановку блоков. Оставлены
оба: локальный провайдер в add_account и рабочие обработчики ниже.
Исправлено при слиянии:
1. Адаптер отдавал пустой ответ как успех. У сервера владельца
--reasoning on --reasoning-budget 4096: при скромном max_tokens весь
бюджет уходит на рассуждения, llama.cpp возвращает 200, заполняет
reasoning_content и оставляет content пустым. Проверено на живой
модели: max_tokens=40 — ответа нет, 200 — приходит «ОК». Роутер
засчитал бы такой вызов, а пользователь не получил бы ничего.
Теперь это явный отказ с объяснением, и срабатывает переключение.
Проверка вынесена из блока перехвата: иначе оборачивалась в
«Transport Error», хотя транспорт отработал штатно.
2. Заглушка в тесте A25 возвращала "choices": [] — такого настоящий
сервер не отдаёт. Приведена к реальному виду.
Тесты: 404 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец: «грок не даёт добавить https://x.ai/device» — страница отдаёт
404. Причина хуже опечатки в адресе.
Веб-мастер для Grok и Codex не был подключён к серверу ВООБЩЕ. Он
показывал жёстко вписанный адрес x.ai/device (настоящий —
auth.x.ai/device, и тот приходит от провайдера полем verification_uri) и
ВЫДУМАННЫЕ коды устройства GRK-7842 и CDX-9104. Пользователь вводил бы
несуществующий код бесконечно.
Это тот самый класс дефекта, который вычищали из десктопа в первом
аудите, вернувшийся в новом коде.
Выдуманные значения убраны. Пока поток не проведён через веб-API, шаг
честно сообщает, что подключение через веб не реализовано, и указывает
рабочий путь — десктопное приложение, где поток проведён полностью.
Отдельно: тест test_headless_server_auth_matrix ТРЕБОВАЛ наличия
"https://x.ai/device" в коде, то есть закреплял дефект как требование.
Переписан на противоположное — запрещает выдуманные коды и зашитые
адреса провайдеров.
Тесты: 375 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Со скриншота владельца: заголовок «Ролей в строю: 0/6», а ниже шесть
предупреждений «роль работает через резервный аккаунт». Пять ролей
исправно отвечали, интерфейс сообщал, что не работает ни одна.
roles_ready считал только роли со здоровым ОСНОВНЫМ профилем. Роль,
обслуживаемая резервом, попадала в degraded_roles, но не в ready.
Ошибка в худшую сторону: отказ показывался там, где всё работает — а
переключение на резерв это ровно то, ради чего продукт и создан.
Теперь роль с живым резервом считается работающей и одновременно
помечается деградировавшей: оба состояния остаются различимыми.
Попутно склонение: «Есть 1 ролей без рабочего маршрута» заменено на
согласованное с числом — 1 роль, 2 роли, 5 ролей.
Проверено на живых данных: 6/6 в строю, состояние «деградация», а не
«критическое».
Тесты: 375 passed, ruff чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Три дефекта из установки владельца на вторую машину.
1. Веб-сервер падал сразу: «web server process terminated unexpectedly».
Установщик ставит в venv Hermes только customtkinter, pillow, pyyaml и
psutil — fastapi и uvicorn отсутствовали в списке вовсе. Добавлены во
все шесть мест: проверка, сообщение, pip, uv, перепроверка.
Поэтому на Windows открывался только десктоп: веб физически не мог
стартовать.
2. Лаунчер показывал голое «terminated unexpectedly» без причины — та же
болезнь, что у кода 12. Теперь перехватывает вывод процесса и выводит
последние строки ошибки в окне.
3. Окно тормозило при перетаскивании и продолжало двигаться несколько
секунд после отпускания мыши. _RouteDiagram перерисовывал всю канву на
КАЖДОЕ событие <Configure>, а при перетаскивании их сотни; очередь не
успевала разгребаться. Гашение в приложении существовало, но
_handle_debounced_resize был пустой заглушкой.
Перерисовка сведена к одной после затишья. Замерено: 199 событий
давали 199 перерисовок, теперь 4.
Тесты: 373 passed, ruff чисто. Установщик пересобран.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>