plans: containerized install design and the I2 to I6 briefs (#1581)

Row 87, the I1 design. The design turns decisions 105 to 108 into
interfaces: one rootless endpoint named on every call, three images,
two broker listeners with a human-socket via value, a host relay with a
peer-pid check, sessions as hardened containers, file-held tokens,
egress only through an allowlist proxy, and the Console in a container
on the human socket. Every container claim cites spikes S0 to S9 or HP.

Mos ruled on the host-process reading (mos-0657): routes open to a
process under Jason's uid are decision 105's accepted limit; the
other-uid reach to the Console port goes to Jason as P19 333, default a
documented limit, and only I6's limits section waits on it.

The rows file holds the I2 to I6 briefs on the template, each with
owner, reviewers, files, what ships and gate. Doc-only: no test reads
either file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
jason.woltjeandClaude Opus 5.5 committed 2026-10-18 08:11:17 -05:00
1 parent c0af7f2486
commit 2034817252
2 files changed
+1007

No files matched your search

@@ -0,0 +1,627 @@
# The containerized install: design (2026-10-18)
Status: written by Sage, lead, as row 87 (#1581). Reviewers: Darkwing and
Filbert. Brief: `docs/plans/2026-10-12_i1-containerized-install.md`, § I1
design. The I2 to I6 briefs are in
`docs/plans/2026-10-18_containerized-install-rows.md`.
This doc turns decisions 105 to 108 into interfaces. It doesn't reopen
them. Every claim about container behavior cites a spike in
`agents/researcher/work/i1-spikes/REPORT.md` (S0 to S9, commit ad0bdc209)
or `agents/researcher/work/i1-human-proof/REPORT.md` (HP). Line numbers
are at 8d47d6184. Where a spike didn't settle something, the doc says so
and names the row that has to measure it.
## 1. The model in one paragraph
The containerized install runs every Mosaic container on the rootless
daemon at `unix:///run/user/1000/docker.sock`, named on every call. A
host process, the bus host, holds that endpoint and is the only thing
that starts or stops a container. No container gets an engine socket. The
broker and the notifier share one container, the only one with the
dataRoot and the service credentials. The Console is a container with
the human socket and nothing else of the broker's. Each managed session
is a container with its run directory, its workspace, the agent socket
and nothing else. Agent containers sit on an internal network whose only
way out is an egress proxy with a reviewed host list. A host-side relay
keeps today's human proof for the `mosaic` CLI.
## 2. One uid, isolation by mounts, networks and capabilities
Every Mosaic container runs as container uid 0, which is the host user
(S2: `uid_map 0 1000 1`), with `--cap-drop ALL` and
`--security-opt no-new-privileges`.
Why not S2's recommended split, a non-root broker uid with a group per
socket:
- Files a non-root container uid creates are owned by `100999`-style
uids that the host user can't read or change, and the host user can't
chgrp to a container gid (S2). Run records are evidence the host reads.
The host's git socket commits from instance workspaces. Prune, reset,
release and the re-pin scripts all work on the dataRoot as the host
user. Under the split, each of those would need a root helper
container.
- The Credentials file rule (`packages/bus/src/credentials.mjs:60-72`)
requires the file's uid to equal `process.getuid()` and the mode to be
exactly 0600. In a root container, the host user's files show as uid 0,
and the process is uid 0, so the rule passes unchanged.
- With `--cap-drop ALL`, container root gets no DAC override (S2: a root
client with cap-drop ALL is refused at a 0660 socket it neither owns
nor is grouped on). Owner access still works, so a socket is reachable
exactly where its directory is mounted.
The tradeoff, stated plainly: the mount list is the access control. A
wrong mount isn't caught by a uid check behind it. I5 and I4 therefore
test each container's mount list exactly (section 9), not just that the
expected mounts exist. The host user can already reach everything these
containers hold, so a container escape lands where a host-side agent
already is (decision 95's limit), not higher.
## 3. Components
| Component | Runs as | Image | Networks | Started by |
|---|---|---|---|---|
| bus host: launcher, git socket, relay, supervisor | host process, host user | none, the pinned copy | host | the bus unit (decision 91), as today |
| broker and notifier | container root | `mosaic-core` | `mosaic-services` | the bus host |
| egress proxy, agents | container root | `mosaic-egress` | `mosaic-agents`, `mosaic-egress` | the bus host |
| egress proxy, services | container root | `mosaic-egress` | `mosaic-services`, `mosaic-egress` | the bus host |
| Console | container root | `mosaic-core` | `mosaic-console` | Jason or Mos, as today (decisions 94, 102) |
| managed session | container root | `mosaic-session` | `mosaic-agents` | the launcher, on `role.launch` |
| pi worker | container root | `mosaic-session` | `mosaic-agents` | `run-task.sh`, through the bus host's endpoint check |
The bus host stays on the host because it starts containers, and only a
host process may hold the engine socket (decision 106). It's the same
trust as today's bus host. The notifier moves into the broker's
container, because decision 105 lets only the broker hold service
credentials, and the Discord token is one.
The control board (`packages/control-board`) isn't part of the
containerized install. It drives tmux seats on the host, and section 8
replaces its reply path for the Console.
### Images
- Base: `node@sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20`
(`node:24-bookworm-slim`, node 24.21.0), the image every spike ran on
(REPORT.md:19). It's pinned by digest, and a bump is a reviewed commit.
- `mosaic-core`: the base plus the repository's packages at the release
commit and `RELEASE`. The broker, notifier and Console run from it.
- `mosaic-session`: `mosaic-core` plus Claude Code and pi at the
versions `package.json` and the session code pin today. The host's
`MOSAIC_CLAUDE_BIN` isn't used in containerized mode.
- `mosaic-egress`: the base plus `packages/egress` (section 7).
- Tags derive from `RELEASE` (`<image>:<RELEASE>`), built on the rootless
endpoint only.
### Hardening, every container
From the I1 review and S7 and S9:
- `--read-only`, `--tmpfs /tmp:rw,noexec,nosuid`. Sessions also get
`--tmpfs` for HOME (S7: Claude writes `.claude*` under HOME and
`claude-1000/` and `cc-socks/` under /tmp, and pi writes `.pi/agent/`).
- `--cap-drop ALL`, `--security-opt no-new-privileges`, and the default
seccomp profile.
- `--pids-limit`, `--memory` with `--memory-swap` set to the same value
(S9: otherwise swap is unlimited), and `--cpus`.
- Starting limits. They are starting values, and I5 measures a real
session and sets the final numbers in review:
| Container | pids | memory | cpus |
|---|---|---|---|
| session | 512 | 4g | 2 |
| broker | 256 | 1g | 1 |
| Console | 128 | 512m | 0.5 |
| egress | 64 | 128m | 0.5 |
- Sessions run with `--init`, so `docker stop` delivers SIGTERM and the
container exits 143 (S8).
S9 didn't test io or bandwidth limits, ulimits or a custom seccomp
profile, and this design adds none of them.
## 4. Configuration: the `install` key
A new optional top-level object in `~/.config/mosaic-dev/config.json`:
```json
"install": { "mode": "containerized", "endpoint": "unix:///run/user/1000/docker.sock" }
```
- Absent means `{"mode": "local"}`. Today's configs stay valid without an
edit, and `configVersion` doesn't change.
- `mode` is `local` or `containerized`. Any other value exits 2.
- `containerized` requires `endpoint`. `local` forbids it, so the pi
worker can't move to the rootless daemon by accident.
- `endpoint` must match `^unix:///[^\s]+\.sock$`. `tcp://`, `ssh://`, a
relative path or a missing value exits 2.
- Any other key under `install` exits 2.
- `scripts/mosaic-config.mjs:115` adds `install` to the allowed top-level
keys, and `validate()` (109-160) checks it.
- `execution.backend` stays `docker` (decision 106).
`validate` checks only the shape and never contacts the daemon. The
rootless check runs before every use of the endpoint:
```
docker --host <endpoint> info --format '{{json .SecurityOptions}}'
```
The check passes only if the exit status is 0 and the output contains
`name=rootless`. S1 says to test the exit status first, because a missing
socket exits 1 and prints `null`. `--host` overrides `DOCKER_HOST` (S1).
Everything that runs docker in containerized mode goes through one helper
that adds `--host <endpoint>` and runs this check first. A failure
refuses with `endpoint-not-rootless` and starts nothing.
This is the config schema change decision 106 sent to review. A live
validator older than I2 refuses a config that carries `install`, so
nobody adds the key to Jason's config until I2 is live.
The broker learns its mode from this key. It never guesses from the
environment or from whether a path exists.
## 5. Sockets and the human proof
### Layout, containerized mode
Each socket gets a directory of its own, so a directory mount grants
exactly one socket. A directory mount survives a server restart, and a
file mount keeps the dead inode (S3), so mounts are directories, made
with `--mount type=bind` (S3: `-v` creates a missing source as a
directory).
| Socket | Host path | Mode | Listener | Mounted into |
|---|---|---|---|---|
| agent | `<dataRoot>/sock/agent/broker.sock` | 0600 | broker | sessions, pi worker |
| human | `<dataRoot>/sock/human/broker.sock` | 0600 | broker | Console |
| launch | `<dataRoot>/sock/launch/launch.sock` | 0600 | bus host | sessions whose role holds launch |
| git | `<dataRoot>/sock/git/git.sock` | 0600 | bus host | sessions that push, review or land (`launcher.mjs:279`) |
| relay | `<dataRoot>/sock/relay/human.sock` | 0600 | bus host | nothing; host only |
| tools | `<run dir>/tools.sock` | as today | in the session | its own session, through the run dir |
`<dataRoot>/sock/` and each subdirectory are 0700, owned by the host
user. Every path is checked against the 107-byte limit, the way
`newRun` checks `tools.sock` (`packages/seat/src/session.mjs`). In
containerized mode `bus-host/` holds no socket, which section 8 relies
on.
The local install keeps its single `<store>/broker.sock` and
`bus-host/launch.sock` and `bus-host/git.sock`, unchanged.
### Two listeners
In containerized mode the broker serves both sockets with the same
verbs. What differs is the envelope each one accepts:
- **Agent listener.** Capability envelopes only. A `human` or `tester`
envelope gets `human-required`, as the spike measured (S4, HP).
- **Human listener.** `human` and `tester` envelopes. A capability
envelope gets `invalid-envelope`. Reaching the socket is the proof
(decision 105), so `authenticateHuman` here looks up the business and
calls `bindHuman` with `via: "human-socket"`. It reads no `/proc`. HP
measured that no container can read a host process's environ, even
with `--pid=host`.
`serve()` (`packages/bus/src/server.mjs:132-137`) takes the path and mode
for each listener rather than hard-coding one. Today it refuses to start
over an existing socket (14-19). After a kill -9 the old file remains and
answers ECONNREFUSED (S3). So a broker started in containerized mode
removes a socket at its own listener path only if the path is a socket
and a connect gets ECONNREFUSED. Any other answer refuses the start. S3
inferred this from reading the code, not from a real broker crash, so I3
tests it against the real `serve()`.
The request size limit and the 5 s timeout stay as they are on both
listeners. Row 148's 1 MiB allowance for project save
(`server.mjs:5-7,39-43,51`) applies only on the human listener, since an
agent can't save a project. S4's "1 MiB is too large" result is out of
date for that verb.
### The new `via` value and what reads it
`human-socket`. Every place that writes or reads a human `via` at
8517e5f45 (the working tree's broker.mjs carries another row's
uncommitted edits, so these are HEAD lines):
- `packages/bus/src/runtime.mjs:117` binds with `via: 'cli'`. The human
listener's bind passes `human-socket`.
- `packages/bus/src/broker.mjs`:
- 456, `bindHuman`, accepts `cli` or `human-socket`;
- 316, `#human`, accepts either. For `human-socket` the listener has
already established that the caller is outside any agent;
- 514, `#protectedEnd`, Q169's "a decision the human resolved" check,
accepts either;
- 1551, the `decision_events` row, and 1699, message delivery, record
the session's `via` instead of the literal `'cli'`;
- 1847, launch revoke and restore, records the session's `via`.
- `packages/bus/src/landing.mjs:107` counts a resolution as the human's
only when `via` is `cli`. It accepts either, or a merge Jason resolved
through the Console or the relay can't land.
- `packages/webui/src/public/bus.js:188` labels a decision event
`human` only for `cli`. It accepts either. That's a change under
`public/`, so decision 123 applies to I3's review.
- `packages/tasks/live/run.mjs:108` stays `cli`. It's the local live run.
I3 adds one exported helper, `isHumanVia(via)`, in `packages/bus`, and
every reader above uses it, so a third value can't miss a reader. I3's
tests cover each reader with `human-socket`.
The trail doesn't distinguish the Console from the CLI in containerized
mode. Both arrive on the human socket, and a field the client sets would
prove nothing. That's no loss: today the Console also goes through
`human-cli` (`packages/webui/src/bus.mjs:27-31`) and records `cli`.
### The relay: the CLI's human proof on the host
`mosaic` on the host can't run today's proof against a containerized
broker, because the proof needs the host's `/proc` (HP). The relay, HP's
option B, is a host process in the bus host:
- It listens on `<dataRoot>/sock/relay/human.sock` (0600).
- It runs `verifyHuman` unchanged (`packages/bus/src/runtime.mjs`).
`cliPath` is the host path of `human-cli.mjs` in the tree the bus host
runs from (the path-bound proof, decision 102).
- It adds the peer-pid check: the claimed pid must equal the connecting
socket's `SO_PEERCRED` pid. That closes the snooped-nonce forgery HP
measured against today's proof. I3 adds the same check to the local
install's proof.
- Then it forwards the request over the human socket and passes the
answer back. A `tester` proof goes through `verifyTester` the same way
and is forwarded as a tester envelope. Anyone on the human socket is
already human, so claiming tester gains nothing.
- `human-cli` points at the relay's socket in containerized mode. The
proof object doesn't change.
- It holds no credentials and no dataRoot handle beyond its own socket.
HP item 5 asked about the `launches` refusal, which needs the launcher's
records. In containerized mode every managed session is a container, and
none has the relay's directory, so there is no launched host process for
that refusal to find. The marker and argv checks still apply to
host-side agents such as T3 seats.
Node can't read `SO_PEERCRED` (HP). I3 picks the reader: a small native
helper, or perl as a stated host dependency, which is what the spike
used, through the undocumented `socket._handle.fd`. Either way the relay
fails closed when it can't read the peer, and checks for the reader at
bus start. `SO_PEERPIDFD` (HP option E) would close the pid-reuse race.
It's optional for I3 and recorded as a known window if I3 doesn't take
it.
### Reading decisions 95 and 105 on a same-uid host process
The I1 queue note asked this doc to answer HP's flag. Decision 95 said
the containerized install "is meant to close" the local-forgery limit.
Decision 105, which Jason agreed later, says "a host-side agent under
Jason's uid still passes. That stays decision 95's accepted limit, and
the docs say so."
This design closes the limit for every managed session and the pi
worker. They are containers, and none of them can reach the Console, the
human socket or the relay. It doesn't close it for a deliberate process
on the host. Decision 95's own case stays open for host processes. The
Console is published on its configured loopback port, `127.0.0.1:7332`
in the live install today. 7330 is only the bare `serve` default.
Reaching it is the proof (decision 105), and the Authentik header only
names the user. A loopback Host skips the header check entirely
(`packages/webui/README.md`, "What the proxy must do"). So any host
process that can reach that port can act as Jason, including a process
under another host uid. Today's install already has that reach.
dragon-lin has no other login user. A process under Jason's uid has
three more routes. It can connect to the human socket directly. HP
measured that against 0666, and here the socket is 0600, but the host
user owns it, so the result is the same. It can also detach with a clean
environment and pass the relay, as it passes today's proof. Or it can
use the engine to mount the socket into a container of its own (HP:
measured, accepted). Nothing under one host uid prevents the engine
route, so no design can close it short of a second host uid (HP option
F, which needs root) or a secret the human types.
Mos ruled on this reading (mos-0657, 2026-10-18):
- The routes open to a process under Jason's uid are covered. Decision
105 carries that case into the containerized install.
- The other-uid reach to the Console port is not covered. Decision 95
accepted "any same-UID process" for the PoC only, and said it doesn't
carry the limit into the containerized install. Decision 105 carries
only the Jason-uid case. Accepting the other-uid reach is Jason's call.
It goes to him in the PM report and as Vikunja P19 333, with the
default that it stays a documented limit. It doesn't block this
design or I2 to I5. Only I6's limits section waits on his answer.
The cheapest fix if Jason doesn't accept it: the Console serves a 0600
unix socket in a host directory instead of a published TCP port, so only
Jason's uid can connect, the same rule as the human socket. Its cost
depends on the hop in front of the Console. The proxy that forwards
Authentik's requests has to run on this host under Jason's uid, or a
group the socket allows, and speak to a unix socket upstream. The
operator's loopback browser path goes away, or moves behind a local
forwarder that reopens a port for Jason's uid only. Nobody in the
development seats may read the proxy config, so Mos or Jason confirms
that cost.
The design no longer needs the 0666 socket HP proposed, so the human
socket is no more open to host processes than any other file the host
user owns.
## 6. Managed sessions as containers
### Launch
The launcher (`packages/cli/src/launcher.mjs`) keeps its checks 1 to 9.
In containerized mode, check 6 ("bwrap runs here") becomes the rootless
endpoint check from section 4. Check 5 reads the token file from
section 7 instead of the environment. Then:
```
docker --host <endpoint> run -i --init --name mosaic-<business>-<run>
--label mosaic.business=<business> --label mosaic.instance=<instance> --label mosaic.run=<run>
--network mosaic-agents <hardening flags>
--mount type=bind,src=<run dir>,dst=/run/mosaic/run
--mount type=bind,src=<workspace>,dst=/work
[--mount type=bind,src=<thread store>,dst=/run/mosaic/thread]
--mount type=bind,src=<dataRoot>/sock/agent,dst=/run/mosaic/agent,readonly
[--mount type=bind,src=<dataRoot>/sock/launch,dst=/run/mosaic/launch,readonly]
[--mount type=bind,src=<dataRoot>/sock/git,dst=/run/mosaic/git,readonly]
--mount type=bind,src=<token file>,dst=/run/mosaic/secrets/<name>,readonly
-e HTTPS_PROXY=http://proxy:3128 -e https_proxy=... -e HTTP_PROXY=... -e http_proxy=...
-e CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 -e MOSAIC_RUN_ID=<run>
mosaic-session:<RELEASE> node <runner> /run/mosaic/run
```
- A `readonly` mount doesn't stop a connect on a socket inside it (S2: a
client connected through a `:ro` directory mount). The directory is
read-only only so the session can't replace the socket.
- `session.json` names the in-container paths.
- The contracts reach the session through the bundle, as today.
- The provider variables in `ENV_ALLOW`
(`packages/seat/src/session.mjs:87-92`) don't go into `docker run` in
containerized mode. Only the variables above do, so the container
config never holds a credential (decision 108).
### Capability over stdin
The launcher writes `{"cap": "..."}\n` on the `docker run` client's
stdin and closes it, as it does for bwrap today (`launcher.mjs:330`). S8
measured that the capability arrives intact and appears nowhere in
inspect, top, logs, `/proc/1/environ`, cmdline or ps. S8 didn't test
whether a child inherits it through the environment. The runner keeps it
out of the environment it builds for the adapter, and I5 tests that.
### The session record
In containerized mode the registry entry (`launcher.mjs:326-328`)
replaces the four pid fields: pid, startTime, runnerPid and
runnerStartTime. In their place it records:
- `container`: the name;
- `containerId`;
- `endpoint`;
- `containerStartedAt`: `.State.StartedAt` from inspect.
`bindLaunch` binds the run to `{containerId, containerStartedAt}`
instead of `{pid, startTime}`.
- **Liveness.** `docker --host <endpoint> inspect -f '{{.State.Running}} {{.State.StartedAt}}' <id>`
reports `true` with the recorded start. That replaces
`startTimeOf(runnerPid) === runnerStartTime`
(`packages/seat/src/session.mjs:230-241`). A missing container counts
as dead.
- **Stop.** `docker stop --time 30 <id>`. That's the 30 s `stopSession`
gives today (247-268). With `--init` a clean stop exits 143, and a
stop after the grace period exits 137 (S8). `close()` in the launcher
(566-600) does the same, then `docker kill` if `stopTimeoutMs` passes.
- **Exit.** The `docker run` client is the launcher's child, and its
exit status is the container's. Containers run without `--rm`, so the
launcher can still read the status with inspect if the client died.
The launcher removes the container once it has recorded the exit.
- **Recovery.** `recover()` (489-513) lists containers labelled
`mosaic.business=<business>`. It removes, with `docker rm -f`, every
one whose run isn't live in the registry, and logs each one to the
launch log.
The local install keeps its bwrap path and pid record unchanged.
### Mounts a session never gets
The engine socket, `bus/`, `bus-host/`, `runs/` beyond its own run
directory, `sock/human`, `sock/relay`, the config directory, and any
credentials file other than its own token file (decision 105).
## 7. Secrets and egress
### The session token (decision 108)
- **File and mount.** The token lives at
`~/.config/mosaic-dev/secrets/<business>/claude-code-oauth.token`,
owned by the host user, mode 0600, no symlink. It's mounted read-only
at `/run/mosaic/secrets/claude-code-oauth.token`. This is S2's
mechanism (a): container root with `--cap-drop ALL` reads it as the
owner.
- **Before launch.** The launcher checks the file under the same rule
as `credentials.mjs:35-79`. That replaces `hasSessionCredential` in
containerized mode. A missing or bad file refuses with
`no-session-credential`.
- **In the runner.** The runner reads the file and puts
`CLAUDE_CODE_OAUTH_TOKEN` only into the environment it spawns the
adapter with. It never writes the token anywhere else.
- **What's accepted.** The adapter's process can read its own environ,
and so can anything it starts. Decision 108 accepts that, with the
egress list limiting where a copy can go, and the workspace being
writable means a copy can land on the host disk. The docs say both.
- **pi's provider keys.** They follow the same route, one file per
variable. Today the worker's compose file passes `ZAI_API_KEY` and
`ANTHROPIC_API_KEY` as environment (`compose.yaml:36-37`). I6 moves the
worker to files when it moves the worker to the rootless daemon.
- **Unknown.** S7 didn't find out whether pi writes the key into
`.pi/agent/auth.json`. That file is on the session's HOME tmpfs, so it
dies with the container either way.
### The broker's credential files
These are the Gitea, Vikunja and Discord files under the `file` rule. The
broker container mounts the directory that holds them read-only, and the
rule passes unchanged (section 2). The Console and the sessions never
get that directory.
### Egress
There are three networks:
- **`mosaic-agents`** (`internal: true`): sessions, the pi worker, and
the agents proxy.
- **`mosaic-services`** (`internal: true`): the broker and the services
proxy.
- **`mosaic-egress`**: a normal bridge. Only the two proxies join it.
Each proxy answers on its internal network under the alias `proxy`, port
3128, and takes CONNECT only. An internal network has no DNS and no route
out, CONNECT to a listed host returns 200, and any other host gets 403
(S5).
Two containers run from one image so that each network gets its own
list. An agent can't reach the services list, and the broker can't reach
a provider host.
The lists live in `contracts/egress.json`, which the egress image bakes
in. A change is a reviewed commit and a new image, like any contract.
The two keys:
- **`agents`.** The provider hosts for the models in the model table.
Claude Code needs `api.anthropic.com:443`. With
`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` it doesn't need the
datadog intake (S6). I2 measures pi's provider hosts with a real token,
because S6 used a made-up one and left one `UPSTREAM-ERROR` unexplained.
- **`services`.** The Vikunja host (tasks.woltje.com), the Gitea host and
Discord's API and gateway hosts. I2 lists each with the code path that
needs it.
Both CLIs honour `HTTPS_PROXY` in both cases, and pi ignores `ALL_PROXY`
(S6).
Not spiked: whether the broker's and notifier's own `fetch` honours the
proxy on node 24.21.0 (`NODE_USE_ENV_PROXY=1`). I2 measures it. If it
doesn't, I2 stops and asks before adding a dependency.
The proxy writes one JSON line per CONNECT to stdout,
`{at, list, host, port, allowed}`. The bus host appends those lines to
`<dataRoot>/state/egress.jsonl`. That's an append-only log under
invariant 9.
`packages/egress` is the reviewed version of the 43-line spike tool
(`agents/researcher/work/i1-spikes/tools/connect-proxy.mjs`), with tests: allow, deny, a malformed
request, a slow client and a connect to a port that isn't listed.
## 8. The Console
- **Container and port.** `mosaic-core` running `packages/webui`, on
`mosaic-console`, a normal bridge, published at `127.0.0.1` on the
port the Console is configured with (7332 in the live install). S5:
a container only on an internal network accepts `-p` and publishes
nothing, so the bridge is required, and I4 checks that the port
answers. Authentik forwards to it as today.
- **Mounts:**
- `sock/human`;
- `config.json` as a read-only file mount (the config is never
rewritten, so S3's dead-inode problem doesn't arise; a config change
needs a Console restart, as `loadSystem` at start already does);
- `bus-host/` read-only, for `host.json` and `sessions.json`
(`bus.mjs:65`, `reads.mjs:125-132,234`). This is safe only because
`bus-host/` holds no socket in containerized mode (section 5). I4
adds a check at Console start that refuses if `bus-host/` holds a
socket or anything that passes for a credentials file.
- **What it never gets:** the dataRoot, `bus/`, the relay, the agent
socket, the secrets directory, or the notifier config file. Today
`readNotifyConfig` (`reads.mjs:229`) reads the file. In containerized
mode I4 replaces it with a human read verb on the broker, since the
notifier lives there.
- **Other reads.** `RELEASE` comes from the image. The Board's queue
panel runs `queue-read.mjs` in a checkout (`reads.mjs:75-79`). I4 mounts
the canonical checkout read-only for it, which is safe because the
repository holds no secrets (invariant 3). If the config names no
checkout, I4 turns the panel off.
- **Reply path out of tmux.** Today POST `/api/reply` goes to the
control board, which runs `tools/tmux/agent-send.sh`
(`packages/control-board/src/serve.mjs:59,74-94,239-248`). In
containerized mode the Console has no route to the board, and the
route answers 404. A reply to a managed instance is a human
`message.send` over the human socket, the path row 76 built. The
board's tmux liveness check (`scan.mjs:196-209`) has no containerized
counterpart, because the sessions list comes from `sessions.json`.
- **The 107 limit.** A compromised Console holds human authority. Its
route list and CSP limit what it can do. I4 lists every route in the
README with what it can reach.
## 9. What each row must test
- **Mount lists, exactly.** For each container kind, a test reads
`docker inspect` `.Mounts` and compares the whole list to the expected
list. Section 2's tradeoff depends on it.
- **Networks.** A session can't resolve or reach any host except
through `proxy` (S5). The Console's port answers on 127.0.0.1. The
broker can't reach a provider host.
- **Hardening.** A session's `/proc/self/status` shows CapEff 0,
NoNewPrivs 1 and Seccomp 2 (S9).
- **Envelopes and `via`.** A human envelope on the agent socket gets
`human-required`, and a capability on the human socket gets
`invalid-envelope`. A decision resolved through the human socket
records `via: human-socket`.
- **The relay:**
- a genuine CLI passes;
- an agent marker in the CLI or an ancestor is refused;
- a snooped nonce on another connection is refused, in both modes;
- with no reader the relay refuses.
- **The stop matrix.** `docker stop` gives 143, and a container that
ignores TERM gives 137 after the grace period (S8). After a launcher
restart, `recover()` removes an orphan.
- **The token.** It is absent from inspect, top, logs and the container
config. The adapter's child has it, and the runner's other children
don't.
- **The rootless refusal.** The rootful socket, a missing socket and a
non-rootless daemon each refuse with `endpoint-not-rootless`. The
missing-socket case covers S1's `null` output.
## 10. Limits the docs state
Each goes in the install section of the README and in `LAYERS.md`. I6
writes them.
- **A host process.** Any host process that reaches the Console's
loopback port passes as human through the Console, and one under
Jason's uid also passes directly, through the relay or through the
engine (decisions 95 and 105, section 5). The other-uid half of this
line waits on Jason's answer, P19 333 (section 5). I6 writes what he
rules. REQ-DEC-3 holds against
agent containers and against accidental or inherited launches.
- **The rootful daemon.** Jason is in the `docker` group, so any host
process under his uid can reach the rootful daemon, which is
root-equivalent (decision 106).
- **The Console.** A compromised Console holds human authority, within
its route list and CSP (decision 107).
- **The session token.** A session can read and copy its token. The
egress list limits where it can send it, and a copy can land in the
workspace on the host disk (decision 108).
- **The uid choice.** The mount list is the access control, so a wrong
mount has no uid check behind it (section 2).
- **Untested limits.** io limits, bandwidth limits and a custom seccomp
profile weren't tested and aren't set (S9).
- **pid reuse.** The relay has a pid-reuse window unless I3 uses
`SO_PEERPIDFD`.
## 11. The rows
| Row | Piece | Needs |
|---|---|---|
| I2 | Images, the `install` key, the endpoint helper and the egress proxy | nothing |
| I3 | Broker listeners, `via: human-socket`, the relay and the peer-pid check | I2 |
| I4 | The Console in a container with signed-in human authority | I2, I3 |
| I5 | Managed sessions as containers | I2, I3 |
| I6 | Install mode, the pi worker's move and the documented limits | I4, I5 |
Decision 96's order puts I2 to I6 after D1 and D2, and I4 lands before
M3 retires T3 Mos.
The follow-ups from the I1 review that this doc doesn't own:
- the worker's whole-dataRoot mount at `compose.yaml:40`, which I6
replaces;
- the stale text in `LAYERS.md` L4 and `docs/plans/CONDUCTOR.md:17,40`,
which I6 rewrites.
@@ -0,0 +1,380 @@
# The containerized install: rows I2 to I6 (2026-10-18)
Status: written by Sage, lead, as part of row 87 (#1581). Each section
follows `docs/plans/BRIEF-TEMPLATE.md`. The design they build is
`docs/plans/2026-10-18_containerized-install-design.md`, cited below as
"the design" with its section number. A builder follows the design and
doesn't reopen a decision in it. A departure goes to Sage before the
build continues.
Order, from decision 96: I2, then I3, then I4 and I5 in either order,
then I6. I4 lands before M3 retires T3 Mos. Owners are starting choices.
Sage can move a row with `queue assign`. Every row's gate is green under
the rootless daemon at the endpoint the config names. No row touches the
live stack, and moving the live install to containerized mode is a
separate re-pin plan with Mos's go.
Rules every row keeps:
- Never `docker` without `--host`. In containerized mode every call goes
through the helper from I2.
- No Docker volume, daemon or restart change. A test's networks and
containers carry a name prefix of the row, and its cleanup removes them
and only them.
- No real credential in a test. Where a measurement needs one, the row
ships the script and its expected output, and Mos runs it.
---
## I2 install images, the install config key, the endpoint helper and the egress proxy
### Problem
Nothing builds the containerized install's images, the config can't name
a docker endpoint, and every script reaches Docker through the ambient
context (`scripts/hello.sh:24`, `build.sh:13`, `release.sh:39,122`,
`agent.sh:208`, `verify.sh:36,39,45`, `land-work.sh:87-88`,
`test-release.sh:58`, `mosaic-task.mjs:396`). S1 showed that the ambient
context can be the rootful daemon. Decision 105's egress rule has no
proxy that the repository owns, only the 43-line spike tool.
### Owner and reviewer
- Owner: Researcher, who ran the spikes.
- Reviewers: Filbert and Darkwing.
### Files owned
- `images/core/Dockerfile`, `images/session/Dockerfile`,
`images/egress/Dockerfile`, `images/README.md`. `images/` is a new
top-level directory, which invariant 1 allows for a dedicated purpose.
- `scripts/images.sh` (`build` and `list`), `scripts/lib/docker.sh`.
- `scripts/mosaic-config.mjs`, `scripts/test-config.sh`.
- `packages/egress/` (new: `package.json`, `src/`, `tests/`, `README.md`).
- `contracts/egress.json` (new), and the contracts README if it lists
files.
- `scripts/test-install.sh` (new suite), `docs/TOOLS.md` (the images,
helper and config sections), `README.md` (the config key only).
- `agents/researcher/work/queue-<row>/` for BUILD.md and the measurement
scripts.
### What ships
- The three images on the base digest, as the design describes in section
3, with tags `<image>:<RELEASE>`. `scripts/images.sh build` builds them
through the helper and refuses without containerized mode.
- The `install` key: the rules in design section 4, with a
`test-config.sh` case for each one: absent; local; local with an
endpoint; containerized without an endpoint; containerized with each
bad endpoint form; an unknown mode; an unknown key under `install`. Each
case asserts the exit code and that the config file is unchanged.
- `scripts/lib/docker.sh`: reads the mode and endpoint through
`scripts/load_config`. It adds `--host` and runs the rootless check
before the first call in a process. On any failure it exits with
`endpoint-not-rootless` on stderr. `test-install.sh` covers the rootful
socket, a missing socket (S1's exit 1 and `null`), and the rootless
endpoint passing.
- `packages/egress`: the CONNECT proxy from design section 7, reading one
list from `contracts/egress.json` by name. Tests cover allow, deny, a
malformed request, a slow client, a port that isn't listed and the JSON
log line. A suite run in `test-install.sh` puts a client on an
internal network, as S5 did, and shows a listed host reached and an
unlisted host refused.
- `contracts/egress.json`, with `agents` and `services` lists. Each entry
names the code path that needs it. The two measurements below settle
the lists:
- Whether node 24.21.0's `fetch` in the broker honours `HTTPS_PROXY`
with `NODE_USE_ENV_PROXY=1`, measured with no credential against
the services hosts. If it doesn't, stop and tell Sage before adding a
dependency.
- The provider hosts Claude Code and pi need with a real token. The row
ships the script and the expected output. Mos runs it and posts the
output on the row's issue. The list is final when that output
matches.
- Suites: `scripts/test-config.sh`, `scripts/test-install.sh`, the egress
package tests, and every `scripts/test-*.sh` green.
### Out of scope
Any script's move to the helper other than `scripts/images.sh` (I6). The
broker's listeners (I3). Running any image as part of the stack (I4, I5,
I6). Adding the `install` key to any real config.
### Gate
Filbert and Darkwing each approve on the row's issue naming the
candidate manifest, and Mos's provider-host output is on the issue. Sage
runs the full gate green on the landing tree.
---
## I3 two broker listeners, the human-socket via value and the host relay
### Problem
The broker serves one socket and trusts a human envelope only after the
host's ancestry proof (`packages/bus/src/runtime.mjs:110-118`), which
no container can run (HP). Decision 105 needs an agent socket that
refuses human envelopes and a human socket whose reachability is the
proof. The `mosaic` CLI then needs a host relay. HP also found that
today's proof accepts a nonce snooped from another connection.
### Owner and reviewer
- Owner: Dewey.
- Reviewers: Filbert and Darkwing.
### Files owned
- `packages/bus/src/server.mjs`, `runtime.mjs`, `broker.mjs` (only the
`via` sites listed in design section 5), `landing.mjs`, `human-cli.mjs`,
and a new `packages/bus/src/relay.mjs` and `packages/bus/src/via.mjs`
(the `isHumanVia` helper).
- The `SO_PEERCRED` reader the row picks: a file under
`packages/bus/src/` or `packages/bus/native/`, with its build step if
native.
- `packages/webui/src/public/bus.js` (line 188 only). Decision 123
applies.
- `packages/cli/src/` where the bus host starts the relay.
- `packages/bus/tests/`, `packages/webui/tests/` for the line 188 case,
`packages/bus/README.md`, `docs/TOOLS.md`.
- `agents/dewey/work/queue-<row>/`.
### What ships
- `serve()` takes a list of listeners, each with a path, a mode and an
envelope rule, as design section 5 describes. In local mode the broker
serves one socket as today, with today's rules.
- In containerized mode the agent listener refuses `human` and `tester`
with `human-required`. The human listener refuses a capability with
`invalid-envelope` and binds through `bindHuman` with
`via: 'human-socket'`.
- The stale-socket rule from design section 5, tested against the real
`serve()` after a kill -9.
- `isHumanVia`, and every reader in design section 5 uses it. Each
reader has a test with `human-socket`. The landing test shows a merge
Jason resolved over the human socket lands.
- The relay: `verifyHuman` and `verifyTester` unchanged, plus the
peer-pid check, forwarding to the human socket. The peer-pid check also
goes into the local install's proof. Tests: a genuine CLI passes; an
agent marker in the CLI or an ancestor is refused; a nonce snooped and
sent on a second connection is refused, in both modes; a missing
reader refuses at bus start.
- The request-size rule per listener: row 148's project-save allowance
only on the human listener.
- Suites: bus, webui, cli, and every `scripts/test-*.sh` green, plus
mutants on the envelope rules, the peer-pid check and `isHumanVia`.
### Out of scope
Running the broker in a container (I6 starts it, I4 and I5 use it). The
Console's own changes beyond `public/bus.js:188` (I4). `SO_PEERPIDFD` is
optional: if the row doesn't take it, BUILD.md says so and design
section 10's pid-reuse limit stands.
### Gate
Filbert and Darkwing each approve on the row's issue naming the
candidate manifest. Sage runs the full gate green on the landing tree.
Needs I2 done.
---
## I4 the Console in a container with signed-in human authority
### Problem
The Console runs on the host and reaches the broker through `human-cli`
and the host proof (`packages/webui/src/bus.mjs:27-31`). Its replies go
to the control board's tmux path
(`packages/control-board/src/serve.mjs:59,74-94,239-248`), and it reads
the notifier config file (`packages/webui/src/reads.mjs:229`). Decision
107 puts it in a container, with the human socket as its only route to
the broker.
### Owner and reviewer
- Owner: Darkwing.
- Reviewers: Filbert and Dewey.
### Files owned
- `packages/webui/src/` (`cli.mjs`, `bus.mjs`, `reads.mjs`, `serve.mjs`,
and `public/` under decision 123), `packages/webui/tests/`,
`packages/webui/README.md`.
- `packages/bus/src/broker.mjs` for one human read verb that returns the
notifier binding, and its test.
- `scripts/console.sh` (new): `start` and `stop` through the helper,
for Jason or Mos to run.
- `scripts/test-install.sh` (the Console cases), `docs/TOOLS.md`.
- `agents/darkwing/work/queue-<row>/`.
### What ships
- The Console container from design section 8: its image, network,
published port, mounts and limits. It reaches the broker only through
the mounted human socket.
- A start check that refuses when `bus-host/` holds a socket, or a file
that passes the credentials file rule.
- The notifier read through the new broker verb in containerized mode.
The file read stays in local mode.
- The queue panel through a read-only checkout mount, or off when the
config names no checkout.
- `/api/reply` answers 404 in containerized mode. A reply is a human
`message.send` over the human socket.
- The README lists every route with what it can reach (decision 107).
- Tests: the exact mount list from inspect; the port answers on
127.0.0.1 and not on another address; a signed-in decision resolves and
records `via: human-socket`; the start check refuses each bad
`bus-host/` case; `/api/reply` is 404 in containerized mode and
unchanged in local mode.
- Suites: webui, bus, and every `scripts/test-*.sh` green.
### Out of scope
Starting or stopping the live Console (decisions 94 and 102: Jason or
Mos). The unix-socket Console from design section 5, unless Jason picks
that fix on P19 333 before I4 starts. Then the brief is re-pinned with
it first. The control board, which isn't part of the containerized install.
Authentik's configuration.
### Gate
Filbert and Dewey each approve on the row's issue naming the candidate
manifest. Sage runs the full gate green on the landing tree. Needs I2 and
I3 done. Lands before M3.
---
## I5 managed sessions as containers
### Problem
The launcher starts each managed session under bwrap on the host, with
the provider token in the environment (`packages/seat/src/session.mjs:87-92`)
and liveness from host pids (`packages/cli/src/launcher.mjs:326-328`,
`packages/seat/src/session.mjs:230-241`). Decisions 105, 106 and 108 put
each session in a hardened container on the rootless daemon, with the
token in a file and egress only through the proxy.
### Owner and reviewer
- Owner: Researcher.
- Reviewers: Filbert and Dewey.
### Files owned
- `packages/cli/src/launcher.mjs`, `packages/seat/src/session.mjs`,
`packages/seat/src/sandbox.mjs`, a new
`packages/seat/src/container.mjs`, the session runner, and their tests.
- `packages/seat/README.md`, `packages/cli/README.md`, `docs/TOOLS.md`.
- `scripts/test-install.sh` (the session cases).
- `agents/researcher/work/queue-<row>/`.
### What ships
- The launch from design section 6, with the record, liveness, stop,
exit and recovery rules there. In local mode, bwrap and the pid record
stay unchanged.
- The token file check before launch, the runner's read, and the token
only in the adapter's environment (design section 7). The `docker run`
environment holds no provider variable.
- The starting limits from design section 3. The row measures one real
session's pids and memory peak with a dummy task and no token, and
proposes final numbers in BUILD.md.
- Tests:
- the exact mount list from inspect, including what a role without the
launch or git grant doesn't get;
- CapEff 0, NoNewPrivs 1 and Seccomp 2 in the session;
- a session reaches a listed host only through `proxy` and can't
resolve any other host;
- the token is absent from inspect, top, logs and the container
config, present in the adapter's child and absent from the runner's
other children;
- the stop matrix (143 on stop, 137 after the grace period);
- `recover()` removes an orphan after a launcher restart;
- `mosaic stop <run>` stops a container session;
- each launch refusal from design section 6, including
`endpoint-not-rootless` and `no-session-credential`.
- Suites: seat, cli, bus, and every `scripts/test-*.sh` green, plus
mutants on the record, the liveness check and the token path.
### Out of scope
The pi worker (I6). Choosing models or changing role grants. Any live
launch.
### Gate
Filbert and Dewey each approve on the row's issue naming the candidate
manifest. Sage runs the full gate green on the landing tree. Needs I2 and
I3 done.
---
## I6 install mode, the pi worker's move and the documented limits
### Problem
Nothing starts the containerized install as one unit. The pi worker
mounts the whole dataRoot (`compose.yaml:40`) and takes provider keys
from the environment (`compose.yaml:36-37`). The scripts I2 listed still
use the ambient Docker context. `LAYERS.md` L4 and
`docs/plans/CONDUCTOR.md:17,40` describe isolation that is out of date,
and no document states the limits in design section 10.
### Owner and reviewer
- Owner: Dewey.
- Reviewers: Filbert and Darkwing.
### Files owned
- `packages/cli/src/` where `mosaic bus start` starts the broker,
notifier and egress containers in containerized mode, with
`scripts/bus-service.sh`.
- `compose.yaml`, `scripts/run-task.sh` and the worker adapter files it
uses.
- The scripts listed in I2's problem, moved to `scripts/lib/docker.sh`.
- `scripts/bootstrap.sh` only to print the install steps. It still never
writes the config.
- `README.md` (the install section), `docs/TOOLS.md`, `LAYERS.md`,
`docs/plans/CONDUCTOR.md`.
- `scripts/test-install.sh` (the whole-install cases) and the existing
suites the moved scripts touch.
- `agents/dewey/work/queue-<row>/`.
### What ships
- `mosaic bus start <business>` in containerized mode starts the
egress containers, then the broker and notifier container, then the
launcher, the git and launch sockets, and the relay on the host. It
stops them in reverse order. Local mode is unchanged.
- The pi worker runs through the helper on the rootless daemon. It
mounts only its run directory and workspace. Its provider keys come
from files, as design section 7 describes for the session token.
- Every listed script calls Docker through the helper. In local mode the
helper keeps today's behavior.
- The install section of the README: the steps, the config key, and
every limit in design section 10, each with its decision number. The
other-uid half of the host-process limit says what Jason ruled on
P19 333. If he hasn't ruled when I6 reaches that section, the row
waits on it.
`LAYERS.md` L4 and `CONDUCTOR.md:17,40` describe what is built.
- Tests: the whole install starts and stops cleanly, and leaves no
container or network. A pi worker task runs in containerized mode and
writes its run record. Each moved script refuses `endpoint-not-rootless`
against the rootful socket in containerized mode.
- Suites: every `scripts/test-*.sh` and the node suites of each touched
package green.
### Out of scope
Moving the live install. That's a re-pin plan with Mos's go and
Jason's time. Changing the rootful daemon or the `docker` group (decision
106 records that limit).
### Gate
Filbert and Darkwing each approve on the row's issue naming the
candidate manifest. Sage runs the full gate green on the landing tree.
Needs I4 and I5 done.