Mos relayed Jason's answers in mos-0787. P19 332: a CLI command and a Console view for role-instance changes, held until Jason confirms Mos's reading. P19 333: accepted as a documented limit, so the I6 brief and the design's limits line now say I6 writes it as a known limit. Co-Authored-By: Claude Opus 5.5 <[email protected]>
59 KiB
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. Round 2 answers
Filbert's review (comment 28851) and Darkwing's (comment 28852). Round 3
answers their round 2 reviews (Darkwing 28863, Filbert 28864). Round 4
answers their round 3 reviews (Darkwing 28871, Filbert 28870). Round 5
answers Darkwing's round 4 review (28877) and Filbert's doc-only
follow-up on the stop timeout (28876). Round 6 answers Darkwing's round
5 review (28880) and Filbert's doc-only follow-ups (28881). Round 7
answers Darkwing's round 6 review (28884) and Filbert's doc-only
follow-up on the start order (28883). Round 8 answers Darkwing's round
7 review (28890) and Filbert's doc-only follow-ups (28889).
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 0f3eb25ea. Since 8d47d6184 only row 143 (0278b9e0b) moved a cited
file, packages/bus/src/broker.mjs, and round 4 updated those cites. 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? 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 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 equalprocess.getuid()and the mode to be 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. 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
| 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 host channel, a boundary change
Today the bus host forks the broker and the notifier with an IPC channel
(packages/cli/src/host.mjs:215-217, 242-244). Everything the host
trusts them with goes over it: the boot config, bindLaunch, the launch
ops (packages/bus/src/process.mjs:5-20) and the notifier's reader
capability (host.mjs:244). Without the channel the broker refuses to
run, trusted-host-required (process.mjs:57-59). A container can't be
forked, so containerized mode needs another channel.
The channel is the docker run client's stdin and stdout. The bus host
starts the core container as its own child with docker run -i, without
-t, the way the launcher starts a session's client (section 6). Inside,
a new entry, packages/cli/src/core-entry.mjs, runs under --init. It
forks process.mjs and notifier-process.mjs with IPC, as the host does
today, so neither file changes and trusted-host-required still holds.
The entry reads one JSON line per message from stdin, tagged broker or
notifier, and passes it to that fork. It writes each reply as one JSON
line on stdout with the same tag. The forks' stdout and stderr go to the
container's stderr, so stdout carries only replies. EOF on stdin closes
both forks, as a disconnect does today (process.mjs:107).
Replies carry capabilities: the boot reply holds the reader caps
(host.mjs:239) and each bindLaunch reply a launch cap. The engine
keeps a container's stdout in its log driver, so the core container runs
with --log-driver none. S8 measured that a stdin line appears nowhere
in inspect, top, logs, /proc/1/environ, cmdline or ps, but stdout
wasn't part of it. That the client still gets stdout under
--log-driver none, and that no cap reaches docker logs or the
daemon's container directory, are not spiked. I6 tests both.
The boundary change is that the channel adds no authentication of its
own. The writer on stdin is whoever holds the client's pipe, and that's
the bus host. The other ways in are docker attach and docker exec
through the engine endpoint. A caller with the endpoint can already run
anything in the container, so the channel is no weaker than the
container it reaches. Decision 106 lets only a host process hold the
endpoint, and decision 105 accepts the Jason-uid routes to it. This
change needs a ruling before I6 is queued (section 11).
The ruling also covers where caps travel. process.mjs:1-2 and
notifier-process.mjs:1-3 say a cap never touches stdout. That stays
true of each fork's own stdout, which goes to the container's stderr.
The entry's stdout does carry caps, and the engine relays it to the
client. B2 rules on that too.
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.
The core container's lifecycle
Today each fork is a separate child of the host. A fork that dies alone
stops the host with exit 1 (packages/cli/src/host.mjs:355-364, tested
at packages/cli/tests/host.test.mjs:218-233), close() takes each
fork's exit code (host.mjs:341-347), and the start failure path kills
the notifier alone (host.mjs:252-257). Containerized mode keeps all of
that by putting each fork's events on the channel.
- The start order. Bus start takes
host.lock, then sets the store path, then runs the rootless check (section 4) before the leftover rule's first engine call, then the leftover rule, thendocker run. - The store path. Right after
host.lock, bus start makes<dataRoot>/busif it is missing, mode 0700, the way the broker'sStoredoes today (packages/bus/src/store.mjs:126), which only the broker does now. The host makes onlybus-host/(host.mjs:80,:120), so on a fresh install nothing else would. Bus start then takes the real path of<dataRoot>/bus. That one string, the store path, is the store's only spelling in containerized mode. It is the source of the store's bind mount, themosaic.storelabel, the leftover rule's filter andhostStatus's filter.Store'ssafe()checks only the leaf, so a data root under a symlinked parent has two spellings. With the real path everywhere, both spellings reach the same name and the same label. - Name and labels. The host runs the core container with
--name mosaic-core-<store hash>,--label mosaic.business=<business>,--label mosaic.kind=core,--label mosaic.store=<store path>and--rm.<store hash>is the first 16 hex digits of the SHA-256 of the store path. The name is keyed on the store, aswriter.lockis, not on the business: two data roots that run the same business get two names. Thepackages/clisuite runs its test files in parallel, and many of them use the businessacme, each with its own data root. The fixed name means the engine refuses a second core container for the store while one exists in any state. The host reads the forks' exit codes from the channel, not from the container, so--rmloses nothing it reads. - A name conflict. The leftover rule below runs first and waits
until every container it listed is gone, so a conflict means a
container holds the name without the store's labels, which only a
hand-made container would. That needs the operator, so the refusal
is exit 3, which the unit doesn't retry
(
packages/cli/systemd/mosaic-bus.service.in:33). Thedocker runclient then exits before any line, and the engine's error goes to the host log through the client's inherited stderr. The host doesn't parse that text. Bus start refuses with exit 3,core container mosaic-core-<store hash> did not start (docker run exited <code>); see the host log, giveshost.lockback and starts nothing else. It never removes a container it didn't list under the store's labels. - The close timeout.
close()waitsCLOSE_TIMEOUT_MS(20 s,host.mjs:30) for the notifier, then again for the broker (host.mjs:343,:347). So stopping both forks the wayclose()does can take two close timeouts. The host passes the value to the entry asMOSAIC_CLOSE_TIMEOUT_MSon thedocker runcommand line, taken from the same constant, which I6 exports. The entry refuses to start without it. The core stop timeout is two close timeouts plus 5 s, rounded up to whole seconds: 45 s at today's value. The leftover rule below passes it todocker stop -t. - Lines. A line from the host is
{to, msg}, wheretoisbrokerornotifierandmsgis the IPC message today's host sends, or{to, signal}, which the entry delivers askill(signal)on that fork. A line to the host is{from, msg}for an IPC reply, or{from, exit: {code, signal}}once that fork has exited. The entry starts a fork when the first line for it arrives: the broker onboot, the notifier onstart. It passes that line to the fork as its first IPC message, so a line never waits for a fork that isn't there. No notifier line means no notifier fork, as today when the business has no notifier. Once a fork has started, the entry writes{from, pid}for it, before any other line from that fork, wherepidis in the container's pid namespace. The host wraps each fork in an object withsend,kill,connected,exitCode,signalCodeand anexitevent driven by those lines.firstReply(host.mjs:129),ended(:148),watchChildren(:159-165) andclose()keep their logic:close()sends{op: 'stop'}to the notifier and{op: 'close'}to the broker, as today, andended()gets each code from the exit line;- the start failure path's
notify.kill('SIGTERM')becomes{to: 'notifier', signal: 'SIGTERM'}.
connected. A wrapper'sconnectedfollows the channel, as a forked child's does today: it is true from the moment the host makes the wrapper. The broker's wrapper is made when thedocker runclient starts, and the notifier's wherehost.mjs:242forks today. The host sendsbootandstartright after (host.mjs:217,:244), and those writes go through, because the entry starts the fork on that line. The{from, pid}line changes nothing inconnected; it gives the log its pid.connectedturns false, and never back, on the first of three events: the fork's exit line, the client's stdout closing or the client exiting, or a write to the client's stdin failing. Once it is false,sendandkillwrite nothing and return false. The host'sconnectedchecks before a send (host.mjs:256,:342,:346) therefore skip a fork whose channel is gone, as they do today when a child has disconnected.host.pids. In containerized mode both entries are null, and the object addscontainer, the core container's name. The host doesn't know a fork's pid in its own namespace. A container pid passed toprocess.killwould signal an unrelated host process. Four tests usehost.pidsas host pids:host.test.mjs:202-205reads each pid's/proccmdline and environ,:227sends the notifier SIGKILL,:546-563sends the broker SIGSTOP and SIGCONT, and:612finds the broker's child process by pid to send it IPC. They stay in local mode, where the host logic they test is unchanged. The host logs each fork's container pid from its{from, pid}line. I6's lifecycle tests reach a fork withdocker exec <container> kill -<signal> <pid>, using the pid from that log line.- One fork exits alone. The entry writes that fork's exit line, then
stops the other, if it started, the way
close()does, waits up to the host's close timeout, sends SIGKILL if it hasn't exited by then, writes its exit line and exits 1. The host gets the first exit line, logs "<name>exited", and stops with exit 1, ashost.mjs:355-364does. When the host stops the forks, the entry exits 0 once each fork it started has exited 0, and 1 otherwise. - The client ends first. If the
docker runclient exits, or its stdout closes, before a fork's exit line arrives, the host treats that fork as exited with signalSIGKILLand stops with exit 1. - SIGTERM to the entry.
docker stopreaches the entry through--init. The entry stops both forks asclose()does and exits. The broker's own SIGTERM and close paths (process.mjs:81-83,108) then removewriter.lock(packages/bus/src/store.mjs:265). - The host dies without closing stdin. Today a SIGKILLed host drops
the forks' IPC and the broker closes (
process.mjs:107). In containerized mode the host's death closes the client's stdin pipe. The expected path is that the client passes that EOF to the container, and the entry closes both forks. If the client itself is killed, the expected path is that the engine closes the container's stdin when the attach connection drops. Neither is spiked. I6 measures both, and the design doesn't rely on them, because of the next rule. - Leftovers at bus start. After
host.lockis taken and before the core container starts, bus start lists containers labelledmosaic.kind=coreandmosaic.store=<store path>through the endpoint helper. It runsdocker stop -t <core stop timeout>on each, 45 s at today's value, which covers both of the entry's close waits.--rmremoves a container afterstopreturns, anddocker rm -fduring that removal errors instead of waiting, so the rule then waits until each listed container is gone, by id, bounded by the core stop timeout. Gone means the endpoint answers "no such container" for that id. The same answer fromdocker stopordocker rm -falso counts as gone, so a leftover the engine removed between the list and the stop isn't a refusal. A container still there at the bound getsdocker rm -fand the same bounded wait. One still there after that makes bus start refuse with exit 1,leftover core container <id> not removed. That is the one refusal that exits 1, not 3: the unit retries it afterRestartSec(mosaic-bus.service.in:31-33), since an engine still removing is a passing state. I6 updates the unit'sExecStartcomment, which today says refusals exit 3. Each bound is per container: the stop, the wait and the wait afterrm -feach get one core stop timeout, so one leftover costs at most three, 135 s at today's value. The unit isType=simplewith no start timeout. Five exit-1 starts within 600 s reachStartLimitBurst=5(mosaic-bus.service.in:9-10), and systemd then stops retrying. Only fast failures can trip it: a refusal that waits out all three bounds takes 135 s plusRestartSec, so five of them don't fit in 600 s, and I6's docs don't promise that a stuck leftover ends in the start limit. Once it trips,systemctl --user reset-failedclears the limit but doesn't start the unit, so a human starts it again after that, or once the 600 s window has passed. Each step goes to the host log, with the leftover's id.docker runstarts only once every listed container is gone.host.lockis held, so no live host owns a leftover. A leftover that stopped on SIGTERM has removedwriter.lock. One thatrm -fkilled leaves it, and the start refuseswriter-locked, as it does today after a broker dies hard. The lock is an exclusive create in the bind-mounted store directory (store.mjs:128-133), and the broker opens the store before it listens (packages/bus/src/runtime.mjs:62, then the socket path at:104). So even if a leftover survives, a second broker refuses before it writes the store or bindssock/human. mosaic bus status.writer.lockholds the broker's pid in the container's pid namespace (store.mjs:138), andhostStatus(host.mjs:389-408) checks it against the host's/proc. A running broker would show as "a broker died hard". In containerized modehostStatusignores the lock's pid and asks the engine instead. The lock is live when a container labelledmosaic.store=<store path>andmosaic.kind=coreis running, and dead when none is. If the endpoint doesn't answer, it reports the lock as unknown, never dead. Thesocket:line checkssock/agentandsock/human(section 5) instead ofbus/broker.sock. I6 owns both changes.
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 andRELEASE. The broker, notifier and Console run from it. It has nogit, which the base image lacks, so the Console's queue panel is off in containerized mode (section 8).mosaic-session:mosaic-coreplus Claude Code and pi at the versionspackage.jsonand the session code pin today. The host'sMOSAIC_CLAUDE_BINisn't used in containerized mode.mosaic-egress: the base pluspackages/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--tmpfsfor HOME (S7: Claude writes.claude*under HOME andclaude-1000/andcc-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,--memorywith--memory-swapset 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. S8 measureddocker stopon an--initcontainer 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--initis 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.
4. Configuration: the install key
A new optional top-level object in ~/.config/mosaic-dev/config.json:
"install": { "mode": "containerized", "endpoint": "unix:///run/user/1000/docker.sock" }
- Absent means
{"mode": "local"}. Today's configs stay valid without an edit, andconfigVersiondoesn't change. modeislocalorcontainerized. Any other value exits 2.containerizedrequiresendpoint.localforbids it, so the pi worker can't move to the rootless daemon by accident.endpointmust match^unix:///[^\s]+\.sock$.tcp://,ssh://, a relative path or a missing value exits 2.- Any other key under
installexits 2. scripts/mosaic-config.mjs:115addsinstallto the allowed top-level keys, andvalidate()(109-160) checks it.execution.backendstaysdocker(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 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.
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 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. As patterns:
<file>\.[0-9]+\.tmp (host.mjs:122, boot-status.mjs:34,
session.mjs:222), and host\.lock\.[0-9]+\.[0-9a-f]{8}\.tmp and
host\.lock\.[0-9]+\.[0-9a-f]{8}\.stale (host.mjs:82-84), where
<file> is one of the five names. The number is the writer's pid.
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
humanenvelope getshuman-required, as S4 and HP measured (S:239-241, HP:150). Atesterenvelope gets the same answer by design. No spike sent one. - Human listener.
humanandtesterenvelopes. A capability envelope getsinvalid-envelope. Reaching the socket is the proof (decision 105), soauthenticateHumanhere looks up the business and callsbindHumanwithvia: "human-socket". It reads no/proc. HP measured that container root with--cap-drop ALLand--pid=hostgets 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: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
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
0f3eb25ea:
packages/bus/src/runtime.mjs:117binds withvia: 'cli'. The human listener's bind passeshuman-socket.packages/bus/src/broker.mjs:- 465,
bindHuman, whose check at 468 acceptscliorhuman-socket; - 328,
#human, accepts either. Forhuman-socketthe listener has already established that the caller is outside any agent; - 526,
#protectedEnd, Q169's "a decision the human resolved" check, accepts either; - 1569, the
decision_eventsrow, and 1719, message delivery, record the session'sviainstead of the literal'cli'; - 1867, launch revoke and restore, records the session's
via.
- 465,
packages/bus/src/landing.mjs:107counts a resolution as the human's only whenviaiscli. It accepts either, or a merge Jason resolved through the Console or the relay can't land.packages/webui/src/public/bus.js:188labels a decision eventhumanonly forcli. It accepts either. That's a change underpublic/, so decision 123 applies to I3's review.packages/cli/src/git-socket.mjs:252mapsviato the squash message's text (RESOLVED_VIA:cliis "via the CLI",brokeris "as its arbiter, via the broker"), and 259 falls back tovia ${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 addshuman-socketwith the text "via the human socket".packages/tasks/live/run.mjs:108stayscli. It's the local live run.
I3 adds one exported helper, isHumanVia(via), in packages/bus, and
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
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
verifyHumanunchanged (packages/bus/src/human.mjs:52, called today atruntime.mjs:111).cliPathis the host path ofhuman-cli.mjsin 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_PEERCREDpid. 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
testerproof goes throughverifyTesterthe same way and is forwarded as a tester envelope. Anyone on the human socket is already human, so claiming tester gains nothing. human-clipoints 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 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
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 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 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 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
readonlymount doesn't stop a connect on a socket inside it (S2: a client connected through a:rodirectory mount). The directory is read-only only so the session can't replace the socket. session.jsonnames 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 intodocker runin 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) keeps
pid and startTime, which are now the docker run client's, the
launcher's child. It replaces runnerPid and runnerStartTime, since
the runner lives in the container's pid namespace. In their place it
records:
container: the name;containerId;endpoint;containerStartedAt:.State.StartedAtfrom inspect.
bindLaunch is unchanged and binds the run to the client's
{pid, startTime} (launcher.mjs:323). The broker requires an integer
pid and a numeric start time (packages/bus/src/runtime.mjs:84-91) and
refuses unknown keys (packages/bus/src/broker.mjs:391-395), and both
stay as they are. I5 changes no file in packages/bus.
The human proof's launch check walks the caller's host ancestry and
refuses a process that matches a launch record
(packages/bus/src/human.mjs:80). A container's processes descend from
the engine's shim, not from the client, so that walk never meets the
client's pid. In containerized mode the boundary is the mount list
instead: no session gets sock/human or sock/relay, so a session
process can't reach either. The ancestry claim is not spiked. I5 tests
that a session process can't connect to either socket.
- Liveness.
docker --host <endpoint> inspect -f '{{.State.Running}} {{.State.StartedAt}}' <id>reportstruewith the recorded start. That replacesstartTimeOf(runnerPid) === runnerStartTime(packages/seat/src/session.mjs:230-241). A missing container counts as dead. Not spiked. I5 tests it. - Stop.
docker stop --time 30 <id>. That's the 30 sstopSessiongives 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--inittoo is inferred, and I5 measures it.close()in the launcher (566-600) does the same, thendocker killifstopTimeoutMspasses. - Exit. The
docker runclient 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. 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 runhost-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 runsdocker rm -f <containerId>and kills the leftover client ifstartTimeOf(pid)still matches, then callsbindLaunchwith the entry's{pid, startTime}as today, thenendRun(run, "host-lost"), theendrecord and the log line, as 501-510 do today. After the leftovers, it lists containers labelledmosaic.business=<business>and removes, withdocker rm -f, any that the registry doesn't name, logging each to the launch log. The label filter andrm -fare not spiked. I5 tests all of it, including that a leftover run endshost-lostand its role is free again.
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 ALLreads it as the owner. - Before launch. The launcher checks the file under the same rule
as
credentials.mjs:35-79. That replaceshasSessionCredentialin containerized mode. A missing or bad file refuses withno-session-credential. - In the runner. The runner reads the file and puts
CLAUDE_CODE_OAUTH_TOKENonly 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_KEYandANTHROPIC_API_KEYas 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 four 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.mosaic-console: a normal bridge for the Console alone, because an internal network publishes nothing (S5, section 8). It has no proxy, and section 10 states the outbound reach that leaves.
Each proxy answers on its internal network under the alias proxy, port
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. 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.
The two keys:
agents. The provider hosts for the models in the model table. Claude Code needsapi.anthropic.com:443. WithCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1it doesn't need the 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 itsUPSTREAM-ERRORrun 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.
Both CLIs honour HTTPS_PROXY in both cases, and pi ignores ALL_PROXY
(S6).
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
<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-corerunningpackages/webui, onmosaic-console, a normal bridge, published at127.0.0.1on the port the Console is configured with (7332 in the live install). S5: a container only on an internal network accepts-pand publishes nothing, so the bridge is required, and I4 checks that the port answers. Authentik forwards to it as today. -
Bind address, a decision 107 enforcement change.
startServerrefuses 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 binds0.0.0.0inside its own network namespace, and the publish stays on host127.0.0.1. The in-container port equals the published port, because the loopback-Host check compares the Host's port withserver.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, asloadSystemat start already does;<configDir>/businesses/, read-only, forloadBusinessandlaunchSettings(reads.mjs:187-195,234,packages/business/src/business.mjs:25-26). Never the config directory itself, which holdssecrets/;<dataRoot>/bus-host/, read-only, forhost.json,sessions.jsonandboot-status.json(bus.mjs:65,reads.mjs:125-132,216,234).
MOSAIC_CONFIGis set to the host path ofconfig.json.mosaic-config.mjsrequires 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. ThatloadSystempasses in the container isn't spiked, and I4 tests it.rolesDircomes from the image (packages/cli/src/config.mjs:35). I4 changespackages/webui/src/cli.mjs:58so that in containerized mode the broker path is<dataRoot>/sock/human/broker.sockinstead ofsocketPath(dataRoot). The Console keepshumanCall, now pointed at the human socket.human-clialready carriesbusinessin its proof (packages/bus/src/human-cli.mjs:28). -
The
bus-host/start check. The mount is safe only becausebus-host/holds no socket and nothing of the git socket's in containerized mode (section 5). At start, in containerized mode, the Console listsbus-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, andhost.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 five 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. TodayreadNotifyConfig(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.
RELEASEcomes from the image. The queue panel runsqueue-read.mjs, which spawnsgitin the checkout--checkoutnames (reads.mjs:75-79,cli.mjs:30-35,packages/queue/src/store.mjs:51-90).mosaic-corehas nogit(section 3), so in containerized mode the queue read answers a fixed refusal without spawning anything. A later row can addgitto the image and a same-path checkout mount with--checkout. In local mode, if--checkoutis absent the panel reads the Console's own copy, as today. -
No route to the board.
/api/board,/api/conversationsand/api/conversation(GET), and/api/seenand/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 humanmessage.sendover the human socket, the path row 76 built. The board's/api/replyrunstools/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 fromsessions.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.
9. What each row must test
- Mount lists, exactly. For each container kind, a test reads
docker inspect.Mountsand compares the whole list to the expected 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/statusshows 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), 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 host channel. I6 tests boot,
bindLaunchand the notifier's start over the client's stdio, thatprocess.mjsstarted without the entry still refusestrusted-host-required, that EOF stops both forks, and that no cap reachesdocker logs, inspect or the daemon's container directory (section 3). It also tests the core container's lifecycle:- a fork that dies alone stops the other and the host with exit 1;
- a notifier's non-zero stop makes
close()return 1; - after a host SIGKILL, no core container for the store runs and the old broker is gone before the next one boots, and the test records whether EOF reached the entry;
- a running leftover is stopped and removed at bus start;
- a leftover whose notifier is slower than one close timeout still
exits on SIGTERM within the core stop timeout and removes
writer.lock; - two data roots with the same business get two core containers;
- the entry refuses to start without
MOSAIC_CLOSE_TIMEOUT_MS, andconnectedturns false after an exit line or the client's exit; bus statusshows a running broker's lock as live, a lock left afterrm -fas dead, and the lock as unknown when the endpoint doesn't answer.
- The human sockets from a session. I5 tests that a session process
can't connect to
sock/humanorsock/relay(section 6). - The Console's start and routes. In containerized mode
loadSystempasses in the container. Thebus-host/start check accepts the five state files and a leftover.tmp, and refuses a socket, an unknown file, a plantedlaunch.sockorgit.sock, aland/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 getshuman-required, and a capability on the human socket getsinvalid-envelope. A decision resolved through the human socket recordsvia: 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. Under
--init,docker stopon 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 runhost-lostand 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 and a missing socket each
refuse with
endpoint-not-rootless, and the rootless endpoint passes, as in I2. The missing-socket case covers S1'snulloutput.
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 Jason rules. Correction, 2026-10-18: Jason accepted it as a documented limit (mos-0787), so I6 writes it as a known limit. REQ-DEC-3 holds against agent containers and against accidental or inherited launches.
- The rootful daemon. Jason is in the
dockergroup, so any host 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). 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 Console's outbound reach.
mosaic-consoleis a normal bridge with no proxy, so a compromised Console can open a connection to any host. That's no wider than today, where the Console is a host process with the host's network. No measured option narrows it: an internal network publishes nothing (S5). A later row could run the Console with--network nonebehind a host forwarder from 127.0.0.1:7332 to a unix socket, which is also the cheapest fix named on P19 333. Not spiked. - The host channel. Anything with the engine endpoint can attach to the core container's stdin and act as the bus host, as it can already exec in that container (section 3).
- 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.
Two boundary changes need a ruling before their row is queued:
- B1, the Console's bind (section 8), before I4;
- B2, the host channel (section 3), before I6.
packages/cli/src/git-socket.mjs is split three ways: I3 owns the
RESOLVED_VIA entry, I5 the socket and git-host/ paths, and I6
dropNetwork's --host.
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.mdL4 anddocs/plans/CONDUCTOR.md:17,40, which I6 rewrites.