hermes-hub/docs/web-api/CONTRACT.md
Hermes Team 0be0a58a5d feat(web): авторизация по коду устройства для Grok и Codex прямо из веба
Владелец упёрся в заглушку «подключение через веб-интерфейс пока не
реализовано» и не смог подключить 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>
2026-08-24 15:20:05 +07:00

230 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Контракт веб-интерфейса Hermes Hub
Версия контракта: **1.4**
Дата: 2026-08-24
Этот документ — **единственный** источник истины для двух сторон: серверной (задание A15) и клиентской (A16). Обе стороны разрабатываются параллельно и до слияния друг друга не видят.
Правило, купленное дорого: в прошлом раунде служба обнаружения моделей была написана правильно, но названа `model_discovery_service.py`, тогда как потребитель импортировал `model_discovery`. Импорт был обёрнут в `except ImportError`, поэтому расхождение **не дало ошибки** — выбор моделей просто остался пустым навсегда. Здесь то же самое повторится в виде «страница грузится, но данных нет», если стороны разойдутся хоть в одном имени.
Поэтому: **любое отклонение от этого документа — дефект, даже если код работает.** Меняется контракт — сначала правится документ и согласуется с обеими сторонами.
---
## 1. Стек и его обоснование
**Сервер:** FastAPI + uvicorn. Обе зависимости уже объявлены в `pyproject.toml` в группе `legacy` — она осталась от удалённого `gui_server.py` и сейчас не используется ничем. Переименовать группу в `web` и подключить.
**Клиент:** обычный JavaScript и `fetch`, **без сборки, без npm, без фреймворка**.
Обоснование, а не вкусовщина. Проект ведут агенты на трёх разных машинах; любой шаг сборки означает согласование версий Node, установку зависимостей и дрейф lock-файлов между машинами. Интерфейс и так был единственным узким местом всех прошлых раундов. Страница, которую можно открыть файлом и отладить в браузере без инструментов, снимает целый класс проблем. React в этом проекте отклонён и раньше — по той же причине.
## 2. Границы и структура разделов
Веб-интерфейс **дополняет** десктоп, а не заменяет его немедленно. Десктопное приложение обязано продолжать работать до тех пор, пока веб не достигнет паритета. Ничего из `router/ui/**` не удаляется в рамках этих заданий.
Веб-клиент организован в **7 функциональных разделов** (в порядке меню навигации):
1. **`overview` (Обзор)** — Сводные счетчики слотов и подключений провайдеров, схема маршрутизации и выбор моделей ролей прямо на схеме.
2. **`accounts` (Аккаунты)** — Список подключенных профилей, статусы авторизации и оперативные квоты.
3. **`routing` (Маршрутизация)** — Главный центр управления: перетаскивание узлов Drag-and-Drop, выбор модели на узле, добавление (+ Добавить) и удаление (✕) профилей из цепочек.
4. **`analytics` (Аналитика)** — Телеметрия вызовов и задержек за 24ч (p50/p95/max), пояснение fast-fail и честное отображение неподключенных провайдеров.
5. **`health` (Состояние)** — Готовность системы, мониторинг системных ресурсов и рисков.
6. **`logs` (Журнал событий)** — Обратный хронологический журнал событий Hermes Hub.
7. **`settings` (Настройки)** — Параметры веб-сервера, токены, интервалы и системные пути.
Веб-слой живёт в пакете:
```
src/antigravity_provider/router/web/
__init__.py
server.py # FastAPI-приложение и запуск uvicorn
api.py # эндпоинты
security.py # привязка к адресу, токен
static/
index.html
app.js
style.css
```
## 3. Безопасность — обязательная часть, не опция
Веб-интерфейс будет работать на сервере, доступном по сети. Требования:
1. **По умолчанию слушать только `127.0.0.1`.** Другой адрес — только явным параметром запуска, и при этом **обязателен токен**.
2. Токен передаётся заголовком `X-Hub-Token`, сравнивается через `secrets.compare_digest`. При запуске без токена на не-локальном адресе сервер **отказывается стартовать** с внятным сообщением, а не поднимается открытым.
3. **Секреты не покидают сервер ни при каких условиях.** Проверено на живых данных: сериализованный снапшот не содержит ни `access_token`, ни `refresh_token`, ни `api_key`, ни JWT. Это состояние обязано сохраниться — на стороне сервера нужен тест, который падает при появлении в ответе любого из этих ключей.
4. Почты аккаунтов в снапшоте присутствуют открытым текстом (43 вхождения на машине владельца). Это персональные данные, не учётные. Маскирование — решение владельца; по умолчанию отдаём как есть, поскольку интерфейс без идентичности аккаунта бесполезен.
5. Никаких секретов в URL и параметрах запроса.
## 4. Эндпоинты
### `GET /api/snapshot`
Возвращает всё состояние интерфейса одним объектом.
Формат — **точный результат `dataclasses.asdict(HubSnapshot)`** с сериализацией `datetime` в ISO-8601. Проверено исполнением: снапшот сериализуется одним вызовом, объём около 100 КБ.
Эталонный пример лежит рядом: **`docs/web-api/snapshot.example.json`** — снят с живой машины владельца, почты замаскированы. Клиентская сторона разрабатывается против этого файла и до готовности сервера в нём не нуждается.
Ключи верхнего уровня:
```
generation, seq, timestamp, profiles_by_provider, all_profiles,
readiness, agents, providers, routing, quotas, metrics, is_stale
```
**Обновление схемы ProfileViewModel:**
В рамках задачи A17 добавлены новые поля и состояния для отслеживания честного статуса:
- `health_state` может принимать значение `not_tested`, если профиль никогда не проверялся.
- Добавлено поле `last_success_at` (время последней успешной проверки, `%H:%M:%S`).
Ответ: `200` и JSON. При ошибках сборки — `503` с телом `{"error": "<описание проблемы>"}`.
### `POST /api/action`
Единая точка для всех действий. Тело:
```json
{ "action": "<имя>", "data": { ... } }
```
Имена действий берутся **ровно** из общего слоя `action_handler.py`:
```
account_details add_account agent_settings assign_role
auto_assign_all check_updates delete_credentials edit_route
oauth open_routing refresh_account refresh_all
refresh_data refresh_models reorder_chain save_chain
save_settings set_main set_model set_orchestrator
test
```
Ответ:
```json
{ "ok": true, "message": "<текст для пользователя>", "data": { ... } }
{ "ok": false, "message": "<причина отказа по-русски>" }
```
**`ok: false` — это `200`, а не `4xx`.** Отказ действия — нормальный результат, а не ошибка протокола. `4xx` остаётся для неизвестного действия и непройденной авторизации.
- **`save_chain`** / **`reorder_chain`** / **`edit_route`** (добавлено/расширено в A24): сохранение упорядоченной цепочки профилей для роли маршрутизатора. `data: {"role_id": "<role_id>", "chain": ["<pid1>", "<pid2>", ...]}`. Сохраняет конфигурацию в `router_profiles.yaml` через `AutoAssigner.persist_role_chain`.
- **`assign_role`**: назначение профиля на роль. `data: {"profile_id": "<pid>", "role_id": "<role_id>", "is_primary": true|false}`. Выполняется через `AutoAssigner.assign_profile_to_role`.
- **`open_routing`** и **`account_details`** в вебе — навигация, состояние держит клиент; сервер на них отвечает `ok: true` без побочных эффектов.
- **`refresh_models`** (добавлено в A23): принудительное обновление списка моделей провайдера. `data: {"provider": "<id>"}`. Обнаружение ходит в сеть и подпроцесс — действие возвращается сразу, результат приходит следующим снапшотом.
**`start_device_auth` / `poll_device_auth`** (добавлено при разборе жалобы «не даёт зайти в грок»): авторизация по коду устройства для Grok и OpenAI Codex прямо из веба. Раньше поток был только в десктопе, а веб-мастер показывал заглушку.
`start_device_auth``data: {"provider": "grok"|"openai-codex"}`. Возвращает `{"session_id", "url", "code", "profile_id"}`. **Адрес и код выдаёт провайдер**; интерфейс их только отображает и не имеет права подставлять свои.
`poll_device_auth``data: {"provider", "session_id"}`. `ok: true` со `status: "pending"` означает ожидание подтверждения; `status: "completed"` — аккаунт подключён; `ok: false` — конечный отказ с причиной (пользователь отклонил, код истёк, сессия не найдена). Отказ и просрочку клиент обязан показывать как окончательные и прекращать опрос.
### `GET /api/health`
`{"ok": true, "version": "<версия Hub>", "auth_flows": {...}}`. Без авторизации — нужен для проверки, что сервер поднялся.
Поле `auth_flows` содержит информацию о доступности потоков авторизации на сервере. Формат:
```json
{
"auth_flows": {
"openai-codex": {"supported": true, "reason": "device-code"},
"grok": {"supported": true, "reason": "device-code"},
"antigravity": {"supported": false, "reason": "Требует redirect на localhost; используйте десктоп или проброс портов"},
"claude": {"supported": false, "reason": "Требует redirect на localhost; используйте десктоп или проброс портов"}
}
}
```
Веб-клиент **обязан** проверять это поле и не показывать неработающую кнопку, а предлагать обходной путь.
### `GET /api/events`
Возвращает список недавних событий системы из `EventLogService` в обратном хронологическом порядке.
Параметры запроса (Query Params):
- `limit` (int, необязательно, по умолчанию `50`, максимум `200`): количество событий;
- `category` (string, необязательно): фильтрация по категории (`account`, `quota`, `routing`, `auth`, `system`).
Ответ:
```json
{
"events": [
{
"timestamp": "22:50:14",
"category": "system",
"message": "Успешная проверка подключения ag-w2 (gemini-3.1-pro-high) за 1.4s.",
"details": "...",
"level": "success"
}
]
}
```
**Секреты в события не попадают.** На эндпоинт распространяется санитайзер `sanitize_snapshot`.
---
### `GET /api/settings`
Возвращает текущие настройки из `hub_settings.json` и пути системы.
**Секреты не отдаются:** поле `web_api_token` скрыто; наружу отдаётся только флаг `web_api_token_configured` (`true`/`false`).
Ответ:
```json
{
"web_api_host": "127.0.0.1",
"web_api_port": 5800,
"web_api_token_configured": false,
"theme": "system",
"quota_refresh_interval_sec": 300,
"hermes_home": "C:\\Users\\...\\.hermes",
"config_dir": "C:\\Users\\...\\.hermes\\config",
"log_file": "C:\\Users\\...\\.hermes\\logs\\hermes-hub.log"
}
```
Сохранение настроек выполняется через стандартное действие `POST /api/action` с `action: "save_settings"`.
---
### `GET /` и статика
**Сервер обязан отдавать сам интерфейс, а не только API.** Корневой маршрут возвращает `static/index.html`, каталог `static/` монтируется целиком.
Пункт добавлен постфактум: в версии 1.0 контракт описал каталог `static/` в структуре пакета, но не назвал, **кто его отдаёт**. Обе стороны выполнили написанное — API отвечал, файлы клиента лежали в репозитории — и всё равно не собрались: в браузере был `404`. Ответственность за пропуск на авторе контракта, а не на исполнителях.
Урок общего свойства: в контракте между двумя сторонами недостаточно описать, что каждая делает. Нужно назвать, **где именно они стыкуются** и кто отвечает за стык.
## 5. Обновление данных
Первая версия — **опрос**: клиент запрашивает `/api/snapshot` раз в 5 секунд и при действиях пользователя.
Поле `seq` в снапшоте монотонно растёт. Клиент обязан **игнорировать ответ с `seq` меньше уже применённого** — иначе поздний ответ затрёт свежий. Этот дефект в десктопе уже ловили, повторять не нужно.
Server-Sent Events — следующий шаг, в эти задания не входит. Закладывать под них структуру не нужно; опрос заменяется без переделки клиента.
## 6. Честность данных — правило распространяется на веб целиком
Прежнее и главное правило проекта действует без исключений:
- **ни одного числа, идентификатора или названия модели без измерения;**
- нет данных — «Н/Д» **и причина рядом**, доступная пользователю. Причина уже приходит в снапшоте полем `unavailable_reason`;
- **отличать «данных нет» от «данные ещё грузятся».** Сейчас это не различается, и владелец видел пустые карточки без объяснения — квота появляется только после фонового опроса. В вебе состояние загрузки обязано выглядеть как загрузка. Для этого в объект квоты (в `quotas` и связанных местах) добавлено поле `is_loading: bool`. Если `is_loading == true`, значит идёт опрос; если `false` и нет квот — значит данных действительно нет.
## 7. Что известно про Linux заранее
Проверено по коду, чтобы никто не выяснял это заново:
- `paths.py` уже кроссплатформенный: при отсутствии `LOCALAPPDATA` уходит в `~/.hermes`;
- поиск CLI провайдеров уже готов: `shutil.which("agy") or shutil.which("agy.exe")`;
- **восемь мест дублируют логику `LOCALAPPDATA` в обход `paths.py`** и на Linux дадут неверные пути: `hermes_hub_app`, `router_config` (две штуки), `launcher_bootstrap`, `model_discovery_service`, `ui/assets`, `agy_subprocess`.
**Авторизация на headless-сервере** — ограничение, которое надо признать честно, а не обойти молча:
| Провайдер | Поток | На сервере без экрана |
|---|---|---|
| OpenAI Codex | device-code | **работает** — код вводится с любой машины |
| Grok | device-code | **работает** |
| Antigravity | redirect на localhost | **не работает** — редирект придёт на машину пользователя, а не сервера |
| Claude | redirect на localhost | **не работает** |
Для двух последних интерфейс обязан сказать об этом прямо и предложить обходной путь (проброс порта по SSH либо авторизация на десктопе с переносом профиля), а не показывать неработающую кнопку.