plans: row 87 round 2 answers Filbert's and Darkwing's reviews of the containerized install design (#1581)

The Console binds 0.0.0.0 in its own namespace behind a host-loopback
publish (a decision 107 enforcement change, reach unchanged). It mounts
businesses/ and its other mounts at their host paths, checks bus-host/
against an allowlist, turns the queue panel off, and answers 404 on every
board route. The git socket's bundles and land logs move to git-host/.
recover() still ends leftover runs host-lost. RESOLVED_VIA gets a
human-socket entry, and every container kind gets mount-list and
hardening tests. Spike citations are corrected, and the root case and
two-network setup are marked as not spiked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 08:41:55 -05:00
co-authored by Claude Opus 5.5
parent 8fcd0887e0
commit 47b030d9bc
2 changed files with 357 additions and 136 deletions
@@ -3,7 +3,8 @@
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`.
`docs/plans/2026-10-18_containerized-install-rows.md`. Round 2 answers
Filbert's review (comment 28851) and Darkwing's (comment 28852).
This doc turns decisions 105 to 108 into interfaces. It doesn't reopen
them. Every claim about container behavior cites a spike in
@@ -32,8 +33,10 @@ 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:
Why not S2's recommended split? S2 recommends a non-root broker uid with
one gid of its own, each client a non-root uid given `--group-add` with
that gid, and the sockets kept apart by which directory each container
mounts (S:166-169). The reasons against it:
- 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
@@ -44,19 +47,26 @@ socket:
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.
exactly 0600. In a root container, the host user's files show as owner
0:0 (HP:120-121), and the process is uid 0, so the rule passes
unchanged. Container root reads the host user's 0600 file as its owner
(S:148-149).
- 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.
wrong mount isn't caught by a uid check behind it. So every container
kind gets a test of its whole mount list (section 9), not just that the
expected mounts exist: the Console in I4, sessions in I5, and the broker
and notifier, both egress proxies and the pi worker in I6.
A container escape would land at the host user, because container uid 0
is host uid 1000 (S:146-147) and the rootless daemon runs as that user.
That's a reading of the uid map. No spike tried an escape. The host user
can already reach everything these containers hold, so an escape lands
where a host-side agent already is (decision 95's limit), not higher.
## 3. Components
@@ -87,6 +97,8 @@ replaces its reply path for the Console.
(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.
It has no `git`, which the base image lacks, so the Console's queue
panel is off in containerized mode (section 8).
- `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.
@@ -100,6 +112,11 @@ 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/`).
- The root case is unmeasured. Sessions run as container root, but S6
and S7 ran Claude and pi as `--user 1000:1000` (S:352-354, S:433). The
tmpfs list above and the egress list in section 7 rest on those runs.
I5 repeats S7's write check and S6's proxy check as container root
before either list or the limits below are final.
- `--cap-drop ALL`, `--security-opt no-new-privileges`, and the default
seccomp profile.
- `--pids-limit`, `--memory` with `--memory-swap` set to the same value
@@ -113,8 +130,13 @@ From the I1 review and S7 and S9:
| 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).
- Sessions run with `--init`. S8 measured `docker stop` on an `--init`
container with no SIGTERM handler: tini forwards the signal and the
container exits 143 in 18 ms (S:493). The session runner has no handler
today, so 143 is its clean stop. With a handler it would be 0 (S:494).
S8 measured 137 after the grace period only without `--init` (S:491).
That a runner which ignores the forwarded TERM also ends 137 under
`--init` is inferred, and I5 measures it.
S9 didn't test io or bandwidth limits, ulimits or a custom seccomp
profile, and this design adds none of them.
@@ -146,9 +168,10 @@ 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).
The check tests the exit status first, because a missing socket exits 1
and prints `null`. Then it parses the output as a JSON array and passes
only if one element is exactly `name=rootless` (S:100-103). A substring
match isn't enough. `--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.
@@ -181,9 +204,21 @@ directory).
`<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.
`newRun` checks `tools.sock` (`packages/seat/src/session.mjs`).
In containerized mode `bus-host/` holds no socket and nothing of the git
socket's, which section 8 relies on. Today the git socket keeps its
bundle files and its landing logs there (`dir` at
`packages/cli/src/git-socket.mjs:277`, used at 349 for `land/<id>/` and
409 for `<hex>.bundle`). In containerized mode that `dir` is
`<dataRoot>/git-host/` (0700, host user), which no container mounts. I5
makes the move with the socket paths. What stays in `bus-host/` is
`host.json`, `host.lock`, `sessions.json`, `boot-status.json` and
`boot-events.jsonl` (`packages/cli/src/host.mjs:35-37`,
`boot-status.mjs:13-15`, `packages/seat/src/session.mjs:47`), plus the
temporary names their writers make and remove: `<file>.<suffix>.tmp`
(`host.mjs:83,122`, `boot-status.mjs:34`, `session.mjs:222`) and
`host.lock.<suffix>.stale` (`host.mjs:84`).
The local install keeps its single `<store>/broker.sock` and
`bus-host/launch.sock` and `bus-host/git.sock`, unchanged.
@@ -193,17 +228,22 @@ The local install keeps its single `<store>/broker.sock` and
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).
- **Agent listener.** Capability envelopes only. A `human` envelope gets
`human-required`, as S4 and HP measured (S:239-241, HP:150). A
`tester` envelope gets the same answer by design. No spike sent one.
- **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`.
measured that container root with `--cap-drop ALL` and `--pid=host`
gets EACCES on a host process's environ, and so does the same with
`--cap-add SYS_PTRACE`, and so does uid 1001 (HP:64-66, 149). That no
container can read it is HP's reading of the kernel source, which HP
marks as not spiked (HP:68).
`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
`serve()` (`packages/bus/src/server.mjs:13`, with the listen and the
`chmodSync(path, 0o600)` at 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
@@ -220,8 +260,8 @@ 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):
8d47d6184. The working tree's broker.mjs carries another row's
uncommitted edits, so these are committed lines:
- `packages/bus/src/runtime.mjs:117` binds with `via: 'cli'`. The human
listener's bind passes `human-socket`.
@@ -240,11 +280,21 @@ uncommitted edits, so these are HEAD lines):
- `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/cli/src/git-socket.mjs:252` maps `via` to the squash
message's text (`RESOLVED_VIA`: `cli` is "via the CLI", `broker` is
"as its arbiter, via the broker"), and 259 falls back to
`via ${f.via}`. Without an entry, a merge Jason resolved over the human
socket would say "via human-socket" in its squash message, the row 137
r3 provenance problem. I3 adds `human-socket` with the text "via the
human socket".
- `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`.
every reader above that decides whether the human acted uses it, so a
third value can't miss a reader. `RESOLVED_VIA` is a text map, not a
check, so it gets its own entry instead. I3's tests cover each reader
with `human-socket`, and the squash message of a merge resolved over the
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
@@ -258,7 +308,8 @@ 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`).
- It runs `verifyHuman` unchanged (`packages/bus/src/human.mjs:52`,
called today at `runtime.mjs:111`).
`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
@@ -302,10 +353,15 @@ 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.
names the user. A request with a loopback Host skips the header check
(`packages/webui/README.md`, "What the proxy must do"), but in preview
mode it is read-only, since every POST but the named ones is refused
(`packages/webui/src/serve.mjs:288-302`). Acting as Jason takes the
public Host plus exactly one header equal to the allowed user (270,
283-287). Any local process can send both, because the Console can't
tell the proxy from another client on the port. 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
@@ -324,9 +380,9 @@ Mos ruled on this reading (mos-0657, 2026-10-18):
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
It goes to Jason 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.
design or I2 to I5. Only I6's limits section waits on Jason's 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
@@ -405,19 +461,32 @@ instead of `{pid, startTime}`.
reports `true` with the recorded start. That replaces
`startTimeOf(runnerPid) === runnerStartTime`
(`packages/seat/src/session.mjs:230-241`). A missing container counts
as dead.
as dead. Not spiked. I5 tests it.
- **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.
gives today (247-268). S8 measured, under `--init`, 143 in 18 ms for a
process with no TERM handler (S:493), and 0 for one whose handler exits
cleanly (S:494). The session runner has no TERM handler today, so 143
is its clean stop. A stop after the grace period exits 137, which S8
measured without `--init` (S:491). That it's 137 under `--init` too is
inferred, and I5 measures it. `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 launcher removes the container once it has recorded the exit. Not
spiked. I5 tests it.
- **Recovery.** `recover()` (`packages/cli/src/launcher.mjs:491-513`)
today kills each leftover runner, binds its launch again, ends the run
`host-lost`, records the end and logs it. That ending is what releases
the role. In containerized mode only the kill changes. For each
leftover entry it runs `docker rm -f <containerId>`, then calls
`bindLaunch` with `{containerId, containerStartedAt}`, then
`endRun(run, "host-lost")`, the `end` record and the log line, as
501-510 do today. After the leftovers, it lists containers labelled
`mosaic.business=<business>` and removes, with `docker rm -f`, any
that the registry doesn't name, logging each to the launch log. The
label filter and `rm -f` are not spiked. I5 tests all of it, including
that a leftover run ends `host-lost` and its role is free again.
The local install keeps its bwrap path and pid record unchanged.
@@ -474,13 +543,14 @@ There are three networks:
- **`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).
3128, and takes CONNECT only. On an internal network the alias `proxy`
resolves (S:311), there is no external DNS and no route out (S:329),
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.
a provider host. S5 ran one internal network. Two internal networks with
a proxy each are not spiked, and I6 tests them.
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.
@@ -489,8 +559,11 @@ 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.
datadog intake (S6). I2 measures both CLIs' provider hosts with a real
token. Both of S6's caveats are Claude's. S6 used a made-up token
(S:410-413), and its `UPSTREAM-ERROR` run ended with Claude's "Not
logged in" (S:416-418). pi didn't touch the network with no key
(S:372), so its hosts are unmeasured.
- **`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.
@@ -498,9 +571,12 @@ The two keys:
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.
S5 measured node's `fetch` through the proxy on node 24.21.0 with
`NODE_USE_ENV_PROXY=1` and `HTTPS_PROXY`, and got the provider's HTTP 401
(S:320, image at S:19, cited by S6 at S:388-389). What isn't measured is
whether the broker's and notifier's own code paths go through `fetch`
with that variable set. I2 checks each path. If one 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
@@ -519,34 +595,85 @@ request, a slow client and a connect to a port that isn't listed.
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`.
- **Bind address, a decision 107 enforcement change.** `startServer`
refuses a non-loopback bind (`packages/webui/src/serve.mjs:252-253`).
Inside the container that can't work: Docker forwards a published port
to the container's bridge address, so a server bound to the
container's 127.0.0.1 is unreachable through `-p`. In containerized
mode the Console binds `0.0.0.0` inside its own network namespace, and
the publish stays on host `127.0.0.1`. The in-container port equals the
published port, because the loopback-Host check compares the Host's
port with `server.address().port` (277). The reach doesn't change: the
only way in is still host loopback on that port. What changes is where
that's enforced, from the bind check in the process to the publish in
the engine. Local mode keeps the loopback refusal. Not spiked. I4 tests
that the port answers on host 127.0.0.1 and not on the host's other
addresses.
- **Mounts.** Each lands at its host path, so `loadSystem`,
`configDir()` and the reads resolve as they do today and no path code
changes:
- `<dataRoot>/sock/human/`, the directory;
- `<configDir>/config.json`, a read-only file mount. The config is
never rewritten, so S3's dead-inode problem doesn't arise, and a
config change needs a Console restart, as `loadSystem` at start
already does;
- `<configDir>/businesses/`, read-only, for `loadBusiness` and
`launchSettings` (`reads.mjs:187-195,234`,
`packages/business/src/business.mjs:25-26`). Never the config
directory itself, which holds `secrets/`;
- `<dataRoot>/bus-host/`, read-only, for `host.json`, `sessions.json`
and `boot-status.json` (`bus.mjs:65`, `reads.mjs:125-132,216,234`).
`MOSAIC_CONFIG` is set to the host path of `config.json`.
`mosaic-config.mjs` requires a regular non-symlink file there, and a
dataRoot that is absolute and doesn't contain the home directory or the
config directory (`scripts/mosaic-config.mjs:41-44,50-54,76-100`). Its home
check reads the container's HOME, which differs from the host's. That
`loadSystem` passes in the container isn't spiked, and I4 tests it.
`rolesDir` comes from the image (`packages/cli/src/config.mjs:35`). I4 changes
`packages/webui/src/cli.mjs:58` so that in containerized mode the
broker path is `<dataRoot>/sock/human/broker.sock` instead of
`socketPath(dataRoot)`. The Console keeps `humanCall`, now pointed at
the human socket. `human-cli` already carries `business` in its proof
(`packages/bus/src/human-cli.mjs:28`).
- **The `bus-host/` start check.** The mount is safe only because
`bus-host/` holds no socket and nothing of the git socket's in
containerized mode (section 5). At start, in containerized mode, the
Console lists `bus-host/` and accepts only the five state files and
their writers' temporary names: `host.json`, `host.lock`,
`sessions.json`, `boot-status.json`, `boot-events.jsonl`, any of them
followed by `.<suffix>.tmp`, and `host.lock.<suffix>.stale`. Each must
be a regular file. It refuses any other entry, including a socket, a
directory, a symlink and a fifo, and the refusal names the entry. A
rule like "refuse anything that passes for a credentials file" can't
work, because the four state files are small 0600 files owned by the
host user and pass the credentials rule (`credentials.mjs:60-72`).
- **What it never gets:** the dataRoot as a whole, `bus/`, `git-host/`,
the relay, the agent socket, the config directory, 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.
- **The queue panel is off.** `RELEASE` comes from the image. The
queue panel runs `queue-read.mjs`, which spawns `git` in the checkout
`--checkout` names (`reads.mjs:75-79`, `cli.mjs:30-35`,
`packages/queue/src/store.mjs:51-90`). `mosaic-core` has no `git`
(section 3), so in containerized mode the queue read answers a fixed
refusal without spawning anything. A later row can add `git` to the
image and a same-path checkout mount with `--checkout`. In local mode,
if `--checkout` is absent the panel reads the Console's own copy, as
today.
- **No route to the board.** `/api/board`, `/api/conversations` and
`/api/conversation` (GET), and `/api/seen` and `/api/reply` (POST), are
proxied to the control board today (`serve.mjs:304-323`). In
containerized mode each answers 404 and the Console opens no
connection to the board. Local mode keeps all five. A reply to a
managed instance is a human `message.send` over the human socket, the
path row 76 built. The board's `/api/reply` runs
`tools/tmux/agent-send.sh`
(`packages/control-board/src/serve.mjs:59,74-94,239-248`), and its
tmux liveness check (`scan.mjs:196-209`) has no containerized
counterpart, because the sessions list comes from `sessions.json`. I4
tests each route.
- **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.
@@ -555,30 +682,50 @@ request, a slow client and a connect to a port that isn't listed.
- **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.
list. Section 2's tradeoff depends on it. I4 tests the Console's, I5 a
session's, and I6 the broker and notifier's, each egress proxy's and
the pi worker's.
- **Hardening, every kind.** `/proc/self/status` shows CapEff 0,
NoNewPrivs 1 and Seccomp 2 (S9), and the root filesystem is read-only.
I4 tests the Console, I5 a session, and I6 the broker and notifier,
both egress proxies and the pi worker.
- **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).
through `proxy` (S5), and I5 repeats S6's proxy check as container
root. I6 tests the two internal networks, each with its own proxy, and
that the broker can't reach a provider host. I4 tests that the
Console's port answers on host 127.0.0.1 and not on the host's other
addresses.
- **The Console's start and routes.** In containerized mode `loadSystem`
passes in the container. The `bus-host/` start check accepts the five
state files and a leftover `.tmp`, and refuses a socket, an unknown
file, a planted `launch.sock` or `git.sock`, a `land/` directory and a
`.bundle`, naming the entry. Each board-proxied route answers 404, and
each is unchanged in local mode. The queue read refuses without
spawning. All in I4.
- **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`.
records `via: human-socket`, and a merge it resolves says "via the
human socket" in its squash message.
- **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 stop matrix.** Under `--init`, `docker stop` on the runner gives
143 (S:493). A container that ignores TERM gives 137 after the grace
period, which I5 measures under `--init`. After a launcher restart,
`recover()` removes the leftover container, ends its run `host-lost`
and frees its role, then removes a labelled container the registry
doesn't name.
- **The root case.** I5 repeats S7's write check as container root
before the tmpfs list is final.
- **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.
- **The rootless refusal.** The rootful socket and a missing socket each
refuse with `endpoint-not-rootless`, and the rootless endpoint passes,
as in I2. The missing-socket case covers S1's `null` output.
## 10. Limits the docs state
@@ -589,14 +736,17 @@ writes them.
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.
line waits on Jason's answer, P19 333 (section 5). I6 writes what
Jason 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
process under Jason's 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).
its route list and CSP (decision 107). In containerized mode the
Console binds all addresses inside its own network namespace, and the
host-loopback publish, not the process's bind check, keeps it off
other addresses (section 8).
- **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).
@@ -1,6 +1,8 @@
# The containerized install: rows I2 to I6 (2026-10-18)
Status: written by Sage, lead, as part of row 87 (#1581). Each section
Status: written by Sage, lead, as part of row 87 (#1581). Round 2
answers Filbert's review (comment 28851) and Darkwing's (comment 28852).
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
@@ -33,9 +35,13 @@ 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.
`test-release.sh:58`, `test-task.sh:139,395,442,517`,
`mosaic-task.mjs:396`, and `docker network rm` in `dropNetwork` at
`packages/cli/src/git-socket.mjs:243`). The `test-release.sh` and
`test-task.sh` lines are `docker info` probes. With `DOCKER_HOST` and the
context unset, the default is the system socket, and S1 queried that
socket directly and found it rootful (S:25, 84-87). Decision 105's egress
rule has no proxy that the repository owns, only the 43-line spike tool.
### Owner and reviewer
@@ -69,10 +75,14 @@ proxy that the repository owns, only the 43-line spike tool.
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
before the first call in a process. The check tests the exit status,
then parses the output as a JSON array and passes only if one element
is exactly `name=rootless` (design section 4, S:100-103). A substring
match isn't enough. 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.
socket, a missing socket (S1's exit 1 and `null`), an output that holds
`name=rootless` only as a substring, 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
@@ -82,11 +92,15 @@ proxy that the repository owns, only the 43-line spike tool.
- `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
- S5 already measured node 24.21.0's `fetch` through the proxy with
`NODE_USE_ENV_PROXY=1` and `HTTPS_PROXY` (S:320). Don't repeat it.
Check that each of the broker's and notifier's outbound code paths
goes through `fetch` with that variable set, with no credential,
against the services hosts. If one doesn't, stop and tell Sage before
adding a dependency.
- The provider hosts Claude Code and pi need with a real token. S6's
run used a made-up token, and pi made no network call without a key
(design section 7). 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.
@@ -134,7 +148,9 @@ today's proof accepts a nonce snooped from another connection.
native.
- `packages/webui/src/public/bus.js` (line 188 only). Decision 123
applies.
- `packages/cli/src/` where the bus host starts the relay.
- `packages/cli/src/` where the bus host starts the relay, and
`packages/cli/src/git-socket.mjs` for the `RESOLVED_VIA` entry only,
with `packages/cli/tests/git-socket.test.mjs`.
- `packages/bus/tests/`, `packages/webui/tests/` for the line 188 case,
`packages/bus/README.md`, `docs/TOOLS.md`.
- `agents/dewey/work/queue-<row>/`.
@@ -153,6 +169,10 @@ today's proof accepts a nonce snooped from another connection.
- `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.
- `RESOLVED_VIA` (`packages/cli/src/git-socket.mjs:252`, used at 259)
gains `human-socket: "via the human socket"`. The git-socket test shows
the squash message of a merge resolved over the human socket says "via
the human socket", and the existing "via the CLI" case is unchanged.
- 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
@@ -211,22 +231,43 @@ the broker.
### 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.
published port, mounts at their host paths, `MOSAIC_CONFIG`, and
limits. It reaches the broker only through the mounted human socket:
`cli.mjs:58` uses `<dataRoot>/sock/human/broker.sock` in containerized
mode and keeps `humanCall`.
- The bind from design section 8. In containerized mode the Console
binds `0.0.0.0` in its own network namespace on the published port,
and the publish stays on host `127.0.0.1`. Local mode keeps the
loopback refusal (`serve.mjs:252-253`).
- The `bus-host/` start check from design section 8, an allowlist of the
five state files and their writers' `.tmp` and `.stale` names. Any
other entry refuses, and the refusal names it.
- 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.
- The queue panel is off in containerized mode: the read answers a fixed
refusal without spawning. In local mode, if `--checkout` is absent, it
reads the Console's own copy as today.
- `/api/board`, `/api/conversations`, `/api/conversation`, `/api/seen`
and `/api/reply` answer 404 in containerized mode, and the Console
opens no connection to the board. A reply is a human `message.send`
over the human socket.
- The README lists every route with what it can reach (decision 107),
and states the bind change.
- Tests:
- the exact mount list from inspect;
- CapEff 0, NoNewPrivs 1, Seccomp 2 and a read-only root filesystem
in the Console container;
- `loadSystem` passes in the container, with `MOSAIC_CONFIG` at the
host path and the container's own HOME;
- the port answers on host 127.0.0.1 and not on the host's other
addresses;
- a signed-in decision resolves and records `via: human-socket`;
- the start check accepts the five files and a leftover `.tmp`, and
refuses a socket, an unknown file, a planted `launch.sock` and
`git.sock`, a `land/` directory and a `.bundle`;
- each of the five board routes answers 404 in containerized mode and
is unchanged in local mode;
- the queue read refuses in containerized mode without spawning.
- Suites: webui, bus, and every `scripts/test-*.sh` green.
### Out of scope
@@ -263,7 +304,8 @@ token in a file and egress only through the proxy.
### Files owned
- `packages/cli/src/launcher.mjs`, `packages/seat/src/session.mjs`,
- `packages/cli/src/launcher.mjs`, `packages/cli/src/git-socket.mjs`
(the socket and `git-host/` paths only), `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`.
@@ -273,8 +315,17 @@ token in a file and egress only through the proxy.
### 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.
exit and recovery rules there. `recover()` still ends each leftover run
`host-lost` after removing its container. In local mode, bwrap and the
pid record stay unchanged.
- The socket move from design section 5: in containerized mode the
launch and git sockets live in `sock/launch/` and `sock/git/`, and the
git socket's bundle files and `land/` logs live in `<dataRoot>/git-host/`
(0700), which no container mounts. Local mode keeps `bus-host/`.
- The root re-measure from design section 3. Before the tmpfs list and
the limits are final, the row repeats S7's write check and S6's proxy
check with the session running as container root, and records both in
BUILD.md.
- 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.
@@ -284,14 +335,22 @@ token in a file and egress only through the proxy.
- 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;
- CapEff 0, NoNewPrivs 1, Seccomp 2 and a read-only root filesystem
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;
- the stop matrix under `--init`: 143 on stop (S:493), and 137 after
the grace period, which no spike measured under `--init`;
- liveness from inspect, a missing container counted as dead, and the
exit status read through the client and through inspect;
- after a launcher restart, `recover()` removes the leftover
container, ends its run `host-lost`, records the end and frees the
role, then removes a labelled container the registry doesn't name;
- in containerized mode nothing of the git socket's lands in
`bus-host/`;
- `mosaic stop <run>` stops a container session;
- each launch refusal from design section 6, including
`endpoint-not-rootless` and `no-session-credential`.
@@ -335,6 +394,9 @@ and no document states the limits in design section 10.
- `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`.
`dropNetwork` (`packages/cli/src/git-socket.mjs:243`) is JavaScript and
can't source the helper, so in containerized mode it passes `--host`
with the configured endpoint after the same rootless check.
- `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`,
@@ -357,13 +419,22 @@ and no document states the limits in design section 10.
- 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
P19 333. If Jason 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.
- Tests:
- the whole install starts and stops cleanly, and leaves no container
or network;
- the exact mount list from inspect for the broker and notifier
container, each egress container and the pi worker;
- CapEff 0, NoNewPrivs 1, Seccomp 2 and a read-only root filesystem
in each of those;
- the two internal networks, each with its own proxy: an agent can't
reach a services host, and the broker can't reach a provider host;
- 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.