Skip to main content

ADR 0118 — Browser-driven runner setup: an Ed25519 device key as a second runner-ticket credential, a GitHub Releases proxy for the binary, and the File System Access API for a one-click configured folder

  • Status: Accepted
  • Date: 2026-08-27
  • Context: closes the friction of setting up brabo-runner on the user's machine — until now it required manually assembling three things scattered across different screens: the project id, the folder path, and a Personal Access Token minted on a third screen
  • References (without editing): ADR 0103 (the runner itself and its security boundary), ADR 0104 (execution_mode: runner, the PAT backlog item this ADR's device key complements), ADR 0105 (the PAT this decision is additive to, never a replacement), ADR 0106 and ADR 0112 (the standalone binaries this ADR's proxy serves), ADR 0107 (folder browsing THROUGH an already-connected runner — this ADR is about getting the runner connected in the first place)

Context​

execution_mode: runner (ADR 0104) lets a project's code live in a folder on the user's own machine, with no bind-mount and no container — but reaching that state meant a person had to, by hand: find the project's id, generate a Personal Access Token on Project Settings → Access Tokens (ADR 0105), copy a brabo-runner --project <id> --dir <path> --token <token> command someone had typed correctly, download the right binary for their OS/architecture themselves, and run it from the right folder. Three pieces of information, three screens, and a secret the user had to type or paste into a terminal.

NewProjectWizard.tsx and RunnerOnboardingPanel.tsx had independently grown their OWN command text for this over time, and they had drifted apart — the wizard's copy never included --token at all, and the panel's copy used pnpm --filter runner start syntax instead of the binary. Neither was wrong when written; nothing forced the two surfaces to agree, so they silently stopped agreeing.

The product owner also asked, explicitly, whether the runner could DISCOVER a local Docker/Kubernetes container on its own and connect over SSH instead of needing a human to walk through any setup at all. That path was considered and rejected in this same session — see "Alternatives considered and rejected" below.

Decision​

1. An Ed25519 device key, generated in the browser, is a second credential for runner-ticket — additive to the PAT, never a replacement​

New table runner_device_keys (apps/api/src/db/schema.ts:2058-2081, migration 0054_demonic_shiver_man.sql): id, userId (FK users, cascade), projectId (FK projects, cascade), name, publicKeyJwk, createdAt, revokedAt, revokedReason, lastUsedAt — the PUBLIC half of an Ed25519 key pair the browser generates with crypto.subtle.generateKey (apps/web/src/lib/runner-bootstrap.ts:123-131). The private half never leaves the browser over the network; only the exported public JWK is POSTed to RegisterRunnerDeviceKeyUseCase (apps/api/src/application/use-cases/auth/register-runner-device-key.use-case.ts) via POST /projects/:projectId/runner-device-keys (apps/api/src/interfaces/http/runner/runner-device-keys.controller.ts). That route is authenticated by the NORMAL session JWT, @RequireRole('developer') — the caller here really is the logged-in browser registering the device it's about to configure, the mirror image of PersonalAccessTokensController issuing a PAT. RevokeRunnerDeviceKeyUseCase deletes (marks revoked) the same way RevokePersonalAccessTokenUseCase does — idempotent, null for "not found or not yours," same non-disclosure discipline as RN-426.

