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

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, объявлено — не усиливает аргумент вокруг неё сверх написанного: она помогает поймать грубую ошибку, это не стена.