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