plans: row 87 round 3 adds the host channel and keeps bindLaunch, answering Darkwing's and Filbert's round 2 reviews (#1581)

Darkwing L (comment 28863): section 3 gains "The host channel, a
boundary change". The bus host starts the core container with
`docker run -i` as its own child; core-entry.mjs forks process.mjs and
notifier-process.mjs with IPC inside it and relays tagged JSON lines over
the client's stdin and stdout. The core container runs with
--log-driver none, since replies carry caps; I6 tests that. The channel
adds no authentication of its own, so it is boundary change B2.

Darkwing M: the registry keeps the docker run client's pid and
startTime, so bindLaunch, runtime.mjs and broker.mjs are unchanged and
I5 changes no file in packages/bus. The human proof's ancestry check
never meets the client, so the mount list carries that boundary and I5
tests that a session can't reach sock/human or sock/relay. recover()
removes the container, kills a leftover client and binds as today.

Darkwing nits: the start check's temp names are patterns from their
writers, five state files, and the git-socket.mjs split across I3, I5
and I6.

Filbert (comment 28864): section 10 states the Console's outbound
reach on the mosaic-console bridge, no wider than today, with the
--network none forwarder as the not-spiked follow-up named on P19 333.
Section 7 says four networks and lists mosaic-console.

Section 11 lists B1 (the Console bind, before I4) and B2 (the host
channel, before I6); I4's and I6's gates name their ruling.

Doc-only: no test reads either file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 08:56:57 -05:00
co-authored by Claude Opus 5.5
parent e1964c854b
commit ac55e84fe4
2 changed files with 122 additions and 19 deletions
@@ -4,7 +4,8 @@ Status: written by Sage, lead, as row 87 (#1581). Reviewers: Darkwing and
Filbert. Brief: `docs/plans/2026-10-12_i1-containerized-install.md`, § I1
design. The I2 to I6 briefs are in
`docs/plans/2026-10-18_containerized-install-rows.md`. Round 2 answers
Filbert's review (comment 28851) and Darkwing's (comment 28852).
Filbert's review (comment 28851) and Darkwing's (comment 28852). Round 3
answers their round 2 reviews (Darkwing 28863, Filbert 28864).
This doc turns decisions 105 to 108 into interfaces. It doesn't reopen
them. Every claim about container behavior cites a spike in
@@ -86,6 +87,46 @@ 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-12`) 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 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.
@@ -216,9 +257,11 @@ makes the move with the socket paths. What stays in `bus-host/` is
`host.json`, `host.lock`, `sessions.json`, `boot-status.json` and
`boot-events.jsonl` (`packages/cli/src/host.mjs:35-37`,
`boot-status.mjs:13-15`, `packages/seat/src/session.mjs:47`), plus the
temporary names their writers make and remove: `<file>.<suffix>.tmp`
(`host.mjs:83,122`, `boot-status.mjs:34`, `session.mjs:222`) and
`host.lock.<suffix>.stale` (`host.mjs:84`).
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.
@@ -445,17 +488,31 @@ out of the environment it builds for the adapter, and I5 tests that.
### The session record
In containerized mode the registry entry (`launcher.mjs:326-328`)
replaces the four pid fields: pid, startTime, runnerPid and
runnerStartTime. In their place it records:
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` binds the run to `{containerId, containerStartedAt}`
instead of `{pid, startTime}`.
`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:379-383`), 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
@@ -479,8 +536,9 @@ instead of `{pid, startTime}`.
today kills each leftover runner, binds its launch again, ends the run
`host-lost`, records the end and logs it. That ending is what releases
the role. In containerized mode only the kill changes. For each
leftover entry it runs `docker rm -f <containerId>`, then calls
`bindLaunch` with `{containerId, containerStartedAt}`, then
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
@@ -534,13 +592,16 @@ get that directory.
### Egress
There are three networks:
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`
@@ -646,7 +707,7 @@ request, a slow client and a connect to a port that isn't listed.
be a regular file. It refuses any other entry, including a socket, a
directory, a symlink and a fifo, and the refusal names the entry. A
rule like "refuse anything that passes for a credentials file" can't
work, because the four state files are small 0600 files owned by the
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
@@ -695,6 +756,13 @@ request, a slow client and a connect to a port that isn't listed.
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).
- **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
@@ -747,6 +815,16 @@ writes them.
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).
@@ -770,6 +848,14 @@ writes them.
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;
@@ -2,6 +2,7 @@
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
@@ -19,6 +20,9 @@ separate re-pin plan with Mos's go.
Rules every row keeps:
- Never `docker` without `--host`. In containerized mode every call goes
through the helper from I2.
- `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`. 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.
@@ -282,7 +286,8 @@ Authentik's configuration.
Filbert and Dewey each approve on the row's issue naming the candidate
manifest. Sage runs the full gate green on the landing tree. Needs I2 and
I3 done. Lands before M3.
I3 done, and the B1 ruling on the Console's bind (design section 11)
before the row is queued. Lands before M3.
---
@@ -315,9 +320,11 @@ token in a file and egress only through the proxy.
### What ships
- The launch from design section 6, with the record, liveness, stop,
exit and recovery rules there. `recover()` still ends each leftover run
`host-lost` after removing its container. In local mode, bwrap and the
pid record stay unchanged.
exit and recovery rules there. The registry keeps the `docker run`
client's `pid` and `startTime`, and `bindLaunch` binds to them
unchanged, so this row changes no file in `packages/bus`. `recover()`
still ends each leftover run `host-lost` after removing its container.
In local mode, bwrap and the pid record stay unchanged.
- The socket move from design section 5: in containerized mode the
launch and git sockets live in `sock/launch/` and `sock/git/`, and the
git socket's bundle files and `land/` logs live in `<dataRoot>/git-host/`
@@ -351,6 +358,7 @@ token in a file and egress only through the proxy.
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/human` or `sock/relay`;
- `mosaic stop <run>` stops a container session;
- each launch refusal from design section 6, including
`endpoint-not-rootless` and `no-session-credential`.
@@ -390,7 +398,9 @@ and no document states the limits in design section 10.
- `packages/cli/src/` where `mosaic bus start` starts the broker,
notifier and egress containers in containerized mode, with
`scripts/bus-service.sh`.
`scripts/bus-service.sh`, and a new `packages/cli/src/core-entry.mjs`
for the host channel (design section 3). `packages/bus/src/process.mjs`
and `packages/cli/src/notifier-process.mjs` don't change.
- `compose.yaml`, `scripts/run-task.sh` and the worker adapter files it
uses.
- The scripts listed in I2's problem, moved to `scripts/lib/docker.sh`.
@@ -431,6 +441,12 @@ and no document states the limits in design section 10.
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, `bindLaunch` and the notifier's start work
over the client's stdin and stdout; `process.mjs` started in the
container without the entry still refuses `trusted-host-required`;
EOF on stdin stops both forks; under `--log-driver none` the client
still gets every reply, and no cap appears in `docker logs`,
inspect or the daemon's container directory;
- a pi worker task runs in containerized mode and writes its run
record;
- each moved script refuses `endpoint-not-rootless` against the
@@ -448,4 +464,5 @@ Jason's time. Changing the rootful daemon or the `docker` group (decision
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.
Needs I4 and I5 done, and the B2 ruling on the host channel (design
section 11) before the row is queued.