Runner
Onda 1 do ADR 0104 — execution_mode em três valores, workspace verificado pelo runner
RN-423 — O runner CONFIRMA o caminho; sem confirmação, o comando é recusado — nunca cai no container
O runner é a fonte da verdade do caminho de um projeto runner, não o
que foi digitado no wizard. Logo depois do canal Phoenix conectar
(terminal:<projectId>, papel :runner), apps/runner/src/index.ts
empurra workspace_confirm com o --dir que recebeu na linha de comando.
O engine repassa (Engine.Sessions.EngineApiClient.confirm_workspace/4)
para POST /internal/projects/:projectId/workspace-verification
(ConfirmProjectWorkspaceUseCase), que:
- Recusa com
400se o projeto não estiver no modorunner; - Revalida o caminho pelo MESMO predicado léxico da criação (RN-422) — mesmo vindo do runner, raiz de sistema e sobreposição com o Brabo continuam proibidas;
- SOBRESCREVE
workspacePathcom o caminho normalizado e gravaworkspaceVerifiedAt = now()— sem exigir igualdade com o que foi digitado na criação: o runner manda; - É IDEMPOTENTE — reconectar reportando o MESMO caminho não regrava nada
(
changed: false); um caminho DIFERENTE regrava, porque o runner continua sendo a fonte da verdade a cada reconexão; - Sem sessão no projeto ainda (
ProjectSession.latest_id/1devolvenil): oUPDATEacontece do mesmo jeito, só o eventoproject.workspace_verified(e a evidência do gateworkspace-verificado) fica ausente — a MESMA degradação quepty_open/pty_closejá aceitam (RN-108/RN-420). Lacuna aceita e declarada, não silenciosa.
Engine.Actions.TerminalExecutor.decisao_de_execucao/1 ganhou QUATRO
saídas, não duas: runner com workspace_verified_at: nil recusa
(:recusar_nao_verificado); runner verificado sem runner conectado
recusa (:recusar_runner_desconectado); runner verificado e conectado
roteia (:rotear_runner); qualquer outro modo segue o caminho de sempre
(:caminho_de_sempre). As duas recusas NUNCA caem no System.cmd — um
projeto runner não tem bind-mount, e "cair pro caminho de sempre" seria
executar às cegas numa pasta que o processo do engine não enxerga. A
mesma recusa vale na corrida (Registry dizia conectado no início de
run/3, mas o runner caiu antes do dispatch responder).
- Onde:
apps/api/src/application/use-cases/iam/confirm-project-workspace.use-case.ts,apps/api/src/interfaces/http/internal/internal-projects.controller.ts(POST :projectId/workspace-verification),apps/engine/lib/engine/actions/terminal_executor.ex(decisao_de_execucao/1),apps/engine/lib/engine_web/channels/terminal_channel.ex(handle_in("workspace_confirm", ...)),apps/engine/lib/engine/sessions/engine_api_client.ex(confirm_workspace/4),apps/runner/src/channel.ts(enviarWorkspaceConfirm),apps/runner/src/index.ts - Teste:
apps/api/test/application/use-cases/iam/confirm-project-workspace.use-case.spec.ts,apps/engine/test/engine/actions/terminal_executor_test.exs(describe "roteamento pro runner local"),apps/engine/test/engine_web/channels/terminal_channel_test.exs(describe "workspace_confirm"),apps/runner/src/channel.spec.ts(enviarWorkspaceConfirmdepois do join) - Borda: o
apps/api/config/gates.ymlregistraworkspace-verificadocomoseveridade: warn— quem trava de verdade é a recusa explícita do engine, o gate é só o registro/evidência. - Decisão arquitetural: ADR 0104
- Origem: reconciliação do ADR 0104, decisão confirmada com o dono do produto (o runner é a fonte da verdade; sem sessão, o UPDATE acontece mesmo assim; comando recusado explicitamente sem verificação/conexão)
RN-429 — Navegação de pasta local é relay puro pelo Runner, nunca a api enumerando o container
O canal terminal:<projectId> ganha dois eventos, no MESMO desenho de relay
do PTY: fs_list_dir/fs_home_dir (:web pede, engine faz relay DIRETO
pro pid do runner registrado, erro imediato quando não há runner conectado
— nunca fica esperando uma resposta que não vem) e
fs_list_dir_reply/fs_home_dir_reply (:runner responde, broadcast
filtrado só pra :web, correlacionado por ref gerado pelo cliente). A api
NÃO ganha rota nova nenhuma — ela continua sem enumerar filesystem, o
argumento que a ADR 0072 já tinha fixado contra um seletor de pasta continua
de pé. apps/runner/src/guard.ts (que restringe cwd de comando já
aprovado à raiz do projeto) de propósito NÃO se aplica à navegação — listar
diretório é leitura livre pela máquina do usuário, com os privilégios que
ele já tem, e uma entrada sem permissão é pulada, não aborta a listagem
inteira.
Gap declarado: o ticket do canal é emitido POR PROJETO já existente — na
tela de criação de projeto (NewProjectWizard.tsx), o projeto só nasce na
confirmação, então FolderBrowserModal recebe projectId: null ali e
mostra o estado declarado em vez de tentar uma conexão impossível; o campo
de texto livre continua sendo o caminho manual, como antes.
- Onde:
apps/engine/lib/engine_web/channels/terminal_channel.ex;apps/runner/src/fs-browser.ts,apps/runner/src/channel.ts,apps/runner/src/index.ts;apps/web/src/lib/fs-browser-channel.ts,apps/web/src/components/FolderBrowserModal.tsx,apps/web/src/components/RunnerOnboardingPanel.tsx,apps/web/src/routes/NewProjectWizard.tsx,apps/web/src/routes/code/TerminalPanel.tsx - Teste:
terminal_channel_test.exs(relay puro web↔runner, erro imediato sem runner, papel errado ignorado);fs-browser.spec.ts(ordenação, pasta vazia/inexistente/arquivo, permissão por entrada);FolderBrowserModal.test.tsx(projectId nulo, navegação, seleção, sem runner);NewProjectWizard.test.tsx(botão "Procurar pasta...") - ADR: 0107, revisa a 0072 sem editá-la
- Origem: pedido do dono do produto — "não consegui linkar com uma pasta do usuário"
Terminal do runner local preso em "Abrindo terminal..." para sempre (RN-433)
RN-434 — No Linux, --dir do runner local só é aceito dentro do $HOME do usuário
O CLI brabo-runner (modo runner, ADR 0103/0104) validava só que
--dir existe e é uma pasta — sem restrição nenhuma de ONDE essa pasta
podia estar no sistema de arquivos. lerArgumentos()
(apps/runner/src/index.ts) passou a chamar
validarDirDentroDoHomeNoLinux(dir, process.platform, homedir())
(apps/runner/src/guard.ts) logo depois da checagem de existência: no
Linux (process.platform === 'linux'), --dir só é aceito dentro de
os.homedir() — o próprio $HOME ou qualquer subpasta dele; um caminho
fora dessa árvore (/etc, /root, /, ou até outra conta em /home)
é recusado com process.exit(2) e mensagem explicando o motivo e
mostrando o home esperado. Fora do Linux (macOS, Windows) o
comportamento não muda — a restrição é só para Linux.
A checagem reusa dentroDoEscopo/semBarraFinal, os mesmos primitivos
de comparação de caminho que validarCwdDentroDaRaiz já usa no mesmo
módulo, mas é deliberadamente mais simples que ela: sem realpath nem
proteção a TOCTOU/symlink, porque não protege contra um SERVIDOR
malicioso (aquele é o papel de validarCwdDentroDaRaiz, exercido a cada
exec) — é uma checagem de STARTUP do próprio CLI local, orientando o
usuário que digitou um caminho errado ao subir o runner.
- Onde:
apps/runner/src/guard.ts(DirForaDoHomeError/validarDirDentroDoHomeNoLinux),apps/runner/src/index.ts(lerArgumentos) - Teste:
apps/runner/src/guard.spec.ts(describe('validarDirDentroDoHomeNoLinux', …)— aceita o próprio home e subpasta dele no Linux, recusa caminho fora do home e outra conta dentro de/homeno Linux, e confirma que a restrição NÃO se aplica fora do Linux) - ADR: 0104
(extensão aditiva — o runner já era declarado "fonte da verdade do
caminho"; esta regra só estreita o que ele aceita como
--dirno Linux) - Origem: pedido explícito do dono do produto, durante um teste real
de criação de projeto (tentativa de apontar
--dirpara um caminho fora do home,/home/dev/exp001, que não existia na máquina — levou à decisão de travar caminhos fora do$HOMEno Linux)
RN-435 — --dir do runner local inexistente é criado, não recusado
O CLI brabo-runner (modo runner, ADR 0103/0104) recusava com
process.exit(2) qualquer --dir que ainda não existisse no disco — a
pasta do projeto tinha de ser criada manualmente antes de subir o CLI.
garantirDiretorio() (apps/runner/src/guard.ts) passou a criar a pasta
automaticamente (mkdirSync(dir, { recursive: true })) quando ela não
existe, e lerArgumentos() (apps/runner/src/index.ts) chama essa função
LOGO DEPOIS de validarDirDentroDoHomeNoLinux (RN-434) — a ORDEM é a
regra: a checagem do $HOME funciona em caminho que ainda não existe (só
resolve(), sem tocar disco), então roda primeiro e continua recusando
--dir fora do home no Linux mesmo quando ele ainda não existe, ANTES de
qualquer tentativa de criação — criar primeiro reabriria a brecha que a
RN-434 tinha acabado de fechar. --dir apontando para um ARQUIVO já
existente continua erro real (DirNaoEUmaPastaError, process.exit(2),
sem tentar criar nada) — este CLI nunca sobrescreve um arquivo
silenciosamente. Falha na criação em si (permissão negada, disco cheio,
etc.) também recusa (NaoConsegiuCriarDiretorioError), com a mensagem do
erro original embutida.
- Onde:
apps/runner/src/guard.ts(garantirDiretorio/DirNaoEUmaPastaError/NaoConsegiuCriarDiretorioError),apps/runner/src/index.ts(lerArgumentos) - Teste:
apps/runner/src/guard.spec.ts(describe('garantirDiretorio', …)— não faz nada quando a pasta já existe, cria recursivamente quando não existe, recusa sem criar quando o caminho já existe e é um arquivo, e embrulha falha demkdiremNaoConsegiuCriarDiretorioError; e um caso a mais emdescribe('validarDirDentroDoHomeNoLinux', …)provando que um--dirfora do home e AINDA INEXISTENTE continua recusado, confirmando que a ordem das duas checagens não abre brecha) - ADR: 0104 (extensão aditiva — não muda o desenho do runner como fonte da verdade do caminho, só reduz o atrito de subir um projeto novo)
- Origem: pedido do dono do produto, ao notar que a pasta de um
projeto novo no modo
runnernão existia na máquina e precisava ser criada manualmente antes de rodar o CLI — o mesmo cenário que motivou a RN-434 (/home/dev/exp001inexistente), agora resolvido criando a pasta em vez de só recusar
O explorador de pasta do Runner vira três colunas, e a criação de um projeto runner deixa de esperar o passo final (RN-436/436)
RN-452 — node-pty resolvido por injeção, não import estático — e a lacuna que isso abriu no smoke do npm foi fechada no mesmo commit
pty.ts deixou de fazer import * as nodePty from 'node-pty' estático no
topo do módulo — passou a receber o módulo já resolvido por injeção no
construtor de GerenciadorDePty, resolvido uma vez em main()
(src/index.ts) via native-pty-loader.ts#carregarNodePty(). A mudança
existe para o binário standalone (ADR 0112): o caminho compilado precisa
extrair os arquivos embutidos pra um diretório real ANTES de resolver
node-pty, o que exige uma chamada assíncrona — incompatível com um
import estático hoisted. Fora do binário compilado, o comportamento é
idêntico a antes (await import('node-pty'), resolvido do node_modules
de quem instalou o pacote).
A lacuna que a mudança abriu, fechada no MESMO commit: a ADR 0106 já
registrava que o import estático de node-pty, por ser hoisted antes de
qualquer parsing de argumento, fazia smoke-dist.mjs provar que o binding
nativo carregava só por IMPORTAR dist/index.cjs — mesmo no caminho de
uso() (zero argumentos), que nunca chega a main(). Mover a resolução
pra dentro de main() quebrou essa garantia em silêncio, porque
smoke-dist.mjs só exercitava o caminho de uso(). smoke-dist.mjs
ganhou uma terceira checagem — roda o CLI com argumentos válidos (mas sem
api/engine reais do outro lado) e espera a linha node-pty carregado com sucesso no stdout antes de matar o processo — fechando a lacuna que a
própria mudança abriu, em vez de deixá-la como perda de cobertura
silenciosa.
- Onde:
apps/runner/src/pty.ts(GerenciadorDePty, injeção deNodePtyModule);apps/runner/src/native-pty-loader.ts(carregarNodePty);apps/runner/src/index.ts(main, resolve uma vez);apps/runner/scripts/smoke-dist.mjs(terceira checagem,verificarNodePtyCarrega) - Teste:
apps/runner/scripts/smoke-dist.mjs(caminho npm/tsup) eapps/runner/scripts/smoke-bin.mjs(caminho binário, RN-451) — os dois únicos lugares que exercitam o artefato empacotado de verdade, nenhum teste unitário substitui - ADR: 0112
- Origem: necessidade técnica do binário standalone; a lacuna no smoke do npm foi achada por auditoria própria da mudança, não por execução real reportando falha
Configuração do runner pelo navegador (RN-464..466, ADR 0118)
RN-466 — brabo-runner roda sem --project/--dir/--token quando a pasta tem config local gravada pelo navegador
Revisada pela RN-687 (ADR 0203, 01/10): o navegador não grava mais esses arquivos — o fluxo foi aposentado. A LEITURA continua inteira: uma pasta configurada antes do ADR 0203 segue funcionando, e é também por
--dirapontando para$XDG_CONFIG_HOME/brabo/que a unit de máquina acha a chave criada no terminal (RN-551).
apps/runner/src/device-key.ts (módulo NOVO, separado de propósito de
auth.ts) lê — nunca escreve — brabo-runner.config.json
({projectId, apiUrl}) e brabo-runner-device-key.jwk.json do cwd()
atual; ausência de qualquer um dos dois devolve null, nunca lança, porque
essa ausência é o caso NORMAL de quem ainda usa flags explícitas. Em
lerArgumentos (apps/runner/src/index.ts), a regra é a MESMA três vezes —
flag explícita sempre vence o arquivo local: --project vence
configLocal.projectId; --dir ausente cai para . (a própria pasta onde
o comando roda) quando havia config local, em vez do erro de uso de antes;
--token/BRABO_ACCOUNT_TOKEN vence a chave de dispositivo local quando
ambos existem. Sem NENHUMA credencial (nem token, nem chave local), o CLI
continua recusando com a mensagem de uso — nada aqui torna a autenticação
opcional, só qual das duas formas é usada.
- Onde:
apps/runner/src/device-key.ts(lerConfigLocal,lerChaveDeDispositivo),apps/runner/src/index.ts:95-206(lerArgumentos) - Teste:
apps/runner/src/device-key.spec.ts(caminho feliz de cada leitura;nullsem lançar para arquivo ausente/JSON inválido/campo faltando; respeita ocwdrecebido; módulo não importawriteFileSync/mkdirSync) - ADR: 0118
- Origem: pedido do dono do produto
O artefato de decisão dos conversacionais (RN-505)
RN-507 — Docker vira pré-requisito real do modo runner: os TRÊS modos de execução exigem o MESMO predicado
Até aqui, Engine.Actions.TerminalExecutor.decisao_de_execucao/1 checava só
DUAS pré-condições para rotear terminal a um projeto execution_mode: runner
— workspace CONFIRMADO e runner CONECTADO agora — e roteava incondicionalmente
assim que as duas batiam, sem checar se havia container running REGISTRADO
(Engine.Containers.ProjectContainerLifecycle.running?/1), o mesmo predicado
que container/mounted já exigiam desde a RN-502 (ADR 0143). O runner do
lado de lá decidia sozinho se o comando rodava dentro do container que ele
subiu (ADR 0137) ou caía no HOST puro — e sem Docker de pé na máquina do
usuário, caía sempre no host, silenciosamente: o isolamento que o ADR 0134
buscou criar para container nunca valeu para runner.
A correção unifica os três modos sob a MESMA pergunta. runner ganha uma
TERCEIRA pré-condição, na mesma ordem que as outras duas: verificado →
conectado → container running. As três recusas nomeadas (workspace não
verificado, runner desconectado, sem container) vivem hoje em
Engine.Runners.RunnerReadiness — extraído porque um SEGUNDO consumidor
nasceu no mesmo PR (Engine.Actions.Workspace.RunnerGit, abaixo): "não são
duas derivações, é esta função com dois consumidores", o mesmo raciocínio do
moduledoc de ProjectContainerLifecycle.
A materialização do worktree do dev agent muda de mecanismo, não de
resultado. Engine.Actions.Workspace.ensure!/4 tentava File.mkdir_p!/
System.cmd("git", ...) LOCAL contra o caminho do HOST, mesmo em modo
runner — a lacuna que a RN-478 registrou e deixou ABERTA de propósito ("o
working tree do dev agent não tem onde nascer", já que o processo do engine
não tem bind-mount nenhum para uma pasta que mora na máquina do usuário). A
correção não é "melhorar a mensagem de erro" de novo: ensure!/4 passa a
bifurcar por execution_mode — LOCAL para container/mounted (inalterado:
mounted já enxerga sua pasta via BRABO_PROJECTS_BASE, bind-mount por
identidade no engine desde o ADR 0141), via Engine.Actions.Workspace. RunnerGit para runner. RunnerGit faz git init/remote add/fetch/
checkout (e os equivalentes de File.dir?/ls/rm_rf que
Engine.Dev.WorktreeManager precisa) como comandos de shell NORMAIS,
entregues pelo MESMO canal Phoenix que já executa terminal aprovado
(Engine.Runners.RunnerRouter.exec/5) — nunca um mecanismo novo, e sempre
depois de RunnerReadiness.verificar/1 confirmar as três pré-condições
(levanta com mensagem nomeada antes de tentar qualquer I/O, nunca um exec
às cegas). Engine.Dev.WorktreeManager (create/3, remove/2, list/1,
cleanup_orphans/2) bifurca pelo mesmo critério.
O job periódico de limpeza PULA em silêncio, nunca falha.
Engine.Dev.WorktreeCleanup.run/0 varria File.dir?(work_dir) para decidir
se podava — sempre false para runner (o caminho é do HOST, o engine não
o enxerga), então runner nunca era podado de verdade nem testado por essa
via. O gate vira por MODO: File.dir? continua para container/mounted;
para runner, RunnerReadiness.pronto?/1 decide — sem runner conectado ou
sem container running AGORA, o projeto é pulado NESTA rodada ("não dá pra saber agora", nunca um erro, nunca um worktree tratado como órfão por
engano), e Enum.each/2 continua para os projetos seguintes sem exceção
nenhuma atravessar.
O protocolo exec/exec_result ganha um campo NOVO: env (ADR 0056). O
git fetch autenticado de RunnerGit precisa da credencial do provider —
que só pode viajar no AMBIENTE do processo filho (nunca argv, nunca
.git/config, mesma disciplina de Engine.Actions.GitAuth local). Como o
processo filho agora nasce na máquina do usuário, o campo env (opcional,
Record<string,string>) entra no payload que RunnerRouter.exec/5
despacha — presente só quando há credencial de verdade (provider local
nunca o carrega). Do lado do runner: apps/runner/src/exec.ts MESCLA (nunca
substitui) opts.env em cima de process.env antes do spawn — env
definido sem merge descartaria PATH e quebraria a resolução do próprio
binário git/sh. apps/runner/src/index.ts repassa msg.env só para o
caminho HOST (executarComando) — o container (docker exec, via
packages/docker-port) não ganhou suporte a env (fora de escopo desta
entrega: a operação exec da porta continua sem esse campo, de propósito,
ADR 0130). Nenhum log do runner imprime env em ponto nenhum — auditado por
grep, console.log/console.warn/console.error seguem citando só
ref/command/cwd/exitCode.
- Onde:
apps/engine/lib/engine/actions/terminal_executor.ex(decisao_de_execucao/1);apps/engine/lib/engine/runners/ runner_readiness.ex(novo);apps/engine/lib/engine/actions/workspace.ex(ensure!/4);apps/engine/lib/engine/actions/workspace/runner_git.ex(novo);apps/engine/lib/engine/dev/worktree_manager.ex;apps/engine/lib/engine/dev/worktree_cleanup.ex;apps/engine/lib/engine/projects/project.ex(all_workspace_dirs/0ganhaexecution_mode);apps/engine/lib/engine/runners/runner_router.ex(exec/5);apps/engine/lib/engine_web/channels/terminal_channel.ex;apps/runner/src/channel.ts(ExecMessage.env);apps/runner/src/exec.ts;apps/runner/src/index.ts - Teste:
apps/engine/test/engine/actions/terminal_executor_test.exs,apps/engine/test/engine/actions/workspace_runner_test.exs,apps/engine/test/engine/dev/worktree_manager_test.exs,apps/engine/test/engine/dev/worktree_cleanup_test.exs,apps/runner/src/channel.spec.ts - ADR: 0145
- Origem: decisão do dono do produto — Docker vira pré-requisito real do
modo
runner, sem fallback pro host
O espelho copia o trabalho para a pasta do usuário (RN-516)
RN-516 — O destino do espelho viaja na CONCESSÃO do join, o espelho copia a lista do git numa direção só e nunca apaga, e o gatilho é um momento nomeado — nunca um watcher
A metade B da sessão 6 da FASE 28: a capacidade espelho
(RN-514, ADR 0147)
passa a EXISTIR. A RN-515 decidiu onde o destino mora e não
copiou arquivo nenhum; esta decide como ele chega ao agente local, o que é
copiado e quando.
O destino viaja na concessão do join, nunca em configuração do runner.
A resposta do join do canal terminal:<projectId> deixou de ser vazia para
o papel :runner: quando o projeto tem destino declarado
(projects.mirror_path) e a capacidade espelho foi concedida, ela
carrega %{espelho: %{destino: "<caminho>"}}. O runner guarda isso em
estado.destinoDoEspelho, que morre junto com a conexão, e recusa
mirror_sync para destino que não lhe foi concedido naquela conexão (ADR
0147, ponto 4). Nunca variável de ambiente, nunca arquivo local: um destino
global faria o artefato do projeto B aterrissar na pasta do projeto A, e o
usuário descobriria isso pelo conteúdo, não por um erro.
Consequência declarada, e ela é o preço: trocar o destino com o runner conectado exige reconectá-lo. A concessão é do join, e um destino novo chegando por outra porta seria exatamente a configuração global que a regra recusa.
mirror_path não-nulo passa a EXIGIR a capacidade. Ela não entra no mapa
por execution_mode — quem a exige é um DADO do projeto, não o modo (e
mounted e runner podem ter destino enquanto container não pode). Este é
o primeiro caso REAL do mecanismo de recusa que a RN-514 deixou
implementado e sem nenhum disparo: um brabo-runner anterior a esta versão
conectado a um projeto com destino deixa de conectar, com recusa nomeada
(JoinRecusadoError, fatal e sem retry). É opt-in — só acontece onde
alguém configurou um destino — e o custo está declarado no próprio ADR 0147.
As TRÊS recusas da guarda, e a passada de realpath não é redundante.
apps/runner/src/espelho-guard.ts é irmão de guard.ts, não uma extensão
dele: reusa semBarraFinal/dentroDoEscopo/realpathMaisProximo e a dupla
passada léxica-depois-realpath de validarCwdDentroDaRaiz, e herda por
escrito a mesma ressalva de TOCTOU — é best-effort, não fronteira de
segurança. Ele recusa (1) symlink que escape do destino, (2) destino que
contenha o workspace e (3) destino dentro do workspace; (2) e (3) são
o mesmo laço em sentidos opostos, e com o bind por identidade do
ADR 0141 escrever o espelho
dentro da origem faz o espelho copiar o próprio espelho. A api já recusou a
metade léxica (RN-515) e diz, no próprio código, que é só o
léxico — ela não tem disco onde resolver um symlink do computador do usuário.
destino/atalho -> /caminho/do/projeto passa por lá sem ser visto, e é aqui
que ele é resolvido.
Uma diferença de mecanismo, achada implementando: realpathMaisProximo cai no
primeiro ancestral que existe, e o destino do espelho quase nunca existe (é o
mkdir -p desta rodada que o cria). Colapsar os dois lados no ancestral faria
/base-outra e /base virarem ambos /, e a guarda recusaria toda pasta
irmã. Por isso a segunda passada resolve o ancestral e recoloca o sufixo —
symlink só pode morar num segmento que existe, então o que sobra nunca esconde
link nenhum. A recusa (1) tem ainda uma segunda metade, por arquivo
(caminhoNoDestino): um destino/sub que é link para /etc faria a cópia de
sub/passwd escrever fora, com o destino em si perfeitamente válido.
Uma direção, e NUNCA apaga. Workspace → destino. Nunca --delete, nunca
unlink, nunca rm. Arquivo apagado no workspace permanece no destino, e
isso é decisão, não descuido: a pasta do usuário não é réplica, é acúmulo — um
agente que apaga arquivo na pasta pessoal de alguém por causa de um git clean
do outro lado é a surpresa que o produto existe para não produzir.
Sobrescrever arquivo que mudou é esperado; remover nunca é. O preço, declarado
no ADR: o destino só cresce, e nenhum mecanismo automático o limpa.
O que se copia é a LISTA DO GIT, e nada mais. Os arquivos rastreados
(git ls-files) mais os não-rastreados que não são ignorados
(git ls-files --others --exclude-standard), com -z (o git aspeia e escapa
caminho com espaço ou acento sem ele). Isso é exatamente "o trabalho": pula
node_modules, dist, saída de build e o próprio .git sem manter lista de
exclusão nenhuma, que envelheceria a cada stack nova — o .gitignore do
projeto já é a lista, mantida por quem mantém o projeto. O workspace é
repositório git por construção (o produto provisiona Gitflow nele e o
materializa com git init/fetch/checkout), então a pré-condição é do
PRODUTO, não uma suposição sobre a máquina. Git que falha ou pasta que não é
repositório falham com motivo nomeado — nunca um cp -r de tudo como
plano B, que é como node_modules inteiro acabaria na pasta pessoal de alguém.
Duas propriedades verificadas e declaradas, não escondidas: repositório git
aninhado volta como UMA entrada de diretório e não é descido — o worktree
de cada dev agent (<workspace>/.worktrees/<agent_id>) é um desses, então o
espelho mostra o checkout principal, nunca o trabalho ainda isolado num
worktree; e symlink na origem é pulado, porque seguir o link traria
conteúdo de fora do projeto para dentro da pasta pessoal. O que fica de fora é
CONTADO (pulados/recusados), nunca engolido. E não há heurística de
"mudou?": copiar sempre custa bytes já iguais, enquanto pular por tamanho+mtime
faria o espelho, em silêncio, não copiar um arquivo que mudou.
Predicado PRÓPRIO, e o espelho não exige Docker.
Engine.Runners.RunnerReadiness fica byte a byte como está (ADR 0147,
ponto 3) — Engine.Runners.Espelho não o chama, não o estende e não recebe
nada dele. O espelho tem DUAS pré-condições, workspace confirmado e runner
conectado, e não a terceira que a RN-507 acrescentou (container
REGISTRADO running). Um RunnerReadiness com flag "pula container" é
precisamente o mecanismo pelo qual essa terceira cai por acidente para o exec
numa refatoração futura, e o
ADR 0145 existe para
ela não cair. E o espelho legitimamente não precisa dela: a terceira existe
porque havia duas execuções possíveis e a errada era invisível; cópia de
arquivo na máquina do usuário não tem container de onde cair. Ele também não
pergunta o execution_mode — quem já decidiu isso foi a api ao gravar o
destino (RN-515 recusa container), e repetir a decisão seria a
segunda fonte da mesma regra.
Gatilho por MOMENTO NOMEADO, nunca watcher. O engine empurra mirror_sync
de um ponto do código que tem nome — hoje o commit bem-sucedido
(EngineWeb.ActionCommandController, git_commit). Nunca fs.watch: watcher
é trabalho ilimitado disparado por qualquer coisa — uma instalação de
dependências, um build, e pior, pelas escritas do próprio espelho, que é laço —
e rodaria continuamente na máquina do usuário sem ninguém ter pedido, que é o
oposto do que "agente local" deve significar. O empurrão é fire-and-forget:
sem from, sem pending_execs e sem mirror_sync_result, porque quem
disparou está no meio de outra coisa e a telemetria da sincronização é o ponto
7 do ADR, de outra sessão. Falha do espelho vira log local, nunca exceção que
derruba o runner e nunca silêncio.
Ele NÃO é proposed_action, e não deve virar uma. A escrita do espelho é
configuração que o usuário declarou, não um agente pedindo para agir — o ADR
0147 é explícito disso, e sincronizar por comando de terminal cairia no escopo
do ADR 0055 e viraria
fila de aprovações rotineiras, corroendo o teto que dá sentido ao clique.
- Onde:
apps/runner/src/espelho-guard.ts(as três recusas, a dupla passada com sufixo preservado, a ressalva de TOCTOU herdada);apps/runner/src/espelho.ts(a lista do git, a cópia numa direção, omkdir -psó DEPOIS da guarda);apps/runner/src/guard.ts(os três helpers passam a ser exportados — uma fonte só de comparação de caminho);apps/runner/src/channel.ts(CAPACIDADES_DO_RUNNERganhaespelho,MirrorSyncMessage,EspelhoConcedido,espelhoConcedidoDaResposta);apps/runner/src/index.ts(EstadoDoRunner.destinoDoEspelho,tratarMirrorSync— a recusa do destino não concedido);apps/engine/lib/engine/runners/capacidades.ex(exigidas/2/conceder/3edestino?/1— o destino, não o modo, é quem exige);apps/engine/lib/engine/runners/espelho.ex(o predicado próprio, odestino/1do join e osincronizar/2fire-and-forget);apps/engine/lib/engine/projects/project.ex(mirror_pathno schema de leitura);apps/engine/lib/engine_web/channels/terminal_channel.ex(a concessão na resposta do join e odispatch_mirror_sync);apps/engine/lib/engine_web/controllers/action_command_controller.ex(o gatilho do commit) - Teste:
apps/runner/src/espelho-guard.spec.ts(destino dentro, destino contendo, symlink de verdade emtmpescapando pelo próprio destino e por um segmento do meio,/base-outravs/base, o alvo por arquivo,lstatvsstat);apps/runner/src/espelho.spec.ts(repositório git de verdade: rastreado + não-rastreado-não-ignorado, ignorado de fora, arquivo apagado na origem permanece no destino, repositório aninhado pulado, symlink da origem pulado, symlink do destino recusado, guarda antes domkdir, pasta sem git falhando nomeada);apps/runner/src/channel.spec.ts(o destino vindo na resposta do join, as três capacidades declaradas,mirror_syncchegando ao handler);apps/runner/src/index-handlers.spec.ts(describe "tratarMirrorSync"— o destino não concedido recusado, a conexão sem concessão, a falha que não lança, e o canal sem push nenhum de volta);apps/engine/test/engine/runners/capacidades_test.exs(o destino exigindo a capacidade, o binário legado recusado,destino?/1);apps/engine/test/engine/runners/espelho_test.exs(as DUAS pré-condições e a AUSÊNCIA da terceira —:prontosem container nenhum registrado);apps/engine/test/engine_web/channels/terminal_channel_test.exs(describe "o destino do espelho na concessão do join"— a concessão, as duas recusas e o push demirror_sync);apps/engine/test/engine_web/controllers/action_command_controller_test.exs(o gatilho: commit bem-sucedido dispara, commit que falha não dispara) - ADR: 0147, pontos 2, 3, 4 e 8
- Origem: FASE 28, sessão 6 (metade B). Ficou declarado e não ligado o
segundo momento nomeado do ponto 8, "fim de turno de agente": o engine tem
duas coisas disjuntas com esse nome e nenhuma é um enganche não-ambíguo —
Engine.Agents.TurnoAssincrono.finalizar/1fecha o turno dos seis conversacionais, que não têm ferramenta de escrita no workspace, e também dispara em crash e em cancelamento;Engine.Dev.AgentIo.finish_task/3não é fronteira de ociosidade (reivindica a próxima task na mesma chamada) e o que o dev agent escreve mora no worktree, que o espelho deliberadamente não desce. Escolher um seria inventar produto onde o ADR nomeou um momento sem nomear uma função
O agente local vira serviço de USUÁRIO, e status não colapsa estado (RN-518)
RN-518 — brabo-runner service install|uninstall|status instala nível de USUÁRIO e nunca root; uninstall limpa a pasta que a UNIT registra; status responde quatro estados sem colapsar nenhum; Windows é recusa nomeada
A sessão 7 da FASE 28 e o ponto 5 do
ADR 0147. Até aqui o agente local
era só primeiro plano: um processo encerrado por SIGINT/SIGTERM, que morre
com o terminal e não volta com a sessão do usuário. Não havia precedente
nenhum — nenhuma menção a systemd, launchd ou LaunchAgent em
apps/runner/.
Nível de usuário, SEMPRE — e root é RECUSA, não aviso. systemd --user
no Linux, LaunchAgent no macOS. Isso não é preferência de empacotamento: o
docblock de apps/runner/src/guard.ts:9-31 declara "o runner roda NA máquina
do usuário, com os privilégios DELE" como a premissa em que as três fronteiras
reais do produto se apoiam (autenticação, pipeline de aprovação,
consentimento) — e a guarda de cwd é best-effort por escrito, justamente
porque não é ela que segura nada. Um serviço de sistema rodando como root
quebraria o invariante e deixaria a guarda best-effort como única coisa entre
um comando aprovado e o disco da máquina. Por isso install com uid 0
recusa nomeando o motivo, sem escrever arquivo nenhum e sem rodar comando
nenhum: instalar-e-avisar deixaria a instalação errada de pé, e o aviso seria
lido uma vez só.
Windows fica FORA DE ESCOPO, e a recusa é nomeada. A matriz de build já produz o binário de Windows (ADR 0112), mas serviço de usuário ali é um terceiro mecanismo (Serviços do SO com conta de usuário, ou Agendador de Tarefas no logon) — não uma variação dos dois. Os três subcomandos respondem nomeando a plataforma, dizendo que a decisão é declarada, e apontando o caminho que continua funcionando (rodar em primeiro plano). Nunca falha muda, nunca um "instalado" que não instalou nada. Qualquer outra plataforma cai na mesma recusa.
Uma unit POR PROJETO, nomeada pelo projectId — brabo-runner-<id>.service
/ dev.brabo.runner.<id> — porque o servidor já impõe um runner por
projeto: um segundo join no mesmo terminal:<projectId> é recusado pelo
Engine.Runners.Registry. Nomear pela PASTA permitiria instalar duas units que
nunca podem estar de pé ao mesmo tempo, e a segunda apareceria como falha de
conexão em vez de erro de instalação. Instalar de novo, de outra pasta,
sobrescreve. uninstall e status alcançam exatamente a mesma unit porque
derivam o nome do MESMO projectId, resolvido pelas MESMAS duas fontes na
mesma ordem do CLI de sempre (--project, senão brabo-runner.config.json), e
a pasta sai de resolverDir de guard.ts — nunca uma segunda régua. No Linux
o arquivo vai para onde o systemd procura, com a precedência dele:
$XDG_CONFIG_HOME/systemd/user quando a variável está posta, ~/.config/systemd/user
quando não — escolher só o segundo faria a unit nascer onde o gerenciador nunca
olha, e a falha apareceria como "unit file does not exist" no enable,
visível mas pelo motivo errado.
O projectId vira NOME DE ARQUIVO, então ele é validado aqui. Ele chega de
duas fontes que não são o servidor (uma flag digitada e um arquivo da pasta), e
um --project ../../etc/systemd escreveria fora do diretório de units. Só
letras, dígitos, ., - e _, até 64 caracteres — UUID, o formato real de
todo projeto do produto, passa inteiro. Caminho do runner e pasta com aspa
dupla ou quebra de linha também são recusa nomeada: quebra de linha num arquivo
de unit é injeção de diretiva, não caso de borda.
Um serviço autentica por CHAVE DE DISPOSITIVO, nunca por token. install
exige brabo-runner-device-key.jwk.json válido na pasta e recusa sem ele,
ignorando --token/BRABO_ACCOUNT_TOKEN de propósito: gravar um token de
conta dentro de um arquivo de unit o deixaria em disco, legível por qualquer
processo do usuário e sobrevivendo ao shell que o exportou — e este CLI nunca
gravou credencial em disco (o docblock de auth.ts tem uma trava de teste
contra exatamente isso). A RN-434 roda igual, pela MESMA função:
serviço apontando para fora do $HOME no Linux é recusado, porque fazer por
unit o que a CLI recusa fazer por flag seria contornar a regra pela porta dos
fundos.
Restart=on-abnormal, e nunca on-failure (no plist, KeepAlive com
Crashed, e nunca SuccessfulExit). O runner sai com 1 quando o join foi
RECUSADO — ticket inválido, outro runner no projeto, capacidade que falta
(RN-514) — ou quando esgotou o próprio teto de tentativas, e as duas
coisas são "pare e chame um humano". on-failure reiniciaria uma recusa fatal
em laço, que é exatamente o que o CLI recusa fazer sozinho. Sinal, OOM e
watchdog — o que on-abnormal cobre — são a única falha que reiniciar
conserta. O PATH vai congelado na unit, com o custo declarado no próprio
arquivo: os dois gerenciadores dão ao serviço um PATH mínimo, e o runner chama
git (o espelho, RN-516) e docker (ADR 0137);
sem isso o serviço subiria perfeitamente e as duas funções falhariam com "não
encontrado". É um retrato, e muda só reinstalando.
As DUAS metades do arquivo de unit NÃO têm a mesma sintaxe, e o
WorkingDirectory= vai SEM aspas. ExecStart= é parseado com unquoting e
separação em palavras — por isso cada argumento vai entre aspas —, e
WorkingDirectory= não é: o systemd toma o resto da linha inteiro como
caminho. Tratar as duas como se fossem iguais custou uma release inteira: com
aspas o valor deixa de começar com /, a unit é recusada na carga
(Loaded: bad-setting, WorkingDirectory= path is not absolute) e nunca
inicia — nas duas espécies, em instalação nenhuma. A suíte estava verde o
tempo todo, porque cada asserção pedia de volta exatamente a forma errada: por
isso a prova agora é feita contra systemd-analyze --user verify, o validador
de verdade, e não contra texto. O que SOBRA para escapar é um caractere, e
não é o espaço (a linha vai inteira, então /home/eu/pasta com espaço funciona
literal): é o %, porque essa diretiva passa por expansão de
especificador — /home/dan/50%off vira /home/dan/50<id-do-os-release>ff
sem erro nenhum, e o serviço sobe na pasta errada. %% é a forma de dizer %
literal. A leitura de volta aceita as duas formas, e isso é dívida
deliberada com quem já tem uma unit quebrada no disco: status e uninstall
precisam continuar alcançando a pasta dela. Quem está nesse estado conserta
reinstalando — install sobrescreve o arquivo inteiro.
E há uma TERCEIRA sintaxe no mesmo arquivo: Environment= (AT-095). Ela é
uma LISTA de atribuições separadas por espaço, com unquoting e escape estilo
C, e também expande especificador. Escrita crua — como saía o
XDG_CONFIG_HOME da unit de MÁQUINA e o PATH das duas espécies —,
Environment=XDG_CONFIG_HOME=/home/dan/50%off com espaco chegava ao serviço
como /home/dan/50popff: %o expandido e com/espaco descartados pela
separação em palavras. A unit carrega, verify aprova, o serviço sobe — e o
agente procura a base e a chave numa pasta que não existe, calado. Por isso a
atribuição INTEIRA vai entre aspas (Environment="VAR=valor"), com \ e "
escapados por barra e % como %%; $ não se escapa ali (Environment= não
expande variável). O --dir do ExecStart= tinha a mesma classe e foi junto:
cada argumento continua entre aspas, e agora \ vira \\, % vira %% e $
vira $$ (a substituição de variável do ExecStart=). Quebra de linha em
PATH/XDG_CONFIG_HOME é RECUSADA antes de escrever — num arquivo de unit ela
é diretiva nova, não valor. Nada disso muda a LEITURA de volta: ela só lê o
WorkingDirectory=, e nenhum dos dois valores de ambiente é lido do disco. A
prova é o VALOR EFETIVO, não o verify (que aprova a forma quebrada): o
próprio systemd --test --user --unit=<u>, sobre uma pasta de units
temporária, despeja a configuração resolvida, e o teste compara o que o
systemd ENTENDEU com o que foi gravado.
Ativação que falha NÃO apaga o arquivo, e não diz "instalado". Escrever a
unit e ativá-la são dois passos, e o gerenciador pode não estar lá
(systemctl fora do PATH, launchd recusando). O arquivo fica — é o que a
pessoa precisa para terminar à mão — e a resposta é um terceiro desfecho, que
nomeia o comando pendente e o motivo. Apagar o arquivo transformaria "não
consegui ativar" em "não aconteceu nada", que é falso.
uninstall remove os TRÊS, e a pasta sai da UNIT — nunca do cwd. O ADR é
explícito: o arquivo de unit, o brabo-runner.config.json e o
brabo-runner-device-key.jwk.json. Os dois últimos moram na pasta do runner, e
a pasta que interessa é a que foi INSTALADA, não a de onde a pessoa por
acaso digitou o comando — por isso o WorkingDirectory é lido de volta do
próprio arquivo da unit, que é o registro do que foi instalado. Sem unit não
há registro: os dois arquivos só são removidos com --dir explícito, e a
mensagem diz por quê. Adivinhar pelo cwd apagaria a chave de um projeto que
ninguém pediu para remover. Desativar que falha vira aviso e o arquivo é
removido assim mesmo — uma unit que o gerenciador não reconhece mais não é
motivo para deixar o arquivo para trás.
E uninstall DIZ o que ele não fez: a chave saiu do disco, não do
servidor. A pública correspondente continua em runner_device_keys, e
revogá-la é outra coisa (DELETE /projects/:projectId/runner-device-keys/:deviceKeyId,
ADR 0147 ponto 6). Um "removido"
que deixasse a pessoa achar que revogou seria pior que não remover nada. O log
do LaunchAgent também não é apagado: ele é o registro do usuário sobre o
que aconteceu, e apagá-lo não foi pedido.
status responde QUATRO estados, e eles não colapsam (RN-088):
não instalado, instalado e rodando, instalado e parado, e instalado e
não consegui perguntar. A primeira resposta vem do DISCO — há arquivo de
unit? —, e é por isso que ela continua certa numa máquina onde o gerenciador
não existe; só depois se pergunta ao gerenciador. spawn que falhou (binário
ausente do PATH) nunca vira "o gerenciador disse não": são respostas
diferentes, separadas na fonte, e uma palavra que este CLI não sabe interpretar
também cai em "não consegui perguntar" em vez de virar "parado" por chute.
Cada estado tem frase própria e código de saída próprio (0 / 3 / 4 / 5,
com 1 e 2 fora porque já significam falha genérica e erro de uso neste CLI):
um exit(1) único para os três "não está rodando" colapsaria pelo código
exatamente o que a frase separa.
Nada aqui é proposed_action. É o usuário rodando um comando na própria
máquina, sobre o próprio serviço — não um agente pedindo para agir. service
é despachado antes de lerArgumentos, porque nenhum dos três subcomandos
conecta a nada e o que mais importa (status) precisa funcionar numa pasta
cuja configuração está quebrada, que é justamente quando alguém pergunta.
- Onde:
apps/runner/src/servico.ts(novo — o vocabulário, as duas plataformas como respostas às MESMAS cinco perguntas, as recusas, os quatro estados eCODIGO_POR_ESTADO);apps/runner/src/servico-sistema.ts(novo — o adaptador real de disco e desystemctl/launchctl, separado para que a garantia "servico.tsnão tocanode:fs/node:child_process" continue verificável);apps/runner/src/index.ts(comandoDoRunnerParaServico,rodarSubcomandoDeServico, o despacho deserviceemmain()antes delerArgumentos, e a linha nova emuso()) - Teste:
apps/runner/src/servico.spec.ts(novo — caminho feliz nas duas plataformas com o conteúdo real da unit/plist; root recusado sem escrever nada; Windows nomeado; sem chave de dispositivo;projectIdque viraria caminho;systemctlausente deixando o arquivo e dizendo que não ativou; a RN-434 repassada;uninstalllendo a pasta da unit e não docwd, sem unit e sem--dirnão tocando em arquivo nenhum, e odisableque falha virando aviso; os QUATRO estados destatusmais a palavra desconhecida e os três desfechos dolaunchctl; e os quatro códigos de saída distintos);apps/runner/src/index.spec.ts(describe "brabo-runner service é despachado antes de exigir credencial"— processo de VERDADE numa pasta sem credencial nenhuma:statusresponde 4 em vez do bloco de uso, e subcomando desconhecido cai no uso deservice);apps/runner/src/servico-systemd.spec.ts(a unit GERADA perguntada aosystemd-analyze --user verifyde verdade, nas duas espécies, com pasta que tem espaço e pasta que tem%— mais a forma ANTIGA, entre aspas, sendo REPROVADA pelo mesmo validador; sem systemd na máquina o arquivo PULA nomeando o motivo, nunca passa em silêncio nem reprova por ambiente) — e, desde a AT-095,describe "o valor EFETIVO de Environment= é o valor gravado"edescribe "o --dir do ExecStart= e o WorkingDirectory= também chegam intactos", que perguntam ao despejo desystemd --test --usero valor RESOLVIDO deXDG_CONFIG_HOME,PATH,WorkingDirectorye do--dircom%, espaço, aspas, barra invertida e$, e fixam a forma antiga doEnvironment=como entregando OUTRO valor;servico.spec.tsganhou as duas recusas de quebra de linha e a unit de projeto semXDG_CONFIG_HOME - ADR: 0147, ponto 5
- Origem: FASE 28, sessão 7. Fica declarado e NÃO feito: BRB-031 (o
chmod +xmanual do fluxo do ADR 0118) não morre aqui — quem rodabrabo-runner service installjá precisou tornar o binário executável para chegar até o comando, e o ADR 0147 amarra esse efeito colateral à instalação vir do bootstrap versionado do ADR 0146, que esta entrega não toca. Também fora: qualquer subcomando destart/stop/restart(quem gerencia o ciclo de vida é o gerenciador de serviços do SO, e duplicá-lo no CLI criaria uma segunda fonte de verdade sobre o estado) e Windows, acima
A flag que liga um agente tem de CHEGAR ao processo (RN-540)
RN-529 — O brabo-runner nasce com uma BASE local, cada projeto é uma subpasta dela, e a base NUNCA invalida --dir
O agente local não tinha noção de base. A única raiz era --dir, resolvida por
resolverDir contra INIT_CWD ?? process.cwd(); sem a flag, a raiz era o
próprio cwd. estado.dir é um caminho, singular — o do projeto daquela
conexão —, e não existia constante, variável nem campo que dissesse onde os
projetos moram naquela máquina. A palavra "base" só aparecia em comentário.
A base é LOCAL, e nunca chega pela rede. É o desenho do broker
(ADR 0144) aplicado ao agente local:
quem tem a raiz é quem executa, e o que atravessa a rede é o segmento
relativo. Não existe campo de base vindo do servidor, e
resolverPastaDoProjetoNaBase recusa um segmento ABSOLUTO por léxico — nunca o
"aceita e reinterpreta".
Duas fontes, e a flag vence. --base <caminho> na linha de comando, e
$XDG_CONFIG_HOME/brabo/runner.json (senão ~/.config/brabo/runner.json), que
é onde o instalador (ADR 0150) a grava. O
arquivo é de USUÁRIO e não de projeto de propósito: a base é uma para N
projetos, e guardá-la no brabo-runner.config.json — que é por projeto, vive
DENTRO da pasta do projeto e é escrito pelo NAVEGADOR — daria N cópias do mesmo
valor com N chances de divergir. Variável de ambiente foi recusada por duas
razões: BRABO_PROJECTS_BASE já existe e é a base dos projetos mounted do
lado SERVIDOR (RN-500), a colisão de namespace que o
ADR 0141 recusou por escrito; e
sob systemd --user/LaunchAgent (RN-518) o ambiente é o da unit, não
o do shell de quem instalou. Só a flag também não bastaria: a base entraria no
ExecStart de cada unit, e trocá-la exigiria reescrever todas.
--dir continua significando exatamente o que sempre significou, e a base
NÃO entra na validação dele. Isto é a proibição da api transposta:
project-workspaces-root.ts proíbe por escrito que a regra da base desça para
caminhoDeWorkspaceLocalValido, porque aquele predicado roda em toda LEITURA e
um projeto legado fora da base explodiria ao ser lido — "a base é regra de
CRIAÇÃO e CONVERSÃO; o léxico é para sempre" (RN-501). Deste lado
vale igual: --dir segue validado por validarDirDentroDoHomeNoLinux +
garantirDiretorio (RN-434/RN-435), e estar FORA da base
não o invalida. Sem isso a base seria breaking/ sem ganho, contra o binário
legado da RN-514. A base decide só onde uma pasta de projeto NOVA
nasce.
A guarda reusa a régua; não nasce uma quarta cópia. base-guard.ts é o
TERCEIRO irmão de guard.ts, ao lado de espelho-guard.ts: importa
semBarraFinal, dentroDoEscopo e realpathMaisProximo — os três helpers que
deixaram de ser privados justamente para isso (RN-516) —, repete a
dupla passada léxica-depois-realpath de validarCwdDentroDaRaiz, e reusa
validarDirDentroDoHomeNoLinux INTEIRA para exigir a base dentro do $HOME no
Linux (base dentro do $HOME implica toda subpasta dentro dele, então as duas
checagens continuam concordando). Da recusa de laço de espelho-guard.ts reusa
o que é régua — dentroDoEscopo, comparação por SEGMENTO e nunca startsWith
cru, para /base-outra não contar como dentro de /base — e não a função
recusarLaco, que lança DestinoDeEspelhoInvalidoError e reportaria uma base
malformada como problema de espelho. Mesma lição da RN-515: reusa-se
a régua, não a mensagem.
O laço aqui é ASSIMÉTRICO, e esse é o achado. No espelho os dois sentidos são defeito. Aqui a pasta do projeto DENTRO da base é o arranjo NORMAL — é literalmente o que "cada projeto é uma subpasta dela" quer dizer. O sentido contrário é que é laço: base dentro da raiz deste projeto (ou igual a ela) faria todo projeto novo nascer dentro deste, com o git e o espelho deste passando a varrer os outros. Só esse é recusado.
Recusa tem disposição diferente por FONTE, e nenhuma é silenciosa. Base
ausente é o estado NORMAL (null, o legado intacto). Base pela FLAG que não
passa na guarda sai com código 2 — é um pedido explícito digitado agora e
impossível de honrar, a mesma disposição de --dir recusado. Base pelo ARQUIVO
que não passa é dita em stderr, nomeia o que se perde ("não terá onde criar a
pasta de um projeto novo") e o runner segue sem base: derrubar um agente
local que atende um projeto por causa de uma configuração que ele ainda nem usa
seria desproporcional. Um arquivo que existe e não declara base é ausência, não
recusa — é configuração SEM base consentida, não configuração quebrada. E a
linha de startup diz a base nos DOIS estados, porque omitir "nenhuma" deixaria
alguém procurando por que a pasta não apareceu.
Isto é BEST-EFFORT, e a ressalva de TOCTOU é herdada por escrito de
guard.ts e espelho-guard.ts: symlink criado DEPOIS da checagem e ANTES do
mkdir não é coberto, nem link cujo alvo ainda não existe. A fronteira de
segurança continua sendo autenticação + pipeline de aprovação + o consentimento
de quem rodou o CLI.
Nada aqui CRIA pasta: a validação da base não exige que ela exista (quem a cria é o instalador, e é o mesmo raciocínio do ADR 0142 — validar disco onde só o léxico é conhecível impediria consentir uma base antes de ela existir). A única pergunta de disco é a que nunca é ambígua: um caminho que JÁ existe e não é pasta nunca virará uma.
- Onde:
apps/runner/src/base.ts:69(caminhoDoArquivoDeBase, a precedência XDG deservico.ts),:106(lerBaseConsentida, os quatro desfechos) e:181(resolverBaseConsentida, a ordem das fontes e a origem da recusa);apps/runner/src/base-guard.ts:128(validarBaseDeProjetos),:209(recusarLacoComARaiz, o laço assimétrico) e:296(resolverPastaDoProjetoNaBase, a subpasta e a dupla passada);apps/runner/src/index.ts:321(a resolução nolerArgumentos, DEPOIS degarantirDiretorioe sem tocar na validação de--dir) e:452(EstadoDoRunner.base) - Teste:
apps/runner/src/base-guard.spec.ts(caminho feliz com base que ainda não existe; a irmã com prefixo comum que NÃO é confundida;..,/, arquivo,$HOMEno Linux; o laço nos dois sentidos, incluindo o construído por symlink REAL; o segmento absoluto, com.., o que aponta para a própria base e o que escapa por link no meio);apps/runner/src/base.spec.ts(a precedência XDG, os quatro desfechos da leitura, a flag vencendo o arquivo, e a recusa carregando a ORIGEM) - ADR: 0151 pontos 1 e 2
- Origem: FASE 29. O par
workspace_create/workspace_create_resulte a capacidadeworkspace(pontos 3 a 6 do ADR), declarados aqui como não feitos, FECHARAM na RN-532 — a base passou a ter consumidor. Segue declarado e NÃO feito: oinstall.sh, que é quem vai gravar o arquivo
A base do agente local ganha um consumidor: a pasta do projeto nasce pelo canal (RN-532)
RN-532 — workspace_create cria a pasta sob a base LOCAL, e quem GRAVA continua sendo o workspace_confirm que já existia
A RN-529 deu ao brabo-runner uma BASE e a validou, e declarou por
escrito que nada a consumia. Este é o consumidor: o par
workspace_create/workspace_create_result, a capacidade workspace e um
predicado próprio no engine.
O par é de PEDIDO COM RESPOSTA, e não podia ser modelado no
workspace_confirm. Aquele é UNIDIRECIONAL: handle_in("workspace_confirm", …) responde {:noreply, socket} e não empurra nada de volta. Quem pede a
criação de uma pasta precisa saber se ela apareceu, e com que caminho — então o
molde é o de exec/exec_result (despachar_pedido + pending_execs +
responder_pedido_pendente/3), o mesmo dos três pares de container. Registro de
precisão, porque o nome circulou: workspace.verified não existe neste
repositório — o que existe é o evento project.workspace_verified e a coluna
workspace_verified_at.
Só o SEGMENTO atravessa a rede. A base é local e nunca vem do servidor
(RN-529); o engine manda projectId e o segmento RELATIVO, e é
resolverPastaDoProjetoNaBase — do lado do runner, que tem a raiz — quem os
junta. É o invariante do ADR 0130/
ADR 0144 aplicado ao agente local: quem
tem a raiz é quem executa. Um segmento ABSOLUTO é recusado por LÉXICO, nunca
aceito e reinterpretado.
A confirmação REUSA o workspace_confirm, e é o que economiza uma frente
inteira. Tendo criado a pasta, o runner empurra o workspace_confirm que
existe desde a RN-423 — e é ele que grava, pelo caminho já
construído: canal → engine → HTTP interno → ConfirmProjectWorkspaceUseCase,
que revalida o léxico, é idempotente e carimba workspace_verified_at.
Nenhuma rota nova de gravação nasce, o engine continua não escrevendo a
tabela, e o único caminho que carimba continua sendo um só. A ORDEM é o
mecanismo, não estética: os dois pushes chegam ao MESMO processo de canal, em
ordem, e o handler do workspace_confirm é síncrono — mandá-lo ANTES do
resultado é o que garante que o carimbo já existe quando o pedinte destrava do
receive.
A capacidade workspace entra no vocabulário na sessão em que o código
entra, dos dois lados (ADR 0151
ponto 4). A RN-514 tinha feito a aposta oposta com espelho — nome
no servidor sem código do outro lado — e ela só se pagou porque alguém a
cobrou; repetir seria repetir o defeito silencioso que a negociação existe para
impedir.
Ela é a única das quatro cuja declaração depende do ESTADO daquela execução,
e não da versão do binário. O runner só a declara quando tem base consentida
(capacidadesDoRunner), e é por essa linha — e por mais nenhuma — que o
servidor SABE que existe base: o engine não lê o disco do usuário e não tem
tabela de bases. Ninguém a EXIGE no join, e isso é decisão: exigi-la em
runner faria todo binário sem base deixar de conectar, o que é breaking/ e
MAJOR, por uma função que aquele projeto talvez nunca use. Ela é opt-in pela
outra ponta — quem tem base declara, e só quem declarou recebe a mensagem.
Predicado PRÓPRIO, e RunnerReadiness fica byte a byte.
Engine.Runners.PastaDoProjeto tem DUAS pré-condições — runner conectado, base
consentida — e o ADR adota literalmente a recusa que Engine.Runners.Espelho já
tinha escrito: uma função compartilhada com flag "pula container" é precisamente
o caminho pelo qual a terceira pré-condição cai POR ACIDENTE para o exec numa
refatoração futura (RN-507/ADR 0145
existe para ela não cair). E aqui a terceira seria pior que desnecessária: é
circular — um projeto runner só chega a ter container running
REGISTRADO depois de o próprio runner o subir, e o container sobe sobre a pasta
que esta mensagem existe para criar.
Ele também NÃO pergunta workspace_verified_at (ao contrário do espelho: aqui
ele é o RESULTADO, e exigi-lo tornaria a mensagem inalcançável exatamente no
caso para o qual foi feita) nem o execution_mode (a segunda fonte da mesma
regra — quem tem base consentida é o agente local, e só existe agente local
onde ele existe).
Onde cada pré-condição é respondida, e por que não são as duas no mesmo
lugar. "Runner conectado" é do Registry, e o módulo a responde sozinho.
"Base consentida" é propriedade daquela CONEXÃO: ela vive em socket.assigns e
nunca em tabela (a régua do
ADR 0147), então quem a responde é o
despacho — o canal recusa ANTES de empurrar e devolve motivo: "sem-base" pelo
MESMO ref, no formato que o módulo já espera, exatamente como dispatch_exec
faz com o 126. Nunca um timeout, que é como o defeito silencioso apareceria. Os
dois motivos, as duas mensagens e a ordem em que se aplicam moram no módulo, e
em nenhum outro lugar.
Erro NOMEADO, e cinco motivos que não colapsam. sem-base, segmento,
nao-e-pasta, mkdir e git viajam como motivo ao lado do texto de erro —
separados de propósito: o texto é para um humano ler, o motivo é para o outro
lado DECIDIR, e colapsá-los obrigaria o engine a casar substring de pt-BR. Só
sem-base vira átomo próprio no engine (:sem_base), porque só ele tem
conserto próprio; os outros quatro são :recusado com a mensagem do runner. O
init/clone que falha é erro nomeado e nunca um plano B: uma pasta sem
.git quebraria o worktree do dev agent, o espelho e o próprio versionamento do
usuário num lugar longe da causa.
Idempotente, e os três desfechos não colapsam. mkdir -p já é idempotente;
o que precisou de decisão foi o repositório — uma pasta que JÁ é repositório
volta como SUCESSO, com o modo ja-era-repositorio, nunca um init por cima
(que mentiria sobre o que aconteceu) nem um clone (que falharia por pasta
não-vazia e transformaria "já estava pronto" em erro). É a mesma disposição de
ConfirmProjectWorkspaceUseCase, e pelo mesmo motivo: a mensagem pode chegar
duas vezes.
estado.dir NÃO muda. A pasta criada é a do projeto do ponto de vista do
SERVIDOR — e é o workspace_confirm que a grava lá. A raiz que guard.ts usa
para conter comando aprovado continua sendo a desta execução: trocá-la em
runtime moveria uma fronteira de contenção por causa de uma mensagem de rede, e
este CLI nunca faz isso.
Isto NÃO é proposed_action, e nenhum teto ganha exceção. Criar a pasta do
projeto que o usuário acabou de pedir é configuração CONSENTIDA, não um agente
pedindo para agir — a mesma linha que o espelho já estabeleceu
(RN-516). Fazê-la por comando de terminal cairia no escopo do
ADR 0055 e viraria
fila de aprovações rotineiras, corroendo o teto que dá sentido ao clique.
- Onde:
apps/runner/src/pasta-do-projeto.ts:132(criarPastaDoProjeto— a ordem guarda→disco→versionamento e os cinco motivos);apps/runner/src/channel.ts:173(WorkspaceCreateMessage),:194(WorkspaceCreateResultMessage),:476(CAPACIDADE_DE_WORKSPACEecapacidadesDoRunner);apps/runner/src/index.ts:1034(tratarWorkspaceCreate— a ordemworkspace_confirmantes do resultado);apps/engine/lib/engine/runners/pasta_do_projeto.ex:107(verificar/1) e:134(criar/3);apps/engine/lib/engine/runners/runner_router.ex:181(create_workspace/3);apps/engine/lib/engine/runners/capacidades.ex:95(workspaceno vocabulário, e fora do legado);apps/engine/lib/engine_web/channels/terminal_channel.ex:666(handle_info({:dispatch_workspace_create, …}), a recusa nomeada) e:380(handle_in("workspace_create_result", …)) - Teste:
apps/runner/src/pasta-do-projeto.spec.ts(caminho feliz cominite com clone; a idempotência provada com o binário de versionamento de VERDADE; os cinco motivos, com textos distintos, e a pasta que NÃO nasce fora da base);apps/runner/src/index-handlers.spec.ts(a ORDEMworkspace_confirm→workspace_create_result; sem base, nenhum confirm; conexão caída não empurra nada);apps/runner/src/channel.spec.ts(workspacesó declarada com base; payload semsegmento/projectIddescartado;envmalformado virandoundefined);apps/engine/test/engine/runners/pasta_do_projeto_test.exs(as duas pré-condições SEM banco — a asserção é oExUnit.Casepuro —, o payload exato, os quatro motivos e o sucesso sem caminho que não vira sucesso);apps/engine/test/engine/runners/runner_router_test.exs(o roundtrip e o timeout);apps/engine/test/engine/runners/capacidades_test.exs(workspacedeclarável, nunca no legado, nunca exigida);apps/engine/test/engine_web/channels/terminal_channel_test.exs(a recusa nomeada pelo mesmoref, e nada empurrado ao runner) - ADR: 0151 pontos 3 a 6
- Origem: FASE 29, sessão 8. Fica declarado e NÃO feito: quem CHAMA
PastaDoProjeto.criar/3— o caminho web→api→engine é da sessão 9 —, e oinstall.shque grava o arquivo da base, que é de outra frente
O agente local abre N conexões, uma por projeto (RN-544, FASE 30)
RN-544 — O agente local abre N conexões, uma por projeto descoberto pela rota — e o teto de tentativas e a recusa de join deixam de ser do PROCESSO
A RN-543 abriu GET runner/projects e ficou sem chamador. O
brabo-runner continuava exigindo --project: index.ts tinha
if (!projectId) uso(), e não existia execução sem projeto. Esta RN é o
consumidor — e é a metade do ADR 0154
que prova a descoberta dele: "um único processo que abra N conexões — uma por
projeto que ele atende — satisfaz os itens 1, 2 e 3 byte a byte". Nada no
engine muda: o tópico terminal:<projectId>, o socket id e o ticket
descrevem uma CONEXÃO, e N conexões continuam sendo N conexões. A recusa de
segundo runner no mesmo projeto fica intacta, e é garantia real, não acidente.
Dois modos, e o antigo fica byte a byte. --project/--dir com
credencial de projeto — inclusive o fluxo do navegador
(ADR 0118,
RN-464) — é o caminho de sempre: UMA conexão, e o desfecho dela
continua sendo o do processo. O modo novo exige as DUAS coisas, e por motivos
diferentes: credencial de MÁQUINA, porque é ela que a rota aceita; e base
consentida (RN-529), porque sem ela não há de onde derivar a pasta
de cada projeto, e inventar uma seria escrever no disco do usuário um caminho
que ele não consentiu. Sem base, rodar sem --project segue caindo em uso(),
dizendo qual das duas faltou.
O runner não sabe de que espécie é a própria chave, e isso é decisão. Em
disco, chave de máquina e chave de projeto são o MESMO arquivo: uma JWK privada
com um kid (RN-475). Quem sabe é o SERVIDOR, que acha a pública
por esse kid. Então o CLI pergunta, e o 403 nomeado do PatAuthGuard vira
CredencialNaoEDeMaquinaError, com as palavras da api e o conserto
(--project) — nunca um "falha na conexão" genérico. Um palpite local seria
uma segunda fonte de verdade sobre a mesma coisa, e a local seria a errada. O
JWT dessa chamada vai sem o claim projectId, porque o guard compara
payload.projectId !== request.params.projectId e numa rota sem projeto os
dois precisam ser undefined.
N conexões exigem N estados. Quatro campos de EstadoDoRunner são POR
PROJETO — dir, canalAtual, containerAtivo, destinoDoEspelho — mais o
gerenciadorPty, que nasce de dir. Um estado compartilhado faria o
docker exec de um projeto rodar no container de OUTRO, e o PTY de um abrir na
pasta de outro. docker e base são da MÁQUINA e entram como o MESMO valor em
todos, nunca cópias que possam divergir. O tipo não mudou, e os handlers
(tratarExec, tratarMirrorSync, tratarWorkspaceCreate…) não mudaram uma
linha: o espelho (RN-516) e o workspace_create
(RN-532) já eram por projeto e já viajavam na concessão do join
daquela conexão, que é exatamente o que os torna corretos com N.
O teto e a recusa viram do PROJETO. Eram do processo: recusa de join dava
process.exit(1), e TETO_DE_TENTATIVAS_SEGUIDAS esgotado também. Com N
conexões, matar tudo por causa de uma derrubaria o agente de todos os projetos
que estão funcionando. Então cada projeto tem seu laço e seu contador; um
projeto que recusa ou esgota encerra sozinho, NOMEADO, e os demais seguem —
e só quando NENHUM sobra o processo sai com 1, com um resumo dizendo o desfecho
de cada um. Todo log do laço é prefixado pelo nome do projeto: com N laços
intercalados, "falha na conexão" sem dono é a forma que o silêncio toma quando
há N de algo. No modo projeto a disposição antiga fica idêntica, e pelo mesmo
raciocínio invertido: com UMA conexão, não sobra nada a atender.
A lista é consultada UMA vez, no start. Repesquisar periodicamente foi considerado e recusado: custa chamada recorrente por um evento raro, e o preço real não é tráfego — é que uma lista que volta MENOR é ambígua (projeto apagado, convertido de modo, papel revogado, ou um 500 transitório se disfarçando dos três), e derrubar conexão VIVA por causa dessa ambiguidade trocaria um estado certo por um palpite. O processo DIZ isso ao subir, e o gesto para pegar projeto novo é reconectar o agente — um comando só, com o serviço de usuário da RN-518.
Lista VAZIA é estado NORMAL, e a saída é 0. É o estado de toda máquina
recém-instalada: o instalador sobe o agente antes de existir projeto nenhum
(ADR 0155). Não é erro, então
não é código de erro — e Restart=on-abnormal (RN-518) não o
reergue, que é o certo: não há o que reerguer. Ficar de pé com zero conexões
seria um serviço "ativo" que não faz nada, e o status da unit passaria a
mentir.
A pasta de cada projeto é <base>/<workspaceDirName>, pelas guardas que já
existem. resolverPastaDoProjetoNaBase (a MESMA que workspace_create usa,
e pelo mesmo motivo: o nome veio pela rede), mais
validarDirDentroDoHomeNoLinux (RN-434) e garantirDiretorio
(RN-435) — nenhuma quarta cópia de régua nasce. Criar a pasta é a
RN-435 aplicada a um caminho DERIVADO em vez de digitado; o que NÃO acontece
aqui é git init/clone, que continua sendo workspace_create
(RN-532). Um projeto cuja pasta é recusada sai do plano nomeado e
não derruba os outros. OpcoesDaBase.raizDoProjeto aceita null no modo de
máquina: sem --project não há --dir, a recusa de laço fica sem sujeito
(toda raiz é derivada da base, então "a base dentro da raiz de um projeto" não
tem como ser construída), e passar a própria base ali seria pior —
dentroDoEscopo(base, base) é verdadeiro e toda base seria recusada.
- Código:
apps/runner/src/index.ts(o portão emlerArgumentos,lerArgumentosDeMaquina,manterConexaoDoProjeto,criarEstado,rodarComoAgenteDeMaquina);apps/runner/src/projetos.ts(listarProjetosDoRunner,planejarConexoes,CredencialNaoEDeMaquinaError);apps/runner/src/auth.ts(assinarDescobertaComChaveDeDispositivo— o JWT sem o claimprojectId);apps/runner/src/base-guard.ts(OpcoesDaBase.raizDoProjetonulo) - Teste:
apps/runner/src/projetos.spec.ts— caminho feliz da rota e do plano, o 403 virando erro PRÓPRIO, resposta fora do contrato, e o segmento que escapa da base sendo recusado SEM derrubar o vizinho (inclusive por symlink, na segunda passada);apps/runner/src/index.spec.ts— o PORTÃO no processo de verdade (sem base,uso()com o motivo; com base, a recusa passa a ser da credencial);apps/runner/src/base-guard.spec.ts—raizDoProjetonulo não afrouxa nenhuma outra recusa - Lacuna DECLARADA, FECHADA depois pela RN-552: quando esta RN
fechou, ninguém CRIAVA chave de máquina (era o
install.sh, ADR 0155 ponto 4), então o modo novo só era exercitável com uma chave registrada à mão — a metade que a RN-543 já declarava. AapiUrlno modo de máquina vem de--api-url/BRABO_API_URL/default, e não dobrabo-runner.config.json, que é por PROJETO: uma instalação em porta ou host diferentes precisa da flag noExecStart, e quem a escreve é a unit por máquina, que é a sessão 4 desta fase. E a unit continua sendo POR PROJETO — este PR muda o PROCESSO, não o serviço - ADR: 0154
- Origem: FASE 30, sessão 3 — o recorte da fase
A unit de MÁQUINA, convivendo com as por projeto (RN-545)
RN-545 — service install --machine instala UMA unit para a máquina, e as duas espécies não se sobrepõem
A RN-544 fez o agente de máquina existir como PROCESSO — N conexões,
uma por projeto descoberto pela rota — e declarou por escrito o que faltava:
"a unit continua sendo POR PROJETO; este PR muda o PROCESSO, não o serviço".
Esta RN é a outra metade, e é o quinto e último dos cinco acoplamentos que o
ADR 0154 mediu
(apps/runner/src/servico.ts:264,391). Os outros quatro já estão fechados ou
declarados intocáveis: os itens 1, 2 e 3 descrevem uma CONEXÃO e N conexões os
satisfazem byte a byte, e o item 4 fechou na RN-543.
O nome da unit de máquina não pode colidir com projectId nenhum, e não
colide por construção. brabo-runner.service / dev.brabo.runner, sem
sufixo, contra brabo-runner-<projectId>.service /
dev.brabo.runner.<projectId>. PROJECT_ID_VALIDO exige ao menos um
caractere, então o nome de projeto sempre tem um - (ou um . de separação,
no launchd) onde o de máquina termina — os dois nunca produzem a mesma string.
É travado por teste, e não por leitura da regex.
A camada de plataforma passa a ser chaveada por ESPÉCIE, não por
projectId. Seis dos oito membros de PlataformaDeServico recebiam
projectId: string; eles passam a receber AlvoDaUnidade, um tipo discriminado
({especie:'projeto', projectId} | {especie:'maquina'}) — e nunca
projectId: string | null, porque com o nulo cada membro decidiria sozinho o
que fazer com ele e o compilador não cobraria nenhum. PlanoDeInstalacao vira
união pelo mesmo motivo: os três campos do plano de projeto perdem sentido na
máquina, onde não há projeto, não há --dir (cada raiz sai da base) e a api não
pode vir de um arquivo que é por projeto. caminhoDaUnidade DESCEU de membro
para função, porque as duas plataformas respondiam a mesma coisa — pasta mais
nome — e manter duas implementações idênticas era a chance de elas divergirem
quando a segunda espécie chegasse.
O discriminador é a flag --machine, e NUNCA a ausência de --project. O
ADR 0154 ponto 5 escreveu "service install sem --project instala a unit de
máquina", e isso foi medido nesta sessão e não pôde ser implementado ao pé
da letra: resolverProjeto tem DUAS fontes, e o caminho normal de hoje é rodar
install sem flag nenhuma de dentro da pasta que o navegador configurou — é o
brabo-runner.config.json que responde. Tratar "sem --project" como "máquina"
converteria em silêncio a instalação de quem já usa o produto, que é exatamente
o que o ponto 5 do mesmo ADR promete não fazer. Então a espécie nova é opt-in
explícito, as duas fontes de projeto ficam byte a byte, e --machine junto de
--project é recusa nomeada em vez de precedência inventada.
install --machine recusa mais que a metade de projeto, e as duas recusas
próprias são medidas. A primeira: a pasta não pode ter
brabo-runner.config.json. lerArgumentos resolve o projectId por esse
arquivo quando --project falta, e o modo de máquina é justamente "sem
projectId" — uma unit de máquina cujo WorkingDirectory tenha esse arquivo
subiria o processo em modo de PROJETO, em silêncio, atendendo UM projeto e
chamando-se agente da máquina. A segunda: sem base consentida
(RN-529) o agente de máquina sai em uso() no primeiro boot
(RN-544), e gravar um serviço que nunca sobe é a "instalação errada
de pé" que este módulo recusa em toda parte — a base entra INJETADA
(DependenciasDeInstalacao.resolverBase), como as outras quatro dependências,
para que servico.ts continue sem ler disco nem ambiente por conta própria.
A unit congela XDG_CONFIG_HOME, e nunca o VALOR da base. É a mesma decisão
do PATH da RN-518, com uma consequência maior: é essa variável que
decide ONDE o arquivo da base mora (base.ts), e systemd --user não repassa o
ambiente do shell de quem instalou — sem ela, um usuário com a variável apontada
para fora de ~/.config teria o serviço lendo a base errada, ou nenhuma. O
valor da base fica fora de propósito: trocá-la continua sendo editar o arquivo e
reiniciar, nunca reinstalar, que é o que o docblock de base.ts já defendia ao
rejeitar "só a flag --base".
A apiUrl da unit de máquina lê BRABO_API_URL, e a de projeto não. A
precedência do plano de PROJETO fica byte a byte (--api-url › config daquela
pasta › default), porque lá existe um arquivo respondendo. No de máquina esse
arquivo é por projeto e não serve, então sem a variável a única alternativa a
--api-url seria o default localhost — instalar de um shell com
BRABO_API_URL posta geraria em silêncio uma unit apontando para o lugar
errado. Ela entra no ContextoDoServico (apiUrlDoAmbiente), nunca lida
diretamente do process.env.
install RECUSA quando a outra espécie já está instalada, e nada é
removido. O agente de máquina atende TODO projeto em modo runner do dono da
chave; somar a ele uma unit por projeto (ou o contrário) põe dois processos
disputando o mesmo terminal:<projectId>, e o servidor nega um dos dois —
provavelmente o que a pessoa acabou de instalar, que é a pior ordem possível
para descobrir o problema. É recusa pelo MESMO critério da recusa de root que já
mora nesse arquivo: "instalar e avisar deixaria a instalação errada de pé, e a
mensagem seria lida uma vez só". A unit que já existe fica intacta, a mensagem
nomeia o uninstall de cada uma, e não há --force — uma flag que derruba
uma guarda cujo sintoma é silencioso devolve o sintoma silencioso. Isto NÃO
contradiz o ponto 5 do ADR: o que ele garante é que unit por projeto já
instalada continua funcionando, e ela continua, byte a byte; o que se recusa é
criar a sobreposição AGORA.
status responde sobre a espécie perguntada, e os quatro estados não viram
oito. O código de saída continua sendo o da unit PERGUNTADA
(RN-088) — somar as duas apagaria qual delas está pedindo atenção, do
mesmo jeito que somar as cinco filas de decisão apagaria qual fila é qual. A
coexistência aparece em TEXTO, e é resposta do DISCO: as units da outra
espécie são listadas pelo nome, sem perguntar o estado de cada uma ao
gerenciador, e a saída DIZ que não perguntou. É o ponto 5 do ADR 0154 cumprido —
"deixar seis processos disputando três projetos em silêncio" é o defeito que
esse bloco existe para impedir. Sem --machine e sem projeto resolvível, o
principal é a unit de máquina (a única cujo nome não precisa de argumento) e
a saída nomeia --project para quem queria a outra; antes desta RN a mesma
entrada respondia "precisa saber QUAL projeto", que é uma pergunta e não uma
resposta para quem tem exatamente a instalação que o instalador produz.
uninstall NÃO herda esse default, e a assimetria é deliberada. Ler a
espécie errada custa uma linha errada; remover a espécie errada custa um serviço
e uma chave de dispositivo. Sem --machine e sem projeto resolvível ele RECUSA,
listando o que existe no disco com o comando exato de cada um — e nunca remove
as duas, que seria remover mais do que foi pedido.
Restart=on-abnormal fica byte a byte, nas duas espécies. Exit 1 é recusa
fatal de join ou teto de tentativas esgotado, e reiniciar seria o laço que o CLI
recusa fazer sozinho (RN-514). Mais: a RN-544 fez lista vazia sair
com 0 justamente para que on-abnormal não reerga o serviço numa instalação
nova, onde não há o que atender — trocar por on-failure desfaria as duas
decisões de uma vez.
- Código:
apps/runner/src/servico.ts(AlvoDaUnidade,ALVO_DE_MAQUINA,alvoDeProjeto,resolverEspecie,PlanoDeInstalacaocomo união,caminhoDaUnidade/projetosInstalados/maquinaInstalada,prepararPlanoDeProjeto/prepararPlanoDeMaquina,recusaDeSobreposicao,linhasDoQueExiste/linhasDeCoexistencia,ContextoDoServico.apiUrlDoAmbiente,DependenciasDeInstalacao.resolverBase,SistemaDeServico.listarPasta);apps/runner/src/servico-sistema.ts(listarPastasobrereaddirSync);apps/runner/src/index.ts(o adaptador deresolverBasesobreresolverBaseConsentida,apiUrlDoAmbiente, o texto de uso) - Teste:
apps/runner/src/servico.spec.ts—service install --machine(a unit sem--project/--dir, a ausência de colisão de nome, o congelamento deXDG_CONFIG_HOMEsem o valor da base, o Label e o log do launchd, oBRABO_API_URL) e as cinco recusas próprias (config de projeto na pasta, sem base, base inválida, sem chave,--machinecom--project); e as duas espécies não se sobrepondo (recusa nos DOIS sentidos sem remover nada, ostatusde cada lado dizendo da outra sem somar código nem perguntar ao gerenciador, ostatussem argumento caindo na máquina, ouninstall --machinedeixando a de projeto intacta, e ouninstallsem espécie recusando e listando as duas) - Lacuna DECLARADA: o
install --machinenão sabe se a chave daquela pasta é mesmo de máquina — em disco as duas espécies são o mesmo arquivo, uma JWK comkid(RN-475), e quem sabe é o SERVIDOR (RN-544). Uma pasta com chave de PROJETO instala a unit sem erro, e a recusa aparece só no primeiro boot, comoCredencialNaoEDeMaquinaError. Verificar noinstallexigiria uma chamada de rede num subcomando que hoje não faz nenhuma, e inventar um palpite local produziria a segunda fonte de verdade que a RN-544 recusou. E, à época desta RN, ninguém CRIAVA chave de máquina: quem registra é oinstall.sh(ADR 0155 ponto 4), na sessão 6 — esta sessão entrega o subcomando que ele vai chamar, e a rota que ele chama chegou na RN-552 - ADR: 0154
- Origem: FASE 30, sessão 4 —
O agente de máquina espera o primeiro projeto, e a chave nasce no terminal (RN-550/551, FASE 30)
RN-550 — Com ZERO conexões o agente local NÃO sai: ele espera e reconsulta a lista; com conexão viva a consulta continua sendo só no start
Sessão 8 da FASE 30. A RN-544 fez lista vazia ser estado NORMAL e o processo sair com 0, dizendo "crie um projeto em modo Runner e suba o agente de novo — a lista é consultada só no start". Isso contradizia, por escrito, o ADR 0155 ponto 5 — "ele espera… quando a pessoa criar o primeiro projeto na web, o agente já está lá" — e quebrava exatamente o caso que a fase persegue: a instalação nova, que é a instalação com ZERO projetos. O instalador subia o agente, o agente descobria que não havia nada e ia embora, e a pessoa que criasse o primeiro projeto minutos depois voltaria ao terminal sem nunca ter sido avisada disso.
A regra é ASSIMÉTRICA, e a assimetria é o desenho:
conexões > 0 → a lista é consultada SÓ no start (a RN-544 fica byte a byte)
conexões = 0 → reconsulta, com cadência declarada
E ela NÃO desfaz a decisão da RN-544. O argumento daquela sessão continua inteiro: uma lista que volta MENOR é ambígua — projeto apagado, convertido de modo, papel revogado, ou um 500 transitório se disfarçando dos três —, e derrubar uma conexão VIVA por causa dessa ambiguidade trocaria um estado certo por um palpite. Com zero conexões não há nada a derrubar e não há ambiguidade nenhuma: qualquer projeto que apareça é ganho puro, e nenhuma decisão é tomada sobre o que sumiu. A reconsulta PARA no instante em que a primeira lista não-vazia chega, e daí em diante o comportamento é byte a byte o de hoje — o módulo da espera não é chamado de novo enquanto o processo viver.
O argumento do exit 0 caiu porque a premissa dele caiu. A RN-544 escreveu
que "ficar de pé com zero conexões seria um serviço 'ativo' que não faz nada, e
o status da unit passaria a mentir" — e isso dependia de o processo não fazer
nada. É essa premissa que esta RN remove: ele reconsulta, com cadência
declarada, e DIZ que está fazendo isso. active (running) passa a ser
verdadeiro, e quem mentia era a saída com 0, que deixava a instalação nova sem
agente justamente no minuto em que a pessoa ia criar o primeiro projeto.
A cadência escalona e ESTABILIZA: 15s, 30s, 60s, e o último se repete para sempre. Os dois degraus curtos existem porque o caso a atender é o de alguém criando o primeiro projeto AGORA; um teto de minutos faria o agente aparecer depois de a pessoa desistir de olhar. O platô de 60s é o preço em regime — UMA requisição por minuto, de uma instalação, contra uma rota que devolve a lista do próprio dono da credencial. Crescer além disso economizaria tráfego que ninguém está pagando e pioraria a única coisa que a espera entrega.
Há teto, e ele é de FALHAS, nunca de espera. Esperar não tem limite —
esperar é o estado normal de uma instalação nova, e pode durar dias. O que tem
teto são as falhas SEGUIDAS da consulta: dez, e então o processo sai com 1
NOMEANDO o número, como o laço de conexão já faz com
TETO_DE_TENTATIVAS_SEGUIDAS e pelo mesmo motivo (não martelar a api para
sempre quando o problema não é transitório). Uma consulta que RESPONDE zera o
contador — inclusive quando responde VAZIO, que é sucesso, porque a lista vazia
é a resposta certa. A constante é SEPARADA de propósito, com o mesmo valor: uma
conta falhas de CONECTAR e a outra, falhas de LISTAR, e amarrá-las faria uma
mudança de cadência de conexão mexer, sem ninguém pedir, no tempo que uma
instalação nova espera antes de desistir. CredencialNaoEDeMaquinaError (o 403
por espécie de chave, RN-544) não entra no teto: ela é fatal na
hora, porque uma chave de projeto não vira chave de máquina por esperar mais um
minuto.
Ficar de pé em silêncio seria pior que sair. "Esperando" e "travado" são a
mesma imagem para quem lê journalctl, então o processo diz a cadência ao
entrar na espera, diz quando o primeiro projeto aparece, e bate um BATIMENTO a
cada 30 consultas (meia hora, no platô) informando quantas consultas já
houve. Nem toda consulta vira linha, também de propósito: um log por minuto,
para sempre, é o ruído que enterra as linhas que importam.
Restart=on-abnormal fica byte a byte, nas DUAS espécies de unit
(RN-545), e nada nelas muda. Medido: on-abnormal reergue por
SINAL, watchdog ou timeout — nunca por código de saída, nem 0 nem 1. A saída
com 0 nunca foi o que impedia o serviço de ser reerguido; o que impedia é que
on-abnormal não olha código nenhum. O efeito real da mudança é o oposto do
temido: a unit de MÁQUINA, que é a que roda este modo, passa a ficar
active (running) de verdade em vez de terminar em inactive (dead) segundos
depois de subir — e service status --machine passa a responder rodando (0)
onde antes respondia parado (3) numa instalação nova. A unit por PROJETO não
é afetada em linha nenhuma: ela roda o modo projeto, que nunca chega aqui.
Uma exceção, declarada: lista NÃO-vazia cujos projetos são todos recusados continua saindo com 1. Também são zero conexões, e mesmo assim não espera — porque ali cada recusa nomeia um defeito local concreto (segmento que escapa da base, pasta que é um arquivo) com conserto próprio, e reconsultar repetiria as mesmas recusas indefinidamente, enterrando no log a linha que diz o que consertar. A espera é para "ainda não existe projeto", nunca para "existe e eu não consigo atendê-lo".
- Código:
apps/runner/src/espera-de-projetos.ts:127(esperarPrimeiroProjeto),:60(CADENCIA_DA_ESPERA_MS),:75(TETO_DE_FALHAS_DE_CONSULTA),:88(CONSULTAS_POR_BATIMENTO);apps/runner/src/index.ts:1465(a espera dentro derodarComoAgenteDeMaquina, e a exceção dos recusados logo abaixo) - Teste:
apps/runner/src/espera-de-projetos.spec.ts— o caminho feliz (espera, reconsulta, devolve a primeira lista não-vazia e PARA ali), a cadência que estabiliza, o batimento a cada N consultas, o teto de falhas seguidas com o número na mensagem, a resposta no meio ZERANDO o contador, o 403 de espécie subindo intacto e o SIGTERM devolvendoparado;apps/runner/src/index.spec.ts— a JUNÇÃO no PROCESSO de verdade, contra uma api de mentira que devolve[]: o processo DIZ que fica de pé e não termina, que era o defeito - ADR: 0155 ponto 5
- Origem: FASE 30, sessão 8 — o recorte da fase
RN-551 — brabo-runner device-key create/finish: o par Ed25519 nasce NA MÁQUINA, a privada nunca viaja, e o arquivo que o runner lê nunca existe sem kid
Sessão 8 da FASE 30, a outra
metade. Até aqui, só o navegador gerava par de chaves de dispositivo
(apps/web/src/lib/runner-bootstrap.ts, ADR 0118,
RN-464); o CLI só sabia ler o que alguém tinha posto na pasta
(device-key.ts). O ADR 0155
ponto 4 pede o outro lado: "o par é gerado na máquina; a privada nunca
viaja… mantém verdadeira a frase que a RN-519 usa para recusar a visão de
maintainer: 'a privada de uma chave de dispositivo nunca sai do navegador' —
passa a ser 'nunca sai da máquina'". Sem isso, a RN-543, a
RN-544 e a RN-545 continuavam todas com a mesma lacuna
declarada: ninguém cria chave de máquina.
São DOIS passos, e o motivo é o kid. O kid gravado dentro da JWK privada
É o id do registro no servidor (RN-475) — a cadeia inteira só o
REPASSA (o runner lê jwk.kid, o JWT o leva no header, o PatAuthGuard acha a
pública por ele), e ninguém o deriva de outra coisa. Ele só existe depois de
a pública ser registrada, e uma privada gravada antes disso nasce inútil, que é
literalmente o defeito que a RN-475 custou uma caçada para achar. O navegador
resolve mantendo o par em MEMÓRIA entre o registro e a gravação; o CLI resolve
com um arquivo PARCIAL:
PUB=$(brabo-runner device-key create) # gera aqui, imprime a PÚBLICA
ID=$(… registra PUB na api … | jq -r .id) # quem registra é o instalador
brabo-runner device-key finish --id "$ID" # carimba o kid e grava
Em nenhum instante existe um brabo-runner-device-key.jwk.json sem kid. O
create grava em …jwk.json.parcial, um nome que lerChaveDeDispositivo não
procura; o finish carimba e grava o nome de verdade. O arquivo que o runner LÊ
ou está completo ou não existe, e uma interrupção entre os dois passos deixa um
.parcial que o runner IGNORA e que o próximo create NOMEIA — dizendo que o
registro daquela tentativa pode ter ficado ÓRFÃO, que é inerte e aparece na
listagem da RN-519.
O CLI NÃO fala com a api, e a razão é de SEGREDO, não técnica. Quem registra
a chave de máquina é o instalador, autenticado pelo BRABO_SERVICE_TOKEN que
ele acabou de gerar (ADR 0155
ponto 1). Fazer o brabo-runner chamar a rota exigiria pôr esse token na mão
dele — um segredo de INSTALAÇÃO, que abre rotas internas, entregue a um processo
que hoje só vê credencial de dispositivo e que fica de pé para sempre na máquina
do usuário. O corte é o oposto: cada lado guarda exatamente um segredo, e
nenhum vê o do outro. O que atravessa a fronteira entre eles é a JWK PÚBLICA e
o id — nada que precise ser protegido.
O destino padrão é a pasta de configuração da máquina, e a precedência é
REUSADA. $XDG_CONFIG_HOME/brabo/ (senão ~/.config/brabo/), ao lado do
runner.json da RN-529 e pelo mesmo motivo — é configuração da
MÁQUINA, e uma chave de máquina não tem pasta de projeto onde morar.
pastaDeConfiguracaoDoBrabo foi EXTRAÍDA de caminhoDoArquivoDeBase quando
ganhou o segundo consumidor, nunca copiada. Isso não reabre a porta que a
Onda 2 do ADR 0104
fechou (credencial em caminho global e IMPLÍCITO): device-key.ts continua lendo só do cwd que o chamador
passar, e quem aponta para esta pasta é o --dir da unit de máquina
(RN-545), explicitamente, escrito no serviço. --dir existe e serve
também a uma pasta de projeto — em disco as duas espécies são o mesmo arquivo, e
quem sabe a diferença é o SERVIDOR (RN-544): este comando não afirma
espécie nenhuma.
A privada é gravada com modo 600, explícito, e nunca deixado ao umask. A
pasta nasce 700 quando é ESTE comando que a cria; uma pasta que já existia é do
usuário (pode ser a que o navegador configurou) e não é reapertada. O --dir
passa pela MESMA validarDirDentroDoHomeNoLinux da RN-434, reusada
inteira: o que se grava é um segredo do usuário, e uma pasta fora do $HOME
dele não é onde o systemd --user dele vai procurar.
O stdout carrega UM valor, numa linha, e todo o resto é stderr. É esse o
contrato de que o install.sh depende (PUB=$(…)): misturar as duas coisas
obrigaria o script a filtrar saída de humano, que é como um contrato se quebra
sem ninguém perceber. No create o stdout é a JWK pública; no finish, o
CAMINHO do arquivo — o valor que o passo seguinte (service install --machine --dir <pasta>) precisa.
Sobrescrever chave completa é RECUSADO, e não há --force. Trocar a chave
de uma máquina já pareada trocaria a identidade de um agente que pode estar de
pé AGORA, em silêncio: a pública continuaria registrada e o processo passaria a
assinar com uma chave que ninguém conhece. A recusa nomeia o gesto (revogar,
apagar, recriar), e uma flag que derruba uma guarda cujo sintoma é silencioso
devolveria o sintoma silencioso.
- Código:
apps/runner/src/criar-chave-de-dispositivo.ts:202(criarChaveDeDispositivo),:275(finalizarChaveDeDispositivo),:82(SUFIXO_PARCIAL),:177(resolverDestino);apps/runner/src/base.ts:85(pastaDeConfiguracaoDoBrabo, extraída);apps/runner/src/index.ts:1280(o despacho antes delerArgumentos, e a separação stdout/stderr) - Teste:
apps/runner/src/criar-chave-de-dispositivo.spec.ts— o caminho feliz ponta a ponta (a pública no stdout, o parcial em 600, ofinishcarimbando, e a chave resultante lida porlerChaveDeDispositivoe ASSINANDO um JWT de ticket de verdade), o--dir, e as recusas:finishsemcreate, sem--id, com--idcolado do JSON inteiro, chave completa já no lugar (nos dois comandos), parcial que não é a JWK, subcomando desconhecido e o aviso de órfão;apps/runner/src/index.spec.ts— a JUNÇÃO no processo de verdade:device-key createroda numa pasta SEM credencial nenhuma, e o stdout contém só a JWK pública - Lacuna DECLARADA, e FECHADA nas duas metades: quem REGISTRA a pública
não existia neste repositório quando esta RN fechou — a rota chegou na
RN-552 e o
install.shque encadeia os dois comandos, na RN-547. O contrato que este comando assume é o que a rota de chave de PROJETO já tem ({ name, publicKeyJwk }, compublicKeyJwksendo o JSON da JWK pública Ed25519 —kty"OKP",crv"Ed25519",x— e resposta comid), e é isso que o stdout docreateproduz: uma linha nesse formato. O comando também não escolhe onamedo dispositivo, de propósito — ele é campo do registro, e quem nomeia é quem registra - ADR: 0155 ponto 4
- Origem: FASE 30, sessão 8 — o recorte da fase
A credencial que não atravessa o container do agente local (RN-558)
RN-558 — Credencial no exec com container ativo é RECUSADA pelo runner, com desfecho nomeado e origem politica
Lacuna declarada por escrito ao fechar a RN-507/RN-508 (ADR 0145) e medida de novo aqui, elo por elo, antes de qualquer linha ser escrita.
A cadeia, medida. Engine.Runners.RunnerReadiness.verificar/1
(runner_readiness.ex:52) exige container running REGISTRADO antes de
QUALQUER operação de Engine.Actions.Workspace.RunnerGit — inclusive o
git fetch autenticado inicial, que sai de fetch!/3
(runner_git.ex:226, via exec/4 em :288). A ÚNICA forma de esse registro
existir num projeto runner é o MESMO runner ter subido o próprio container
(ADR 0137), e é esse mesmo
sucesso que marca estado.containerAtivo nele
(apps/runner/src/index.ts:822, em tratarContainerStart). Com
containerAtivo setado, tratarExec rota o comando para dentro do container
(index.ts:728–730, e a recusa nova em :693), e a operação exec de
packages/docker-port não tem campo de env — de propósito (ADR 0130:
sem -e livre nenhum). O campo env que a RN-507 acrescentou ao par
exec/exec_result (RunnerRouter.exec/5, runner_router.ex:50;
ExecMessage.env, apps/runner/src/channel.ts:62) só é aplicado no caminho
HOST, onde apps/runner/src/exec.ts:94 o MESCLA sobre process.env.
Resultado: no instante em que a RN-507 deixa o fetch autenticado rodar, o
container quase sempre já está de pé no MESMO runner — e era aí que a
credencial (ADR 0056,
GitAuth.env_de_auth/1) era descartada, em silêncio, sem erro. O git
rodava com o helper efêmero instalado por -c e as duas variáveis VAZIAS, e
saía com falha de autenticação — indistinguível de token inválido, permissão
errada ou rede fora. A pessoa caçava a coisa errada, e o produto não dizia
nada.
A correção é o desfecho NOMEADO, não a entrega da credencial. O docker exec continua sem campo de env — mexer nisso é mudar a porta de contenção,
e é decisão de ADR, não recorte (ver a lacuna declarada abaixo). O que muda é
que o par (env presente, container ativo) deixa de ser executado e passa a
ser recusado, com marca, mensagem e origem.
Quem recusa é o RUNNER, e só ele pode. É o único processo que sabe as duas
metades ao mesmo tempo: que o comando carrega credencial e que ele vai para
dentro do container. O engine não sabe a segunda, e isso foi MEDIDO:
estado.containerAtivo nasce null a cada execução do runner
(index.ts:1449) e só é setado por tratarContainerStart, então um container
running REGISTRADO no banco não implica container ativo naquele processo
— um runner reiniciado com o container de pé roteia pro HOST, e ali a
credencial chega normalmente. Subir a checagem para RunnerReadiness ou para
RunnerGit recusaria um caminho que funciona. E RunnerReadiness fica BYTE A
BYTE como está: ela não ganha flag nem quarta pré-condição — é a mesma regra
que a RN-516 já enunciou sobre não lhe pendurar uma flag "pula
container".
A MARCA é constante de PROTOCOLO, não detalhe de mensagem.
MARCA_DE_CREDENCIAL_NAO_ENTREGUE (index.ts) e @marca
(credencial_de_git.ex) carregam o MESMO token, e é por ele — não pela frase —
que o engine reconhece a recusa depois de ela ter atravessado um rescue e
virado string solta. Mudar um lado só não quebra compilação de nada: a recusa
continua acontecendo e só a CLASSIFICAÇÃO volta a errar, que é a forma exata do
defeito que esta RN fecha. Nenhuma das duas suítes alcança a outra linguagem
(ExUnit não lê TypeScript, o vitest do runner não lê Elixir), então a guarda
mora em scripts/ci/, com os outros testes que leem o repositório inteiro como
texto.
A origem é politica, e a escolha aponta AÇÃO. As quatro do
ADR 0020 não distribuem culpa, dizem o que
fazer. Isto não é cláusula que ninguém escreveu (codigo) nem rede que caiu
(infra): é a contenção do ADR 0130 funcionando como desenhada, contra um caso
que ela não previu. Quem lê politica sabe que não há bug para caçar — há uma
decisão de produto pendente. Engine.Runners.CredencialDeGit.desfecho/1
acrescenta UM caso e não reclassifica nenhum outro: qualquer outra falha de
worktree mantém byte a byte {"falha ao preparar o worktree", "codigo"}, nos
DOIS dev agents (o real e o Noop, que existe para exercitar ESTE caminho e não
uma cópia dele).
A recusa de CONTENÇÃO vence, e a ordem é decisão. Ela entra DEPOIS de
validarCwdDentroDaRaiz (guard.ts): um comando apontado para fora da raiz
precisa ouvir isso, porque ali há uma fronteira de contenção sendo
recusada, enquanto aqui há uma capacidade que falta. Colapsar as duas faria um
comando fora do escopo parecer problema de credencial — o mesmo defeito de
diagnóstico, com outro sinal.
O env nunca aparece na recusa — só a CONTAGEM. A invariante da RN-507 é
que msg.env não vai para log nenhum, e esta saída vai para o event log do
produto: a recusa diz "2 variável(is) de ambiente", jamais um nome ou um valor.
O número já diz o que se precisa saber ali.
O que NÃO é afetado, confirmado por leitura: repositório local (provider
sem token) — GitAuth.env_de_auth/1 devolve [], fetch!/3 converte para
nil, o campo nem entra no payload, e a recusa não dispara; os modos
container e mounted — nunca passam por RunnerGit (Workspace.ensure!/4
bifurca por execution_mode e só runner vai para lá), e o container deles
sobe pelo broker, do outro lado da rede; e comando de terminal comum, que nunca
carregou env. workspace_create também carrega env, e também não é afetado:
ele roda no HOST (criarPastaDoProjeto), não no container.
- Código:
apps/runner/src/index.ts(MARCA_DE_CREDENCIAL_NAO_ENTREGUEe a recusa emtratarExec, depois da validação decwd),apps/runner/src/channel.ts(o docblock deExecMessage.env, que prometia "roda só sem a credencial"),apps/engine/lib/engine/runners/credencial_de_git.ex(novo — a marca,recusada?/1,mensagem/2edesfecho/1),apps/engine/lib/engine/actions/workspace/runner_git.ex(a cláusula nova emfetch!/3, antes da genérica, e o moduledoc),apps/engine/lib/engine/dev/dev_agent_server.exeapps/engine/lib/engine/dev/noop_dev_agent_server.ex(o desfecho de{:error, reason}na criação do worktree) - Teste:
apps/runner/src/index-handlers.spec.ts, bloco "a credencial não atravessa o docker exec (RN-558)" — o caminho feliz (sem container, oenvchega ao host e nada é recusado), o caso de falha (com container, recusa nomeada edocker.execNÃO chamado), a prova negativa de que nome e valor das variáveis não vazam na saída,envvazio não virando recusa, e a recusa de contenção deguard.tsvencendo esta quando ocwdestá fora da raiz;apps/engine/test/engine/actions/workspace_runner_test.exs(os dois testesRN-558: a recusa que vira mensagem nomeada com origempolitica, e a falha REAL do fetch que mantém a mensagem de sempre com origemcodigo— o par negativo que prova que a cláusula nova não sequestrou a antiga);apps/engine/test/engine_web/channels/credencial_no_runner_test.exs(AT-111, a CORRENTE inteira peloTerminalChannelreal: oenvchega ao canal só nofetch, a resposta do runner é a fixtureapps/runner/fixtures/exec-result-recusa-de-credencial.json— que o spec do runner compara byte a byte com a saída real detratarExec—,RunnerGitdevolve a mensagem nomeada, e oDevAgentServergravadev.blockedcom origempolitica; mais o ramo sem container ativo, em que ofetchresponde 0, e a falha real, que seguecodigo. NÃO prova a ponte entre linguagens por execução: nenhum processonodesobe no ExUnit, e o par que a fecha é a fixture comum. Verificado por mutação: cortar oenvno canal, noRunnerGit, o reconhecimento da recusa, odesfecho/1ou o uso dele no dev agent reprova o teste; desligar a recusa emtratarExecreprova o spec do runner);scripts/ci/marca-de-credencial-do-runner.spec.ts(a marca idêntica nos dois lados). Verificado por mutação: neutralizar a cláusula defetch!/3reprova o teste do engine com a mensagem antiga à vista - Lacuna DECLARADA, que é a METADE que continua aberta — FECHADA pela
RN-676 (ADR 0193,
decisão do dono de 01/10): o fetch credenciado vai MARCADO pelo engine e
roda no HOST do runner mesmo com container ativo; esta recusa encolheu para o
envSEM a marca (na prática, um runner anterior ao ADR 0193). O texto abaixo é o de quando ela fechou a metade do silêncio: a credencial continua não atravessando odocker exec. Clone/fetch de repositório remoto AUTENTICADO em modorunnersegue impossível com o container de pé — o que mudou é que agora ele falha DIZENDO isso, em vez de parecer erro de token. Fechar exige decidir COMO uma operação credenciada fala com umdocker execsem campo deenv, e as opções conhecidas mexem todas na porta de contenção do ADR 0130 (darenvà operação; um arquivo de credencial montado e apagado; rodar ofetchno host mesmo com container ativo, o que quebraria o invariante de que o trabalho acontece dentro do container). É ADR, nunca correção de passagem. A ADJACÊNCIA que esta entrega também declarou — a recusa acontecia DEPOIS deinit_from_bare!/5já ter feitogit init, e ogit_dir?/2deensure!/5marcava o workspace pronto na tentativa seguinte por encontrar o.git, de modo que a segunda tentativa falhava adiante noworktree addem vez de repetir a recusa — FECHOU no AT-112: a inicialização que falha (qualquer passo, não só ofetchcredenciado) DESFAZ o.gitque ela mesma criou (inicializar_ou_desfazer!emEngine.Actions.Workspacee emRunnerGit) e relança a exceção original, então a tentativa seguinte repete a MESMA causa. O desfazer só roda no ramo em que NÃO havia.git— o de "workspace de antes da marca" (que tem.gite não se re-inicializa) segue intacto, então não apaga trabalho de ninguém. Do ladorunnerorm -rfé best-effort (o runner pode ter caído, uma das causas da falha). Sinais considerados e recusados: marcador de "em andamento" (segundo arquivo a manter, e órfão se o processo morrer entre os dois passos) e checar a ref esperada (o repositório vazio, semorigin/<branch>, é um estado LEGÍTIMO e cairia como falha) - ADR: nenhum novo — a entrega não move fronteira nenhuma. Ela CONSOME o
0130 (a porta sem
env), o 0137 (o container na máquina do usuário), o 0145 (a pré-condição e o campoenv) e o 0020 (as quatro origens) - Origem:
AT-053— a lacuna que o ADR 0145 deixou declarada por escrito
O git credenciado roda no host do runner (RN-676)
RN-676 — Com container ativo, o git fetch credenciado de RunnerGit roda no HOST do runner, marcado pelo engine; o código continua no container
Decisão do dono (01/10), registrada no
ADR 0193: operação de git
CREDENCIADA roda no HOST do runner, e o código roda no container. Fecha a
metade que a RN-558 deixou aberta — o fetch autenticado em modo
runner com o container de pé, que é o caminho comum, terminava sempre na
recusa nomeada.
- Quem marca é o engine, e só num ponto.
RunnerGit.fetch!/3despacha o fetch com credencial porRunnerRouter.exec_git_credenciado/5, que põegitCredenciado: trueno payloadexece exigeenvnão vazio por guarda. O comando de terminal do dev agent passa porRunnerRouter.exec/4e nunca leva a marca. Sem credencial (providerlocal), o fetch vai sem marca. - O runner roda no host só com a CONJUNÇÃO: a marca
trueliteral eenvnão vazio. Aí o comando vai aexecutarComando(oenvmesclado sobreprocess.env), mesmo com container ativo. A pasta é a mesma:estado.diré o bind-mount de/work. envsem a marca não ganha o host. Com container ativo, esse par segue RECUSADO com a marca da RN-558 e origempolitica. Hoje nenhum chamador do engine o produz; na prática ele chega de umbrabo-runneranterior ao ADR 0193, que não lê a marca — e a mensagem do engine diz para atualizá-lo. A marca semenvtambém não muda nada: o comando vai ao container.- Nenhuma contenção muda:
DockerPort.execsegue semenv(ADR 0130), eRunnerReadiness(RN-507) fica byte a byte — a marca decide ONDE o runner executa, nunca SE o engine despacha. - O log nunca traz nome nem valor de variável do
env: a recusa dá a contagem, e o log do caminho host acrescenta só[git credenciado, no host].
- Código:
apps/runner/src/index.ts:701(gitCredenciadoNoHost),:666(tratarExec);apps/runner/src/channel.ts:71(gitCredenciado),:627(gitCredenciado);apps/engine/lib/engine/runners/runner_router.ex:93(exec_git_credenciado),:98(despachar_exec);apps/engine/lib/engine/actions/workspace/runner_git.ex:357(exec),:443(despachar);apps/engine/lib/engine_web/channels/terminal_channel.ex:609(git_credenciado);apps/engine/lib/engine/runners/credencial_de_git.ex:93(mensagem) - Teste:
apps/runner/src/index-handlers.spec.ts:366(o helper real dogitrecebe a credencial no host com container ativo — caminho feliz),:391(umgit fetch originreal sucede no host),:430(envsem a marca segue recusado — caso de falha);apps/runner/src/channel.spec.ts:208;apps/engine/test/engine/actions/workspace_runner_test.exs:451,:466,:476;apps/engine/test/engine_web/channels/credencial_no_runner_test.exs:149(AT-111: a corrente peloTerminalChannelreal termina em{:ok, _}),:177. Os testes ExUnit não rodaram no ambiente desta entrega (repo.hex.pm403); o CI os prova. - Origem: AT-116 (prova: AT-111), decisão do dono de 01/10
A Anamnese religada: sujeito elegível e fato do perfil (RN-680)
RN-688 — O binário do runner lê o PTY com leitor próprio sob o Bun, e o auto-teste prova a SEGUNDA leitura
Sob o Bun (o binário do bun build --compile, ADR 0112),
o tty.ReadStream com que o node-pty lê o lado mestre do PTY é um
fs.ReadStream, e a primeira leitura sem dados do fd não-bloqueante SOBE como
EAGAIN: o stream se destrói, fecha o fd, e nenhum onData chega depois disso
(oven-sh/bun#25822). O terminal interativo do binário parava depois do primeiro
pedaço de saída nas TRÊS plataformas Unix; o Linux passava na prova só porque a
primeira leitura já trazia o eco e a resposta juntos.
A regra: sob o Bun e fora do Windows, o spawn do node-pty roda com o
tty.ReadStream trocado por um leitor próprio que lê o MESMO fd com fs.read
e, no EAGAIN, ESPERA e tenta de novo (de 4 ms, dobrando até 32 ms, voltando
ao mínimo a cada dado); EIO ou zero bytes é fim. A troca vale só DURANTE o
spawn e é restaurada mesmo quando ele lança; sob o Node o módulo volta
INTACTO. O fd e o processo filho continuam os de verdade — nada simula saída.
E o --self-test-pty faz DUAS voltas, a segunda depois de uma pausa sem nada a
ler: é a pausa que força a leitura a passar por um EAGAIN, e é por ela que o
leitor antigo reprova também no Linux. Quando reprova, o auto-teste diz por quê
(pedaços, saída do filho, uma sonda num segundo PTY e os eventos do stream) — o
veredito não muda.
Foi a sonda que mediu o Windows: lá o lado de leitura é o pipe nomeado do
ConPTY, aberto pelo node-pty como net.Socket, sem fd que o leitor possa
assumir, e o pipe termina depois do primeiro pedaço — por isso o win32-x64
saiu da matriz (ADR 0187) em vez de
ganhar contorno.
- Onde:
apps/runner/src/leitor-de-pty.ts:60(precisaDoLeitorProprio),:70(LeitorDeFdNaoBloqueante),:115(agendar),:152(comLeitorDePtyProprio);apps/runner/src/native-pty-loader.ts:114(comLeitorDePtyProprio);apps/runner/src/index.ts:1263(PAUSA_ENTRE_VOLTAS_MS),:1298(sondarPty),:1353(rodarAutoTestePty) - Teste:
apps/runner/src/leitor-de-pty.spec.ts:14(só sob o Bun e fora do Windows),:23(fora dele, o MESMO módulo),:28(troca só durante ospawn, restaura quando ele lança — caso de falha),:48(as duas voltas de umcatreal, comEAGAINentre elas); a prova de ponta a ponta é osmoke:binda matriz, verde emdarwin-arm64,linux-x64elinux-arm64no ensaio36779817686 - ADR: 0112, 0187
- Origem: AT-342, AT-343