Files
stack/docs/plans/2026-10-18_containerized-install-rows.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

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 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.
  • 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 (build and list), 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 build builds them through the helper and refuses without containerized mode.
  • The install key: the rules in design section 4, with a test-config.sh case 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 under install. Each case asserts the exit code and that the config file is unchanged.
  • scripts/lib/docker.sh: reads the mode and endpoint through scripts/load_config. It adds --host and runs the rootless check before the first call in a process. The check tests the exit status, then parses the output as a JSON array and passes only if one element is exactly name=rootless (design section 4, S:100-103). A substring match isn't enough. On any failure it exits with endpoint-not-rootless on stderr. test-install.sh covers the rootful socket, a missing socket (S1's exit 1 and null), an output that holds name=rootless only as a substring, and the rootless endpoint passing.
  • packages/egress: the CONNECT proxy from design section 7, reading one list from contracts/egress.json by name. Tests cover allow, deny, a malformed request, a slow client, a port that isn't listed and the JSON log line. A suite run in test-install.sh puts a client on an internal network, as S5 did, and shows a listed host reached and an unlisted host refused.
  • contracts/egress.json, with agents and services lists. Each entry names the code path that needs it. The two measurements below settle the lists:
    • S5 already measured node 24.21.0's fetch through the proxy with NODE_USE_ENV_PROXY=1 and HTTPS_PROXY (S:320). Don't repeat it. Check that each of the broker's and notifier's outbound code paths goes through fetch with that variable set, with no credential, against the services hosts. If one doesn't, stop and tell Sage before adding a dependency.
    • The provider hosts Claude Code and pi need with a real token. S6's run used a made-up token, and pi made no network call without a key (design section 7). The row ships the script and the expected output. Mos runs it and posts the output on the row's issue. The list is final when that output matches.
  • Suites: scripts/test-config.sh, scripts/test-install.sh, the egress package tests, and every scripts/test-*.sh green.

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 the via sites listed in design section 5), landing.mjs, human-cli.mjs, and a new packages/bus/src/relay.mjs and packages/bus/src/via.mjs (the isHumanVia helper).
  • The SO_PEERCRED reader the row picks: a file under packages/bus/src/ or packages/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, and packages/cli/src/git-socket.mjs for the RESOLVED_VIA entry only, with packages/cli/tests/git-socket.test.mjs.
  • packages/bus/tests/, packages/webui/tests/ for the line 188 case, packages/bus/README.md, docs/TOOLS.md.
  • agents/dewey/work/queue-<row>/.

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 human and tester with human-required. The human listener refuses a capability with invalid-envelope and binds through bindHuman with via: '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 with human-socket. The landing test shows a merge Jason resolved over the human socket lands.
  • RESOLVED_VIA (packages/cli/src/git-socket.mjs:252, used at 259) gains human-socket: "via the human socket". The git-socket test shows the squash message of a merge resolved over the human socket says "via the human socket", and the existing "via the CLI" case is unchanged.
  • The relay: verifyHuman and verifyTester unchanged, plus the peer-pid check, forwarding to the human socket. The peer-pid check also goes into the local install's proof. Tests: a genuine CLI passes; an 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-*.sh green, plus mutants on the envelope rules, the peer-pid check and isHumanVia.

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, and public/ under decision 123), packages/webui/tests/, packages/webui/README.md.
  • packages/bus/src/broker.mjs for one human read verb that returns the notifier binding, and its test.
  • scripts/console.sh (new): start and stop through 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:58 uses <dataRoot>/sock/human/broker.sock in containerized mode and keeps humanCall.
  • The bind from design section 8. In containerized mode the Console binds 0.0.0.0 in its own network namespace on the published port, and the publish stays on host 127.0.0.1. Local mode keeps the loopback refusal (serve.mjs:252-253).
  • The bus-host/ start check from design section 8, an allowlist of the five state files and their writers' .tmp and .stale names. Any other entry refuses, and the refusal names it.
  • The notifier read through the new broker verb in containerized mode. The file read stays in local mode.
  • The queue panel is off in containerized mode: the read answers a fixed refusal without spawning. In local mode, if --checkout is absent, it reads the Console's own copy as today.
  • /api/board, /api/conversations, /api/conversation, /api/seen and /api/reply answer 404 in containerized mode, and the Console opens no connection to the board. A reply is a human message.send over the human socket.
  • The README lists every route with what it can reach (decision 107), and states the bind change.
  • Tests:
    • the exact mount list from inspect;
    • CapEff 0, NoNewPrivs 1, Seccomp 2 and a read-only root filesystem in the Console container;
    • loadSystem passes in the container, with MOSAIC_CONFIG at the host path and the container's own HOME;
    • the port answers on host 127.0.0.1 and not on the host's other addresses;
    • a 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-console network, 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 planted launch.sock and git.sock, a land/ directory and a .bundle;
    • each of the five board routes answers 404 in containerized mode and is unchanged in local mode;
    • the queue read refuses in containerized mode without spawning.
  • Suites: webui, bus, and every scripts/test-*.sh green.

