Skip to main content

ADR 0146 — The base consented at bootstrap, and mounted as the local default

Context​

ADR 0141 created BRABO_PROJECTS_BASE: one base on the host, mounted by identity ($X:$X) in api and engine, where the folders of every project in the Mounted folder mode live. It solved the problem it set out to solve — the per-project bind-mount line in the compose is gone, and with it the restart that killed every in-flight agent turn to onboard ONE project.

But it left the variable absent by default, and the consequence of that is the reason this phase exists. Without the variable the api reports projectsBase: null and the creation wizard does not offer the mode (docs/adr/0141-base-unica-dos-projetos-montados.md:68-72). The normal state of a freshly cloned installation is therefore: the only mode that puts the code in a folder the user opens in their editor does not appear on screen. container has no folder they see; runner requires a binary, chmod, pairing and — since ADR 0145 — Docker up on their machine. The product has three execution modes and none of them delivers, without configuration work nobody asked the user to do, the simplest thing they expect: to see the files.

A gesture of consent is missing. Choosing where projects will live is a decision of the machine's owner, happens once, and happens on the host — not inside a container, not through the api, not through the web interface. The product already has exactly one place with those three properties: pnpm bootstrap (scripts/dev/bootstrap.sh), the menu that already creates the .env, fixes folder owners and reconfigures Ollama.

There is a second fact of the world that changed since ADR 0130 and that this phase cannot ignore. The broker starts under profiles: ["container-broker"] and does not start by default, and the recorded justification is 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.

The premise of that sentence — "nothing calls it" — stopped being true. ADR 0133 gave container_start a real caller; ADR 0136 added stop and remove; ADR 0134 added exec. And ADR 0144 made mounted bring up a container through the broker. A mounted project in a local compose without the profile on does not end in a configuration error: it ends in BrokerIndisponivelError, after the user has chosen the mode, created the project and talked to the Arquiteto (Architect).

Decision​

1. The default base is $HOME/projetos-brabo, and Node writes it, not bash​

The proposed path is $HOME/projetos-brabo, always expanded to absolute before being written. ~ remains forbidden, and not by taste: Compose does not expand it on the side that matters, and writing it creates a folder called ~ (.env.example:225-229).

Under $HOME because that is what Docker Desktop shares by default — /Users on macOS, the user profile on Windows. A default that requires the user to open Docker's settings before it works is not a default; it is a disguised question. Not $HOME/brabo, because the base cannot be (nor contain) Brabo's own checkout — scripts/dev/preflight.mjs:316 already refuses to start in that case — and whoever clones the product there would fall exactly into the refusal.

And not $HOME/brabo-projetos, despite being the most natural name, because .env.example:202 already uses that path as a commented example of PROJECT_WORKSPACES_HOST_DIR — the root of the workspaces the PRODUCT manages. They are different variables with opposite owners, and ADR 0141 refused to conflate them precisely because the collision "breaks in a way that is hard to see": <base>/loja would collide with a container project whose workspace_dir_name was also loja, and the bootstrap would git init inside the other's project (.env.example:231-237). Proposing as default the path the documentation already suggests for the other variable would be walking into that trap through the front door.

The name, however, is only the dodge. The protection is mechanism: the script refuses any base that is equal to, contains or is contained in PROJECT_WORKSPACES_HOST_DIR, reusing dentroDe from scripts/dev/base-de-projetos.mjs — the same function that already sustains baseSobrepoeOCheckout. The check applies to the typed path, not only to the default; a well-chosen default only prevents the refusal from being the first thing the user sees.

Whoever writes it is scripts/dev/consentir-base.mjs, new, sibling of scripts/dev/base-de-projetos.mjs (which already hosts the pure normalization and overlap logic), called by a menu item of bootstrap.sh.

It is not bash, and the two reasons are measured, not aesthetic. The first: bootstrap.sh does not have a helper to write a variable in the .env — garantir_env (scripts/dev/bootstrap.sh:91) only copies .env.example when the file does not exist. The only helper that updates a key in place, preserving comments and appending at the end when it does not exist, is escreverEnv (scripts/dev/preflight.mjs:128). The second, and the decisive one: a menu item cannot ask. Every menu command runs in the background with stdin coming from /dev/null (scripts/dev/bootstrap.sh:770), and the comment above the line explains why — without it, anything that reads stdin steals the user's arrow keys, and a command that waits for an answer hangs without ever showing the question. A consent step written in bash inside the menu would be, by construction, a step that cannot consent.

