ADR 0147 — Один локальный агент, возможности, объявляемые при подключении
Контекст
brabo-runner (ADR 0104/0105) родился ради одной вещи: выполнять на машине
пользователя команду, уже одобренную конвейером proposed_action. С тех пор
он накопил функции, а протокол ничего об этом не сказал, — интерактивный
PTY (apps/runner/src/pty.ts), навигация по папкам
(apps/runner/src/fs-browser.ts), а начиная с ADR 0137 — жизненный цикл
контейнера Docker НА МАШИНЕ ПОЛЬЗОВАТЕЛЯ. FASE 28 хочет добавить ещё одну:
зеркало, копирующее работу агентов в папку, которую выбрал пользователь,
вне смонтированной базы.
Соблазн — второй бинарник. Он плохо окупается: это были бы две установки, две модели аутентификации, два отзыва и две вещи, которые пользователю нужно останавливать, когда он захочет остановить. А у продукта уже есть живой процесс на машине пользователя — проблема не в отсутствии агента, а в том, что текущий агент не умеет сказать, что он умеет.
Сегодня join нем с обеих сторон. Runner отправляет пустые params —
socket.channel(\terminal:${projectId}`, {}) (apps/runner/src/channel.ts:304) — а сервер буквально их игнорирует: def join("terminal:" <> project_id,
_params, socket) (apps/engine/lib/engine_web/channels/terminal_channel.ex:99). Вся идентичность берётся из тикета: роль (:runnerили:web) выводится из kind (terminal_channel.ex:110, papel_do_kind/1), и сервер не может знать, является ли бинарник на другой стороне версией, понимающей mirror_sync, или той, что молча проигнорирует сообщение. Единственное, что runner объявляет о себе, — это workspace_confirm, отправляемый после join (apps/runner/src/index.ts:530`).
Агент, получающий возможности без их согласования, деградирует наихудшим образом: сообщение приходит, обработчика нет, и ничего не происходит — без ошибки, без лога, и никто не знает, что зеркало ни разу не запускалось.
Решение
Один локальный агент, с возможностями, объявляемыми в join и предоставляемыми
сервером. Для начала три возможности: exec, pty, espelho.
1. Где объявляется и где предоставляется
Runner объявляет в месте, сегодня пустом: params join
(channel.ts:304). Сервер предоставляет в autorizar_por_papel/2
(terminal_channel.ex:110-138) — в той же точке, которая уже собирает socket.assigns
с :role, :pending_execs и :open_pty_refs, — вычисляя пересечение
того, что runner объявил, и того, что требует execution_mode проекта.
Предоставленное множество живёт в socket.assigns, никогда в таблице. Это то же
рассуждение, что удерживает containerAtivo на стороне клиента
(apps/runner/src/index.ts:270-277: «a decisão é INTERNA ao runner, o engine
não sabe disso e não precisa saber») и заставляет запись :global в
Engine.Runners.Registry умирать вместе с pid: возможность — свойство
этого соединения, а не проекта и не пользователя. Таблица создала бы
возможность того, что база утверждает, будто runner умеет что-то, чего подключённый сейчас
процесс не умеет.
Требуемая и не объявленная возможность отклоняет join. Runner уже трактует
JoinRecusadoError как фатальный без повтора (index.ts:725-732, с комментарием,
объясняющим почему: «Recusa não é transitória… encerra com mensagem clara, SEM
laço automático»), и это ровно правильная форма — слишком старый для
проекта runner не должен оставаться в backoff, пытаясь снова с тем же бинарником. Это также
то место, где Registry уже отклоняет второй runner того же проекта
(terminal_channel.ex:126-135), поэтому отказ по возможности рождается рядом с
отказом, который пользователь уже знает.
Почему в join, а не в push после него, как workspace_confirm. Тот
говорит о workspace, который меняется между запусками и подтверждается runner, обладающим
полномочиями над диском (RN-423). Возможность говорит о бинарнике, который фиксирован в
момент подключения. Объявление после join означало бы окно, в котором
сервер уже принял соединение и ещё не знает, что оно умеет, — и именно в это
окно приходит первое сообщение.
2. Зеркало: одно направление, никогда не удаляет, собственная область
apps/runner/src/espelho-guard.ts (новый), брат guard.ts, а не его
расширение. Переиспользует realpathMaisProximo и двойной проход
лексический-затем-realpath из validarCwdDentroDaRaiz (guard.ts:134-170) —
второй проход — это то, что сегодня ловит symlink в среднем сегменте, с оговоркой
о TOCTOU, уже там записанной.
Отклоняет три вещи:
- symlink, выходящий за пределы места назначения;
- место назначения, содержащее workspace;
- место назначения внутри workspace.
Два последних — один и тот же цикл в противоположных направлениях: при bind по идентичности из ADR 0141 запись зеркала внутрь источника заставляет зеркало копировать само зеркало.
Только одно направление — workspace → место назначения. Никогда --delete, никогда unlink.
Файл, удалённый в workspace, остаётся в месте назначения. Это решение, а не небрежность:
папка пользователя — не реплика, а накопление, — и агент, удаляющий файл в
личной папке кого-то из-за git clean на другой стороне, — это ровно тот
тип сюрприза, ради непроизведения которого существует весь продукт.
3. Зеркало не требует Docker
Engine.Runners.RunnerReadiness остаётся байт в байт как есть
(apps/engine/lib/engine/runners/runner_readiness.ex:44-61), со своими двумя
всегдашними потребителями (TerminalExecutor и Engine.Actions.Workspace.RunnerGit).
Зеркало получает собственный предикат, с двумя предусловиями, которые ему действительно
нужны, — подтверждённый workspace и подключённый runner, — и никогда
RunnerReadiness с параметром. Общая функция с флагом «пропустить
контейнер» — ровно тот механизм, через который третье предусловие случайно
отпадёт для exec при будущем рефакторинге, а ADR 0145 существует, чтобы это
третье предусловие не отпало.
Почему зеркалу законно оно не нужно: третье существует потому, что команда без контейнера молча попадала на ХОСТ (ADR 0145) — было два возможных выполнения, и неправильное было невидимым. У зеркала нет этой неоднозначности: это по определению копирование файла на машине пользователя, без контейнера, из которого можно выпасть. Требовать там Docker значило бы импортировать предусловие без дефекта, который его оправдывает, а стоимость была бы реальной — зеркало как раз та возможность, которая должна работать для тех, кто не хочет Docker.
4. Согласие по проекту и по месту назначения
Место назначения — по проекту, объявляется пользователем и передаётся runner внутри
возможности, предоставленной в join. Runner отклоняет mirror_sync для места назначения,
которое не было ему предоставлено в этом соединении.
Никогда не глобальная конфигурация в runner, никогда не переменная окружения: глобальное место назначения заставило бы артефакт проекта B приземлиться в папку проекта A, и пользователь обнаружил бы это по содержимому, а не по ошибке.
5. Пользовательский сервис
Всегда пользовательский уровень: systemd --user в Linux, LaunchAgent в macOS.
Никогда системный сервис, никогда root. Runner работает с привилегиями
пользователя по замыслу — guard.ts:9-31 объявляет это предпосылкой всей
охраны («O runner roda NA máquina do usuário, com os privilégios DELE») — и
root-сервис сломал бы инвариант, на который опираются три реальные границы того
docblock.
Подкоманды brabo-runner service install | uninstall | status. Удаление
полное: unit file, brabo-runner.config.json и
brabo-runner-device-key.jwk.json.
Прецедентов ноль: в apps/runner/ нет ни одного упоминания systemd, launchd или
LaunchAgent; модель сегодня — foreground, завершаемый
SIGINT/SIGTERM (index.ts:709-717). Windows остаётся вне объёма, объявлено —
матрица сборки уже производит бинарник (ADR 0112), но пользовательский сервис в
Windows — третий механизм, а не вариация двух.
Желаемый побочный эффект: BRB-031 (ручной chmod +x, оставшийся от ADR
0118) умирает, если установка начнёт приходить из версионируемого bootstrap ADR 0146.
6. Отзыв перестаёт быть слепым и начинает доставать до живого соединения
Отзыв существует: DELETE /projects/:projectId/runner-device-keys/:deviceKeyId
(apps/api/src/interfaces/http/runner/runner-device-keys.controller.ts:82-83,
developer, 204, идемпотентно). Но нет способа увидеть, что отзывается.
Docblock контроллера (:39-40) объявляет срез как «sem a visão de
maintainer (listar/revogar de qualquer usuário) que o PAT tem» — а реальный
срез больше, чем он говорит. У PersonalAccessTokensController пять
маршрутов: @Post(), @Get() и @Delete(':tokenId') для самого пользователя
(developer), плюс @Get('all') и @Delete(':tokenId/admin') для maintainer
(personal-access-tokens.controller.ts:63,86,97,113,127). У контроллера ключей
устройств два: @Post() и @Delete(':deviceKeyId'). Не хватает не только
взгляда maintainer — не хватает перечисления для самого владельца. Никто не отзывает
то, чего не видит, а осиротевший ключ (вкладка, закрытая посреди потока ADR 0118)
сегодня невидим и вечен.
ADR закрывает это @Get() в том же формате, что уже есть у PAT, роль developer.
Взгляд maintainer остаётся вне объёма, теперь по решению, а не по
упущению.
Вторая дыра того же размера: отзыв сегодня лишь препятствует новому тикету. Уже
подключённый runner сохраняет канал, пока тот не упадёт сам, с отозванным ключом.
Отзыв начинает ронять живое соединение — Engine.Runners.Registry.whereis/1
(apps/engine/lib/engine/runners/registry.ex:45) уже выдаёт pid, и
новый механизм для доступа к нему не нужен.
7. Видимое состояние
Runner отправляет состояние в канал, в той же форме, что workspace_confirm
(index.ts:530), — единственный прецедент того, как runner рассказывает что-то о себе.
Хранится рядом с другими фактами о runner проекта, не в event log: это
телеметрия соединения, а не доменное событие сессии, а
session_events.session_id — NOT NULL. Это то же рассуждение, что сделало
rag_searches таблицей вместо события (RN-479): когда то, что нужно измерять,
не имеет сессии, event log — неправильное место.
Три поля — последняя синхронизация, количество, последняя ошибка — и «никогда не смотрел» никогда не схлопывается с «смотрел и ничего не нашёл», три состояния RN-088. Зеркало, которое ни разу не запускалось, и зеркало, которое запустилось и ничего не скопировало, — разные ситуации, и экран должен уметь сказать, какая из двух.
8. Триггер по событию, никогда watcher
Engine отправляет mirror_sync в именованные моменты — конец хода агента,
commit. Никогда fs.watch.
Watcher — это неограниченная работа, запускаемая чем угодно: установкой зависимостей, сборкой и — хуже всего — записями самого зеркала, что является циклом. Кроме того, он работал бы постоянно на машине пользователя, хотя никто не просил, — противоположность тому, что должен означать «локальный агент».
Последствия
- Согласие на конфигурацию — не одобрение действия, и зеркало остаётся
на стороне конфигурации. Запись зеркала не проходит через
proposed_action: это не агент, просящий разрешения действовать, а система, пишущая по пути, который пользователь объявил при настройке проекта. Обратное — синхронизация командой terminal — попало бы в область пути ADR 0055, стало бы очередью рутинных одобрений и разъело бы именно потолок, придающий смысл клику: очередь, в которой почти всё рутина, приучает человека одобрять не читая. - Инварианты, которых этот ADR не касается и объявляет, что не касается:
decide.tsи абсолютные потолки;proposed_actionкак источник каждого внешнего эффекта агента; неизменяемость event log; барьер образа в трёх режимах (ADR 0135); пять операций и вычисляемая spec broker (ADR 0128/0130). - Старый runner, подключённый к проекту, требующему
espelho, перестаёт подключаться. Это явный отказ вjoin, с сообщением, — а не молчаливая деградация. Стоимость реальна: обновление бинарника становится предварительным условием использования новой возможности, а у тех, кто установил через поток ADR 0118, нет автоматического обновления (BRB-005/BRB-031 касаются той же точки). - Место назначения зеркала накапливается. Без
--deleteфайл, удалённый в workspace, остаётся там навсегда, и никакой автоматический механизм не чистит. Это прямая цена «никогда не удаляет в папке пользователя», а альтернатива хуже. - Принятые пробелы, не закрытые здесь:
- учётные данные git отбрасываются, когда
execмаршрутизируется черезdocker exec—apps/runner/src/index.ts:311-320; уpackages/docker-portнет поляenv, намеренно (ADR 0130), и ADR 0145 уже объявил стоимость; - symlink изнутри проекта, указывающий наружу, не обнаруживается
решением политики (ADR 0055), а
guard.ts:9-31— best-effort по инварианту —espelho-guard.tsнаследует ту же оговорку о TOCTOU; - эксклюзивность по
{project_id, machine_id}по-прежнему отложена (ADR 0137), поэтому один и тот же проект на двух машинах остаётся одним runner за раз черезRegistry, а не идентичностью на машину; - BRB-005: ни образы, ни бинарники runner не подписаны. Локальный агент, получающий возможность писать вне базы, делает происхождение бинарника более важным, а не менее.
- учётные данные git отбрасываются, когда
- Брат этой фазы: ADR 0146 решает
согласованную базу в bootstrap и
mountedкак локальный режим по умолчанию. Они делятся так: 0146 покрывает папку, до которой достаёт bind, этот — место назначения, до которого он не достаёт.