hermes-android/agy-work/TASK-2026-08-24-07-pairing-protocol.md

15 KiB
Raw Blame History

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 Пункты 26 §Scope (реализация по готовой спецификации)
Кодер 2 Gemini Pro high Пункт 1 (спецификация), затем проверка и доводка работы кодера 1

Порядок здесь обратный обычному: спецификация пишется первой и её пишет кодер 2. Кодер 1 не приступает, пока §Scope 1 не зафиксирован письменно и не принят оркестратором — иначе два исполнителя разойдутся так же, как разошлись две существующие реализации.

Раунд 0 (кодер 2). Спецификация протокола v1 и набор тест-векторов. Результат — файл в репозитории, а не текст в чате. Раунд 1 (кодер 1). Тесты из §Required tests на векторах спецификации, фиксация падения на base SHA, затем код по пунктам 26. Раунд 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 сверх пунктов 45.
  • Формат токенов и авторизацию — задание 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

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