Files
stack/docs/plans/2026-10-18_containerized-install-design.md
jason.woltjeandClaude Opus 5.5 ab9f23d9ef docs: row brief for adding and changing role instances (P19 332, #1662); I6 brief records Jason's P19 333 ruling
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]>
2026-10-18 13:10:41 -05:00

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 equal process.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, then docker run.
  • The store path. Right after host.lock, bus start makes <dataRoot>/bus if it is missing, mode 0700, the way the broker's Store does today (packages/bus/src/store.mjs:126), which only the broker does now. The host makes only bus-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, the mosaic.store label, the leftover rule's filter and hostStatus's filter. Store's safe() 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, as writer.lock is, not on the business: two data roots that run the same business get two names. The packages/cli suite runs its test files in parallel, and many of them use the business acme, 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 --rm loses 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). The docker run client 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, gives host.lock back and starts nothing else. It never removes a container it didn't list under the store's labels.
  • The close timeout. close() waits CLOSE_TIMEOUT_MS (20 s, host.mjs:30) for the notifier, then again for the broker (host.mjs:343, :347). So stopping both forks the way close() does can take two close timeouts. The host passes the value to the entry as MOSAIC_CLOSE_TIMEOUT_MS on the docker run command 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 to docker stop -t.
  • Lines. A line from the host is {to, msg}, where to is broker or notifier and msg is the IPC message today's host sends, or {to, signal}, which the entry delivers as kill(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 on boot, the notifier on start. 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, where pid is in the container's pid namespace. The host wraps each fork in an object with send, kill, connected, exitCode, signalCode and an exit event driven by those lines. firstReply (host.mjs:129), ended (:148), watchChildren (:159-165) and close() keep their logic:
    • close() sends {op: 'stop'} to the notifier and {op: 'close'} to the broker, as today, and ended() gets each code from the exit line;
    • the start failure path's notify.kill('SIGTERM') becomes {to: 'notifier', signal: 'SIGTERM'}.
  • connected. A wrapper's connected follows 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 the docker run client starts, and the notifier's where host.mjs:242 forks today. The host sends boot and start right 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 in connected; it gives the log its pid. connected turns 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, send and kill write nothing and return false. The host's connected checks 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 adds container, the core container's name. The host doesn't know a fork's pid in its own namespace. A container pid passed to process.kill would signal an unrelated host process. Four tests use host.pids as host pids: host.test.mjs:202-205 reads each pid's /proc cmdline and environ, :227 sends the notifier SIGKILL, :546-563 sends the broker SIGSTOP and SIGCONT, and :612 finds 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 with docker 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, as host.mjs:355-364 does. 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 run client exits, or its stdout closes, before a fork's exit line arrives, the host treats that fork as exited with signal SIGKILL and stops with exit 1.
  • SIGTERM to the entry. docker stop reaches the entry through --init. The entry stops both forks as close() does and exits. The broker's own SIGTERM and close paths (process.mjs:81-83, 108) then remove writer.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.lock is taken and before the core container starts, bus start lists containers labelled mosaic.kind=core and mosaic.store=<store path> through the endpoint helper. It runs docker stop -t <core stop timeout> on each, 45 s at today's value, which covers both of the entry's close waits. --rm removes a container after stop returns, and docker rm -f during 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 from docker stop or docker rm -f also 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 gets docker rm -f and 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 after RestartSec (mosaic-bus.service.in:31-33), since an engine still removing is a passing state. I6 updates the unit's ExecStart comment, which today says refusals exit 3. Each bound is per container: the stop, the wait and the wait after rm -f each get one core stop timeout, so one leftover costs at most three, 135 s at today's value. The unit is Type=simple with no start timeout. Five exit-1 starts within 600 s reach StartLimitBurst=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 plus RestartSec, 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-failed clears 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 run starts only once every listed container is gone. host.lock is held, so no live host owns a leftover. A leftover that stopped on SIGTERM has removed writer.lock. One that rm -f killed leaves it, and the start refuses writer-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 binds sock/human.
  • mosaic bus status. writer.lock holds the broker's pid in the container's pid namespace (store.mjs:138), and hostStatus (host.mjs:389-408) checks it against the host's /proc. A running broker would show as "a broker died hard". In containerized mode hostStatus ignores the lock's pid and asks the engine instead. The lock is live when a container labelled mosaic.store=<store path> and mosaic.kind=core is running, and dead when none is. If the endpoint doesn't answer, it reports the lock as unknown, never dead. The socket: line checks sock/agent and sock/human (section 5) instead of bus/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 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.
  • mosaic-egress: the base plus packages/egress (section 7).
  • Tags derive from RELEASE (<image>:<RELEASE>), built on the rootless endpoint only.

