From 46325d6f693b0d219c0fad4d665725da21c1f8e5 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 12 Oct 2026 14:07:20 -0500 Subject: [PATCH] plans: I1 brief, spikes then design (#1580, #1581, Vikunja #153) Co-Authored-By: Claude Opus 5.5 --- .../2026-10-12_i1-containerized-install.md | 288 ++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 docs/plans/2026-10-12_i1-containerized-install.md diff --git a/docs/plans/2026-10-12_i1-containerized-install.md b/docs/plans/2026-10-12_i1-containerized-install.md new file mode 100644 index 000000000..149fbfcd5 --- /dev/null +++ b/docs/plans/2026-10-12_i1-containerized-install.md @@ -0,0 +1,288 @@ +# 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.