ADR 0107 — Local folder navigation via the Runner
- Status: Accepted
- Date: 2026-08-20
- Context: Wave 4 of a larger program (local folder, PR management, i18n) — requested by the product owner
- Revises (without editing) the territory of: ADR 0072, companion to ADR 0103
Context
ADR 0072 (Local project, RN-169/170) EXPLICITLY rejected a folder picker at project creation/adoption time: "a picker would require the api to enumerate the CONTAINER's filesystem to the browser, a new surface just to solve ergonomics". That decision was correct at the time — the api runs inside a container, and the filesystem it sees is not the user's filesystem. The only path was typing the absolute path from memory, knowing in advance how it appears INSIDE the container (RN-170).
Since then, ADR 0103 introduced apps/runner: a CLI that runs ON the
user's MACHINE, with their own privileges, authenticated by a single-use
ticket and already used for two purposes (approved agent command,
interactive PTY). It changes the question: the surface that would enumerate
the filesystem no longer needs to be the api enumerating the CONTAINER — it
can be the RUNNER enumerating the user's real machine, the same component
that already has that view in order to run the terminal.
Decision
Folder navigation now exists, but only through the Runner. Two new
events on the SAME Phoenix channel terminal:<projectId> that the PTY
already uses (apps/runner/src/channel.ts, EngineWeb.TerminalChannel):
fs_list_dir/fs_list_dir_reply and fs_home_dir/fs_home_dir_reply.
Same design as the PTY — :web requests, the engine does a pure relay to
the registered runner's pid (Engine.Runners.Registry), :runner
responds, broadcast filtered to only :web sockets. Correlated by a ref
generated by the client, exactly how the PTY correlates by sessionRef —
the engine NEVER interprets any path, it only forwards.
The api STILL does not enumerate any filesystem. That doesn't change: no new HTTP route is added to the api to list a directory. The read always flows runner → engine (relay) → web, over the channel that already exists. ADR 0072's argument against the api enumerating the CONTAINER still stands, untouched — this decision doesn't contradict it, it redirects the problem to a component that already has the right view.
The security boundary REMAINS the one from ADR 0103, not a path
allowlist. apps/runner/src/guard.ts restricts the cwd of an ALREADY
APPROVED agent command to the project root (--dir) — but that
restriction does NOT apply to folder navigation, on purpose: the whole
point here is to let the user navigate FREELY across their own machine, to
choose ANY folder as the root of a future Local project. Blocking that with
a path allowlist would contradict the very purpose of the feature. The
real boundary continues to be the composition of the same three things
from ADR 0103: authentication (only someone holding the channel's ticket
can even ask), pure read with no effect at all (listing a directory is not
a command, doesn't go through proposed_action, writes nothing), and
CONSENT — it's the user who decides to run the Runner on their own
machine, knowing it lists whatever gets requested.
Error per ENTRY, not for the whole listing. An entry without read
permission (restrictive ACL, broken symlink, odd mount) is skipped —
apps/runner/src/fs-browser.ts never aborts the whole listing because of
one problematic item.
Consequences
- Architectural gap accepted, declared (not closed in this delivery):
the channel ticket (
getTerminalTicket/getRunnerTicket) is issued PER PROJECT — the Runner is--project <id> --dir <folder>, anchored to a project that already exists. On the project CREATION screen (NewProjectWizard.tsx), the project is only created at the confirmation step, so the "Browse folder..." button at the workspace step CANNOT connect to any runner — there's no project to anchor the ticket to.FolderBrowserModalreceivesprojectId: string | nulland, when null, shows the declared state ("available once the project exists") instead of attempting a connection that today's architecture doesn't allow. The free-text field remains the only path in that specific flow — exactly as it was before this delivery. Closing this properly would require either (a) a ticket/channel mode for the Runner that isn't anchored to a project, or (b) the project being created earlier in the wizard flow (before confirmation) — both are larger-scope changes than this Wave, and are left for a future delivery that the product owner prioritizes. - Where a project ALREADY EXISTS (the Code tab of a Local project, for
example — navigation is also available there via the same
FolderBrowserModal, independent of creation), navigation works end to end, including the "no runner connected yet" state with installation onboarding — seeRunnerOnboardingPanel. apps/runnergains a third responsibility on the SAME channel (exec, PTY, and now folder navigation) — no new channel, no new ticket, just two more events on the protocol that already exists.- No audit event (
terminal.session.*) for folder navigation — unlike the PTY, which is a SESSION with duration. Listing a directory is a point-in-time read, with no state, no effect: the same rule that already exempts reads fromproposed_actionelsewhere in the product.