plans: containerized install design and the I2 to I6 briefs (#1581)
Row 87, the I1 design. The design turns decisions 105 to 108 into interfaces: one rootless endpoint named on every call, three images, two broker listeners with a human-socket via value, a host relay with a peer-pid check, sessions as hardened containers, file-held tokens, egress only through an allowlist proxy, and the Console in a container on the human socket. Every container claim cites spikes S0 to S9 or HP. Mos ruled on the host-process reading (mos-0657): routes open to a process under Jason's uid are decision 105's accepted limit; the other-uid reach to the Console port goes to Jason as P19 333, default a documented limit, and only I6's limits section waits on it. The rows file holds the I2 to I6 briefs on the template, each with owner, reviewers, files, what ships and gate. Doc-only: no test reads either file. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c0af7f2486
commit
2034817252
2 files changed
+1007
No files matched your search
@@ -0,0 +1,627 @@
|
||||
# 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`.
|
||||
|
||||
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 8d47d6184. 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, a non-root broker uid with a group per
|
||||
socket:
|
||||
|
||||
- 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 uid 0,
|
||||
and the process is uid 0, so the rule passes unchanged.
|
||||
- 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. I5 and I4 therefore
|
||||
test each container's mount list exactly (section 9), not just that the
|
||||
expected mounts exist. The host user can already reach everything these
|
||||
containers hold, so a container 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 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.
|
||||
|
||||
### 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.
|
||||
- `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/`).
|
||||
- `--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`, so `docker stop` delivers SIGTERM and the
|
||||
container exits 143 (S8).
|
||||
|
||||
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`:
|
||||
|
||||
```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 passes only if the exit status is 0 and the output contains
|
||||
`name=rootless`. S1 says to test the exit status first, because a missing
|
||||
socket exits 1 and prints `null`. `--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, which section 8 relies
|
||||
on.
|
||||
|
||||
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` or `tester`
|
||||
envelope gets `human-required`, as the spike measured (S4, HP).
|
||||
- **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 no container can read a host process's environ, even
|
||||
with `--pid=host`.
|
||||
|
||||
`serve()` (`packages/bus/src/server.mjs: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
|
||||
8517e5f45 (the working tree's broker.mjs carries another row's
|
||||
uncommitted edits, so these are HEAD lines):
|
||||
|
||||
- `packages/bus/src/runtime.mjs:117` binds with `via: 'cli'`. The human
|
||||
listener's bind passes `human-socket`.
|
||||
- `packages/bus/src/broker.mjs`:
|
||||
- 456, `bindHuman`, accepts `cli` or `human-socket`;
|
||||
- 316, `#human`, accepts either. For `human-socket` the listener has
|
||||
already established that the caller is outside any agent;
|
||||
- 514, `#protectedEnd`, Q169's "a decision the human resolved" check,
|
||||
accepts either;
|
||||
- 1551, the `decision_events` row, and 1699, message delivery, record
|
||||
the session's `via` instead of the literal `'cli'`;
|
||||
- 1847, 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/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 uses it, so a third value can't miss a reader. I3's
|
||||
tests cover each reader with `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/runtime.mjs`).
|
||||
`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 loopback Host skips the header check entirely
|
||||
(`packages/webui/README.md`, "What the proxy must do"). 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 him 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 his 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`)
|
||||
replaces the four pid fields: pid, startTime, runnerPid and
|
||||
runnerStartTime. 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}`.
|
||||
|
||||
- **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.
|
||||
- **Stop.** `docker stop --time 30 <id>`. That's the 30 s `stopSession`
|
||||
gives today (247-268). With `--init` a clean stop exits 143, and a
|
||||
stop after the grace period exits 137 (S8). `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.
|
||||
- **Recovery.** `recover()` (489-513) lists containers labelled
|
||||
`mosaic.business=<business>`. It removes, with `docker rm -f`, every
|
||||
one whose run isn't live in the registry, and logs each one to the
|
||||
launch log.
|
||||
|
||||
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 three 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.
|
||||
|
||||
Each proxy answers on its internal network under the alias `proxy`, port
|
||||
3128, and takes CONNECT only. An internal network has no DNS and no route
|
||||
out, 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.
|
||||
|
||||
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 pi's provider hosts with a real token,
|
||||
because S6 used a made-up one and left one `UPSTREAM-ERROR` unexplained.
|
||||
- **`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).
|
||||
|
||||
Not spiked: whether the broker's and notifier's own `fetch` honours the
|
||||
proxy on node 24.21.0 (`NODE_USE_ENV_PROXY=1`). I2 measures it. If it
|
||||
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.
|
||||
- **Mounts:**
|
||||
- `sock/human`;
|
||||
- `config.json` as a read-only file mount (the config is never
|
||||
rewritten, so S3's dead-inode problem doesn't arise; a config change
|
||||
needs a Console restart, as `loadSystem` at start already does);
|
||||
- `bus-host/` read-only, for `host.json` and `sessions.json`
|
||||
(`bus.mjs:65`, `reads.mjs:125-132,234`). This is safe only because
|
||||
`bus-host/` holds no socket in containerized mode (section 5). I4
|
||||
adds a check at Console start that refuses if `bus-host/` holds a
|
||||
socket or anything that passes for a credentials file.
|
||||
- **What it never gets:** the dataRoot, `bus/`, the relay, the agent
|
||||
socket, 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.
|
||||
- **Other reads.** `RELEASE` comes from the image. The Board's queue
|
||||
panel runs `queue-read.mjs` in a checkout (`reads.mjs:75-79`). I4 mounts
|
||||
the canonical checkout read-only for it, which is safe because the
|
||||
repository holds no secrets (invariant 3). If the config names no
|
||||
checkout, I4 turns the panel off.
|
||||
- **Reply path out of tmux.** Today POST `/api/reply` goes to the
|
||||
control board, which runs `tools/tmux/agent-send.sh`
|
||||
(`packages/control-board/src/serve.mjs:59,74-94,239-248`). In
|
||||
containerized mode the Console has no route to the board, and the
|
||||
route answers 404. A reply to a managed instance is a human
|
||||
`message.send` over the human socket, the path row 76 built. The
|
||||
board's tmux liveness check (`scan.mjs:196-209`) has no containerized
|
||||
counterpart, because the sessions list comes from `sessions.json`.
|
||||
- **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.
|
||||
- **Networks.** A session can't resolve or reach any host except
|
||||
through `proxy` (S5). The Console's port answers on 127.0.0.1. The
|
||||
broker can't reach a provider host.
|
||||
- **Hardening.** A session's `/proc/self/status` shows CapEff 0,
|
||||
NoNewPrivs 1 and Seccomp 2 (S9).
|
||||
- **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`.
|
||||
- **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.** `docker stop` gives 143, and a container that
|
||||
ignores TERM gives 137 after the grace period (S8). After a launcher
|
||||
restart, `recover()` removes an orphan.
|
||||
- **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, a missing socket and a
|
||||
non-rootless daemon each refuse with `endpoint-not-rootless`. 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 he
|
||||
rules. 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 his 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).
|
||||
- **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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,380 @@
|
||||
# The containerized install: rows I2 to I6 (2026-10-18)
|
||||
|
||||
Status: written by Sage, lead, as part of row 87 (#1581). 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.
|
||||
- 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`, `mosaic-task.mjs:396`). S1 showed that the ambient
|
||||
context can be the rootful daemon. 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. 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`), 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:
|
||||
- Whether node 24.21.0's `fetch` in the broker honours `HTTPS_PROXY`
|
||||
with `NODE_USE_ENV_PROXY=1`, measured with no credential against
|
||||
the services hosts. If it doesn't, stop and tell Sage before adding a
|
||||
dependency.
|
||||
- The provider hosts Claude Code and pi need with a real token. 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.
|
||||
- `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.
|
||||
- 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 and limits. It reaches the broker only through
|
||||
the mounted human socket.
|
||||
- A start check that refuses when `bus-host/` holds a socket, or a file
|
||||
that passes the credentials file rule.
|
||||
- The notifier read through the new broker verb in containerized mode.
|
||||
The file read stays in local mode.
|
||||
- The queue panel through a read-only checkout mount, or off when the
|
||||
config names no checkout.
|
||||
- `/api/reply` answers 404 in containerized mode. A reply is a human
|
||||
`message.send` over the human socket.
|
||||
- The README lists every route with what it can reach (decision 107).
|
||||
- Tests: the exact mount list from inspect; the port answers on
|
||||
127.0.0.1 and not on another address; a signed-in decision resolves and
|
||||
records `via: human-socket`; the start check refuses each bad
|
||||
`bus-host/` case; `/api/reply` is 404 in containerized mode and
|
||||
unchanged in local mode.
|
||||
- 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. 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/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. In local mode, bwrap and the pid record
|
||||
stay unchanged.
|
||||
- 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 and Seccomp 2 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 (143 on stop, 137 after the grace period);
|
||||
- `recover()` removes an orphan after a launcher restart;
|
||||
- `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`.
|
||||
- `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`.
|
||||
- `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. If he hasn't ruled when I6 reaches that section, the row
|
||||
waits on it.
|
||||
`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. 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.
|
||||
Reference in new issue
Block a user