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]>
25 KiB
The containerized install: rows I2 to I6 (2026-10-18)
Status: written by Sage, lead, as part of row 87 (#1581). Round 2
answers Filbert's review (comment 28851) and Darkwing's (comment 28852).
Round 3 answers their round 2 reviews (Darkwing 28863, Filbert 28864).
Each section
follows docs/plans/BRIEF-TEMPLATE.md. The design they build is
docs/plans/2026-10-18_containerized-install-design.md, cited below as
"the design" with its section number. A builder follows the design and
doesn't reopen a decision in it. A departure goes to Sage before the
build continues.
Order, from decision 96: I2, then I3, then I4 and I5 in either order,
then I6. I4 lands before M3 retires T3 Mos. Owners are starting choices.
Sage can move a row with queue assign. Every row's gate is green under
the rootless daemon at the endpoint the config names. No row touches the
live stack, and moving the live install to containerized mode is a
separate re-pin plan with Mos's go.
Rules every row keeps:
- Never
dockerwithout--host. In containerized mode every call goes through the helper from I2. packages/cli/src/git-socket.mjsis split three ways: I3 owns theRESOLVED_VIAentry, I5 the socket andgit-host/paths, and I6dropNetwork's--host. Each row touches only its part.- No Docker volume, daemon or restart change. A test's networks and containers carry a name prefix of the row, and its cleanup removes them and only them.
- No real credential in a test. Where a measurement needs one, the row ships the script and its expected output, and Mos runs it.
I2 install images, the install config key, the endpoint helper and the egress proxy
Problem
Nothing builds the containerized install's images, the config can't name
a docker endpoint, and every script reaches Docker through the ambient
context (scripts/hello.sh:24, build.sh:13, release.sh:39,122,
agent.sh:208, verify.sh:36,39,45, land-work.sh:87-88,
test-release.sh:58, 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
- Owner: Researcher, who ran the spikes.
- Reviewers: Filbert and Darkwing.
Files owned
images/core/Dockerfile,images/session/Dockerfile,images/egress/Dockerfile,images/README.md.images/is a new top-level directory, which invariant 1 allows for a dedicated purpose.scripts/images.sh(buildandlist),scripts/lib/docker.sh.scripts/mosaic-config.mjs,scripts/test-config.sh.packages/egress/(new:package.json,src/,tests/,README.md).contracts/egress.json(new), and the contracts README if it lists files.scripts/test-install.sh(new suite),docs/TOOLS.md(the images, helper and config sections),README.md(the config key only).agents/researcher/work/queue-<row>/for BUILD.md and the measurement scripts.
What ships
- The three images on the base digest, as the design describes in section
3, with tags
<image>:<RELEASE>.scripts/images.sh buildbuilds them through the helper and refuses without containerized mode. - The
installkey: the rules in design section 4, with atest-config.shcase for each one: absent; local; local with an endpoint; containerized without an endpoint; containerized with each bad endpoint form; an unknown mode; an unknown key underinstall. Each case asserts the exit code and that the config file is unchanged. scripts/lib/docker.sh: reads the mode and endpoint throughscripts/load_config. It adds--hostand runs the rootless check 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 exactlyname=rootless(design section 4, S:100-103). A substring match isn't enough. On any failure it exits withendpoint-not-rootlesson stderr.test-install.shcovers the rootful socket, a missing socket (S1's exit 1 andnull), an output that holdsname=rootlessonly as a substring, and the rootless endpoint passing.packages/egress: the CONNECT proxy from design section 7, reading one list fromcontracts/egress.jsonby name. Tests cover allow, deny, a malformed request, a slow client, a port that isn't listed and the JSON log line. A suite run intest-install.shputs a client on an internal network, as S5 did, and shows a listed host reached and an unlisted host refused.contracts/egress.json, withagentsandserviceslists. Each entry names the code path that needs it. The two measurements below settle the lists:- S5 already measured node 24.21.0's
fetchthrough the proxy withNODE_USE_ENV_PROXY=1andHTTPS_PROXY(S:320). Don't repeat it. Check that each of the broker's and notifier's outbound code paths goes throughfetchwith 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.
- S5 already measured node 24.21.0's
- Suites:
scripts/test-config.sh,scripts/test-install.sh, the egress package tests, and everyscripts/test-*.shgreen.
Out of scope
Any script's move to the helper other than scripts/images.sh (I6). The
broker's listeners (I3). Running any image as part of the stack (I4, I5,
I6). Adding the install key to any real config.
Gate
Filbert and Darkwing each approve on the row's issue naming the candidate manifest, and Mos's provider-host output is on the issue. Sage runs the full gate green on the landing tree.
I3 two broker listeners, the human-socket via value and the host relay
Problem
The broker serves one socket and trusts a human envelope only after the
host's ancestry proof (packages/bus/src/runtime.mjs:110-118), which
no container can run (HP). Decision 105 needs an agent socket that
refuses human envelopes and a human socket whose reachability is the
proof. The mosaic CLI then needs a host relay. HP also found that
today's proof accepts a nonce snooped from another connection.
Owner and reviewer
- Owner: Dewey.
- Reviewers: Filbert and Darkwing.
Files owned
packages/bus/src/server.mjs,runtime.mjs,broker.mjs(only theviasites listed in design section 5),landing.mjs,human-cli.mjs, and a newpackages/bus/src/relay.mjsandpackages/bus/src/via.mjs(theisHumanViahelper).- The
SO_PEERCREDreader the row picks: a file underpackages/bus/src/orpackages/bus/native/, with its build step if native. packages/webui/src/public/bus.js(line 188 only). Decision 123 applies.packages/cli/src/where the bus host starts the relay, andpackages/cli/src/git-socket.mjsfor theRESOLVED_VIAentry only, withpackages/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>/.
What ships
serve()takes a list of listeners, each with a path, a mode and an envelope rule, as design section 5 describes. In local mode the broker serves one socket as today, with today's rules.- In containerized mode the agent listener refuses
humanandtesterwithhuman-required. The human listener refuses a capability withinvalid-envelopeand binds throughbindHumanwithvia: 'human-socket'. - The stale-socket rule from design section 5, tested against the real
serve()after a kill -9. isHumanVia, and every reader in design section 5 uses it. Each reader has a test withhuman-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) gainshuman-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:
verifyHumanandverifyTesterunchanged, plus the peer-pid check, forwarding to the human socket. The peer-pid check also goes into the local install's proof. Tests: a genuine CLI passes; an agent marker in the CLI or an ancestor is refused; a nonce snooped and sent on a second connection is refused, in both modes; a missing reader refuses at bus start. - The request-size rule per listener: row 148's project-save allowance only on the human listener.
- Suites: bus, webui, cli, and every
scripts/test-*.shgreen, plus mutants on the envelope rules, the peer-pid check andisHumanVia.
Out of scope
Running the broker in a container (I6 starts it, I4 and I5 use it). The
Console's own changes beyond public/bus.js:188 (I4). SO_PEERPIDFD is
optional: if the row doesn't take it, BUILD.md says so and design
section 10's pid-reuse limit stands.
Gate
Filbert and Darkwing each approve on the row's issue naming the candidate manifest. Sage runs the full gate green on the landing tree. Needs I2 done.
I4 the Console in a container with signed-in human authority
Problem
The Console runs on the host and reaches the broker through human-cli
and the host proof (packages/webui/src/bus.mjs:27-31). Its replies go
to the control board's tmux path
(packages/control-board/src/serve.mjs:59,74-94,239-248), and it reads
the notifier config file (packages/webui/src/reads.mjs:229). Decision
107 puts it in a container, with the human socket as its only route to
the broker.
Owner and reviewer
- Owner: Darkwing.
- Reviewers: Filbert and Dewey.
Files owned
packages/webui/src/(cli.mjs,bus.mjs,reads.mjs,serve.mjs, andpublic/under decision 123),packages/webui/tests/,packages/webui/README.md.packages/bus/src/broker.mjsfor one human read verb that returns the notifier binding, and its test.scripts/console.sh(new):startandstopthrough the helper, for Jason or Mos to run.scripts/test-install.sh(the Console cases),docs/TOOLS.md.agents/darkwing/work/queue-<row>/.
What ships
- The Console container from design section 8: its image, network,
published port, mounts at their host paths,
MOSAIC_CONFIG, and limits. It reaches the broker only through the mounted human socket:cli.mjs:58uses<dataRoot>/sock/human/broker.sockin containerized mode and keepshumanCall. - The bind from design section 8. In containerized mode the Console
binds
0.0.0.0in its own network namespace on the published port, and the publish stays on host127.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'.tmpand.stalenames. 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 is off in containerized mode: the read answers a fixed
refusal without spawning. In local mode, if
--checkoutis absent, it reads the Console's own copy as today. /api/board,/api/conversations,/api/conversation,/api/seenand/api/replyanswer 404 in containerized mode, and the Console opens no connection to the board. A reply is a humanmessage.sendover 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;
loadSystempasses in the container, withMOSAIC_CONFIGat 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 host process can't reach the Console container's own address on 7332, the container address read from inspect (Mos, mos-0715 and mos-0716);
- no container other than the Console is on the
mosaic-consolenetwork, read from network inspect (Mos, mos-0715 and mos-0716); - 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 plantedlaunch.sockandgit.sock, aland/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-*.shgreen.
Out of scope
Starting or stopping the live Console (decisions 94 and 102: Jason or Mos). The unix-socket Console from design section 5, unless Jason picks that fix on P19 333 before I4 starts. Then the brief is re-pinned with it first. The control board, which isn't part of the containerized install. Authentik's configuration.
Gate
Filbert and Dewey each approve on the row's issue naming the candidate manifest. Sage runs the full gate green on the landing tree. Needs I2 and I3 done, and the B1 ruling on the Console's bind (design section 11) before the row is queued. Lands before M3.
I5 managed sessions as containers
Problem
The launcher starts each managed session under bwrap on the host, with
the provider token in the environment (packages/seat/src/session.mjs:87-92)
and liveness from host pids (packages/cli/src/launcher.mjs:326-328,
packages/seat/src/session.mjs:230-241). Decisions 105, 106 and 108 put
each session in a hardened container on the rootless daemon, with the
token in a file and egress only through the proxy.
Owner and reviewer
- Owner: Researcher.
- Reviewers: Filbert and Dewey.
Files owned
packages/cli/src/launcher.mjs,packages/cli/src/git-socket.mjs(the socket andgit-host/paths only),packages/seat/src/session.mjs,packages/seat/src/sandbox.mjs, a newpackages/seat/src/container.mjs, the session runner, and their tests.packages/seat/README.md,packages/cli/README.md,docs/TOOLS.md.scripts/test-install.sh(the session cases).agents/researcher/work/queue-<row>/.
What ships
- The launch from design section 6, with the record, liveness, stop,
exit and recovery rules there. The registry keeps the
docker runclient'spidandstartTime, andbindLaunchbinds to them unchanged, so this row changes no file inpackages/bus.recover()still ends each leftover runhost-lostafter 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/andsock/git/, and the git socket's bundle files andland/logs live in<dataRoot>/git-host/(0700), which no container mounts. Local mode keepsbus-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 runenvironment holds no provider variable. - The starting limits from design section 3. The row measures one real session's pids and memory peak with a dummy task and no token, and proposes final numbers in BUILD.md.
- Tests:
- the exact mount list from inspect, including what a role without the launch or git grant doesn't get;
- CapEff 0, NoNewPrivs 1, Seccomp 2 and a read-only root filesystem in the session;
- a session reaches a listed host only through
proxyand 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 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 runhost-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/; - a session process can't connect to
sock/humanorsock/relay; mosaic stop <run>stops a container session;- each launch refusal from design section 6, including
endpoint-not-rootlessandno-session-credential.
- Suites: seat, cli, bus, and every
scripts/test-*.shgreen, plus mutants on the record, the liveness check and the token path.
Out of scope
The pi worker (I6). Choosing models or changing role grants. Any live launch.
Gate
Filbert and Dewey each approve on the row's issue naming the candidate manifest. Sage runs the full gate green on the landing tree. Needs I2 and I3 done.
I6 install mode, the pi worker's move and the documented limits
Problem
Nothing starts the containerized install as one unit. The pi worker
mounts the whole dataRoot (compose.yaml:40) and takes provider keys
from the environment (compose.yaml:36-37). The scripts I2 listed still
use the ambient Docker context. LAYERS.md L4 and
docs/plans/CONDUCTOR.md:17,40 describe isolation that is out of date,
and no document states the limits in design section 10.
Owner and reviewer
- Owner: Dewey.
- Reviewers: Filbert and Darkwing.
Files owned
packages/cli/src/wheremosaic bus startstarts the broker, notifier and egress containers in containerized mode, withscripts/bus-service.sh, and a newpackages/cli/src/core-entry.mjsfor the host channel (design section 3).packages/bus/src/process.mjsandpackages/cli/src/notifier-process.mjsdon't change.packages/cli/src/host.mjsfor the channel's fork wrapper, the exported close timeout and the core stop timeout,host.pidsin containerized mode, the store path (making<dataRoot>/busand resolving it), the name-conflict refusal, the leftover removal at bus start andhostStatus, and thebus statuslines inpackages/cli/src/cli.mjs(design section 3, "The core container's lifecycle").packages/cli/systemd/mosaic-bus.service.in, theExecStartcomment only: the leftover refusal exits 1 and the unit retries it, and every other refusal still exits 3.compose.yaml,scripts/run-task.shand 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--hostwith the configured endpoint after the same rootless check. scripts/bootstrap.shonly to print the install steps. It still never writes the config.README.md(the install section),docs/TOOLS.md,LAYERS.md,docs/plans/CONDUCTOR.md.scripts/test-install.sh(the whole-install cases) and the existing suites the moved scripts touch.agents/dewey/work/queue-<row>/.
What ships
mosaic bus start <business>in containerized mode starts the egress containers, then the broker and notifier container, then the launcher, the git and launch sockets, and the relay on the host. It stops them in reverse order. Local mode is unchanged.- The pi worker runs through the helper on the rootless daemon. It mounts only its run directory and workspace. Its provider keys come from files, as design section 7 describes for the session token.
- Every listed script calls Docker through the helper. In local mode the helper keeps today's behavior.
- The install section of the README: the steps, the config key, and
every limit in design section 10, each with its decision number. The
other-uid half of the host-process limit says what Jason ruled on
P19 333: he accepted it as a documented limit (mos-0787, 2026-10-18).
So the limits section states it as a known limit: a host process under
another uid that reaches the Console's loopback port acts as Jason, and
nothing in the install closes that. Sage closes P19 333 when I6 lands.
LAYERS.mdL4 andCONDUCTOR.md:17,40describe what is built. - 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;
- the host channel: boot,
bindLaunchand the notifier's start work over the client's stdin and stdout;process.mjsstarted in the container without the entry still refusestrusted-host-required; EOF on stdin stops both forks; under--log-driver nonethe client still gets every reply, and no cap appears indocker logs, inspect or the daemon's container directory; - the core container's lifecycle (design section 3):
- the notifier fork killed alone: the entry writes its exit line,
closes the broker and exits, and the host logs "notifier exited"
and stops with exit 1, as
host.test.mjs:218-233does in local mode; - a notifier that exits non-zero on stop makes
close()return 1; - the bus host SIGKILLed mid-run: before the next start's core
container boots, no container labelled for the store runs and the
old broker's process is gone. The test records whether EOF reached
the entry or the leftover removal ended it, and whether the next
start booted or refused
writer-locked; - a running leftover core container is stopped, removed and logged at bus start, and a fresh one boots;
- a leftover whose notifier takes longer than one close timeout to
stop still exits on SIGTERM within the core stop timeout, and
writer.lockis gone afterwards; - two data roots running the same business start two core containers with different names, and stopping one leaves the other running;
- the entry refuses to start without
MOSAIC_CLOSE_TIMEOUT_MS; - a wrapper's
connectedturns false after the fork's exit line and after the client exits, and a latersendwrites nothing; boot, sent right after the client starts and before any{from, pid}line, reaches the broker and gets its reply well insideBOOT_TIMEOUT_MS; a business without a notifier gets no notifier fork, and close still returns 0;- a data root under a symlinked parent: the core container's
mosaic.storelabel is the real path, and a leftover started under the other spelling is found, stopped and logged at the next bus start; - on a fresh data root, bus start makes
<dataRoot>/busat 0700 before it resolves the store path; docker runstraight after a leftover's stop boots without a name conflict: the rule waits until the stopped container is gone;- a leftover that is never removed in the bound makes bus start refuse with exit 1 and log its id;
- a leftover removed between the list and
docker stop: the "no such container" answer counts as gone, and the core container boots; - a container that already holds the core name but not the store's
labels makes bus start refuse with exit 3 and the message naming
the name and the client's exit code;
host.lockis given back, and that container is left as it was; mosaic bus statusshows a running broker's lock as live, a lock left afterdocker rm -fas dead, and the lock as unknown when the endpoint doesn't answer;
- the notifier fork killed alone: the entry writes its exit line,
closes the broker and exits, and the host logs "notifier exited"
and stops with exit 1, as
- a pi worker task runs in containerized mode and writes its run record;
- each moved script refuses
endpoint-not-rootlessagainst the rootful socket in containerized mode.
- Suites: every
scripts/test-*.shand the node suites of each touched package green.
Out of scope
Moving the live install. That's a re-pin plan with Mos's go and
Jason's time. Changing the rootful daemon or the docker group (decision
106 records that limit).
Gate
Filbert and Darkwing each approve on the row's issue naming the candidate manifest. Sage runs the full gate green on the landing tree. Needs I4 and I5 done, and the B2 ruling on the host channel (design section 11) before the row is queued.