ADR 0134 — Dev agents executam DENTRO do container real do projeto, e o terminal ganha um piso de auto-aprovação lá dentro
Context
O ADR 0133 (PR 1.5) fechou o caminho container_start (proposed_action →
aprovação → broker → project_containers) de ponta a ponta e declarou, com
todas as letras: "os dev agents AINDA não trabalham dentro do container que
sobe [...] o que roda dentro do container, por enquanto, é só o que o broker
sobe — sem consumidor ainda." Este é o PR que dá o consumidor.
Hoje, mesmo com um container real de pé, Engine.Actions.TerminalExecutor
continua rodando TODO comando de terminal de dev agent via System.cmd
DENTRO do processo do próprio engine, contra a pasta compartilhada
(PROJECT_WORKSPACES_ROOT) — exatamente como sempre fez. O container fica
ocioso: nada nele nunca é docker execado.
Decision
Quando o projeto está em execution_mode: container com uma linha
REGISTRADA running em project_containers, o comando de terminal do dev
agent passa a rodar DENTRO do container, via docker exec (pelo broker) —
não mais via System.cmd local.
A quinta saída de decisao_de_execucao/1
Engine.Actions.TerminalExecutor já tinha QUATRO saídas (RN-423, ADR 0104,
o roteamento pro runner local). Ganha uma quinta, :executar_no_container,
condicionada a execution_mode == "container" E
Engine.Containers.ProjectContainerLifecycle.running?/1 — uma leitura
read-only nova, direto na tabela project_containers (mesmo padrão de
Engine.Projects.Project lendo projects: o engine lê a coluna que a api
escreve, sem duplicar a derivação).
mounted NUNCA cai nessa saída: o broker recusa subir container pra esse
modo (ModoDeExecucaoNaoSuportadoError, política deliberada até o PR 1.7
revisitar os três modos), então a pergunta nem faz sentido pra ele.
A nova perna: engine → api → broker.exec
ContainerBrokerPort.exec (ADR 0130) já existia, com a validação de escopo
inteira do lado do broker (DiretorioForaDoEscopoError fora de /work) —
mas tinha ZERO chamadores. Fecha-se com:
- Rota interna nova,
POST internal/projects/:projectId/container-exec, noInternalProjectsController(não noInternalContainersController— ver "Por que não no controller do broker", abaixo) — mesmoEngineServiceGuardde sempre, chamada pelo ENGINE, não pelo broker. ExecutarComandoNoContainerUseCase: chamaContainerBrokerPort.exece trataBrokerRecusouError/BrokerIndisponivelErrordevolvendo um resultado TIPADO de falha ({ sucesso: false, motivo }) — nunca deixa a exception vazar pela rota HTTP. Qualquer OUTRO erro (defeito real) continua propagando — não é disfarçado de falha de comando.Engine.Sessions.EngineApiClient.executar_comando_no_container/4— o checklist de sempre (callback + delegator + implReq.post+ fake de teste), chamando a rota nova.Engine.Actions.TerminalExecutorganharun_no_container/4(molde:run_via_runner/4), que chama o cliente acima e mapeia o resultado prabuild_result/failed_result.
Falha do broker.exec NUNCA vira crash nem fallback silencioso pro
System.cmd fora do container. sucesso: false (o broker recusou ou não
respondeu) e falha de transporte engine→api viram failed_result normal —
mensagem clara, exit_code: nil, exatamente como qualquer outro comando que
falhou. Um container morto ou removido por fora entre o running
registrado e o momento da chamada (RN-486: registrado e observado nunca se
fundem) É esse caso — não é bug, é o preço de "registrado" nunca ser
garantia. Cair de volta pro System.cmd fora do container reabriria
exatamente o vetor de fuga de isolamento que este PR existe para fechar.
Por que não no controller do broker
InternalContainersController (a rota container-spec) tem um comentário
explícito: "sem @Post aqui, e isso não é esquecimento" — porque quem
ESCREVE o ciclo de vida do container é RegistrarTransicaoDeContainer, e
dar ao BROKER autoridade de escrita ali confundiria quem decide o estado. A
rota nova é diferente nos dois eixos que aquele comentário protege: quem
CHAMA é o engine (não o broker lendo da api), e o que ela faz não é
escrever project_containers — é rodar um comando e devolver o resultado.
Por isso mora em InternalProjectsController, ao lado de
confirm-workspace-verification (outra rota que o engine chama, não o
broker).
O worktree do dev agent JÁ aparece em /work — sem mudar Workspace.ensure!/4
Hipótese verificada: confirmada. apps/broker/src/operacoes.ts,
especificacaoDoProjeto/2, compõe raizDoProjeto como
${PROJECT_WORKSPACES_HOST_ROOT}/${workspaceDirName} — a MESMA raiz
(workspace_dir_name, RN-109) que Engine.Actions.Workspace.workspace_dir/2
já usa para resolver <PROJECT_WORKSPACES_ROOT>/<workspace_dir_name> dentro
do processo do engine. packages/docker-port/src/docker-cli.ts monta essa
raizDoProjeto como o ÚNICO bind (${spec.raizDoProjeto}:${PONTO_DE_MONTAGEM}:rw,
PONTO_DE_MONTAGEM = '/work'). Ou seja: é o MESMO diretório físico, só
alcançado por dois caminhos — de dentro do engine, via
PROJECT_WORKSPACES_ROOT (um bind/volume do lado do engine); de dentro do
container recém-subido, via /work (um bind resolvido pelo daemon contra
PROJECT_WORKSPACES_HOST_ROOT no HOST). O worktree que o dev agent já usa
hoje — criado do jeito de sempre, Workspace.ensure!/4 rodando
System.cmd("git", ...) dentro do processo do engine, escrevendo no volume
compartilhado — já aparece DENTRO do container em /work, sem
Workspace.ensure!/4/ensure_remoto/2 mudarem em NADA. O container é só
mais um observador do MESMO diretório.
Isso depende de PROJECT_WORKSPACES_HOST_ROOT estar configurada
corretamente para apontar pro caminho de HOST que faz a MESMA pasta física
que PROJECT_WORKSPACES_ROOT resolve dentro do container do engine — é
responsabilidade do operador (já documentada nos comentários de
docker-compose.yml), e continua sendo: nada muda aqui.
cwd: tradução de HOST pra /work
opts[:cwd] chega em TerminalExecutor.run/3 como caminho ABSOLUTO DO
HOST (dentro de project_workspaces_root — a raiz compartilhada, ou o
subdiretório .worktrees/<agent_id> de um dev agent específico, via
Engine.Dev.WorktreeManager). run_no_container/4 traduz esse caminho pra
dentro de /work ANTES de mandar pro broker: troca o prefixo
<project_workspaces_root>/<workspace_dir_name> por /work, preservando o
sufixo. cwd ausente (roda na raiz do workspace) vira nil — o broker
default para /work. Um cwd que não estiver dentro dessa raiz (não
deveria acontecer para execution_mode: container) é enviado como veio,
sem tentativa de adivinhar — o broker recusa com DiretorioForaDoEscopoError
se não estiver dentro de /work, defesa em profundidade em vez de um
caminho fabricado silenciosamente.
Decisão 3: autonomia dentro do container
Comando de terminal cujo escopo é a pasta do projeto DENTRO do container
real auto-aprova — mas isso é um PISO, não um teto novo, e os cinco tetos
absolutos de decide.ts ficam byte a byte como estavam.
Mecanismo: ProposeActionUseCase.execute consulta o MESMO
ObterCicloDeVidaDoContainerUseCase que a execução usa (só quando
actionType === 'terminal' && project.executionMode === 'container' —
poupando a query em todo o resto) e monta containerExecutionActive no
DecideContext. Dentro de decide() (que continua PURA — zero IO), o
valor INICIAL de current deixa de ser sempre { policy: 'require_approval', reason: 'default' } e passa a ser { policy: 'auto_approve', ... } quando action.actionType === 'terminal' && ctx.containerExecutionActive.
Por que isso é seguro sem duplicar o mecanismo de tetos que já existe:
todo estágio que segue (agent_autonomy, permissions.json) já SUBSTITUI
current quando tem uma opinião explícita — é assim que o require_approval
default de sempre já podia ser rebaixado por um deny explícito ou mantido
por um ask. Trocar o valor INICIAL não muda essa propriedade: um
agent_autonomy: deny explícito, ou um permissions.json com ask/deny
casando o comando, continuam rebaixando o piso do container exatamente como
já rebaixavam o default require_approval — não é um caminho novo, é o
mesmo current com um ponto de partida diferente. E os tetos absolutos
(escopo, git push/comando privilegiado, merge protegido, instruction_patch,
paralelismo) continuam rodando DEPOIS, sobre current.policy === 'auto_approve' — inclusive quando esse auto_approve veio do piso do
container, não de uma regra explícita.
A justificativa de segurança é uma troca de fronteira, não a ausência de
uma. Fora do container, a fronteira do escopo de terminal (ADR 0055) é
LÉXICA: terminalNoEscopo compara cwd/command contra
ctx.projectScopeRoot, um caminho de HOST — e essa checagem é o que torna
seguro auto-aprovar por permissions.json allow hoje. Dentro do container
real, o mount namespace do Docker é uma fronteira MAIS forte: o processo que
roda o comando fisicamente não enxerga nada fora de /work (fecha o vetor
de symlink que o comentário de path-scope.ts registra como aberto para
mounted/runner, achado U — lá não há mount namespace nenhum, aqui há). A
validação de /work que o broker já faz (DiretorioForaDoEscopoError,
apps/broker/src/operacoes.ts) é a SEGUNDA camada, sobre o cwd já
traduzido. O teto de escopo léxico (terminalNoEscopo) continua rodando
por cima do piso, sem exceção nenhuma — ele opera sobre os MESMOS
caminhos de HOST de sempre (o cwd/command que chegam em decide()
NUNCA são traduzidos pra /work; essa tradução acontece só depois, no
engine, ao montar a chamada pro broker) — é defesa em profundidade, não
substituição: um comando que decide() marcaria fora de escopo hoje
continua marcado fora de escopo com o piso ligado, e volta a exigir decisão
humana.
Por que um PISO e não uma regra agent_autonomy seedada: uma regra
seedada (como o auto mode de open_adr_pr) precisaria ser escrita/apagada
toda vez que o container sobe/desce, e divergiria do REGISTRADO em
project_containers no instante em que o container caísse por fora — o
mesmo problema de duas fontes de verdade que o ADR 0130 já evita ao nunca
fundir registrado e observado (RN-486). Consultar o ciclo de vida na hora
de decidir é a MESMA fonte que a execução consulta na hora de executar —
zero sincronização entre os dois.
Consequences
ContainerBrokerPort.exec ganha o PRIMEIRO chamador real — a última
das cinco operações do ADR 0128/0130 sem um. stop/remove seguem sem
proposed_action própria, inalterado por este PR.
mounted/runner continuam sem container nenhum — RN-421/RN-423
seguem como estavam; o portão de contenção estrutural do join continua
ausente pra esses dois modos (achado do ADR 0072, não fechado aqui).
A pergunta "quantos módulos, um container só" segue como está —
project_id UNIQUE em project_containers, sem mudança nesta entrega.
O escopo de terminal léxico (ADR 0055) não perde nenhum consumidor.
Continua sendo a única fronteira para mounted/runner (que não têm mount
namespace nenhum protegendo), e continua rodando em CIMA do piso do
container como segunda camada — não foi substituído em lugar nenhum.
Alternativa considerada e descartada: tradução do cwd também dentro de
decide(), pra comparar contra /work. Faria o teto de escopo operar
sobre o caminho FINAL, mais "correto" na aparência — mas moveria uma
decisão de INFRAESTRUTURA (onde o broker montou o volume) para dentro de
uma função que o resto do arquivo mantém deliberadamente pura e sem
conhecimento de container nenhum, e duplicaria a tradução de cwd em dois
lugares (decide.ts e TerminalExecutor) que precisariam concordar para
sempre. Descartada: o teto de escopo continua útil operando sobre os
caminhos de HOST que ele sempre operou — é REDUNDANTE com o mount namespace
por dentro do container, não substituído por ele, e redundância que não
exige sincronia é a defesa em profundidade que este ADR busca.