PatAuthGuard — the same guard POST .../runner-ticket already used for the PAT (ADR 0105, RN-439) — grew a second branch (apps/api/src/interfaces/http/auth/pat-auth.guard.ts:104-116): a bearer token starting with brb_ takes the existing PAT path unchanged; a bearer token shaped like a compact JWT (three dot-separated segments) takes a new path, autenticarChaveDeDispositivo (lines 144-216). It reads the unverified kid from the JWT header (decodeProtectedHeader — no signature check yet), looks up an ACTIVE runner_device_keys row by that kid, verifies the signature with jose's importJWK/jwtVerify against the STORED public key, and enforces a short, mandatory TTL: exp - iat must be at most 60 seconds (TTL_MAXIMO_DEVICE_KEY_SEGUNDOS, line 23) — checked against the token's OWN claimed lifetime, not "now," so a captured token can't be replayed usefully no matter when it's replayed. The userId used for authorization comes from the STORED registration, never from a JWT claim — the token only needs to prove possession of the private key, not assert whose it is. projectId is checked twice: the JWT's own claim against the route parameter, and separately the registered key's projectId against the route (mirroring the PAT path's identical two checks) — a forged claim on a key registered to a different project is rejected with 403, matching the PAT path's category distinction between "not authenticated" and "authenticated for the wrong project."

The runner signs this JWT itself, in apps/runner/src/auth.ts (assinarTicketComChaveDeDispositivo, lines 143-154): EdDSA, 30-second expiry, kid set to the device key's registration id, using a private JWK that's already IN MEMORY by the time this function runs — it never touches node:fs itself, preserving the existing "no file I/O in auth.ts" guarantee (auth.spec.ts's "nenhum I/O de arquivo" suite). Reading the key off disk is a SEPARATE, new module, apps/runner/src/device-key.ts, on purpose (see Decision 4).

This is explicitly NOT the "dual-auth with a session JWT" that RN-439 already forbids on this route. The guard's own docblock (pat-auth.guard.ts:41-53) states the distinction: the session JWT from login is still never accepted here — accepting it would let RolesGuard authorize a logged-in browser for anything its role permits everywhere else in the api, which is exactly the scope-escape RN-439/ADR 0105 built @RequirePatAuth() to prevent. The device-key JWT is a different animal: a token the runner signs ITSELF, with a key the api never saw the private half of, asserting only "I am a registered device for this user, on this project" — as scoped as the PAT it sits beside, never a session credential in disguise.

2. The api proxies the already-published GitHub Releases binary — it does not build or store a second copy of anything​

RunnerReleasesController (apps/api/src/interfaces/http/runner/runner-releases.controller.ts) adds GET /runner-releases/binary?platform=<target>, @Public() — unauthenticated, by design: the binary itself carries no secret, and requiring login to download the thing you'd use to set up your FIRST credential would be backwards. platform is checked against a closed allowlist (linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64 — the same five targets ADR 0112 already builds) before it touches anything — never interpolated raw into the GitHub URL or the asset name, closing the SSRF/injection vector a free-form parameter would open. The controller resolves the latest release's asset list from the GitHub REST API, caches that resolution (URLs only, never bytes) in an in-memory Map for five minutes (CACHE_TTL_MS) to stay well under GitHub's 60-requests/hour unauthenticated rate limit even under a burst of concurrent downloads, then streams the actual asset bytes straight through (Readable.fromWeb(...).pipe(res)). No new dependency — fetch is Node's own. The alternative of the browser talking to GitHub directly was rejected: the exact asset name is an implementation detail the api already computes (nomeDoAsset), and a direct browser→GitHub call would forfeit the caching this proxy gets for free.

3. File System Access API writes the configured folder directly; a two-file download is the fallback everywhere else​

apps/web/src/lib/runner-bootstrap.ts is the new module both entry points below call into. detectarPlataforma picks the right binary for the user's OS/architecture — in Chromium, via navigator.userAgentData.getHighEntropyValues (the classical userAgent string is deliberately "reduced" and can't be trusted for architecture on modern Chrome); outside Chromium, a best-effort regex over userAgent/ platform, with null (never a guess presented as fact) when confidence is too low, at which point the UI lets the user pick manually.

configurarPastaAutomaticamente (lines 199-229) is the one-click path, available only where showDirectoryPicker exists (Chrome/Edge/Opera): it generates the Ed25519 pair, registers the public key (registerRunnerDeviceKey), downloads the binary from Decision 2's route, opens the browser's native folder picker, and writes THREE files directly into the chosen folder — the executable, brabo-runner.config.json ({ projectId, apiUrl }), and brabo-runner-device-key.jwk.json (the PRIVATE JWK, written to disk only here, never sent over the network). baixarKitManual (lines 250-268) is the fallback used everywhere the File System Access API doesn't exist (Firefox, Safari) or a user opts out of letting a site touch their filesystem: the same three artifacts, but as two ordinary browser downloads (the binary, and a brabo-runner-kit.json bundling the config and the private key) that the user moves into a folder by hand — no zip library, just two files.

RunnerOnboardingPanel.tsx is now the SINGLE component rendering this flow, consumed by all three surfaces that used to disagree: NewProjectWizard's workspace step (runner mode), FolderBrowserModal's "no runner connected" state, and TerminalPanel's equivalent state. Fixing the real divergence found during this investigation — the wizard's own untracked copy of the manual command, missing --token, and the panel's copy using pnpm --filter runner start instead of the binary — was folded into this same change: there is now one manual-command string, always including --token, collapsed by default behind a "prefer to run it manually" <details> disclosure.

Accepted limitation, not a bug: the File System Access API does not preserve the Unix execute bit. After the automatic write, a Linux/macOS user still has to run chmod +x ./brabo-runner themselves — shown pre-filled, ready to copy, in the success state (instrucaoFinal, runner-bootstrap.ts:224-226). This is a browser-platform limitation with no workaround available to a web page; it is not something a future version of this feature is expected to remove.

4. --project/--dir/--token become optional in the CLI when a local config is present​

apps/runner/src/device-key.ts is a new module, deliberately separate from auth.ts (its own docblock explains why: auth.ts carries a dedicated test, "nenhum I/O de arquivo," guarding against reintroducing the global ~/.brabo/... credential cache ADR 0104's Wave 2 removed on purpose — this new module reads a LOCAL, EXPLICIT file the user just placed there by using the browser flow, never a global path). lerConfigLocal/ lerChaveDeDispositivo read brabo-runner.config.json and brabo-runner-device-key.jwk.json from the CURRENT working directory, returning null — never throwing — for a missing file, invalid JSON, or a missing required field, because the absence of these files is the NORMAL case for anyone still using explicit flags.

lerArgumentos in apps/runner/src/index.ts (lines 95-206) now resolves each of the three inputs with "explicit flag wins over local file" as the one rule repeated three times: --project beats configLocal.projectId; --dir beats . (which resolves to the current working directory, same as today's forced default) when the local config exists; --token/ BRABO_ACCOUNT_TOKEN beats the local device key. With NEITHER a token nor a local device key available, the CLI still exits with usage instructions (uso()) exactly as before — nothing here makes authentication optional, only which of the two forms is used. The net effect: ./brabo-runner with no flags at all works when the current folder is one the browser configured via Decision 3, and every existing invocation with explicit flags is completely unchanged.

Consequences​

Device keys do NOT yet have the maintainer cross-user view that PATs have. RunnerDeviceKeysController's own docblock declares this: unlike PersonalAccessTokensController (RN-427 — a maintainer can list/revoke ANY user's PAT on the project, for incident response), a device key today can only be listed/revoked by the user who registered it. This is a declared cut for this round, not an oversight — extending the same incident-response capability to device keys is straightforward future work if it's asked for, following the exact pattern RN-427 already established for PATs.

A device key JWT never carries the user's identity — only the api's stored registration does. This is intentional (Decision 1), but it means a device key can never be used to impersonate a specific claimed user; it can only ever act as whichever user the ORIGINAL registration belongs to. Revoking the registration is the only way to invalidate it — there is no separate "this specific JWT is bad" denylist, nor does one need to exist, given the 30-second signing TTL plus the 60-second maximum exp - iat enforced independently by the guard.

The binary proxy trusts GitHub Releases as the sole distribution channel. If the "latest" release lacks an asset for a requested platform (a partial release, mid-publish), the route returns 502 rather than guessing at an older tag — consistent with ADR 0112's own declared gap that only linux-x64 has been validated by real execution so far.

The GitHub asset-resolution cache is per-instance, in-memory, with no invalidation hook. A fresh release published mid-window is invisible to a running api instance for up to five minutes. Given how infrequently the runner ships new binaries (tied to the same release cadence as everything else), this was judged an acceptable trade for staying comfortably under GitHub's unauthenticated rate limit — a proper webhook-driven invalidation is not built here.

Alternatives considered and rejected​

  • The runner auto-discovers a local Docker/Kubernetes container and connects to it over SSH, with no folder-selection step at all. Considered and explicitly REJECTED by the product owner in this same session — not a technical dead end, a deliberate scope cut. execution_mode: runner exists precisely because Phase 25b (no product service calls Docker — see CLAUDE.md's "Cortes e pausas vigentes") stays cut: teaching the runner to probe for and connect to local containers would reopen exactly that boundary through a side door, and an SSH-based connection path is a materially different security model from the authentication-plus-approval-pipeline boundary ADR 0103 already established. Left as a candidate for its own future ADR, if and when Phase 25b itself is revisited — not something this feature quietly backs into.
  • Reuse the session JWT as a second runner-ticket credential instead of minting a new device-key mechanism. Rejected on RN-439's own terms: that route's guard exists specifically so a browser session's JWT is NEVER sufficient there, because RolesGuard/@RequireRole would then authorize that session for the full breadth of the user's role everywhere else in the api — the opposite of the narrow, revocable, device-scoped credential this feature needs.
  • Zip the manual-download fallback into a single archive. Rejected: no new dependency justified the convenience — two plain files (the binary, and a small JSON kit) cost nothing extra to produce and nothing extra for a user to unpack.
  • Have the api build and host its own copy of the runner binary instead of proxying GitHub Releases. Rejected: ADR 0106/0112 already publish exactly these five binaries through the release pipeline; maintaining a second build-and-store path for the same artifact would be pure duplication with no benefit — a thin proxy with a short cache is enough.