Pular para o conteúdo principal

ADR 0146 — A base consentida no bootstrap, e mounted como padrão local

Context​

O ADR 0141 criou BRABO_PROJECTS_BASE: uma base no host, montada por identidade ($X:$X) em api e engine, onde moram as pastas de todo projeto no modo Pasta montada. Ele resolveu o problema que se propôs a resolver — acabou a linha de bind-mount por projeto no compose, e com ela o restart que matava todo turno de agente em voo para onboardar UM projeto.

Mas ele deixou a variável ausente por padrão, e a consequência disso é a razão desta fase existir. Sem a variável a api reporta projectsBase: null e o assistente de criação não oferece o modo (docs/adr/0141-base-unica-dos-projetos-montados.md:68-72). O estado normal de uma instalação recém-clonada é, portanto: o único modo que põe o código numa pasta que o usuário abre no editor não aparece na tela. container não tem pasta que ele veja; runner exige binário, chmod, pareamento e — desde o ADR 0145 — Docker de pé na máquina dele. O produto tem três modos de execução e nenhum deles entrega, sem trabalho de configuração que ninguém pediu ao usuário para fazer, a coisa mais simples que ele espera: ver os arquivos.

Falta um gesto de consentimento. Escolher onde os projetos vão morar é decisão do dono da máquina, acontece uma vez, e acontece no host — não dentro de um container, não pela api, não pela interface web. O produto já tem exatamente um lugar com essas três propriedades: pnpm bootstrap (scripts/dev/bootstrap.sh), o menu que já cria o .env, corrige donos de pasta e reconfigura o Ollama.

Há um segundo fato do mundo que mudou desde o ADR 0130 e que esta fase não pode ignorar. O broker sobe sob profiles: ["container-broker"] e não sobe por padrão, e a justificativa registrada é literal (docs/adr/0130-broker-de-container.md:131-135):

Nada o chama para escrever ainda; dar acesso ao Docker do host a toda máquina de desenvolvimento em troca de nada seria uma mudança de postura sem contrapartida.

A premissa dessa frase — "nada o chama" — deixou de ser verdadeira. O ADR 0133 deu a container_start um chamador real; o ADR 0136 acrescentou stop e remove; o ADR 0134 acrescentou exec. E o ADR 0144 fez mounted subir container pelo broker. Um projeto mounted num compose local sem o profile ligado não termina em erro de configuração: termina em BrokerIndisponivelError, depois de o usuário ter escolhido o modo, criado o projeto e conversado com o Arquiteto.

Decision​

1. A base padrão é $HOME/projetos-brabo, e quem a grava é Node, não bash​

O caminho proposto é $HOME/projetos-brabo, sempre expandido para absoluto antes de ser gravado. O ~ continua proibido, e não por gosto: o Compose não o expande no lado que interessa, e escrevê-lo cria uma pasta chamada ~ (.env.example:225-229).

Sob $HOME porque é o que o Docker Desktop compartilha por padrão — /Users no macOS, o perfil do usuário no Windows. Um default que exige o usuário abrir as configurações do Docker antes de funcionar não é um default; é uma pergunta disfarçada. Não $HOME/brabo, porque a base não pode ser (nem conter) o checkout do próprio Brabo — scripts/dev/preflight.mjs:316 já recusa subir nesse caso — e quem clona o produto ali cairia exatamente na recusa.

E não $HOME/brabo-projetos, apesar de ser o nome mais natural, porque .env.example:202 já usa esse caminho como exemplo comentado de PROJECT_WORKSPACES_HOST_DIR — a raiz dos workspaces que o PRODUTO gerencia. São variáveis diferentes com donos opostos, e o ADR 0141 recusou conflá-las justamente porque a colisão "quebra de um jeito difícil de enxergar": <base>/loja colidiria com um projeto container cujo workspace_dir_name também fosse loja, e o bootstrap daria git init dentro do projeto do outro (.env.example:231-237). Propor como default o caminho que a documentação já sugere para a outra variável seria andar para dentro dessa armadilha pela porta da frente.

