Pular para o conteúdo principal

ADR 0118 — Configuração do runner pelo navegador: uma chave de dispositivo Ed25519 como segunda credencial do runner-ticket, um proxy do GitHub Releases para o binário e a File System Access API para uma pasta configurada com um clique

  • Status: Aceito
  • Data: 2026-08-27
  • Contexto: fecha o atrito de configurar o brabo-runner na máquina do usuário — até aqui era preciso montar à mão três coisas espalhadas por telas diferentes: o id do projeto, o caminho da pasta e um Personal Access Token emitido numa terceira tela
  • Referências (sem editar): ADR 0103 (o próprio runner e sua fronteira de segurança), ADR 0104 (execution_mode: runner, o item de backlog do PAT que a chave de dispositivo deste ADR complementa), ADR 0105 (o PAT ao qual esta decisão se SOMA, nunca substitui), ADR 0106 e ADR 0112 (os binários standalone que o proxy deste ADR serve), ADR 0107 (navegação de pasta POR MEIO de um runner já conectado — este ADR trata de conectar o runner em primeiro lugar)

Contexto​

execution_mode: runner (ADR 0104) permite que o código de um projeto more numa pasta da própria máquina do usuário, sem bind-mount e sem container — mas chegar a esse estado exigia que uma pessoa, à mão: achasse o id do projeto, gerasse um Personal Access Token em Configurações do Projeto → Tokens de Acesso (ADR 0105), copiasse um comando brabo-runner --project <id> --dir <path> --token <token> que alguém tivesse digitado corretamente, baixasse ela mesma o binário certo para o seu SO/arquitetura e o rodasse da pasta certa. Três informações, três telas, e um segredo que o usuário tinha de digitar ou colar num terminal.

NewProjectWizard.tsx e RunnerOnboardingPanel.tsx tinham, cada um, desenvolvido com o tempo o PRÓPRIO texto de comando para isso, e os dois divergiram — a cópia do assistente nunca incluiu --token, e a do painel usava a sintaxe pnpm --filter runner start em vez do binário. Nenhuma estava errada quando foi escrita; nada obrigava as duas superfícies a concordar, e elas deixaram de concordar em silêncio.

O dono do produto também perguntou, explicitamente, se o runner poderia DESCOBRIR sozinho um container Docker/Kubernetes local e se conectar por SSH, em vez de exigir que um humano passasse por qualquer configuração. Esse caminho foi considerado e recusado nesta mesma sessão — ver "Alternativas consideradas e recusadas" abaixo.

Decisão​

1. Uma chave de dispositivo Ed25519, gerada no navegador, é uma segunda credencial do runner-ticket — soma-se ao PAT, nunca o substitui​

Tabela nova 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 — a metade PÚBLICA de um par de chaves Ed25519 que o navegador gera com crypto.subtle.generateKey (apps/web/src/lib/runner-bootstrap.ts:123-131). A metade privada nunca sai do navegador pela rede; só a JWK pública exportada é enviada por POST a 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). Essa rota é autenticada pelo JWT de sessão NORMAL, @RequireRole('developer') — quem chama aqui é de fato o navegador logado registrando o dispositivo que ele está prestes a configurar, a imagem espelhada de PersonalAccessTokensController emitindo um PAT. RevokeRunnerDeviceKeyUseCase apaga (marca como revogada) do mesmo jeito que RevokePersonalAccessTokenUseCase — idempotente, null para "não encontrada ou não é sua", a mesma disciplina de não revelar da RN-426.

