ADR 0103 — Local runner: execution on the user's machine over a Phoenix channel with a single-use ticket
- Status: Accepted
- Date: 2026-08-20
- Context: product owner's request, RN-419/420 — companion to ADR 0102
- Revises (without softening) the ground of: ADR 0055, ADR 0072
Context
Until this delivery, NOTHING in the product executed outside the
container: every terminal command is System.cmd("sh", ["-c", cmd])
inside the engine's own process (terminal_executor.ex), and local
mode (ADR 0072) only changes the FOLDER via bind-mount — the command
still runs in the SAME container as the engine. The product owner's
request was to truly cross that boundary: an agent executing on the
user's OWN MACHINE, in the local folder, through the user's standard
terminal — plus a genuinely interactive terminal in the Code tab,
which until now only showed explanatory text + the state of
project_containers (never a real terminal).
Decision
New component, apps/runner (Node/TS): a CLI (brabo-runner)
that the user runs on their OWN machine. It is NOT orchestrated by the
product — it comes up by the user's explicit choice and consent, with
the user's own PRIVILEGES.
Channel: a NEW Phoenix socket at /runner (alongside the existing
session /socket), topic terminal:<projectId>, authenticated by a
SINGLE-USE ticket — the same security pattern as RN-108 (session
socket ticket), but with one INVERTED ownership detail: the ticket is
issued by the ENGINE itself (table runner_socket_tickets, schema
"engine", its own Ecto migration), and the API REQUESTS it from the
engine via an internal HTTP route — the reverse of the session ticket
flow, where the api writes the ticket to its own table. The swap is
justified: the runner ticket has no associated chat session (it's per
PROJECT), and the state that needs to validate exclusivity (only one
runner per project) already lives in the engine
(Engine.Runners.Registry, :global).
Two roles on the same topic: :runner (the CLI, exclusivity
guaranteed by :global.register_name/3 — only ONE per project, a
second join refused) and :web (the Terminal tab, multiple
simultaneous). The engine does a PURE RELAY of the PTY bytes between
the two — it never interprets the content.
Routing ALWAYS happens after approval. TerminalExecutor only
decides to route to the runner (instead of the usual System.cmd)
AFTER the normal pipeline (decide()/proposed_action) has already
approved the command — the runner is never a second execution path
that escapes policy, it's just a different DESTINATION for the same
already-authorized command. With no runner connected, even in local
mode, the usual behavior continues (System.cmd in the container via
bind-mount) — the runner is additive, never a required dependency.
The runner's security boundary is NOT sandboxing. It's the
composition of three things: authentication (the CLI identifies itself
with the user's ACCOUNT token), the usual approval pipeline (every
agent command is still born a proposed_action, with ADR 0102's
absolute ceilings applying equally), and the user's CONSENT to run the
binary on their own machine. apps/runner/src/guard.ts validates that
the received cwd stays inside the project root by lexical
resolution — but this is DECLARED in the code as best-effort, not the
real guarantee; the real guarantee is the composition above. This
revises, without softening, the ground of ADRs 0055/0072: for
execution via the runner, the structural containment of
join(root, column) that protects container mode does not exist — the
command runs with the user's OWN privileges, on their own machine.
Interactive PTY is a user action, with a trail. pty_open/
pty_close coming from the web emit terminal.session.started/ended
into the event log (audit trail) — including when the tab drops without
closing explicitly (the channel's terminate/2 closes the trail).
It doesn't go through proposed_action because it isn't the agent
acting — it's the authenticated user typing into their own machine's
terminal.
Real finding during implementation
The product does NOT have, today, a LONG-LIVED account token mechanism
for automation — account_tokens exists only for single-use email
links (verification, password reset, post-migration initial
password). The runner needed something that would survive across CLI
runs. Adopted solution until a real automation mechanism exists: the
runner replicates the browser's LOGIN flow (username/password on the
first run, httpOnly cookie + CSRF extracted and persisted to
~/.brabo/runner-credentials.json with 0600 permissions, rotated via
/auth/refresh). This is flagged in the code as the module to swap
out once a real automation token (a personal access token, or
equivalent) enters the product — it isn't the final form, it's the
form possible with what exists today.
Consequences
- Four new dependencies, all isolated:
@xterm/xterm+@xterm/addon-fiton the web (the same rule asmermaid/ADR 0068 — dynamicimport(); absence ofeval/new Functionconfirmed by grep on the installed package, declared as strong evidence, NOT a formal guarantee against obfuscation);phoenix+node-ptyon the runner. - The CONTAINER's interactive terminal still doesn't exist — Phase 25b remains cut. The runner is NOT that piece: it's a parallel path, on the user's machine, not inside the project's container.
TERMINAL_ACTION_TIMEOUT_MS/TERMINAL_OUTPUT_MAX_BYTES(caps that already existed for the container executor) are replicated as defaults in the runner — same values, so there aren't two different ceiling behaviors depending on where the command runs.- The scope guard (
guard.ts) is best-effort, declared as such — it doesn't overstate the argument around it beyond what's written: it helps catch a gross error, it isn't a wall.