ADR 0103 — Локальный runner: выполнение на машине пользователя через канал Phoenix с одноразовым тикетом
- Статус: Принят
- Дата: 2026-08-20
- Контекст: запрос владельца продукта, RN-419/420 — компаньон ADR 0102
- Пересматривает (не ослабляя) территорию: ADR 0055, ADR 0072
Контекст
До этой поставки НИЧТО в продукте не выполнялось вне контейнера: каждая
команда терминала — это System.cmd("sh", ["-c", cmd]) внутри процесса
engine (terminal_executor.ex), а режим local (ADR 0072) меняет лишь
ПАПКУ через bind-mount — команда по-прежнему выполняется в ТОМ ЖЕ контейнере, что и
engine. Запрос владельца продукта — действительно пересечь эту границу:
агент выполняет на МАШИНЕ пользователя, в локальной папке, через его
стандартный терминал, — плюс настоящий интерактивный терминал во вкладке Code, которая до
сих пор показывала лишь пояснительный текст + состояние project_containers
(никогда не настоящий терминал).
Решение
Новый компонент, apps/runner (Node/TS): CLI (brabo-runner), который
пользователь запускает на СВОЕЙ машине. Он НЕ оркестрируется продуктом —
поднимается по выбору и явному согласию пользователя, с его
ПРИВИЛЕГИЯМИ.
Канал: НОВЫЙ socket Phoenix на /runner (рядом с уже существующим /socket
сессии), топик terminal:<projectId>, аутентифицированный ОДНОРАЗОВЫМ
тикетом — тот же шаблон безопасности, что RN-108 (тикет socket
сессии), но с ИНВЕРТИРОВАННОЙ деталью владения: тикет
выдаёт сам ENGINE (таблица runner_socket_tickets, схема
"engine", собственная миграция Ecto), а API ЗАПРАШИВАЕТ его у engine через внутренний
HTTP-маршрут — обратный потоку тикета сессии, где api записывает
тикет в собственную таблицу. Обмен обоснован: у тикета runner нет
связанной сессии чата (он на ПРОЕКТ), а состояние, нужное для проверки
исключительности (только один runner на проект), уже живёт в engine
(Engine.Runners.Registry, :global).
Две роли в одном топике: :runner (CLI, исключительность гарантирована
:global.register_name/3 — только ОДИН на проект, второй join
отклоняется) и :web (вкладка Terminal, несколько одновременно). Engine делает
чистый RELAY байтов PTY между ними — никогда не интерпретирует содержимое.
Маршрутизация ВСЕГДА после одобрения. TerminalExecutor решает
направить в runner (вместо обычного System.cmd) только ПОСЛЕ того, как
обычный конвейер (decide()/proposed_action) уже одобрил команду, —
runner никогда не является вторым путём выполнения в обход политики, это
лишь другое НАЗНАЧЕНИЕ для той же уже разрешённой команды. Без подключённого
runner, даже в режиме local, продолжается обычное поведение
(System.cmd в контейнере через bind-mount) — runner аддитивен, никогда не
обязательная зависимость.
Граница безопасности runner — НЕ песочница. Это композиция
трёх вещей: аутентификация (CLI идентифицируется токеном УЧЁТНОЙ ЗАПИСИ
пользователя), обычный конвейер одобрения (каждая команда агента
по-прежнему рождается proposed_action, с абсолютными пределами ADR 0102,
действующими так же) и СОГЛАСИЕ пользователя запускать бинарник на собственной
машине. apps/runner/src/guard.ts проверяет, что полученный cwd находится
внутри корня проекта лексическим разрешением, — но в коде это ОБЪЯВЛЕНО
как best-effort, а не реальная гарантия; реальная гарантия — композиция
выше. Это пересматривает, не ослабляя, территорию ADR 0055/0072:
для выполнения через runner структурного сдерживания join(raiz, coluna),
защищающего режим контейнера, не существует — команда выполняется с
привилегиями САМОГО пользователя, на его собственной машине.
Интерактивный PTY — действие пользователя, со следом. pty_open/pty_close,
приходящие из web, выдают terminal.session.started/ended в event log
(аудит) — даже когда вкладка падает без явного закрытия
(terminate/2 канала закрывает след). Не проходит через proposed_action,
потому что действует не агент — это аутентифицированный пользователь, печатающий в
терминале своей машины.
Реальная находка во время реализации
У продукта сегодня НЕТ механизма ДОЛГОЖИВУЩЕГО токена учётной записи
для автоматизации — account_tokens существует лишь для одноразовых ссылок
в e-mail (верификация, сброс пароля, начальный пароль после миграции).
Runner нуждался в чём-то, что переживает выполнения CLI. Принятое решение,
пока не появится настоящий механизм автоматизации: runner
повторяет поток ВХОДА браузера (логин/пароль при первом выполнении,
httpOnly cookie + CSRF извлекаются и сохраняются в
~/.brabo/runner-credentials.json с правами 0600, ротируются через
/auth/refresh). В коде это помечено как модуль для замены,
когда в продукт войдёт настоящий токен автоматизации (personal access token или эквивалент)
— это не окончательная форма, а форма, возможная с тем, что
существует сегодня.
Последствия
- Четыре новые зависимости, все изолированные:
@xterm/xterm+@xterm/addon-fitв web (то же мерило, чтоmermaid/ADR 0068 — динамическийimport(); отсутствиеeval/new Functionподтверждено grep по установленному пакету, объявлено как сильное свидетельство, НЕ формальная гарантия против обфускации);phoenix+node-ptyв runner. - Интерактивного терминала КОНТЕЙНЕРА по-прежнему не существует — FASE 25b остаётся отрезанной. Runner — НЕ эта часть: это параллельный путь, на машине пользователя, а не внутри контейнера проекта.
TERMINAL_ACTION_TIMEOUT_MS/TERMINAL_OUTPUT_MAX_BYTES(пределы, которые уже существовали для исполнителя контейнера) повторены как значения по умолчанию в runner — те же значения, чтобы не было двух разных поведений пределов в зависимости от того, где выполняется команда.- Защита области (
guard.ts) — best-effort, объявлено — не усиливает аргумент вокруг неё сверх написанного: она помогает поймать грубую ошибку, это не стена.