PatAuthGuard — o mesmo guard que POST .../runner-ticket já usava para o PAT (ADR 0105, RN-439) — ganhou um segundo ramo (apps/api/src/interfaces/http/auth/pat-auth.guard.ts:104-116): um bearer token que começa com brb_ segue o caminho do PAT, inalterado; um bearer token com forma de JWT compacto (três segmentos separados por ponto) segue um caminho novo, autenticarChaveDeDispositivo (linhas 144-216). Ele lê o kid não verificado do cabeçalho do JWT (decodeProtectedHeader — sem checar assinatura ainda), procura uma linha ATIVA de runner_device_keys por esse kid, verifica a assinatura com importJWK/jwtVerify do jose contra a chave pública ARMAZENADA e impõe um TTL curto e obrigatório: exp - iat deve ser no máximo 60 segundos (TTL_MAXIMO_DEVICE_KEY_SEGUNDOS, linha 23) — conferido contra a vida que o PRÓPRIO token declara, não contra "agora", de modo que um token capturado não possa ser reapresentado com proveito, seja quando for. O userId usado na autorização vem do registro ARMAZENADO, nunca de uma claim do JWT — o token só precisa provar a posse da chave privada, não afirmar de quem ela é. O projectId é conferido duas vezes: a claim do próprio JWT contra o parâmetro da rota e, separadamente, o projectId da chave registrada contra a rota (espelhando as duas checagens idênticas do caminho do PAT) — uma claim forjada numa chave registrada para outro projeto é recusada com 403, seguindo a distinção de categoria do caminho do PAT entre "não autenticado" e "autenticado para o projeto errado".

O próprio runner assina esse JWT, em apps/runner/src/auth.ts (assinarTicketComChaveDeDispositivo, linhas 143-154): EdDSA, expiração de 30 segundos, kid igual ao id de registro da chave de dispositivo, usando uma JWK privada que já está EM MEMÓRIA quando a função roda — ela nunca toca node:fs, preservando a garantia existente de "nenhum I/O de arquivo em auth.ts" (a suíte "nenhum I/O de arquivo" de auth.spec.ts). Ler a chave do disco é um módulo NOVO e SEPARADO, apps/runner/src/device-key.ts, de propósito (ver Decisão 4).

Isto explicitamente NÃO é a "autenticação dupla com JWT de sessão" que a RN-439 já proíbe nesta rota. O docblock do próprio guard (pat-auth.guard.ts:41-53) afirma a distinção: o JWT de sessão do login continua nunca sendo aceito aqui — aceitá-lo deixaria o RolesGuard autorizar um navegador logado para tudo o que o papel dele permite em todo o resto da api, que é exatamente a fuga de escopo que a RN-439/ADR 0105 construiu @RequirePatAuth() para impedir. O JWT da chave de dispositivo é outro bicho: um token que o runner assina SOZINHO, com uma chave cuja metade privada a api nunca viu, afirmando só "sou um dispositivo registrado para este usuário, neste projeto" — tão escopado quanto o PAT ao lado do qual ele está, nunca uma credencial de sessão disfarçada.

2. A api faz proxy do binário já publicado no GitHub Releases — ela não constrói nem guarda uma segunda cópia de nada​

RunnerReleasesController (apps/api/src/interfaces/http/runner/runner-releases.controller.ts) acrescenta GET /runner-releases/binary?platform=<target>, @Public() — sem autenticação, por desenho: o binário em si não carrega segredo, e exigir login para baixar a coisa que você usaria para configurar a sua PRIMEIRA credencial seria andar ao contrário. platform é conferido contra uma lista fechada de permitidos (linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64 — os mesmos cinco alvos que o ADR 0112 já constrói) antes de tocar qualquer coisa — nunca interpolado cru na URL do GitHub nem no nome do asset, fechando o vetor de SSRF/injeção que um parâmetro livre abriria. O controller resolve a lista de assets da release latest pela API REST do GitHub, guarda essa resolução em cache (só URLs, nunca bytes) num Map em memória por cinco minutos (CACHE_TTL_MS) para ficar bem abaixo do limite de 60 requisições/hora sem autenticação do GitHub mesmo sob uma rajada de downloads simultâneos, e então repassa os bytes do asset direto (Readable.fromWeb(...).pipe(res)). Nenhuma dependência nova — o fetch é do próprio Node. A alternativa de o navegador falar direto com o GitHub foi recusada: o nome exato do asset é detalhe de implementação que a api já calcula (nomeDoAsset), e uma chamada direta navegador→GitHub abriria mão do cache que este proxy ganha de graça.

3. A File System Access API escreve a pasta configurada diretamente; um download de dois arquivos é o plano B em todo o resto​

