Files
stack/docs/plans/2026-10-12_i1-containerized-install.md
T

15 KiB

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<N>-<name>.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/<date>_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.