Skip to main content

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. FolderBrowserModal receives projectId: string | null and, 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 — see RunnerOnboardingPanel.
  • apps/runner gains 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 from proposed_action elsewhere in the product.