An already present value is never overwritten without confirmation. And without a TTY, the script reports and does nothing — the literal precedent of perguntarUsoDoOllama (scripts/dev/preflight.mjs:208), which without a terminal applies the default and warns instead of hanging. A consent script that "consents" on its own in a CI pipe is the negation of the word.

The folder is created with mkdir -p after the user accepts the default or types another path, never before.

2. Docker Desktop sharing is proven by MOUNTING​

On macOS and Windows, a folder outside Docker Desktop's sharing list mounts empty or makes docker run fail — and the whole Mounted folder mode depends on that mount. Validation is mandatory, and its form is the verdict:

It is proven by mounting, never by reading Docker Desktop's configuration. The test is a docker run --rm -v <base>:/sonda <imagem já presente> ls /sonda. Reading settings.json would be reading the declared intent in an undocumented structure, which changes between versions and between macOS and Windows, and which describes what the user configured — not what the daemon will do. Mounting measures the property. It is the same rule the product already applies to providers: a capability is only declared when proven by execution, never read from official documentation (ADR 0041/0042).

Only on macOS and Windows, by process.platform. On Linux there is no sharing list: the check is existence and write permission, and nothing more. This makes the validation the second platform guard of the product — the first is validarDirDentroDoHomeNoLinux (apps/runner/src/guard.ts:240), which restricts --dir to $HOME only on Linux. The two are symmetric in principle: each restricts only where the restriction means something. It is worth recording that today no dev script detects the platform — neither bootstrap.sh nor preflight.mjs call uname or read process.platform —, so this ADR introduces the pattern, and that is why it is written here instead of becoming an ownerless if.

Base outside the list: refusal with an actionable message, naming the tested path and where to add it (Docker Desktop → Settings → Resources → File sharing). It never writes the variable, never mounts silently. A base accepted and not shared produces a project the api accepts, creates and shows — and whose container comes up with the folder empty, far from the screen where the choice was made. It is the same lesson as RN-170, and the reason the refusal comes before the write.

3. The broker leaves profiles in the LOCAL compose, and only there​

Verdict: it leaves in docker/docker-compose.yml; it remains under profiles: ["container-broker"] in docker/docker-compose.prod.yml.

The original justification (docs/adr/0130-broker-de-container.md:131-135, quoted in the Context) has two halves, and only the second survives. "Nothing calls it to write yet" is false since ADRs 0133/0134/0136 — the broker has four real callers. And "in exchange for nothing" stopped describing the trade: with this ADR making mounted the local default (point 4) and ADR 0144 making mounted come up through the broker, the disabled profile becomes what separates the default mode from ending in BrokerIndisponivelError. The counterpart exists, and it is the common path.

What sustains the local↔production asymmetry is what the socket means on each side. Whoever runs pnpm dev already has the Docker socket — they are running docker compose, with the host's own daemon. The broker receiving it grants nothing the operator does not already have; the marginal surface is the broker's own HTTP, which publishes no port (docker/docker-compose.yml:486-487), lives on an internal: true network only the api reaches, and compares BRABO_SERVICE_TOKEN in constant time. In production the calculation is the opposite: the socket is a real privilege boundary, the operator is not the developer, and nothing in this phase changes the default mode of a production installation. That is why the profile stays there.

The five containment layers are not touched (docs/adr/0130-broker-de-container.md:171-176). This ADR changes when the broker runs, never what it accepts: it still receives a projectId and one of the five operations, still goes to the api to read the Arquiteto's decision, and still has no parameter at all where one could write privileged, cap_add, network: host or a free -v. Containment was never the profile.

DOCKER_GID/group_add remains the fragile environmental point, and it now applies to every development machine instead of only to whoever turned on the profile — see Consequences.

4. With a configured base, mounted is the wizard's default​

Base present: mounted preselected, with the path suggested as <base>/<slug> and editable. Base absent (projectsBase: null): mounted is not offered and container remains the default.