apps/web/src/lib/runner-bootstrap.ts é o módulo novo que os dois pontos de entrada abaixo chamam. detectarPlataforma escolhe o binário certo para o SO/arquitetura do usuário — no Chromium, via navigator.userAgentData.getHighEntropyValues (a string clássica userAgent é deliberadamente "reduzida" e não é confiável para arquitetura no Chrome moderno); fora do Chromium, uma regex de melhor esforço sobre userAgent/platform, com null (nunca um palpite apresentado como fato) quando a confiança é baixa demais, e aí a interface deixa o usuário escolher à mão.

configurarPastaAutomaticamente (linhas 199-229) é o caminho de um clique, disponível só onde showDirectoryPicker existe (Chrome/Edge/Opera): ele gera o par Ed25519, registra a chave pública (registerRunnerDeviceKey), baixa o binário pela rota da Decisão 2, abre o seletor de pasta nativo do navegador e escreve TRÊS arquivos direto na pasta escolhida — o executável, brabo-runner.config.json ({ projectId, apiUrl }) e brabo-runner-device-key.jwk.json (a JWK PRIVADA, escrita em disco só aqui, nunca enviada pela rede). baixarKitManual (linhas 250-268) é o plano B usado em todo lugar onde a File System Access API não existe (Firefox, Safari) ou o usuário prefere não deixar um site tocar o seu sistema de arquivos: os mesmos três artefatos, mas como dois downloads comuns do navegador (o binário e um brabo-runner-kit.json reunindo a configuração e a chave privada) que o usuário move para uma pasta à mão — sem biblioteca de zip, só dois arquivos.

RunnerOnboardingPanel.tsx passa a ser o ÚNICO componente que desenha esse fluxo, consumido pelas três superfícies que antes divergiam: o passo workspace do NewProjectWizard (modo runner), o estado "nenhum runner conectado" do FolderBrowserModal e o estado equivalente do TerminalPanel. A correção da divergência real achada nesta investigação — a cópia própria e não rastreada do comando manual no assistente, sem --token, e a do painel usando pnpm --filter runner start em vez do binário — entrou nesta mesma mudança: agora há uma única string de comando manual, sempre com --token, recolhida por padrão atrás de um <details> "prefere rodar à mão".

Limitação aceita, não defeito: a File System Access API não preserva o bit de execução do Unix. Depois da escrita automática, um usuário de Linux/macOS ainda tem de rodar chmod +x ./brabo-runner ele mesmo — mostrado já preenchido, pronto para copiar, no estado de sucesso (instrucaoFinal, runner-bootstrap.ts:224-226). É uma limitação da plataforma do navegador, sem contorno disponível a uma página web; não é algo que uma versão futura desta funcionalidade deva remover.

4. --project/--dir/--token ficam opcionais no CLI quando há uma configuração local​

apps/runner/src/device-key.ts é um módulo novo, deliberadamente separado de auth.ts (o próprio docblock explica por quê: auth.ts tem um teste dedicado, "nenhum I/O de arquivo", que impede reintroduzir o cache global de credencial ~/.brabo/... que a Onda 2 do ADR 0104 removeu de propósito — este módulo novo lê um arquivo LOCAL e EXPLÍCITO que o usuário acabou de pôr ali usando o fluxo do navegador, nunca um caminho global). lerConfigLocal/lerChaveDeDispositivo leem brabo-runner.config.json e brabo-runner-device-key.jwk.json do diretório de trabalho ATUAL, devolvendo null — nunca lançando — para arquivo ausente, JSON inválido ou campo obrigatório faltando, porque a ausência desses arquivos é o caso NORMAL para quem ainda usa flags explícitas.

lerArgumentos em apps/runner/src/index.ts (linhas 95-206) agora resolve cada uma das três entradas com "flag explícita vence arquivo local" como a única regra, repetida três vezes: --project vence configLocal.projectId; --dir vence . (que resolve para o diretório de trabalho atual, o mesmo default forçado de hoje) quando a configuração local existe; --token/BRABO_ACCOUNT_TOKEN vence a chave de dispositivo local. Sem token NEM chave de dispositivo local, o CLI continua saindo com as instruções de uso (uso()) exatamente como antes — nada aqui torna a autenticação opcional, só qual das duas formas é usada. O efeito líquido: ./brabo-runner sem flag nenhuma funciona quando a pasta atual é uma que o navegador configurou pela Decisão 3, e toda invocação existente com flags explícitas fica completamente inalterada.

