15 KiB
Task 07: Протокол сопряжения и hermes-pair (hermes-android)
Repo: ochenstarik-ui/hermes-android
Assigned to: Antigravity (режим оркестратора, два кодера)
Priority: HIGH (две реализации одного протокола расходятся в четырёх правилах)
Date: 2026-08-24
Base SHA: результат задания 06 — указать фактический SHA при выдаче
Зависимость: задание 06 принято — отпечаток сертификата, закрепляемый при сопряжении, уже существует.
Роли и протокол
| Роль | Модель | Что делает |
|---|---|---|
| Оркестратор | Antigravity | Разбивает работу, маршрутизирует, принимает результат |
| Кодер 1 | Gemini Flash 3.7 high | Пункты 2–6 §Scope (реализация по готовой спецификации) |
| Кодер 2 | Gemini Pro high | Пункт 1 (спецификация), затем проверка и доводка работы кодера 1 |
Порядок здесь обратный обычному: спецификация пишется первой и её пишет кодер 2. Кодер 1 не приступает, пока §Scope 1 не зафиксирован письменно и не принят оркестратором — иначе два исполнителя разойдутся так же, как разошлись две существующие реализации.
Раунд 0 (кодер 2). Спецификация протокола v1 и набор тест-векторов. Результат — файл в репозитории, а не текст в чате. Раунд 1 (кодер 1). Тесты из §Required tests на векторах спецификации, фиксация падения на base SHA, затем код по пунктам 2–6. Раунд 2 (кодер 2). Независимое воспроизведение падения, проход §Anti-checklist с явными отметками, доводка, findings по шкале. Раунд 3 (оркестратор). Приёмка при совпадении поведения обеих реализаций на всех векторах — прогоны с обеих сторон приложены.
Проблема
1. Два парсера расходятся в правилах (PAIR-02). На один протокол четыре расхождения:
| Правило | Rust (pairing.rs) |
Kotlin (HermesPairingParser.kt) |
|---|---|---|
Версия UUID host_id |
строго v4 (:149) |
любая версия (:53-60) |
Base64 для data и nonce |
URL-safe с паддингом и без, standard (:215-219,335-339) |
только URL-safe без паддинга |
Извлечение data |
url.query_pairs() |
split("&").map { split("=") }[1] (:24-25) |
| Форма URI | hermes://pair и hermes:/pair (:305) |
только host == "pair" |
Kotlin-разбор data обрезает значение по первому =, то есть ломается на любом паддинге, и не выполняет URL-декодирование. Пустой сегмент в query (&&) даёт IndexOutOfBounds, замаскированный общим catch в «Unknown error». Любой генератор, кроме этой конкретной сборки Rust, будет отвергнут либо разобран неверно.
2. QR перегенерируется раз в секунду (PAIR-01). cli.rs:229-259 — цикл на каждой итерации вызывает create_pairing_payload: новый nonce, новый expires_at. Картинка меняется под камерой, а счётчик «Expires in» тикает по собственному таймеру и сбрасывается независимо от реального TTL.
3. Nonce не используется (SEC-09). HermesPairingParser.kt:87-97 проверяет форму nonce и забывает о нём: хосту он не отправляется, доказательства владения нет. QR, снятый со спины в пределах TTL, равноценен оригиналу. host_id из payload молча перепривязывает существующую запись хоста — спасает только предупреждение о смене endpoint.
4. Ручной ввод адреса не валидируется (SEC-10). HostsViewModel.kt:59-72 кладёт строку в БД как есть; значение без схемы в convertHttpToWsUrl (HermesHostRuntime.kt:265) превращается в ws://.
5. Ресурсы на каждый опрос (PAIR-03). app.rs:145-172 поднимает std::thread и внутри собирает свежий однопоточный Tokio-runtime, при том что приложение уже под #[tokio::main]; HermesProbeClient::new() создаётся заново на каждый опрос.
6. Только IPv4 (PAIR-04). network.rs:73 обрабатывает лишь IfAddr::V4.
7. Мелочи (PAIR-05, PAIR-06, PAIR-07). Тихая зона QR 2 модуля в терминале и 3 в egui при требуемых спецификацией 4 (qr.rs:11,69) — распознавание с экрана хуже, особенно на тёмном фоне. Таймаут опроса жёстко 2 с без ретраев (hermes.rs:54): на загруженной машине хост ошибочно показывается как Offline. Конфиг пишется без ограничения прав, display_name нельзя задать из CLI (config.rs:78, cli.rs:21-47).
Scope
1. Спецификация v1 (кодер 2, до всего остального).
Файл docs/pairing-protocol-v1.md: поля payload и их типы, обязательность, допустимые кодировки, форма URI, правила валидации, границы TTL и clock skew, поведение при каждом нарушении.
По каждому из четырёх расхождений — решение и обоснование, а не «как в Rust». В частности: требовать ли UUIDv4 (и что делать с уже сгенерированными host_id), какой набор Base64 канонический.
Приложить docs/pairing-vectors.json — общие тест-векторы: валидные payload и по одному невалидному на каждое правило. Векторы используются обеими сторонами.
2. Приведение обеих реализаций к спецификации (PAIR-02).
Kotlin: разбор query через Uri/URLDecoder, а не split; поддержка канонического набора Base64; форма URI по спецификации; отсутствие IndexOutOfBounds на любом входе.
Rust: убрать варианты, которые спецификация не разрешает.
Общий catch, превращающий любую ошибку в «Unknown error», заменить на различимые причины — иначе следующее расхождение снова окажется невидимым.
3. Стабильный QR (PAIR-01).
Payload генерируется один раз и перерисовывается; пересоздание — только по истечении TTL. Счётчик считает по expires_at самого payload, а не по отдельному таймеру. Проверить оба режима: --terminal и GUI.
4. Nonce как доказательство владения (SEC-09).
Nonce отправляется хосту при первом обращении; повторное использование отвергается. Если контракт хоста этого сейчас не поддерживает — не выдумывать метод: зафиксировать в OPEN QUESTIONS требование к стороне хоста и реализовать то, что возможно на клиенте (одноразовость в пределах устройства: использованные nonce запоминаются до истечения TTL).
Перепривязка существующего host_id к новому endpoint остаётся возможной только через явное подтверждение — проверить, что диалог показывается во всех ветках, включая совпадающий endpoint с другим отпечатком (задание 06).
5. Валидация ручного ввода (SEC-10).
Нормализация адреса при сохранении, схема по умолчанию https://, отклонение некорректного URL в форме, предупреждение о дубликате по endpoint.
6. Rust-часть (PAIR-03, PAIR-04, PAIR-05, PAIR-06, PAIR-07).
Один reqwest::Client и Handle существующего runtime вместо потока с новым runtime на каждый опрос.
Поддержка IPv6 с корректным экранированием в URL ([::1]:9119) и приоритезацией наравне с IPv4.
Тихая зона 4 модуля в обоих рендерерах, константа в одном месте.
Таймаут опроса настраиваемый, один быстрый повтор перед вердиктом Offline.
Права 0600 на конфиг под Unix; флаги --display-name и --reset-host-id.
Do not change
- Транспорт, БД, UI Android сверх пунктов 4–5.
- Формат токенов и авторизацию — задание 06.
- Схему БД — миграция для хранения использованных nonce версионируется по правилам задания 02.
- Публикацию бинарников — задание 05.
Anti-checklist
- Спецификация написана после кода и описывает получившееся поведение. Требование: файл спецификации закоммичен раньше кода — проверить по истории.
- Векторы покрывают только валидные случаи; на каждое правило нужен и невалидный.
- Kotlin приведён к спецификации, Rust не тронут — расхождение осталось, просто сместилось. Прогон векторов обязателен с обеих сторон.
IndexOutOfBoundsна&&формально не воспроизводится, потому что общийcatchостался. Проверять надо различимую причину ошибки, а не отсутствие падения.- QR стабилизирован в
--terminal, а GUI по-прежнему перегенерирует. Проверить оба режима. - Счётчик «Expires in» считает по локальному таймеру, синхронизированному вручную с TTL, — при смене payload разъедется снова. Требование: источник истины —
expires_at. - Nonce «используется»: сохраняется локально, но проверка одноразовости не переживает перезапуск приложения.
- Нормализация адреса ломает уже сохранённые хосты в БД — миграция не предусмотрена.
- IPv6 добавлен в обнаружение интерфейсов, но URL собирается без квадратных скобок.
- В отчёте
greenдля незапущенной команды (AGENTS.md §3).
Definition of Done
- Все векторы из
pairing-vectors.jsonдают одинаковый вердикт в Kotlin и в Rust — приложены оба прогона. - Ни один вход не приводит к необработанному исключению; причина отказа различима.
- QR в обоих режимах стабилен в пределах TTL; счётчик соответствует
expires_at. - Повторное использование того же QR отвергается (в пределах реализованного уровня, с явным указанием, что осталось за стороной хоста).
- Ручной ввод без схемы не даёт незашифрованного соединения по умолчанию.
hermes-pair: один клиент и один runtime на процесс; IPv6 работает; тихая зона 4; конфиг с правами 0600; флаги CLI на месте.- Все существующие тесты зелёные, ни один не удалён и не ослаблен.
Required tests
Kotlin core/pairing/PairingVectorsTest.kt — прогон всех векторов из общего файла; каждый невалидный даёт свою причину. Обязан падать на base SHA (минимум на паддинге и на &&).
Kotlin feature/hosts/HostUrlNormalizationTest.kt — нормализация и отклонение некорректного ввода.
Rust tests/vectors.rs — тот же файл векторов, тот же вердикт.
Rust tests/qr_stability.rs — payload не меняется в пределах TTL.
Rust — тесты на IPv6-обнаружение и на сборку URL со скобками.
Required verification
./gradlew --no-daemon testDebugUnitTest
./gradlew --no-daemon lint
./gradlew --no-daemon assembleDebug
cd hermes-pair && cargo test --all-targets && cargo clippy -- -D warnings
cd hermes-pair && cargo run -- qr --port 9119 # визуальная проверка стабильности QR
Result
agents/antigravity/done/TASK-2026-08-24-07-pairing-protocol.md — разделы ## Кодер 2: спецификация, ## Кодер 1, ## Кодер 2 (review + доработка), ## Вердикт оркестратора. Отдельно — таблица «правило → решение → обоснование» по четырём расхождениям.