# 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 + доработка)`, `## Вердикт оркестратора`. Отдельно — таблица «правило → решение → обоснование» по четырём расхождениям.