Consequências​

Chaves de dispositivo AINDA NÃO têm a visão de maintainer sobre outros usuários que os PATs têm. O próprio docblock de RunnerDeviceKeysController declara isso: ao contrário de PersonalAccessTokensController (RN-427 — um maintainer pode listar/revogar o PAT de QUALQUER usuário no projeto, para resposta a incidente), uma chave de dispositivo hoje só pode ser listada/revogada pelo usuário que a registrou. É um corte declarado desta rodada, não um esquecimento — estender a mesma capacidade de resposta a incidente às chaves de dispositivo é trabalho futuro direto, se for pedido, seguindo exatamente o padrão que a RN-427 já estabeleceu para os PATs.

Um JWT de chave de dispositivo nunca carrega a identidade do usuário — só o registro armazenado na api carrega. Isto é intencional (Decisão 1), mas significa que uma chave de dispositivo nunca pode ser usada para se passar por um usuário declarado; ela só age como o usuário a quem o registro ORIGINAL pertence. Revogar o registro é a única forma de invalidá-la — não existe lista negra separada de "este JWT específico é ruim", nem precisa existir, dado o TTL de assinatura de 30 segundos mais o máximo de 60 segundos de exp - iat imposto de forma independente pelo guard.

O proxy do binário confia no GitHub Releases como único canal de distribuição. Se a release "latest" não tiver asset para a plataforma pedida (uma release parcial, no meio da publicação), a rota devolve 502 em vez de chutar uma tag mais antiga — coerente com a lacuna declarada pelo próprio ADR 0112 de que só linux-x64 foi validado por execução real até aqui.

O cache de resolução de assets do GitHub é por instância, em memória, sem gancho de invalidação. Uma release nova publicada no meio da janela fica invisível para uma instância da api em execução por até cinco minutos. Dada a pouca frequência com que o runner publica binários novos (preso à mesma cadência de release de todo o resto), isso foi julgado uma troca aceitável para ficar com folga abaixo do limite sem autenticação do GitHub — uma invalidação de verdade por webhook não é construída aqui.

Alternativas consideradas e recusadas​

  • O runner descobre sozinho um container Docker/Kubernetes local e se conecta a ele por SSH, sem passo nenhum de escolha de pasta. Considerada e explicitamente RECUSADA pelo dono do produto nesta mesma sessão — não um beco técnico, um corte de escopo deliberado. execution_mode: runner existe justamente porque a Fase 25b (nenhum serviço do produto chama o Docker — ver "Cortes e pausas vigentes" no CLAUDE.md) continua cortada: ensinar o runner a sondar e se conectar a containers locais reabriria exatamente essa fronteira por uma porta lateral, e um caminho de conexão por SSH é um modelo de segurança materialmente diferente da fronteira autenticação-mais-pipeline-de-aprovação que o ADR 0103 já estabeleceu. Fica como candidata a ADR próprio, se e quando a própria Fase 25b for revista — não algo em que esta funcionalidade entre de ré, calada.
  • Reusar o JWT de sessão como segunda credencial do runner-ticket em vez de criar um mecanismo novo de chave de dispositivo. Recusada nos termos da própria RN-439: o guard dessa rota existe especificamente para que o JWT de uma sessão de navegador NUNCA baste ali, porque o RolesGuard/@RequireRole autorizaria então essa sessão para toda a amplitude do papel do usuário em todo o resto da api — o oposto da credencial estreita, revogável e escopada ao dispositivo de que esta funcionalidade precisa.
  • Compactar o plano B de download manual num único arquivo zip. Recusada: nenhuma dependência nova justificava a comodidade — dois arquivos simples (o binário e um pequeno kit JSON) não custam nada a mais para produzir nem para o usuário desempacotar.
  • A api construir e hospedar a própria cópia do binário do runner em vez de fazer proxy do GitHub Releases. Recusada: os ADRs 0106/0112 já publicam exatamente esses cinco binários pela esteira de release; manter um segundo caminho de construir-e-guardar o mesmo artefato seria pura duplicação sem benefício — um proxy fino com cache curto basta.