Hardening, every container

From the I1 review and S7 and S9:

  • --read-only, --tmpfs /tmp:rw,noexec,nosuid. Sessions also get --tmpfs for HOME (S7: Claude writes .claude* under HOME and claude-1000/ and cc-socks/ under /tmp, and pi writes .pi/agent/).
  • 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 (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 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.

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, and configVersion doesn't change.
  • mode is local or containerized. Any other value exits 2.
  • containerized requires endpoint. local forbids it, so the pi worker can't move to the rootless daemon by accident.
  • endpoint must match ^unix:///[^\s]+\.sock$. tcp://, ssh://, a relative path or a missing value exits 2.
  • Any other key under install exits 2.
  • scripts/mosaic-config.mjs:115 adds install to the allowed top-level keys, and validate() (109-160) checks it.
  • execution.backend stays docker (decision 106).

validate checks only the shape and never contacts the daemon. The rootless check runs before every use of the endpoint:

docker --host <endpoint> info --format '{{json .SecurityOptions}}'

The check 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 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 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: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:117 binds with via: 'cli'. The human listener's bind passes human-socket.
  • packages/bus/src/broker.mjs:
    • 465, bindHuman, whose check at 468 accepts cli or human-socket;
    • 328, #human, accepts either. For human-socket the 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_events row, and 1719, message delivery, record the session's via instead of the literal 'cli';
    • 1867, launch revoke and restore, records the session's via.
  • packages/bus/src/landing.mjs:107 counts a resolution as the human's only when via is cli. It accepts either, or a merge Jason resolved through the Console or the relay can't land.
  • packages/webui/src/public/bus.js:188 labels a decision event human only for cli. It accepts either. That's a change under public/, so decision 123 applies to I3's review.
  • packages/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 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 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 socket's SO_PEERCRED pid. That closes the snooped-nonce forgery HP measured against today's proof. I3 adds the same check to the local install's proof.
  • Then it forwards the request over the human socket and passes the answer back. A tester proof goes through verifyTester the same way and is forwarded as a tester envelope. Anyone on the human socket is already human, so claiming tester gains nothing.
  • human-cli points at the relay's socket in containerized mode. The proof object doesn't change.
  • It holds no credentials and no dataRoot handle beyond its own socket.

HP item 5 asked about the launches refusal, which needs the launcher's records. In containerized mode every managed session is a container, and none has the relay's directory, so there is no launched host process for that refusal to find. The marker and argv checks still apply to host-side agents such as T3 seats.

Node can't read SO_PEERCRED (HP). I3 picks the reader: a small native helper, or perl as a stated host dependency, which is what the spike used, through the undocumented socket._handle.fd. Either way the relay fails closed when it can't read the peer, and checks for the reader at bus start. SO_PEERPIDFD (HP option E) would close the pid-reuse race. It's optional for I3 and recorded as a known window if I3 doesn't take it.

Reading decisions 95 and 105 on a same-uid host process

The I1 queue note asked this doc to answer HP's flag. Decision 95 said the containerized install "is meant to close" the local-forgery limit. Decision 105, which Jason agreed later, says "a host-side agent under Jason's uid still passes. That stays decision 95's accepted limit, and the docs say so."

This design closes the limit for every managed session and the pi worker. They are containers, and none of them can reach the Console, the human socket or the relay. It doesn't close it for a deliberate process on the host. Decision 95's own case stays open for host processes. The Console is published on its configured loopback port, 127.0.0.1:7332 in the live install today. 7330 is only the bare serve default. Reaching it is the proof (decision 105), and the Authentik header only names the user. A 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 readonly mount doesn't stop a connect on a socket inside it (S2: a client connected through a :ro directory mount). The directory is read-only only so the session can't replace the socket.
  • session.json names the in-container paths.
  • The contracts reach the session through the bundle, as today.
  • The provider variables in ENV_ALLOW (packages/seat/src/session.mjs:87-92) don't go into docker run in containerized mode. Only the variables above do, so the container config never holds a credential (decision 108).

Capability over stdin

The launcher writes {"cap": "..."}\n on the docker run client's stdin and closes it, as it does for bwrap today (launcher.mjs:330). S8 measured that the capability arrives intact and appears nowhere in inspect, top, logs, /proc/1/environ, cmdline or ps. S8 didn't test whether a child inherits it through the environment. The runner keeps it out of the environment it builds for the adapter, and I5 tests that.

The session record

In containerized mode the registry entry (launcher.mjs:326-328) 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.StartedAt from 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> reports true with the recorded start. That replaces startTimeOf(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 s stopSession 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. 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> and kills the leftover client if startTimeOf(pid) still matches, then calls bindLaunch with the entry's {pid, startTime} as today, 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.

Mounts a session never gets

The engine socket, bus/, bus-host/, runs/ beyond its own run directory, sock/human, sock/relay, the config directory, and any credentials file other than its own token file (decision 105).

7. Secrets and egress

The session token (decision 108)

  • File and mount. The token lives at ~/.config/mosaic-dev/secrets/<business>/claude-code-oauth.token, owned by the host user, mode 0600, no symlink. It's mounted read-only at /run/mosaic/secrets/claude-code-oauth.token. This is S2's mechanism (a): container root with --cap-drop ALL reads it as the owner.
  • Before launch. The launcher checks the file under the same rule as credentials.mjs:35-79. That replaces hasSessionCredential in containerized mode. A missing or bad file refuses with no-session-credential.
  • In the runner. The runner reads the file and puts CLAUDE_CODE_OAUTH_TOKEN only into the environment it spawns the adapter with. It never writes the token anywhere else.
  • What's accepted. The adapter's process can read its own environ, and so can anything it starts. Decision 108 accepts that, with the egress list limiting where a copy can go, and the workspace being writable means a copy can land on the host disk. The docs say both.
  • pi's provider keys. They follow the same route, one file per variable. Today the worker's compose file passes ZAI_API_KEY and ANTHROPIC_API_KEY as environment (compose.yaml:36-37). I6 moves the worker to files when it moves the worker to the rootless daemon.
  • Unknown. S7 didn't find out whether pi writes the key into .pi/agent/auth.json. That file is on the session's HOME tmpfs, so it dies with the container either way.

The broker's credential files

These are the Gitea, Vikunja and Discord files under the file rule. The broker container mounts the directory that holds them read-only, and the rule passes unchanged (section 2). The Console and the sessions never get that directory.

Egress

There are 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 needs api.anthropic.com:443. With CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 it 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 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.

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-core running packages/webui, on mosaic-console, a normal bridge, published at 127.0.0.1 on the port the Console is configured with (7332 in the live install). S5: a container only on an internal network accepts -p and publishes nothing, so the bridge is required, and I4 checks that the port answers. Authentik forwards to it as today.

  • 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 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. 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.

9. What each row must test

  • Mount lists, exactly. For each container kind, a test reads docker inspect .Mounts and compares the whole list to the expected list. Section 2's tradeoff depends on it. 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), 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, bindLaunch and the notifier's start over the client's stdio, that process.mjs started without the entry still refuses trusted-host-required, that EOF stops both forks, and that no cap reaches docker 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, and connected turns false after an exit line or the client's exit;
    • bus status shows a running broker's lock as live, a lock left after rm -f as 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/human or sock/relay (section 6).
  • 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, 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 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 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

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 docker group, 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-console is 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 none behind 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.md L4 and docs/plans/CONDUCTOR.md:17,40, which I6 rewrites.