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

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.json continuam 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:

#гдечто навязывает
1apps/engine/lib/engine_web/channels/terminal_channel.ex:204топик — terminal:<projectId>
2apps/engine/lib/engine_web/channels/runner_socket.ex:73socket id — runner_socket:<kind>:<project_id>:<user_id>
3Engine.Runners.SocketTicket (runner_socket_tickets)тикет несёт project_id, и join требует совпадения
4apps/api/src/db/schema/auth.ts:325-326runner_device_keys.project_id — NOT NULL
5apps/runner/src/servico.ts:264,391unit — 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).