ADR 0193 — Credentialed git runs on the runner's HOST; the code runs in the container
Status
Accepted. 2026-10-01 (AT-116, story HS-037, epic EP-010, round 36; owner
decision on 01/10: "credentialed git operations on the HOST, code in the
container"). Closes the half that RN-558 left
declared as open and references, without editing them,
ADR 0130 (the Docker port without a free -e),
ADR 0145 (RunnerReadiness and the
three preconditions of runner mode) and
ADR 0056 (the git credential
only in the child process's ENVIRONMENT). The proof is AT-111.
Context
Measured on dev on 2026-10-01, before changing:
- The ONLY engine
execcarryingenvis the authenticatedgit fetchofEngine.Actions.Workspace.RunnerGit.fetch!/3(GitAuth.env_de_auth/1, converted into a map;nilfor a provider without a token).TerminalExecutorcallsRunnerRouter.exec/4and never passesenv. There is no credentialedpushnorcloneviaexecinrunnermode: the folder creation clone travels inworkspace_create(RN-532), which already runs on the HOST (criarPastaDoProjeto). RunnerReadiness(ADR 0145) requires a REGISTEREDrunningcontainer before anyRunnerGitoperation, and in arunnerproject that record only exists because the SAME runner brought up the container — which setsestado.containerAtivo. With it set,tratarExec(apps/runner/src/index.ts) routed EVERY command todocker exec, which has noenvfield (ADR 0130). Since RN-558 that pair (envpresent, container active) was REFUSED with the markcredencial-nao-atravessa-o-container: the COMMON path of the authenticated fetch ended in a named refusal, never in success.- The folder is the SAME on both sides:
tratarContainerStartmountsestado.dir(the root confirmed by the runner) at/work(DockerViaCli.start,${raizDoProjeto}:/work:rw,PONTO_DE_MONTAGEMinpackages/docker-port/src/docker-port.ts), and everyexec'scwdis validated insideestado.dir(validarCwdDentroDaRaiz) and translated to/work/...only when it goes to the container. The.gita fetch updates on the host is the one the container sees.
The two vetoed exits stay vetoed: a free -e on DockerPort (ADR 0130 refused
it) and a "skip container" flag in RunnerReadiness (it would knock down ADR
0145's third precondition for the whole exec).
Decision
-
The credentialed git operation runs on the runner's HOST, even with an active container. The dev agent's command still goes to
docker exec. The credential reaches the child process by the path that always worked:apps/runner/src/exec.tsmergesenvoverprocess.env. -
The discriminator is an EXPLICIT MARK set by the engine, not the
env. Theexecpayload gainsgitCredenciado: true, optional, and whoever sets it isRunnerRouter.exec_git_credenciado/5, called from ONE point (RunnerGit.despachar/5, fromfetch!/3, only when there is a credential). The function requires a non-emptyenvby a clause guard. The runner runs on the host ONLY with the CONJUNCTION (literaltruemark and non-emptyenv).Why not the
envalone, which already existed: with it, anyexecarriving with anenvwould escape the container — theenvwould become the exit door from the containment ADR 0145 set for code. With the mark, the exit is a NAMED decision of a single engine caller, and anenvwithout the mark does not get the host. The mark is not enough on its own either: without a credential there is nothing to protect, and the command follows the usual routing. -
No ADR 0130 port changes.
DockerPort.execstill has noenvfield; the image, the network, the mount and the broker are not touched.RunnerReadinessstays byte for byte: the mark changes WHERE the runner executes, never WHETHER the engine dispatches — the registeredrunningcontainer precondition still holds for the fetch too. -
RN-558's refusal still exists, and shrinks to one case. The pair (
envpresent, container active) WITHOUT the mark is still refused with the same protocol mark and originpolitica. No engine caller produces that pair today; in practice it appears through a version MISMATCH — abrabo-runnerolder than this ADR does not read the mark, routes the fetch to the container and refuses as before. That is why the message ofEngine.Runners.CredencialDeGit.mensagem/2now states the fix (update the runner, or stop the container), instead of "the missing half does not exist". The runner's refusal text also changes (it says "WITHOUT the credentialed git mark"), and the fixtureapps/runner/fixtures/exec-result-recusa-de-credencial.jsonchanges along. -
The log invariant holds. Neither the refusal nor the host path's log cite the name or value of an
envvariable — the refusal gives the COUNT, and the host log adds only[git credenciado, no host]. The command text is still logged as always (it carries ADR 0056's helper, which references the variables by name, but never the value — that does not change here).
Consequences
- AUTHENTICATED fetch/clone of a remote repository in
runnermode now works with the container up, which is the common path. RN-558 stops firing on it. - Old runner + new engine: the runner ignores the mark and refuses with the RN-558 mark; the engine says "update the runner". New runner + old engine: the engine does not send the mark, and the runner refuses as before. No combination goes back to silent discard.
- Credentialed git runs with the HOST's user and
git, not the container's. The files the fetch writes in.gitare born owned by the runner process's owner — who is the mounted folder's owner. Not measured: arunnerproject container whose--useris not that owner would see the updated.gitwith another owner; today the spec the api sends to the runner (EspecificacaoDeContainerParaRunner) has nousuario, so the container runs with the image's user, and the effect was not exercised against real Docker. - RN-558 listed this option as the one that "would break the invariant that work
happens inside the container". The owner's decision accepts it with that price
written down: what leaves the container is
git's CONVERSATION with the remote (the credentialed fetch), never the project's code — the dev agent'snpm install, tests and build remain indocker exec. What the fetch brings is git objects, and they only become working files in acheckout/worktree addthat carries no credential and follows the usual routing. - The host needs
gitinstalled. It already did:workspace_create(RN-532) and the path without a container always rangiton the host. - The mark is a second protocol constant in the
execpair, alongsideenv. It only travels when it istrue(the ordinary terminal payload stays byte for byte as before), and the runner accepts only the booleantrue— any other value is absence. - Left out: credentialed
pushthrough the runner'sexecdoes not exist today; if it is born, it enters through the same function (exec_git_credenciado/5), never through a second mark path. - The proof (AT-111) swaps refusal for success on both sides:
apps/runner/src/index-handlers.spec.ts(with an active container and the mark, the realgithelper receives the credential and a realgit fetch originsucceeds on the host, withoutdocker.exec;envwithout the mark stays refused) andapps/engine/test/engine_web/channels/credencial_no_runner_test.exs(through the realTerminalChannel, the fetch goes marked and the chain ends in{:ok, _}). The ExUnit tests did not run in this delivery's environment (repo.hex.pmanswered 403); CI is what proves them.