O nome, porém, é só a esquiva. A proteção é mecanismo: o script recusa qualquer base que seja igual a, contenha ou esteja contida em PROJECT_WORKSPACES_HOST_DIR, reusando dentroDe de scripts/dev/base-de-projetos.mjs — a mesma função que já sustenta baseSobrepoeOCheckout. A checagem vale para o caminho digitado, não só para o default; um default bem escolhido só evita que a recusa seja a primeira coisa que o usuário veja.

Quem grava é scripts/dev/consentir-base.mjs, novo, irmão de scripts/dev/base-de-projetos.mjs (que já hospeda a lógica pura de normalização e sobreposição), chamado por um item de menu do bootstrap.sh.

Não é bash, e os dois motivos são medidos, não estéticos. O primeiro: o bootstrap.sh não tem helper de escrever variável no .env — garantir_env (scripts/dev/bootstrap.sh:91) só copia o .env.example quando o arquivo não existe. O único helper que atualiza uma chave in-place, preservando comentários e anexando ao fim quando ela não existe, é escreverEnv (scripts/dev/preflight.mjs:128). O segundo, e o decisivo: item de menu não consegue perguntar. Todo comando do menu roda em background com stdin vindo de /dev/null (scripts/dev/bootstrap.sh:770), e o comentário acima da linha explica por quê — sem isso, qualquer coisa que leia stdin rouba as setas do usuário, e um comando que espera resposta trava sem nunca mostrar a pergunta. Um passo de consentimento escrito em bash dentro do menu seria, por construção, um passo que não pode consentir.

Valor já presente nunca é sobrescrito sem confirmação. E sem TTY, o script reporta e não faz nada — precedente literal de perguntarUsoDoOllama (scripts/dev/preflight.mjs:208), que sem terminal aplica o default e avisa em vez de travar. Um script de consentimento que "consente" sozinho num pipe de CI é a negação da palavra.

A pasta é criada com mkdir -p depois de o usuário aceitar o padrão ou digitar outro caminho, nunca antes.

2. O compartilhamento do Docker Desktop se prova MONTANDO​

Em macOS e Windows, uma pasta fora da lista de compartilhamento do Docker Desktop monta vazia ou faz o docker run falhar — e o modo Pasta montada inteiro depende desse mount. A validação é obrigatória, e a forma dela é o veredito:

Prova-se montando, nunca lendo a configuração do Docker Desktop. O teste é um docker run --rm -v <base>:/sonda <imagem já presente> ls /sonda. Ler settings.json seria ler a intenção declarada numa estrutura não documentada, que muda entre versões e entre macOS e Windows, e que descreve o que o usuário configurou — não o que o daemon fará. Montar mede a propriedade. É a mesma régua que o produto já aplica aos providers: capability só é declarada quando provada por execução, nunca lida da documentação oficial (ADR 0041/0042).

Só em macOS e Windows, por process.platform. Em Linux não há lista de compartilhamento: a checagem é existência e permissão de escrita, e nada mais. Isto torna a validação a segunda guarda de plataforma do produto — a primeira é validarDirDentroDoHomeNoLinux (apps/runner/src/guard.ts:240), que restringe --dir ao $HOME só no Linux. As duas são simétricas no princípio: cada uma restringe apenas onde a restrição significa alguma coisa. Vale registrar que hoje nenhum script de dev detecta plataforma — nem bootstrap.sh nem preflight.mjs chamam uname ou leem process.platform —, então este ADR introduz o padrão, e é por isso que ele fica escrito aqui em vez de virar um if sem dono.

Base fora da lista: recusa com mensagem acionável, nomeando o caminho testado e onde adicioná-lo (Docker Desktop → Settings → Resources → File sharing). Nunca grava a variável, nunca monta em silêncio. Uma base aceita e não compartilhada produz um projeto que a api aceita, cria e mostra — e cujo container sobe com a pasta vazia, longe da tela onde a escolha foi feita. É a mesma lição da RN-170, e a razão de a recusa vir antes da gravação.

