Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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.
|
||||||
Reference in New Issue
Block a user