# I1: the containerized install, spikes first, then the design (2026-10-12) Status: written by Sage, lead. It follows `docs/plans/BRIEF-TEMPLATE.md`. I1 is the design item of decision 96's install track. Jason answered its four questions in Q102 to Q105, recorded as lead decisions 105 to 108. The basis is Researcher's survey, `agents/researcher/work/i1-container-survey/REPORT.md` (25417 bytes), and my review of it, `agents/sage/work/next-target/I1-review.md`. Vikunja #153; Gitea #1580 (spikes) and #1581 (design). Two rows. The spikes go first, because the survey left twelve facts unverified and several design choices depend on them. The design doc follows and turns decisions 105 to 108 into interfaces and the I2 to I6 rows. Under decision 111 the spikes start now: they need nothing from another row, and Researcher is free. ## What is already decided Decisions 105 to 108 settle these, and neither row reopens them: - The broker is the only container that holds service credentials and the dataRoot. It has two sockets. The agent socket takes capability verbs and refuses `human` envelopes. The human socket's directory is mounted into the Console container and no other (105). - Agent containers get no route to the Console, no engine socket and no `bus/`, `bus-host/` or `runs/` mount. Reachability is the human proof. The Authentik header names the user and proves nothing (105). - `authenticateHuman` (`packages/bus/src/runtime.mjs:61-69`) binds a new `via` value in containerized mode. The local install keeps the ancestry proof, and a host-side agent under Jason's uid still passes it, which is decision 95's accepted limit (105). - The Mosaic containerized install runs on the rootless daemon at `unix:///run/user/1000/docker.sock`, named explicitly, never through the ambient context or `DOCKER_HOST`. Every launch refuses unless `docker info` on that endpoint lists `name=rootless`. No container gets either engine socket. The pi worker stays on the rootful daemon until it migrates under this design (106). - No WebAuthn, no new dependency and no per-action key touch. A compromised Console holds human authority, limited by its route list and CSP, and the docs say so (107). - Managed sessions in containers get `CLAUDE_CODE_OAUTH_TOKEN` from a file secret mounted read-only. The runner reads it and passes it only to the adapter's process. It never goes into the container config, the compose environment or the image. An agent in the container can read it, and the egress allowlist limits where a copy can go (108). - Hardening from the review: per-run narrow mounts (run dir, instance workspace, contracts read-only), `cap_drop: [ALL]`, `no-new-privileges`, `read_only` with tmpfs, pids, memory and CPU limits, the default seccomp profile. Agent containers on an `internal: true` network with one egress proxy for the approved provider hosts; broker and notifier on a separate network. Seven of the survey's twelve unknowns are not spikes. I settled three by reading the tree at 70ed77da, and Jason's answers make four moot: - Gitea adapter: none. No Gitea HTTP client exists under `packages/*/src`. The broker loads a `gitea` credential reference (`packages/bus/src/credentials.mjs:22`) and reports its rotation state (`credentials.mjs:128`). The broker's outbound list is Vikunja for now. - `DOCKER_HOST` in `scripts/`: nothing sets it. `agent.sh:208`, `build.sh:13`, `hello.sh:24`, `release.sh:122` and `verify.sh:39,45` call `docker compose` on the ambient context. Decision 106 pins the endpoint, so the design names it on every call. - What the Console reads besides the bus: the system config (`packages/webui/src/cli.mjs:46`, `loadSystem`), `bus-host/host.json` (`reads.mjs:116`, `bus.mjs:65`), the notifier config (`reads.mjs:203`), `RELEASE` (`reads.mjs:198`), and the queue through `queue-read.mjs` run in the checkout (`reads.mjs:63`). The design gives the Console container those paths read-only, or a broker read in place of each. - Moot: `SO_PEERCRED` in Node (option B wasn't chosen; the mount is the access control), a gateway for the OAuth path (108 chose the file secret), Podman `--userns` modes (106 chose rootless Docker), and v1's federated compose and chroot document (no design item depends on them). ## I1 spikes: the container facts the design depends on ### Problem The I1 design can't fix its interfaces until it knows how the rootless daemon on this host behaves. The survey lists these as unverified (`REPORT.md:185-187`), and each one changes a design choice: which uid a role runs as and who owns its socket and secret files, whether a socket directory mount survives a broker restart, whether the provider CLIs honor an egress proxy, how the capability line reaches a runner in a container, and whether the hardening flags apply under rootless Docker with cgroup v2. None has been run. The rootless daemon has answered `docker info` (decision 106, checked 14:46Z) and nothing else. ### Owner and reviewer Owner: Researcher. Reviewer: Darkwing. Researcher has no Gitea token; Sage runs the review request as jarvis on #1580. The spikes run containers, so they follow the rules below exactly; a spike that would break one stops and reports instead. Rules for every spike: - Every `docker` call names the endpoint, `--host unix:///run/user/1000/docker.sock`, and the spike's first step refuses unless `docker --host ... info` lists `name=rootless`. Never the rootful daemon, the ambient context or `DOCKER_HOST`. - Images pinned by digest and recorded. `node:24-bookworm-slim` (the worker's base) for test containers; the proxy image is Researcher's choice, recorded with its digest and why. - Bind mounts of directories under one scratch directory per spike only. No named volumes, no daemon config change, no daemon restart, no engine socket in any container, no `--privileged`. - No credentials. No spike mounts or reads a token, key or `auth.json`. Where a provider CLI needs one, the spike runs it without one and checks the request path, not the answer. - No real dataRoot, bus or broker. A broker for a spike runs on a temp dataRoot the way `packages/bus/tests` start one. - Each spike removes its containers and networks when it ends, pass or fail, and its script checks that nothing named after it is left. ### Files owned - `agents/researcher/work/i1-spikes/`: one script per spike (`s-.sh`), each spike's saved output, and `REPORT.md` Nothing outside that directory. No code under `packages/`, `scripts/`, `adapters/` or the compose files. ### What ships One script per spike. Each exits 0 when the observed behavior is what the script asserts, and 1 otherwise, printing what it saw. A spike whose answer is "no" still ships: its script asserts the "no", and `REPORT.md` says what that rules out. `REPORT.md` gives, for each spike, the question, the command, the output (trimmed), the answer and the design choice it settles. - S1, endpoint pin. `docker --host unix:///run/user/1000/docker.sock info` lists `name=rootless` in its security options; the same check against the default endpoint doesn't. Record the exact field and format the launcher's refusal will parse, and the output for a missing socket. - S2, uid mapping for files and sockets. A host directory owned by jwoltje holding a file mode 0600 and a socket mode 0600 (a small node server on the host), bind-mounted into a container. For container uid 0 and for a non-root uid (`user: 1000:1000` and one more): can it read the file, and can it connect to the socket? Record the host uid each maps to (`/proc/self/uid_map` in the container). This settles who owns the broker's sockets, the Console's mount and decision 108's token file. - S3, a socket directory across a restart. Mount the socket's directory (not the file), connect from the container, stop the host server, start it again on the same path, and connect again without restarting the container. Then the same with the socket file mounted directly. - S4, a hostile client over a mounted broker socket. Start a broker on a temp dataRoot, mount its `bus/` directory into a container, and from the container send: a `human` envelope with a made-up proof, a capability envelope with a made-up capability, and a request over the 64 KiB cap. Record each answer and what the broker logs. Today one socket takes both envelope kinds (`packages/bus/src/server.mjs:43-56`); this is the baseline the two-listener change is measured against. - S5, the internal network and one egress proxy. Agent container on an `internal: true` network; proxy container on that network and an external one, allowing two named hosts. From the agent container: a direct HTTPS request fails; through the proxy, an allowed host connects and another host is refused. Also: does `-p 127.0.0.1:P:P` publish a port for a container that is only on the internal network? The design puts the Console on its own network either way; this records why. - S6, provider CLIs through the proxy. With no credential, run Claude Code (`claude -p`) at the exact version the bus unit's pinned binary reports (`~/.local/bin/claude --version`, lead decision 91) and pi at the exact `package.json` pin of `@earendil-works/pi-coding-agent`, inside a container on the S5 network, with `HTTPS_PROXY` set. Both are installed inside the test image by exact version, never on the host. Pass when the proxy log shows a CONNECT to the provider host and the CLI fails on authentication, not on the network. Record any proxy variable either CLI ignores, and whether `ANTHROPIC_BASE_URL` (in `ENV_ALLOW`, `packages/seat/src/session.mjs:63`) is needed. - S7, a read-only root. Run the Claude CLI and pi with `read_only: true`, tmpfs `/tmp` and tmpfs `HOME`. Do they start? List the paths each writes under `HOME` (names only). This settles the tmpfs list and whether a session needs a writable state directory mounted. - S8, the capability line over stdin. `docker run -i` a node entrypoint that reads one line from stdin and prints its length and a hash. Write a made-up capability string from the host. Check it arrived and that it appears in neither `docker inspect` (Config.Env, Args, Cmd) nor the container's `/proc/1/environ`. Then `docker stop` and `docker kill` with and without `--init`: record which signal the entrypoint gets, as pid 1, and the exit status. The launcher's stop path (`packages/cli/src/launcher.mjs:304-331`) is rewritten from this. - S9, hardening under rootless and cgroup v2. Run with `cap_drop ALL`, `no-new-privileges`, `read_only`, `pids_limit`, `mem_limit` and `cpus`. Check each in `docker inspect` HostConfig and inside the container (`/proc/self/status` CapEff and NoNewPrivs, `pids.max`, `memory.max`, `cpu.max` under `/sys/fs/cgroup`). Record any flag the rootless daemon rejects or ignores. `REPORT.md` ends with a list of design choices the answers settle, and any answer that contradicts decisions 105 to 108. A contradiction goes to Sage before the design row starts. ### Out of scope Any change to the tree outside the spike directory. The rootful daemon, its containers and the pi worker. Any credential, token or real dataRoot. Building the Mosaic images (I2). The two-listener broker change and the `via` value (I3). The Console container and the board's `/api/reply` path. The follow-up rows from the review (the worker's whole-dataRoot mount at `compose.yaml:40`, the stale `LAYERS.md` L4 and `docs/plans/CONDUCTOR.md:17,40` text); I open those separately. ### Gate Darkwing approves on #1580, naming the commit of `agents/researcher/work/i1-spikes/`. Sage reruns every spike script on the rootless endpoint; each exits as `REPORT.md` says, and afterwards `docker --host unix:///run/user/1000/docker.sock ps -a` and `network ls` show nothing a spike created. ## I1 design: interfaces and the I2 to I6 rows ### Problem Decisions 105 to 108 fix the direction and the security boundaries. They don't fix the interfaces the builders need: the socket paths and how the broker is told which mode it is in, the `via` value and what reads it, the config field that carries the rootless endpoint, the launcher's session record once a session is a container rather than a host pid (`packages/seat/src/session.mjs:123,195,202-216`), each role's uid, mounts and network, and how the board replies once tmux doesn't cross into a container. Without them, I2 to I6 can't be briefed as rows a seat can build and a reviewer can check. ### Owner and reviewer Owner: Sage. Reviewers: Darkwing and Filbert. This row starts when the spikes row is done and any contradiction in its report is settled. ### Files owned - `docs/plans/_containerized-install-design.md`, new - the I2 to I6 briefs, under `docs/plans/`, one `##` row heading each - `agents/sage/work/next-target/` ### What ships A design doc that a builder can follow without reopening a decision. It covers, each item citing the spike that grounds it: - Components and images: broker and bus host, notifier, launcher, Console, managed session, egress proxy, and the pi worker's move to the rootless daemon. For each: image base and pin, uid, mounts (path, mode, read-only or not), networks, secrets, and what starts it. - The two broker listeners: socket paths, directory ownership and mode, how the broker learns it runs containerized (a config field, not an environment guess), and the new `via` value with the code that reads it. The local install's single socket and ancestry proof stay as they are, and the shared verb suite runs against both modes. - The rootless endpoint in config: field name, validation, the `name=rootless` refusal, and the `scripts/test-config.sh` cases. This is a config schema change and goes through review like any other. - The container session record and stop path, replacing host pids and `/proc` start times, and the capability delivery over stdin. - Secrets: decision 108's token file (owner, mode, mount path, the runner's read) and the broker's credential files under the `file` rule (`packages/bus/src/credentials.mjs:35-79`). - Egress: the proxy, its allowlist and where the list lives (the approved provider hosts for agents; Vikunja, Gitea and Discord for the broker and notifier). - The board's reply path out of tmux. - What the docs say about the limits that stay: a host-side agent under Jason's uid (decision 95), jwoltje in the `docker` group (106), a compromised Console holding human authority (107), a token copied to the workspace (108). Then the I2 to I6 briefs, each on the template with owner, reviewers, files, suites and gate, and their rows queued with `--after` in Q97's order. Decision 96 names them as the images, the human proof inside containers and the install mode; the design fixes the split. I4, a Console with signed-in human authority, is one of them and lands before M3 retires T3 Mos. ### Out of scope Code. Any departure from decisions 105 to 108; if the design needs one, it goes to Jason through Mos as a question before the doc is final. When T3 Mos retires (Jason's, after M3). WebAuthn (107). ### Gate Darkwing and Filbert approve on #1581, each naming the doc's commit. Sage queues I2 to I6 only after both approvals.