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
humanenvelopes. 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/orruns/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 newviavalue 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 orDOCKER_HOST. Every launch refuses unlessdocker infoon that endpoint listsname=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_TOKENfrom 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_onlywith tmpfs, pids, memory and CPU limits, the default seccomp profile. Agent containers on aninternal: truenetwork 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 agiteacredential 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_HOSTinscripts/: nothing sets it.agent.sh:208,build.sh:13,hello.sh:24,release.sh:122andverify.sh:39,45calldocker composeon 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 throughqueue-read.mjsrun 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_PEERCREDin Node (option B wasn't chosen; the mount is the access control), a gateway for the OAuth path (108 chose the file secret), Podman--usernsmodes (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
dockercall names the endpoint,--host unix:///run/user/1000/docker.sock, and the spike's first step refuses unlessdocker --host ... infolistsname=rootless. Never the rootful daemon, the ambient context orDOCKER_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/testsstart 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, andREPORT.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 infolistsname=rootlessin 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:1000and one more): can it read the file, and can it connect to the socket? Record the host uid each maps to (/proc/self/uid_mapin 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: ahumanenvelope 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: truenetwork; 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:Ppublish 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 exactpackage.jsonpin of@earendil-works/pi-coding-agent, inside a container on the S5 network, withHTTPS_PROXYset. 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 whetherANTHROPIC_BASE_URL(inENV_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/tmpand tmpfsHOME. Do they start? List the paths each writes underHOME(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 -ia 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 neitherdocker inspect(Config.Env, Args, Cmd) nor the container's/proc/1/environ. Thendocker stopanddocker killwith 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_limitandcpus. Check each indocker inspectHostConfig and inside the container (/proc/self/statusCapEff and NoNewPrivs,pids.max,memory.max,cpu.maxunder/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
viavalue 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=rootlessrefusal, and thescripts/test-config.shcases. This is a config schema change and goes through review like any other. - The container session record and stop path, replacing host pids and
/procstart 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
filerule (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
dockergroup (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.