Neither half is a new rule. "Do not offer without a base" is RN-500 and docs/adr/0141-base-unica-dos-projetos-montados.md:68-72; "suggest <base>/<slug>" is RN-501 and ADR 0142, which deferred disk validation precisely so the wizard could propose a path that does not exist yet. What this ADR records is that neither was ever implemented on the client: GET /workspaces/:workspaceId/projects-base has no caller at all in the web, and the mode's card is offered unconditionally. The phase makes them true; it does not invent them.

What is new is the preselection. It revises the default of ADR 0072, where container is "the normal one" and the local mode is an escape hatch — and revises it only for the local installation, where the base was consented by a human on the host. The projectExecutionModeEnum enum does not change, no new mode is born, and conversion between modes (ADR 0111) stays identical.

scripts/dev/consentir-base.mjs lives in the repository, enters the diff, goes through review and is the same file for everyone. Never generated dynamically by the api, never downloaded at runtime.

It is the same supply chain that already pins every third-party action by commit SHA and verifies every downloaded binary with sha256sum -c: a script that configures the user's host, creates a folder and writes to the .env is precisely the artifact whose provenance needs to be auditable. A script generated by the api would be code that runs on the user's machine and that nobody reviewed, delivered through a channel whose integrity the product does not verify.

Explicit corollary: the api gains no route that emits a host script. The GET /runner-releases/binary proxy that already exists is not a precedent for that — it serves an already published artifact from GitHub Releases, it does not generate one; and the absence of a signature on that path is exactly BRB-005, an open backlog item, not a pattern to imitate.

Consequences​

The user who clones Brabo and runs pnpm bootstrap ends up with a consented base, validated against the Docker they really have, and with the wizard offering by default the mode that puts the code in a folder they open in the editor. Sessions 2, 3 and 4 of this phase build, in that order, the bootstrap step, the wizard behavior and the profile change.

What this ADR does NOT touch. decide.ts and the absolute ceilings remain identical — nothing here is an agent action. proposed_action remains the origin of every external effect. The immutability of the event log is not touched. The image gate applies to all three modes as ADR 0135 left it, and a mounted project without a decided artifact.project_image still answers 409 in the Code tab. The broker still has five operations and the specification computed, never accepted (ADR 0128/0130) — a sixth operation remains a product decision with its own ADR.

Declared cost 1: DOCKER_GID now matters for everyone. While the broker only started under a profile, a wrong group_add affected whoever had turned on the profile on purpose. Now it starts by default in the local compose, and a wrong GID becomes a socket permission error for any developer. The default ${DOCKER_GID:-999} is right in many Linux distributions and wrong in others; session 4 should report this in preflight.mjs, together with the base state, instead of letting the failure appear only when someone proposes container_start.

Declared cost 2: a single base, and it is browsable. ADR 0141 already declared both, and this ADR inherits them without improving them: a symlink under the base pointing outside resolves differently on the two sides, and code outside the base requires moving the folder. .env.example:239-241 already instructs choosing a dedicated folder — everything under the base becomes reachable from inside the product's containers —, and the consent step's message repeats that instruction instead of presuming it was read.

Declared cost 3: the sharing probe costs one docker run. It runs once, at the consent gesture, and reuses an image already present so as not to pull from the network. If there is no local image at all, the step says so and asks the user to run pnpm dev once first — it never downloads an image on its own in order to test.

Assumed gaps, with origin. The git credential is still discarded on the docker exec path in runner mode (apps/runner/src/index.ts:318-320; the Docker port has no env field, by the design of ADR 0130) — this phase does not close it. A symlink from inside the project pointing outside is still not detected by decide(), which is pure and does no I/O (docs/adr/0055-escopo-de-caminho-na-politica-de-terminal.md:9-15); apps/runner/src/guard.ts:9-31 remains best-effort by declared invariant. BRB-005 (signature of published artifacts) and BRB-031 (manual chmod +x) remain open: this phase touches them and does not solve them.

Windows stays off the tested path. The sharing probe runs on Windows because process.platform includes it, but the product is still exercised only on Linux and macOS (.env.example:198-201), and nothing in this phase changes that.

References 0130 (the profile and the five layers), 0141 (the base this ADR now consents), 0142 (the deferred validation that makes the suggestion possible), 0144 (mounted comes up through the broker) and 0072 (the default point 4 revises for the local installation). None of them is edited. 0147 is this phase's sibling and decides the other half: the local agent and its capabilities.