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

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, уже там записанной.

Отклоняет три вещи:

  1. symlink, выходящий за пределы места назначения;
  2. место назначения, содержащее workspace;
  3. место назначения внутри 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 не подписаны. Локальный агент, получающий возможность писать вне базы, делает происхождение бинарника более важным, а не менее.
  • Брат этой фазы: ADR 0146 решает согласованную базу в bootstrap и mounted как локальный режим по умолчанию. Они делятся так: 0146 покрывает папку, до которой достаёт bind, этот — место назначения, до которого он не достаёт.