plans: I1 brief, spikes then design (#1580, #1581, Vikunja #153)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 14:07:20 -05:00
co-authored by Claude Opus 5.5
parent 140fe923b1
commit 46325d6f69
@@ -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<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.