3. O broker sai do profiles no compose LOCAL, e só nele​

Veredito: sai em docker/docker-compose.yml; permanece sob profiles: ["container-broker"] em docker/docker-compose.prod.yml.

A justificativa original (docs/adr/0130-broker-de-container.md:131-135, citada no Context) tem duas metades, e só a segunda sobrevive. "Nada o chama para escrever ainda" é falso desde os ADRs 0133/0134/0136 — o broker tem quatro chamadores reais. E "em troca de nada" deixou de descrever a troca: com este ADR tornando mounted o padrão local (ponto 4) e o ADR 0144 fazendo mounted subir pelo broker, o profile desligado passa a ser o que separa o modo padrão de terminar em BrokerIndisponivelError. A contrapartida existe, e ela é o caminho comum.

O que sustenta a assimetria local↔produção é o que o socket significa em cada lado. Quem roda pnpm dev já tem o socket do Docker — está rodando docker compose, com o daemon do próprio host. O broker recebê-lo não concede nada que o operador já não possua; a superfície marginal é o HTTP do próprio broker, que não publica porta (docker/docker-compose.yml:486-487), vive numa rede internal: true que só a api alcança, e compara BRABO_SERVICE_TOKEN em tempo constante. Em produção o cálculo é o oposto: o socket é fronteira de privilégio de verdade, o operador não é o desenvolvedor, e nada nesta fase muda o modo padrão de uma instalação de produção. Por isso o profile fica lá.

As cinco camadas de contenção não são tocadas (docs/adr/0130-broker-de-container.md:171-176). Este ADR muda quando o broker roda, nunca o que ele aceita: ele continua recebendo projectId e uma das cinco operações, continua indo à api ler a decisão do Arquiteto, e continua sem parâmetro nenhum onde se escreva privileged, cap_add, network: host ou um -v livre. A contenção nunca foi o profile.

DOCKER_GID/group_add continua sendo o ponto ambiental frágil, e ele agora vale para toda máquina de desenvolvimento em vez de só para quem ligava o profile — ver Consequences.

4. Com base configurada, mounted é o padrão do assistente​

Base presente: mounted pré-selecionado, com o caminho sugerido como <base>/<slug> e editável. Base ausente (projectsBase: null): mounted não é oferecido e container permanece o padrão.

Nenhuma das duas metades é regra nova. "Não oferecer sem base" é a RN-500 e o docs/adr/0141-base-unica-dos-projetos-montados.md:68-72; "sugerir <base>/<slug>" é a RN-501 e o ADR 0142, que adiou a validação de disco justamente para que o assistente pudesse propor um caminho que ainda não existe. O que este ADR registra é que as duas nunca foram implementadas no cliente: GET /workspaces/:workspaceId/projects-base não tem chamador nenhum no web, e o card do modo é oferecido incondicionalmente. A fase as torna verdadeiras; ela não as inventa.

O que é novo é a pré-seleção. Ela revisa o default do ADR 0072, onde container é "a normal" e o modo local é escape hatch — e revisa-o só para a instalação local, onde a base foi consentida por um humano no host. O enum projectExecutionModeEnum não muda, nenhum modo novo nasce, e a conversão entre modos (ADR 0111) continua idêntica.

5. O script de consentimento é versionado no repositório​

scripts/dev/consentir-base.mjs vive no repositório, entra no diff, passa por revisão e é o mesmo arquivo para todo mundo. Nunca gerado dinamicamente pela api, nunca baixado em tempo de execução.

É a mesma cadeia de suprimentos que já pina toda action de terceiro por commit SHA e verifica todo binário baixado com sha256sum -c: um script que configura o host do usuário, cria pasta e escreve no .env é precisamente o artefato cuja procedência precisa ser auditável. Um script gerado pela api seria código que roda na máquina do usuário e que ninguém revisou, entregue por um canal cuja integridade o produto não verifica.

