# 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 1. Спецификация написана после кода и описывает получившееся поведение. Требование: файл спецификации закоммичен раньше кода — проверить по истории. 2. Векторы покрывают только валидные случаи; на каждое правило нужен и невалидный. 3. Kotlin приведён к спецификации, Rust не тронут — расхождение осталось, просто сместилось. Прогон векторов обязателен с обеих сторон. 4. `IndexOutOfBounds` на `&&` формально не воспроизводится, потому что общий `catch` остался. Проверять надо различимую причину ошибки, а не отсутствие падения. 5. QR стабилизирован в `--terminal`, а GUI по-прежнему перегенерирует. Проверить оба режима. 6. Счётчик «Expires in» считает по локальному таймеру, синхронизированному вручную с TTL, — при смене payload разъедется снова. Требование: источник истины — `expires_at`. 7. Nonce «используется»: сохраняется локально, но проверка одноразовости не переживает перезапуск приложения. 8. Нормализация адреса ломает уже сохранённые хосты в БД — миграция не предусмотрена. 9. IPv6 добавлен в обнаружение интерфейсов, но URL собирается без квадратных скобок. 10. В отчёте `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 ```text ./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 + доработка)`, `## Вердикт оркестратора`. Отдельно — таблица «правило → решение → обоснование» по четырём расхождениям.