Out of scope

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 and git-host/ paths only), packages/seat/src/session.mjs, packages/seat/src/sandbox.mjs, a new packages/seat/src/container.mjs, the session runner, and their tests.
  • packages/seat/README.md, packages/cli/README.md, docs/TOOLS.md.
  • 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 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/ (0700), which no container mounts. Local mode keeps bus-host/.
  • The root re-measure from design section 3. Before the tmpfs list and the limits are final, the row repeats S7's write check and S6's proxy check with the session running as container root, and records both in BUILD.md.
  • The token file check before launch, the runner's read, and the token only in the adapter's environment (design section 7). The docker run environment holds no provider variable.
  • 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 proxy and can't resolve any other host;
    • the token is absent from inspect, top, logs and the container config, present in the adapter's child and absent from the runner's other children;
    • the stop matrix under --init: 143 on stop (S:493), and 137 after the grace period, which no spike measured under --init;
    • liveness from inspect, a missing container counted as dead, and the exit status read through the client and through inspect;
    • after a launcher restart, recover() removes the leftover container, ends its run host-lost, records the end and frees the role, then removes a labelled container the registry doesn't name;
    • in containerized mode nothing of the git socket's lands in bus-host/;
    • 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.
  • Suites: seat, cli, bus, and every scripts/test-*.sh green, 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/ where mosaic bus start starts the broker, notifier and egress containers in containerized mode, with 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.
  • packages/cli/src/host.mjs for the channel's fork wrapper, the exported close timeout and the core stop timeout, host.pids in containerized mode, the store path (making <dataRoot>/bus and resolving it), the name-conflict refusal, the leftover removal at bus start and hostStatus, and the bus status lines in packages/cli/src/cli.mjs (design section 3, "The core container's lifecycle").
  • packages/cli/systemd/mosaic-bus.service.in, the ExecStart comment only: the leftover refusal exits 1 and the unit retries it, and every other refusal still exits 3.
  • compose.yaml, scripts/run-task.sh and the worker adapter files it uses.
  • The scripts listed in I2's problem, moved to scripts/lib/docker.sh. dropNetwork (packages/cli/src/git-socket.mjs:243) is JavaScript and can't source the helper, so in containerized mode it passes --host with the configured endpoint after the same rootless check.
  • scripts/bootstrap.sh only to print the install steps. It still never writes the config.
  • README.md (the install section), docs/TOOLS.md, LAYERS.md, 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.md L4 and CONDUCTOR.md:17,40 describe what is built.
  • Tests:
    • the whole install starts and stops cleanly, and leaves no container or network;
    • 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, 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;
    • 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-233 does 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.lock is 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 connected turns false after the fork's exit line and after the client exits, and a later send writes nothing;
      • boot, sent right after the client starts and before any {from, pid} line, reaches the broker and gets its reply well inside BOOT_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.store label 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>/bus at 0700 before it resolves the store path;
      • docker run straight 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.lock is given back, and that container is left as it was;
      • mosaic bus status shows a running broker's lock as live, a lock left after docker rm -f as dead, and the lock as unknown when the endpoint doesn't answer;
    • a pi worker task runs in containerized mode and writes its run record;
    • each moved script refuses endpoint-not-rootless against the rootful socket in containerized mode.
  • Suites: every scripts/test-*.sh and the node suites of each touched package green.

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.