Corolário explícito: a api não ganha rota que emita script de host. O proxy GET /runner-releases/binary que já existe não é precedente disso — ele serve um artefato já publicado em GitHub Releases, não gera um; e a ausência de assinatura nesse caminho é exatamente o BRB-005, item de backlog aberto, não um padrão a imitar.

Consequences​

O usuário que clona o Brabo e roda pnpm bootstrap termina com uma base consentida, validada contra o Docker que ele realmente tem, e com o assistente oferecendo por padrão o modo que põe o código numa pasta que ele abre no editor. As sessões 2, 3 e 4 desta fase constroem, nessa ordem, o passo do bootstrap, o comportamento do wizard e a mudança do profile.

O que este ADR NÃO toca. decide.ts e os tetos absolutos continuam idênticos — nada aqui é ação de agente. proposed_action continua sendo a origem de todo efeito externo. A imutabilidade do event log não é encostada. O portão da imagem vale para os três modos como o ADR 0135 deixou, e um projeto mounted sem artifact.project_image decidido continua respondendo 409 na aba Code. O broker continua com cinco operações e com a especificação computada, nunca aceita (ADR 0128/0130) — uma sexta operação segue sendo decisão de produto com ADR próprio.

Custo declarado 1: DOCKER_GID passa a importar para todo mundo. Enquanto o broker subia só sob profile, um group_add errado afetava quem tinha ligado o profile de propósito. Agora ele sobe por padrão no compose local, e um GID errado vira erro de permissão no socket para qualquer desenvolvedor. O default ${DOCKER_GID:-999} acerta em boa parte das distribuições Linux e erra em outras; a sessão 4 deve reportar isso no preflight.mjs, junto do estado da base, em vez de deixar a falha aparecer só quando alguém propõe container_start.

Custo declarado 2: uma base só, e ela é navegável. O ADR 0141 já declarou os dois, e este ADR os herda sem melhorá-los: symlink sob a base apontando para fora resolve diferente dos dois lados, e código fora da base exige mover a pasta. O .env.example:239-241 já instrui a escolher uma pasta dedicada — tudo sob a base fica alcançável de dentro dos containers do produto —, e a mensagem do passo de consentimento repete essa instrução em vez de presumi-la lida.

Custo declarado 3: a sonda de compartilhamento custa um docker run. Ela roda uma vez, no gesto de consentimento, e reusa uma imagem já presente para não puxar rede. Se não houver nenhuma imagem local, o passo diz isso e pede que o usuário rode pnpm dev uma vez antes — nunca baixa uma imagem por conta própria para poder testar.

Lacunas assumidas, com origem. A credencial de git continua sendo descartada no caminho docker exec em modo runner (apps/runner/src/index.ts:318-320; a porta de Docker não tem campo env, por desenho do ADR 0130) — esta fase não a fecha. O symlink de dentro do projeto apontando para fora continua não sendo detectado por decide(), que é puro e não faz I/O (docs/adr/0055-escopo-de-caminho-na-politica-de-terminal.md:9-15); apps/runner/src/guard.ts:9-31 segue best-effort por invariante declarado. BRB-005 (assinatura dos artefatos publicados) e BRB-031 (chmod +x manual) permanecem abertos: esta fase os encosta e não os resolve.

Windows fica de fora do caminho testado. A sonda de compartilhamento roda em Windows porque process.platform a inclui, mas o produto continua exercitado só em Linux e macOS (.env.example:198-201), e nada nesta fase muda isso.

Referencia 0130 (o profile e as cinco camadas), 0141 (a base que este ADR passa a consentir), 0142 (a validação adiada que torna a sugestão possível), 0144 (mounted sobe pelo broker) e 0072 (o default que o ponto 4 revisa para a instalação local). Nenhum deles é editado. 0147 é o irmão desta fase e decide a outra metade: o agente local e suas capacidades.