ADR 0154 — Ключ устройства машины
Статус
Accepted 2026-09-27 (AT-108), после реализации — тем же путём,
что и ADR 0055. Он родился
Proposed в сессии 1 FASE 30 (2026-09-11) и был отработан RN-543..552
в сессиях 2–8, а закрытие описано в
historico-de-fases.md и в
fase-30-runner-por-maquina.md.
Там, где текст ниже расходится с кодом, решение НЕ переписывалось:
расхождение названо в «Заметках о принятии» в конце.
Первое из двух решений FASE 30. Второе — ADR 0155, и оно зависит от этого: без идентичности машины установщику нечего регистрировать.
Контекст
Продуктовое направление — то же, что у FASE 29:
установить Brabo одной строкой и получить работающего локального агента. Установщик уже
близок к этому — начиная с RN-531 он скачивает бинарник, сверяет с подписанным
checksums.txt и устанавливает его через install -m 0755, без ручного chmod,
— и затем останавливается. Последняя строка его вывода прямым текстом
говорит почему:
Não pareia o agente local com um projeto: o binário e a base ficam prontos aqui, mas a chave de dispositivo e o
brabo-runner.config.jsoncontinuam vindo da tela do projeto (ADR 0118).
Бинарник лежит на диске без дела. Тот, кто хочет им воспользоваться, возвращается на экран проекта,
выбирает папку и в итоге вставляет в терминал команду, которую собирает
apps/web/src/lib/runner-bootstrap.ts:398, —
chmod +x ./brabo-runner && ./brabo-runner. Комментарий прямо над ней
объясняет, почему экран не может устранить этот шаг, и аргумент
верен:
Uma página web não executa binário na máquina de ninguém: este passo é humano em qualquer desenho, e o que dá para fazer é encolhê-lo a UMA linha copiável.
Чего аргумент не покрывает, так это того, что установщик — не веб-страница. Он
работает в терминале, с TTY, и уже задаёт вопросы — в этом весь замысел
ADR 0150, который отвергает curl | sh именно
для того, чтобы иметь возможность спрашивать. Человеческий шаг неизбежен на экране и устраним там.
Блокирует не бинарник, а идентичность
brabo-runner service install существует с RN-518
(ADR 0147, пункт 5) и
устанавливает агента как пользовательский сервис (systemd --user/LaunchAgent).
Никто не вызывает его из install.sh, и это не забывчивость: ему нужен
projectId и ключ устройства, а на момент установки не существует ни одного
проекта — ни учётной записи, ни workspace. .env только что появился,
compose только что поднялся, никто не зарегистрировался.
FASE 29 измерила связанность и назвала её: «um runner por projeto está
imposto em cinco lugares independentes». Повторно измерено здесь, с
arquivo:linha:
| # | где | что навязывает |
|---|---|---|
| 1 | apps/engine/lib/engine_web/channels/terminal_channel.ex:204 | топик — terminal:<projectId> |
| 2 | apps/engine/lib/engine_web/channels/runner_socket.ex:73 | socket id — runner_socket:<kind>:<project_id>:<user_id> |
| 3 | Engine.Runners.SocketTicket (runner_socket_tickets) | тикет несёт project_id, и join требует совпадения |
| 4 | apps/api/src/db/schema/auth.ts:325-326 | runner_device_keys.project_id — NOT NULL |
| 5 | apps/runner/src/servico.ts:264,391 | unit — brabo-runner-<projectId>.service |
Три из пяти менять не нужно, и в этом открытие этого решения
Очевидным срезом было бы пересечь все пять. При измерении сокета он сжимается.
RunnerSocket.connect/3 (runner_socket.ex:33) аутентифицирует по тикету, а
тикет запрашивается у api по проекту. project_id приходит в socket.assigns, и
атомарное потребление происходит в join, против project_id топика. То
есть пункты 1, 2 и 3 описывают соединение, а не процесс. Единственный
процесс, открывающий N соединений — по одному на каждый обслуживаемый проект, —
удовлетворяет все три байт в байт, не трогая ни топик, ни socket id, ни тикет, ни отказ
второму runner в том же проекте, который остаётся той гарантией, какой является сегодня.
Остаётся то, что подлинно принадлежит процессу: учётные данные, с которыми он запрашивает N тикетов (пункт 4), и unit, которая держит его запущенным (пункт 5). Именно эти две вещи перемещает этот ADR.
А сегодня учётные данные рождаются привязанными к проекту
runner_device_keys.project_id — NOT NULL (auth.ts:325-326), а ключ
генерируется браузером внутри экрана проекта
(ADR 0118, RN-464..466,
RN-475). У машины с тремя проектами в режиме runner три ключа, три
папки с brabo-runner-device-key.jwk.json, три unit сервиса и три
процесса — и человек трижды прошёл один и тот же браузерный поток, чтобы
описать одну машину.
Решение
Идентичность локального агента начинает принадлежать МАШИНЕ, а не проекту.
1. runner_device_keys.project_id становится nullable, и NULL означает машину
Новая таблица не рождается. Ключ машины — это ключ устройства с
project_id NULL — та же таблица, та же колонка public_key_jwk, тот же
kid, записанный внутри приватной JWK (RN-475), тот же отзыв.
Родственная таблица рассматривалась и была отвергнута: PatAuthGuard находит публичный ключ по
kid и не обязан знать, какого он вида; две таблицы потребовали бы двух
поисков, и второй был бы забыт ровно один раз. RN-519 (перечисление) и
RN-520 (отзыв, достающий до живого соединения) продолжают действовать без нового
кода, с одним последствием, объявленным ниже.
NULL, а не сентинел (project_id = '00000000-…'): FK настоящий, и
сентинел потребовал бы фиктивной строки в projects для его удовлетворения.
2. Ключ машины действует для проектов пользователя, разрешаемых в момент выдачи тикета
POST .../runner-ticket остаётся по проекту — именно он несёт
project_id из пункта 3. Меняется то, что он принимает как учётные данные: ключ
машины того же пользователя начинает действовать для любого проекта, в
котором этот пользователь достигает роли, которую маршрут уже требует. Ни нового потолка,
ни ослабленного: авторизация остаётся прежней, разрешаемой против
запрошенного проекта.
Чего ключ машины не делает — так это не расширяет досягаемость: он не даёт runner ничего, чего у его владельца-пользователя уже не было. Он меняет «одни учётные данные на проект» на «одни учётные данные на машину», и только.
3. Runner узнаёт, какие проекты обслуживает, а не угадывает их
Новый маршрут, GET runner/projects (минимум маршрута — developer, тот же, что у
runner-ticket): проекты пользователя в execution_mode: runner, с
workspace_dir_name и состоянием проверки каждого. Runner обращается к нему
при старте и открывает по соединению на каждый перечисленный проект.
Он спрашивает, а не сканирует базу: база принадлежит пользователю и может содержать папку,
которая не является никаким проектом, а угадывание по имени папки — это класс ошибки, который
ADR 0141 отверг, запретив
PROJECT_WORKSPACES_HOST_DIR как базу.
4. Unit сервиса становится ОДНОЙ на машину
brabo-runner.service / dev.brabo.runner, без projectId в имени.
Исходный аргумент servico.ts:32-38 — «nomear pela pasta permitiria
instalar duas units que nunca podem estar de pé ao mesmo tempo» — исчезает
вместе с предпосылкой: при одном процессе на машину двух нет.
Restart=on-abnormal остаётся байт в байт по причине RN-518: exit 1 — это фатальный
отказ join или исчерпанный потолок попыток, а перезапуск был бы тем циклом, который
CLI сам отказывается делать.
5. Unit по проекту продолжают работать, и о конвертации сказано
Новый бинарник при установленной старой unit не ломается, а status отвечает об
обеих. Тот, у кого три unit по проекту, продолжает иметь три процесса, пока не выполнит
service uninstall для каждой, — и status unit машины говорит, что
они существуют, вместо того чтобы молча оставлять шесть процессов, борющихся за три проекта.
Исправлено при реализации (RN-545). Этот ADR говорил, что
service installбез--projectустановит unit машины. Измерено — так нельзя: отсутствие флага уже имеет значение —resolverProjetoпадает наbrabo-runner.config.jsonв папке, а обычный путь того, кто пользуется продуктом сегодня, — запускатьinstallбез каких-либо флагов изнутри папки, настроенной браузером. Буквально правило молча конвертировало бы установку того, кто уже пользуется продуктом, — противоположность тому, что обещает этот самый пункт. Различитель — явный флаг--machine. Отредактировано, а не опровергнуто, потому что этот ADR ещёProposed: принятый ADR никогда не редактируется, новый ссылается на старый.
Что этот ADR явно отвергает
- Слияние топиков в
runner:<userId>. Это означало бы пересечь пункты 1, 2 и 3 ради ничего: N соединений уже их удовлетворяют, а отказ второму runner на проект — настоящая гарантия — живёт именно там. - Ключ машины, пересекающий пользователей.
runner_device_keys.user_idостаётсяNOT NULL. Машина, разделяемая двумя людьми, — это два ключа, и это правильный ответ: учётные данные описывают, кто действует, а не только где. - Обнаружение проектов по диску. См. пункт 3.
- Отзыв по ключу. RN-520 по построению достаёт до
{projeto, usuário}, и этот ADR её не меняет — лишь делает её стоимость выше, что отражено в Последствиях. - Любое исключение в
decide.ts. Пять абсолютных потолков, лексическая область ADR 0055 и нижняя граница автоодобрения RN-493 остаются как есть. Этот ADR перемещает идентичность, никогда не полномочия. RunnerReadinessс флагом. Три предусловия RN-507 остаются байт в байт по причине, уже зафиксированной в пункте 4 ADR 0147: именно через такой флаг третье отпало бы случайно.
Последствия
- Отзыв начинает стоить дороже, и это было предусмотрено. FASE 29 объявила: «quando a FASE 30 fizer o runner por máquina, revogar passará a derrubar todos os projetos daquela máquina. Custo declarado aqui para não ser descoberto lá.» Именно это и происходит, и перечисление RN-519 начинает нуждаться в указании вида каждого ключа — ключ машины — это не «ключ проекта X».
- Один процесс вместо N. Меньше памяти и меньше unit, и единая точка отказа там, где было N независимых: падение сервиса выключает локального агента всех проектов этой машины.
espelhoиworkspace_createне меняются. Оба — по проекту и передаются в предоставленииjoinэтого соединения (RN-516, RN-532). При N соединениях каждое продолжает получать своё — и место назначения зеркала проекта B остаётся недостижимым через соединение проекта A, а это гарантия, ради которой существует RN-515.- Браузерный поток (ADR 0118) не удаляется. Он остаётся путём тех, у кого уже есть установка, и становится вторым. Решить, должен ли он исчезнуть, — то, что BRB-031 называет решением мейнтейнера, и это не последствие этой поставки.
- Возможность
workspace(RN-532) получает вторую причину существовать. Она уже зависела от СОСТОЯНИЯ (согласованная база); с агентом машины именно по ней сервер знает, что этот процесс может материализовать папку только что созданного проекта, — а это и есть «привязать папку позже», к которому стремится эта фаза.
Заметки о принятии (2026-09-27)
Сверено с кодом при принятии. Решение остаётся как есть; это те пункты, где текст и код расходятся, и где разрешил код:
- Пункт 1 — «RN-519 и RN-520 продолжают действовать без нового кода». Без кода
не обошлось. Перечисление стало включать ключи машины в
каждый проект владельца (
apps/api/src/infrastructure/persistence/drizzle/runner-device-key.repository.ts:88) и указывать вид, выводимый изproject_idв одном месте (:23); отзыв ключа машины стал вызыватьdisconnectRunnerOfUserодин раз на каждый проект владельца в режимеrunner(apps/api/src/application/use-cases/auth/revoke-runner-device-key.use-case.ts:120). Это последствие, которое Последствия уже предусматривали, оплаченное кодом (RN-543). - Пункт 3 — «минимум маршрута —
developer». УGET runner/projectsнет@RequireRole: на пути нет проекта, против которого его разрешать. Минимум применяется по СТРОКЕ, с эффективной ролью проекта (apps/api/src/application/use-cases/runner/list-runner-projects.use-case.ts:75), и допускаются только учётные данные МАШИНЫ — привязанные к проекту дают 403 с собственным сообщением (apps/api/src/interfaces/http/auth/pat-auth.guard.ts:279). Перечисляемое множество то же, что обещает пункт (RN-543). - Пункт 4 — различитель unit. Уже исправлено в самом тексте, в
сессии 4, пока
Proposed: это флаг--machine(apps/runner/src/servico.ts:898,resolverEspecieв:912), никогда отсутствие--project(RN-545). - Пункт 5 — сосуществование двух видов. Текст описывает unit по
проекту и unit машины, установленные вместе, со
status, указывающим на перекрытие. Код не даёт до этого дойти:installОТКЛОНЯЕТ, когда ДРУГОЙ вид уже установлен, называяuninstallкаждой unit, ничего не удаляя и без--force(apps/runner/src/servico.ts:993,recusaDeSobreposicaoв:1269).statusпо-прежнему текстом говорит, что другая существует, — от пункта 5 остаётся случай того, кто установил до этой охраны (RN-545).