Перейти к основному содержимому

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 уже публикуют ровно эти пять бинарников через конвейер релизов; поддерживать второй путь сборки и хранения того же артефакта было бы чистым дублированием без пользы — тонкого прокси с коротким кэшем достаточно.