ADR 0118 — Настройка runner через браузер: ключ устройства Ed25519 как вторая учётная запись runner-ticket, прокси GitHub Releases для бинарника и File System Access API для папки, настраиваемой одним кликом
- Статус: Принят
- Дата: 2026-08-27
- Контекст: устраняет трение при настройке
brabo-runnerна машине пользователя — до сих пор требовалось вручную собрать три вещи, разбросанные по разным экранам: id проекта, путь к папке и Personal Access Token, выпущенный на третьем экране - Ссылки (без редактирования): ADR 0103 (сам runner и его граница безопасности), ADR 0104 (
execution_mode: runner, пункт backlog о PAT, который дополняет ключ устройства из этого ADR), ADR 0105 (PAT, к которому это решение ДОБАВЛЯЕТСЯ, никогда не заменяя его), ADR 0106 и ADR 0112 (автономные бинарники, которые отдаёт прокси этого ADR), ADR 0107 (навигация по папке ЧЕРЕЗ уже подключённый runner — этот ADR занимается подключением runner в первую очередь)
Контекст
execution_mode: runner (ADR 0104) позволяет коду проекта жить
в папке на собственной машине пользователя, без bind-mount и без контейнера, —
но чтобы прийти в это состояние, человеку нужно было вручную: найти id
проекта, сгенерировать Personal Access Token в Configurações do Projeto →
Tokens de Acesso (ADR 0105), скопировать команду
brabo-runner --project <id> --dir <path> --token <token>, которую кто-то
правильно набрал, самостоятельно скачать нужный бинарник для своей
ОС/архитектуры и запустить его из нужной папки. Три сведения, три экрана и
секрет, который пользователь должен был набрать или вставить в терминал.
NewProjectWizard.tsx и RunnerOnboardingPanel.tsx со временем каждый
выработали СОБСТВЕННЫЙ текст команды для этого, и они
разошлись — копия мастера никогда не включала --token, а копия панели
использовала синтаксис pnpm --filter runner start вместо бинарника. Ни одна не была
неверной на момент написания; ничто не обязывало две поверхности совпадать, и
они молча перестали совпадать.
Владелец продукта также явно спросил, может ли runner САМ ОБНАРУЖИТЬ локальный контейнер Docker/Kubernetes и подключиться по SSH, вместо того чтобы требовать от человека какой-либо настройки. Этот путь был рассмотрен и отклонён в этой же сессии — см. «Рассмотренные и отклонённые альтернативы» ниже.
Решение
1. Ключ устройства Ed25519, сгенерированный в браузере, — вторая учётная запись runner-ticket: добавляется к PAT, никогда не заменяет его
Новая таблица runner_device_keys (apps/api/src/db/schema.ts:2058-2081,
миграция 0054_demonic_shiver_man.sql): id, userId (FK users,
cascade), projectId (FK projects, cascade), name, publicKeyJwk,
createdAt, revokedAt, revokedReason, lastUsedAt — ПУБЛИЧНАЯ половина
пары ключей Ed25519, которую браузер генерирует через crypto.subtle.generateKey
(apps/web/src/lib/runner-bootstrap.ts:123-131). Приватная половина никогда не покидает
браузер по сети; только экспортированная публичная JWK отправляется POST-запросом в
RegisterRunnerDeviceKeyUseCase
(apps/api/src/application/use-cases/auth/register-runner-device-key.use-case.ts)
через POST /projects/:projectId/runner-device-keys
(apps/api/src/interfaces/http/runner/runner-device-keys.controller.ts).
Этот маршрут аутентифицируется ОБЫЧНЫМ JWT сессии, @RequireRole('developer')
— здесь вызывает действительно вошедший браузер, регистрирующий устройство, которое
он собирается настроить, — зеркальное отражение
PersonalAccessTokensController, выпускающего PAT.
RevokeRunnerDeviceKeyUseCase удаляет (помечает отозванным) так же, как
RevokePersonalAccessTokenUseCase — идемпотентно, null для «не найден
или не ваш», та же дисциплина нераскрытия, что и в RN-426.
PatAuthGuard — тот же guard, который POST .../runner-ticket уже использовал для
PAT (ADR 0105, RN-439), — получил вторую ветвь
(apps/api/src/interfaces/http/auth/pat-auth.guard.ts:104-116): bearer
token, начинающийся с brb_, идёт по пути PAT без изменений; bearer
token в форме компактного JWT (три сегмента, разделённых точкой) идёт по
новому пути, autenticarChaveDeDispositivo (строки 144-216). Он читает
непроверенный kid из заголовка JWT (decodeProtectedHeader — пока без проверки
подписи), ищет АКТИВНУЮ строку runner_device_keys по этому
kid, проверяет подпись с помощью importJWK/jwtVerify из jose по
СОХРАНЁННОМУ публичному ключу и навязывает короткий обязательный TTL: exp - iat должен
быть не более 60 секунд (TTL_MAXIMO_DEVICE_KEY_SEGUNDOS, строка 23) —
сверяется с временем жизни, которое объявляет САМ токен, а не с «сейчас», так
что перехваченный токен нельзя с пользой предъявить повторно, когда бы
то ни было. userId, используемый при авторизации, берётся из СОХРАНЁННОЙ записи, никогда
из claim JWT — токену нужно лишь доказать владение приватным ключом, а не
утверждать, чей он. projectId проверяется дважды: claim
самого JWT против параметра маршрута и, отдельно, projectId
зарегистрированного ключа против маршрута (повторяя две идентичные проверки
пути PAT) — поддельный claim в ключе, зарегистрированном для другого проекта,
отклоняется с 403, следуя различию категорий пути PAT между
«не аутентифицирован» и «аутентифицирован для чужого проекта».
Сам runner подписывает этот JWT в apps/runner/src/auth.ts
(assinarTicketComChaveDeDispositivo, строки 143-154): EdDSA, срок действия
30 секунд, kid, равный id регистрации ключа устройства, с использованием приватной
JWK, которая уже находится В ПАМЯТИ, когда функция выполняется, — она никогда не трогает
node:fs, сохраняя существующую гарантию «никакого файлового I/O в
auth.ts» (набор тестов «никакого файлового I/O» в auth.spec.ts). Чтение ключа
с диска — НОВЫЙ и ОТДЕЛЬНЫЙ модуль, apps/runner/src/device-key.ts,
намеренно (см. Решение 4).
Это явно НЕ «двойная аутентификация с JWT сессии», которую
RN-439 уже запрещает на этом маршруте. Docblock самого guard
(pat-auth.guard.ts:41-53) утверждает различие: JWT сессии входа
по-прежнему никогда здесь не принимается — его принятие позволило бы RolesGuard
авторизовать вошедший браузер на всё, что разрешает его роль во всей
остальной api, а это ровно та утечка области, ради предотвращения которой RN-439/ADR 0105
построил @RequirePatAuth(). JWT ключа устройства — другой
зверь: токен, который runner подписывает САМ ключом, приватную
половину которого api никогда не видела, утверждающий лишь «я зарегистрированное устройство этого
пользователя в этом проекте», — так же ограниченный по области, как PAT, рядом с которым
он стоит, и никогда не замаскированная учётная запись сессии.
2. Api проксирует бинарник, уже опубликованный в GitHub Releases, — она ничего не собирает и не хранит второй копии
RunnerReleasesController
(apps/api/src/interfaces/http/runner/runner-releases.controller.ts)
добавляет GET /runner-releases/binary?platform=<target>, @Public() — без
аутентификации, по замыслу: сам бинарник не несёт секрета, а требовать
входа, чтобы скачать то, чем вы бы настроили свою ПЕРВУЮ
учётную запись, значило бы идти задом наперёд. platform сверяется с закрытым
списком разрешённых (linux-x64, linux-arm64, darwin-x64,
darwin-arm64, win32-x64 — те же пять целей, которые ADR 0112 уже
собирает) до того, как что-либо трогать, — никогда не интерполируется сырым в URL
GitHub или в имя asset, закрывая вектор SSRF/инъекции, который открыл бы
свободный параметр. Контроллер разрешает список assets релиза
latest через REST API GitHub, кэширует это разрешение (только URL,
никогда байты) в Map в памяти на пять минут (CACHE_TTL_MS), чтобы
оставаться значительно ниже лимита GitHub в 60 запросов/час без аутентификации
даже при всплеске одновременных загрузок, а затем передаёт байты
asset напрямую (Readable.fromWeb(...).pipe(res)). Никакой новой зависимости —
fetch — собственный у Node. Альтернатива, при которой браузер обращается напрямую к
GitHub, отклонена: точное имя asset — деталь реализации, которую
api уже вычисляет (nomeDoAsset), а прямой вызов браузер→GitHub
отказался бы от кэша, который этот прокси получает даром.
3. File System Access API напрямую записывает настроенную папку; загрузка двух файлов — запасной план во всех остальных случаях
apps/web/src/lib/runner-bootstrap.ts — новый модуль, который вызывают обе
точки входа ниже. detectarPlataforma выбирает нужный бинарник для
ОС/архитектуры пользователя — в Chromium через
navigator.userAgentData.getHighEntropyValues (классическая строка
userAgent намеренно «урезана» и ненадёжна для архитектуры
в современном Chrome); вне Chromium — регулярное выражение по принципу наилучшего усилия над
userAgent/platform, с null (никогда догадка, выданная за факт),
когда уверенность слишком низкая, и тогда интерфейс позволяет пользователю выбрать
вручную.
configurarPastaAutomaticamente (строки 199-229) — путь в один клик,
доступный только там, где существует showDirectoryPicker (Chrome/Edge/Opera): он генерирует
пару Ed25519, регистрирует публичный ключ (registerRunnerDeviceKey), скачивает
бинарник по маршруту из Решения 2, открывает нативный выбор папки браузера и
записывает ТРИ файла прямо в выбранную папку — исполняемый файл,
brabo-runner.config.json ({ projectId, apiUrl }) и
brabo-runner-device-key.jwk.json (ПРИВАТНАЯ JWK, записываемая на диск только здесь,
никогда не отправляемая по сети). baixarKitManual (строки 250-268) — запасной план,
используемый везде, где File System Access API не существует (Firefox,
Safari) или пользователь предпочитает не позволять сайту трогать свою файловую
систему: те же три артефакта, но как две обычные загрузки
браузера (бинарник и brabo-runner-kit.json, объединяющий конфигурацию и
приватный ключ), которые пользователь вручную перемещает в папку, — без библиотеки
zip, только два файла.
RunnerOnboardingPanel.tsx становится ЕДИНСТВЕННЫМ компонентом, рисующим этот
поток, и используется тремя поверхностями, которые раньше расходились: шагом
workspace в NewProjectWizard (режим runner), состоянием «нет подключённого
runner» в FolderBrowserModal и аналогичным состоянием
TerminalPanel. Исправление реального расхождения, найденного в этом исследовании, —
собственной неотслеживаемой копии ручной команды в мастере, без
--token, и копии панели, использовавшей pnpm --filter runner start вместо
бинарника, — вошло в это же изменение: теперь есть единственная строка ручной
команды, всегда с --token, по умолчанию свёрнутая за <details>
«prefere rodar à mão».
Принятое ограничение, а не дефект: File System Access API не сохраняет
бит исполнения Unix. После автоматической записи пользователь
Linux/macOS всё ещё должен сам выполнить chmod +x ./brabo-runner — это показано
уже заполненным, готовым к копированию, в состоянии успеха (instrucaoFinal,
runner-bootstrap.ts:224-226). Это ограничение браузерной платформы,
без обходного пути, доступного веб-странице; это не то, что будущая версия
этой функциональности должна убрать.
4. --project/--dir/--token становятся необязательными в CLI при наличии локальной конфигурации
apps/runner/src/device-key.ts — новый модуль, намеренно отделённый
от auth.ts (его собственный docblock объясняет почему: у auth.ts есть
выделенный тест «никакого файлового I/O», который не даёт вернуть глобальный кэш
учётных данных ~/.brabo/..., намеренно удалённый Волной 2 ADR 0104, —
этот новый модуль читает ЛОКАЛЬНЫЙ и ЯВНЫЙ файл, который пользователь только что положил
туда с помощью браузерного потока, никогда не глобальный путь).
lerConfigLocal/lerChaveDeDispositivo читают brabo-runner.config.json и
brabo-runner-device-key.jwk.json из ТЕКУЩЕГО рабочего каталога, возвращая
null — никогда не бросая — для отсутствующего файла, невалидного JSON или отсутствующего
обязательного поля, потому что отсутствие этих файлов — НОРМАЛЬНЫЙ случай для
тех, кто по-прежнему использует явные флаги.
lerArgumentos в apps/runner/src/index.ts (строки 95-206) теперь разрешает
каждый из трёх входов по правилу «явный флаг побеждает локальный файл» как
единственному правилу, повторённому трижды: --project побеждает configLocal.projectId;
--dir побеждает . (который разрешается в текущий рабочий каталог, то же
навязанное сегодня значение по умолчанию), когда локальная конфигурация существует;
--token/BRABO_ACCOUNT_TOKEN побеждает локальный ключ устройства. Без
токена И без локального ключа устройства CLI по-прежнему завершается с инструкциями
по использованию (uso()) ровно как раньше — ничто здесь не делает аутентификацию
необязательной, меняется лишь то, какая из двух форм используется. Итоговый эффект: ./brabo-runner
без каких-либо флагов работает, когда текущая папка — это папка, которую браузер
настроил по Решению 3, а каждый существующий вызов с явными флагами
остаётся полностью неизменным.
Последствия
У ключей устройства ПОКА НЕТ того взгляда maintainer на других
пользователей, который есть у PAT. Сам docblock
RunnerDeviceKeysController это объявляет: в отличие от
PersonalAccessTokensController (RN-427 — maintainer может перечислять/отзывать
PAT ЛЮБОГО пользователя проекта для реагирования на инциденты), ключ
устройства сегодня может быть перечислен/отозван только пользователем, который его
зарегистрировал. Это объявленный срез этого раунда, а не забывчивость — распространение
той же возможности реагирования на инциденты на ключи устройств — прямая
будущая работа, если её попросят, ровно по шаблону, который
RN-427 уже установила для PAT.
JWT ключа устройства никогда не несёт идентичность пользователя — её несёт только
запись, хранящаяся в api. Это намеренно (Решение 1), но
означает, что ключ устройства никогда не может использоваться, чтобы выдать себя
за заявленного пользователя; он действует только как пользователь, которому принадлежит ИСХОДНАЯ
запись. Отзыв записи — единственный способ сделать его недействительным — не
существует отдельного чёрного списка «этот конкретный JWT плох», и он не нужен
при TTL подписи 30 секунд плюс максимуме 60
секунд exp - iat, независимо навязываемом guard.
Прокси бинарника доверяет GitHub Releases как единственному каналу
распространения. Если у релиза «latest» нет asset для запрошенной
платформы (частичный релиз в середине публикации), маршрут возвращает 502,
вместо того чтобы угадывать более старый тег, — в соответствии с объявленным
самим ADR 0112 пробелом: до сих пор реальным выполнением проверен только linux-x64.
Кэш разрешения assets GitHub — на экземпляр, в памяти, без крючка инвалидации. Новый релиз, опубликованный посреди окна, остаётся невидимым для работающего экземпляра api до пяти минут. Учитывая, как редко runner публикует новые бинарники (привязанный к той же частоте релизов, что и всё остальное), это сочли приемлемым обменом, чтобы с запасом оставаться ниже лимита GitHub без аутентификации, — настоящая инвалидация через webhook здесь не строится.
Рассмотренные и отклонённые альтернативы
- Runner сам обнаруживает локальный контейнер Docker/Kubernetes и
подключается к нему по SSH, без какого-либо шага выбора папки.
Рассмотрена и явно ОТКЛОНЕНА владельцем продукта в этой же
сессии — не технический тупик, а намеренный срез объёма.
execution_mode: runnerсуществует именно потому, что Фаза 25b (ни один сервис продукта не вызывает Docker — см. «Cortes e pausas vigentes» в CLAUDE.md) по-прежнему отрезана: научить runner зондировать и подключаться к локальным контейнерам значило бы заново открыть именно эту границу через боковую дверь, а путь подключения по SSH — материально иная модель безопасности, чем граница «аутентификация плюс конвейер одобрения», которую ADR 0103 уже установил. Остаётся кандидатом в собственный ADR, если и когда сама Фаза 25b будет пересмотрена, — а не тем, во что эта функциональность въедет задним ходом, молча. - Переиспользовать JWT сессии как вторую учётную запись
runner-ticketвместо создания нового механизма ключа устройства. Отклонено в терминах самой RN-439: guard этого маршрута существует именно для того, чтобы JWT браузерной сессии НИКОГДА там не был достаточен, потому чтоRolesGuard/@RequireRoleтогда авторизовал бы эту сессию на всю широту роли пользователя во всей остальной api — противоположность узкой, отзываемой, ограниченной устройством учётной записи, которая нужна этой функциональности. - Упаковать запасной план ручной загрузки в единый zip-файл. Отклонено: никакая новая зависимость не оправдывала удобства — два простых файла (бинарник и маленький JSON-kit) ничего не стоят дополнительно ни в производстве, ни для распаковки пользователем.
- Api собирает и хранит собственную копию бинарника runner вместо проксирования GitHub Releases. Отклонено: ADR 0106/0112 уже публикуют ровно эти пять бинарников через конвейер релизов; поддерживать второй путь сборки и хранения того же артефакта было бы чистым дублированием без пользы — тонкого прокси с коротким кэшем достаточно.