agents: row 195 Case 1 install design r3 (#1693): Duckworth B1, one ordered .53 activate with a runner per step (case1-repin.sh, stack-release --units external with confirm and revert), stopped-store backup under $DATA/backups/stack; N1-N6; mos-1091 4a-4c with the bwrap AppArmor profile; gosalyn-0034 C2-13 items

This commit is contained in:
2026-10-20 15:56:54 -05:00
parent 818b79696d
commit 9a16d7795d
+235 -73
View File
@@ -1,16 +1,30 @@
# Row 195 design: the Case 1 install on 10.1.1.53 (r2)
# Row 195 design: the Case 1 install on 10.1.1.53 (r3)
Darkwing, 2026-10-20. Brief: `docs/plans/2026-10-20_hub-own-uid.md`, blob 45be7f09afc1 (f24f2cd40,
sage-1439). Gitea #1693. Reviewer Duckworth, gate owner Sage. Chained on row 190's candidate (8862feccc on
`darkwing/row190`, the config canon) and on C2-13's candidate (Gosalyn, `scripts/case2-install.sh`,
`case2-precheck.sh`, `case2-unit.sh`), Q177. C2-13 has no candidate yet. This design names the inputs and
the changes Case 1 needs from it, so the brief at a0f6afc50 on `gosalyn/case2-design` is the interface I
read.
sage-1439). Gitea #1693. Reviewer Duckworth, gate owner Sage. Chained on row 190's candidate (537a02392
on `darkwing/row190-r2`, round 2, the config canon) and on C2-13's candidate (Gosalyn,
`scripts/case2-install.sh`, `case2-precheck.sh`, `case2-unit.sh`), Q177. C2-13 has no candidate yet, so
the interface I read is its brief, `docs/plans/2026-10-20_case2-rows.md` "## C2-13" at ecbfd6deb
(from Gosalyn's 29e85196b).
Changes from r1 (sage-1451): section 6 now holds Sage's rulings on Q1-Q4. Section 4 keeps the system unit,
and the copy id in `ExecStart=` is a C2-13 change. Section 1 marks the bwrap install and the user namespace
fix as host security changes that wait for Jason's ruling through Mos. F2 now states the 127.0.0.1 listener
and `ssh -L`.
Changes from r2:
- Duckworth B1 (duckworth-0047, REVIEW-r1.md at 56d59c4c7). Section 4 is now one ordered list with a
runner for each step: root stops the units, `mosaic` takes the stopped-store backup and logs, root
renders and starts, `mosaic` gates, and a failure runs the list back to the previous copy in the same
run. One root command, `scripts/case1-repin.sh`, drives it. The `mosaic` half is a new
`--units external` mode of `stack-release.sh`, with `confirm` and `revert` verbs. Section 7 names
both and their tests, including tests that dragon-lin's path is unchanged. The r2 tar backup is gone.
- Duckworth N1-N6. P6 is tightened (N1), P1 matches sudo's refusal text (N2), F3 names its commands
(N3), the node download is checked against the signed `SHASUMS256.txt.asc` and a pinned sha256 (N4),
claude is pinned with the autoupdater off (N5), and the run sheet says a restore leaves the credential
in place (N6).
- Mos, mos-1091. Installing bubblewrap is an install step (4a). The user namespace fix is a per-binary
AppArmor profile for `/usr/bin/bwrap` with the sysctl left at 1, and its text, load, test and rollback
are in section 1 (4b). P5 closes at #423 step 4, which Mos moved ahead of the install (4c). Section 6
Q2 records these.
- Gosalyn, gosalyn-0034. C2-13 took `--profile factory|library`, the node and bwrap precheck lines and
the copy-id rule. Section 2 points at them instead of asking.
## 0. What I checked on the host
@@ -22,7 +36,7 @@ Read only, as `localadmin` over ssh, 2026-10-20 about 20:40Z (the brief allows t
| `mosaic` user, `/srv/mosaic` | absent | step 2a creates both |
| node | `/usr/local/bin/node` v22.22.2, root-owned | 24 or later (every `packages/*/package.json` engines line); dragon-lin runs 26.8.1 |
| bwrap | absent; apt candidate `bubblewrap 0.9.0-1ubuntu0.3` | every role session runs under bwrap (`packages/cli/src/launcher.mjs:310`) |
| user namespaces | `kernel.apparmor_restrict_unprivileged_userns = 1` | bwrap needs an unprivileged user namespace; this setting can refuse it |
| user namespaces | `kernel.apparmor_restrict_unprivileged_userns = 1`, apparmor 4.0.1, no bwrap profile shipped | bwrap needs an unprivileged user namespace, which this setting refuses unless a profile allows it |
| claude | not on localadmin's PATH | the factory's harness; dragon-lin runs 2.1.304 |
| ports 7330-7333 | free (no listener) | Console, board, tester |
| `localadmin` | NOPASSWD sudo (#423 step 1, applied 20:05:47Z, mos-1066, mos-1077) | the root steps run through it |
@@ -40,6 +54,7 @@ Instance. Row 190's canon with the default name, `dev`, as Case 2 uses it (Gosal
| config dir | `/srv/mosaic/.config/mosaic-dev` |
| data root | `/srv/mosaic/.mosaic-dev`, marker `instance.json` `{"instanceVersion":1,"name":"dev"}` |
| business file | `<configDir>/businesses/mosaic-stack.json`, the factory business (section 2) |
| profile | `--profile factory` (C2-13) |
| roles | the factory set in section 2 |
| human id | Jason's id, given in the window's go. Not `tester` (`broker.mjs:245`) |
| model credential | `<configDir>/secrets/claude-session.env`, 0600 (row 190's `SESSION_ENV` for the default instance) |
@@ -47,47 +62,90 @@ Instance. Row 190's canon with the default name, `dev`, as Case 2 uses it (Gosal
| Console, board, tester ports | 7332, 7331, 7333, the default instance's ports |
| harness | claude at `/srv/mosaic/.local/bin/claude`, pinned through `MOSAIC_CLAUDE_BIN` (decision 91) |
| node | `/srv/mosaic/.local/node/bin/node`, a pinned 26.x release |
| source | `/srv/mosaic/src/mosaic-stack` (`mosaic`'s checkout, section 4) and `/root/mosaic-stack` (root's) |
Why `dev` and not a named instance: one instance per OS user, and C2-13 already assumes
`/srv/mosaic/.config/mosaic-dev`. A second instance under `mosaic` on .53 would need a name, and row 190
makes that a flag, not a move.
Why node lives under `/srv/mosaic`: changing `/usr/local/bin/node` would change what Hermes and anything
else on the host runs, and Q181 keeps Hermes running. A per-user node touches no other account. The
download is checked against the release's `SHASUMS256.txt` before unpacking.
else on the host runs, and Q181 keeps Hermes running. A per-user node touches no other account.
Node download (N4). The run sheet pins one release, its tarball name and its sha256. As `mosaic`:
fetch the tarball, `SHASUMS256.txt` and `SHASUMS256.txt.asc`; `gpg --verify` the `.asc` against the
Node release keys listed in the nodejs/release-keys repository, imported into a keyring under
`/srv/mosaic/.local/node-keys`; check that the tarball's line in the verified file is the pinned sha256;
then `sha256sum -c` the tarball and unpack. Any mismatch stops the step.
Claude (N5). The run sheet pins the version, 2.1.304 unless the window's plan names a newer one. As
`mosaic`: `npm install --prefix /srv/mosaic/.local @anthropic-ai/claude-code@<pin>`, with `@<pin>`
exact. `/srv/mosaic/.local/bin/claude` is the path `MOSAIC_CLAUDE_BIN` names. The autoupdater stays off
through `DISABLE_AUTOUPDATER=1`. Where that variable lives depends on what reaches a role session: the
unit's environment only helps if the session env allowlist passes it (row A touches that allowlist for
`ZAI_API_KEY`). The build checks the launcher's allowlist. If it passes the variable, I ask Gosalyn for
it as a fixed `Environment=` line in the unit template. If not, it goes in `mosaic`'s claude settings
file (`env`), and the manifest says which. Either way F6 compares `claude --version` with the pin after
F3.
bwrap and the user namespace (mos-1091 4a, 4b). Two root steps, each with its rollback:
1. `apt-get install bubblewrap` (0.9.0-1ubuntu0.3 today). Rollback: `apt-get remove bubblewrap`.
2. The AppArmor profile `/etc/apparmor.d/mosaic-bwrap`, `root:root` 0644, in the form Ubuntu 24.04 uses
for the browsers it lets create user namespaces:
```
abi <abi/4.0>,
include <tunables/global>
profile mosaic-bwrap /usr/bin/bwrap flags=(unconfined) {
userns,
include if exists <local/mosaic-bwrap>
}
```
Load it with `apparmor_parser -r /etc/apparmor.d/mosaic-bwrap`. `aa-status` then lists
`mosaic-bwrap`. The sysctl stays at 1, so no other binary on the host gains user namespaces. Any user
who runs `/usr/bin/bwrap` gets one, though, and that is the residual Duckworth reviews with the
profile. Test: as `mosaic`, `bwrap --unshare-all --ro-bind / / true` exits 0 (C2-13's precheck line,
F4), and a role session runs under bwrap (F5). Rollback: `apparmor_parser -R
/etc/apparmor.d/mosaic-bwrap`, then `rm /etc/apparmor.d/mosaic-bwrap`, then step 1's rollback.
Who runs what:
| Step | Runs as | Through |
|---|---|---|
| 2a: `useradd --system --home-dir /srv/mosaic --shell /usr/sbin/nologin mosaic`, `/srv/mosaic` 0700 | root | ops-01, `ssh localadmin@10.1.1.53`, `sudo` (#423 step 1) |
| 2a': `apt-get install bubblewrap`, and the user namespace fix (section 6, Q2) | root | ops-01, same path, only after Jason rules on both through Mos. Both are host security changes, and nothing runs on .53 until they're ruled |
| 2b: node and claude under `~/.local`, `scripts/bootstrap.sh`, release package and activate, business file, roles, `case2-precheck.sh` | `mosaic` | `sudo -u mosaic -H` from `localadmin`. `mosaic` gets no key and no login shell |
| 2a': bubblewrap and the AppArmor profile, each with the test and rollback above | root | ops-01, same path, R27 (mos-1091) |
| 2a'': a `git bundle` of the reviewed commit copied to .53; root clones it to `/root/mosaic-stack` and checks HEAD is the commit the go names; it installs the bundle as `/srv/mosaic/incoming/<sha>.bundle`, owner `mosaic`, 0600 | root | ops-01, same path |
| 2b: node and claude under `~/.local` (above); `git clone` of the bundle to `/srv/mosaic/src/mosaic-stack`; `scripts/bootstrap.sh`; `stack-copy.sh copy`; `case2-install.sh --profile factory` with the inputs above; `case2-precheck.sh --profile factory` | `mosaic` | `sudo -u mosaic -H` from `localadmin`. `mosaic` gets no key and no login shell |
| 2b': place `claude-session.env` | Jason, or who he names (Q173 secrets rule) | section 3 |
| 2c: `case2-unit.sh` from a root-owned checkout of the reviewed commit outside `/srv/mosaic`, then `systemctl enable` | root | ops-01, same path |
| start, stop, restart of the unit | root | ops-01, or Jason |
| 2c: `case2-unit.sh --user mosaic --home /srv/mosaic --copy <id>` from `/root/mosaic-stack`, then `systemctl enable` and the first start | root | ops-01, same path |
| 2c': `stack-release.sh adopt mosaic-stack --units external`, which records the running copy as the pointer (`do_adopt`, `scripts/stack-release.sh:856`) | `mosaic` | `sudo -u mosaic -H` |
| every later re-pin or rollback | root, with its `mosaic` steps through `sudo -u mosaic -H` | ops-01 runs `case1-repin.sh` (section 4) |
| acceptance (section 5) | `localadmin`, with `sudo -u mosaic -H` for the in-instance checks | the agent the window names |
No git credential lands on .53. Both checkouts come from bundles that ops-01 copies in, and a re-pin
brings a new bundle that `mosaic` fetches into its checkout.
## 2. Where Case 1 differs from Case 2
| Case 2 item (C2-13 brief) | Case 1 | Reason |
|---|---|---|
| business file with the library mapping | business `mosaic-stack` with no `library` key | the factory has no books repo |
| business file with the library mapping | business `mosaic-stack` with no `library` key; `--profile factory` refuses a `kind: "library"` project (`profile-library-business`) | the factory has no books repo |
| `bean`, `chet`, persona vars and files, specialists | factory roles from `roles/`, with a chief that has a harness var | Jason names the set in the go. I haven't read his live business file and won't. The acceptance needs only the chief (F3), since a chief with no harness var answers `no-route`. Others are added later by the human with `instances.set`, no new row |
| token slot `library-gitea` | none | the factory's per-agent git identity is row 199's credential store (Megavolt, #1697). Until it lands, git work stays on dragon-lin (Q182) |
| precheck: `bus.tester` is not on | `bus.tester` is on | the acceptance and the Q173 test system need the tester (agents act as tester, never Jason) |
| precheck: persona file modes and digests | skipped | no persona files |
| precheck: `library.sync`, `library.status` | skipped | no library |
| precheck: `bus.tester` is not on | `bus.tester` is on; `factory` requires it | the acceptance and the Q173 test system need the tester (agents act as tester, never Jason) |
| precheck: persona file modes and digests | skipped under `factory` | no persona files |
| precheck: `library.sync`, `library.status` | skipped under `factory` | no library |
| precheck: `ZAI_API_KEY` in the key file | doesn't run | no instance has provider `zai` |
| precheck: no Discord binding | kept | the factory's Discord connector stays on dragon-lin. Two connectors on one bot token would split its events |
| precheck: marker prompt through claude with `--system-prompt-file` | kept | |
| precheck: `sudo -n true` fails, `id -G` one entry, run root 0700, config validates, free memory, HOME | kept | |
| (not in C2-13) | added: node 24 or later, `bwrap --unshare-all --ro-bind / / true` exits 0 as `mosaic` | Case 2 needs both too; they go to Gosalyn as C2-13 changes |
| precheck: claude marker prompt with `--system-prompt-file` | runs, since the chief's harness is claude-code | |
| precheck: `sudo -n true` fails, `id -G` one entry, run root 0700, config validates, node 24 or later, `bwrap` exits 0, free memory, HOME | kept | node and bwrap are now in C2-13 (gosalyn-0034) |
How the differences reach C2-13's scripts. I'd ask Gosalyn for one input, `--profile factory|library`,
default `library`. `factory` skips the library and persona items and requires `bus.tester` on. The
alternative is a Case 1 copy of the scripts, which the brief rules out. If Gosalyn declines, the profile goes
into this row's manifest as a named change to `case2-install.sh` and `case2-precheck.sh`, reviewed by
Duckworth with Webby told.
All of these are C2-13 inputs. Nothing in C2-13's scripts hardcodes a Case 1 value (gosalyn-0034 item
4). If C2-13's candidate drops any of them, the manifest names the change to `case2-install.sh` or
`case2-precheck.sh`, reviewed by Duckworth with Webby told.
## 3. Model access
@@ -105,30 +163,91 @@ The pi harness stays off on .53 until P19 #383 settles (mos-0865). The business
## 4. Re-pin, rollback and backup on .53
Unit form (ruled, sage-1451 Q1). The Case 2 form: a system unit written by root with `User=mosaic`. Today's
re-pin path (`stack-release.sh activate`, row 85, row 190) drives `systemctl --user` units, which a
system unit doesn't answer to, and `mosaic` can't restart a system unit without sudo. So on .53:
Unit form (ruled, sage-1451 Q1): a system unit that root writes, with `User=mosaic`. `mosaic` can't
start or stop it, and root never runs code that `mosaic` can write. `stack-release.sh activate` today
does everything in one run under `systemctl --user` and puts the old stack back inside that run
(`rollback_in_run`, `scripts/stack-release.sh:561`). On .53 the same run needs two users. So one
re-pin is one ordered list, and one root command drives all of it: `scripts/case1-repin.sh`, run by
ops-01 from `/root/mosaic-stack`. Each `mosaic` step is `sudo -u mosaic -H` running
`/srv/mosaic/src/mosaic-stack/scripts/stack-release.sh` with `--units external`. Copies carry no
`stack-release.sh` (`COPY_PATHS`, `scripts/stack-copy.sh:23-30`), so the `mosaic` steps run from that
checkout, as dragon-lin's run from the canonical one.
- Re-pin. As `mosaic`: `stack-copy.sh` makes the new copy under `~/.local/share/mosaic-stack/copies/`, then
the pointer and the backup (below) are written. `stack-release.sh activate` drives `systemctl --user`
today, so it needs a mode that writes the pointer and backup and leaves units to root. I'll size that in
the build and name it in the manifest. As root (ops-01):
`case2-unit.sh` re-renders the unit for the new copy and refuses any other difference, then `systemctl
restart mosaic-stack.service`. This needs C2-13's fixed `ExecStart=` check to allow exactly one varying
part, the copy id (a 40-hex directory under the copies dir). That is a C2-13 change. I've sent it to
Gosalyn, and the manifest names it either way (section 7).
- Rollback. The same two steps with the previous distinct copy (from the activation log, as on
dragon-lin).
- Backup. Before every activate, as `mosaic`, a tar of the data root and config dir, without `secrets/`,
into `/srv/mosaic/backups/` at 0700 (the row 185 rule: back up every activate). The window's first
backup comes before the first activate even though the data root is new, so the rule has one path.
- Who. The re-pin goes as on dragon-lin: Mos or Sage plans it, ops-01 runs the root half, and the agent
the window names runs the `mosaic` half. Each re-pin is a window with Jason's or Mos's go and a STATE
report.
Root reads only exit codes, and the copy id from the unit file it installed itself. It parses nothing
`mosaic` wrote and runs nothing under `/srv/mosaic` except through `sudo -u mosaic -H`.
Not taken: row 190's user units under `mosaic`, with root running `loginctl enable-linger mosaic` once.
Every existing re-pin tool would work unchanged and root would be needed only once, but the unit form
would differ from Case 2 and a user manager would run as `mosaic`.
The units. C2-13's brief names one unit, `mosaic-stack.service`, and doesn't say whether its
`ExecStart=` runs the bus, the Console or both. Case 1 needs both, the Console for F2 and the bus for F3.
I've asked Gosalyn. Below, UNITS is the list `case2-unit.sh` installs, one unit or two. Root stops them
in reverse order and starts them in order. The gate checks the bus process and the Console's `/healthz`
whichever way they're split.
### Activate: `case1-repin.sh activate ID [--migrate] [--timeout S]`
| # | Step | Runner | On failure |
|---|---|---|---|
| A1 | `/root/mosaic-stack` HEAD is the commit the go names. ID is 40 lowercase hex and names a copy under `/srv/mosaic/.local/share/mosaic-stack/copies` that holds `scripts/mosaic` (C2-13's copy-id rule). Root saves the installed unit's copy id as OLD. `stack-release.sh activate ID mosaic-stack --units external --plan` exits 0 | root; the plan as `mosaic` | refuse, exit 3. Nothing has changed |
| A2 | `systemctl stop` UNITS, then wait until each is inactive, within `--timeout` | root | start UNITS, then R4. Nothing else has changed |
| A3 | `stack-release.sh activate ID mosaic-stack --units external`. It refuses `units-running` unless each of UNITS is inactive (system-manager reads, which need no privilege) and the store is stopped (`stopped`, :359). Then it captures the rollback set (pointer, wrapper, and the sha256 and `ExecStart=` of each installed unit, read only), logs `result started`, takes `backup_store` (:372) into `$DATA/backups/stack/<ACT>`, migrates under `--migrate` (sets TAKEN, as today), installs the wrapper for ID, and writes `$RS/PENDING` naming ID. It writes no pointer | `mosaic` | it puts the wrapper back, restores TAKEN if it migrated, logs `result failed code <c> rollback pending`, exits 1. Root runs R3-R4 |
| A4 | `case2-unit.sh --user mosaic --home /srv/mosaic --copy ID`, `systemctl daemon-reload`, `systemctl start` UNITS | root | R1-R4 |
| A5 | `stack-release.sh confirm <ACT> mosaic-stack --units external`. This is the gate (`gate_once`, :471), with system-manager reads where today's has `--user`: the units are active; the bus's MainPID is the host's pid, and that pid's uid is `mosaic`'s; boot status is from after this start; `/healthz` answers 200 on the Console port; `stack-copy.sh check --units external` agrees on ID. On a pass it writes the pointer and logs `result ok`, exit 0. On a failure it logs `result gate-failed code <gate> rollback pending`, exit 1 | `mosaic` | R1-R4 |
### Rollback inside the run (R1-R4)
| # | Step | Runner | On failure |
|---|---|---|---|
| R1 | `systemctl stop` UNITS, then wait as in A2 | root | rollback-failed |
| R2 | `stack-release.sh revert <ACT> mosaic-stack --units external`. It puts the wrapper back from the rollback set and checks its sha256, and restores TAKEN (`restore_store`, :396) only if this run migrated | `mosaic` | rollback-failed |
| R3 | `case2-unit.sh --user mosaic --home /srv/mosaic --copy OLD`, `systemctl daemon-reload`, `systemctl start` UNITS | root | rollback-failed |
| R4 | `stack-release.sh confirm <ACT> mosaic-stack --units external --rollback`. It gates OLD and logs `result gate-failed code <c> rollback ok`, or `result rollback-failed code <c> rollbackCode <r>` | `mosaic` | rollback-failed |
rollback-failed: root stops UNITS and exits 2, as `rollback_failed` (:587) does. When R2 or R4 can't log
it, root prints the codes, and the next run's `interrupted` check (:146) names the activation that has
`started` and no result. Exit codes match `stack-release.sh`: 0 ok, 1 gate-failed and rolled back, 2
rollback-failed with UNITS stopped, 3 refused with nothing changed.
### Rollback to the previous copy: `case1-repin.sh rollback [--restore ACT --events N]`
The same list. TO is `from_log previous` (:162), the previous distinct copy, and A3 runs `stack-release.sh
rollback mosaic-stack --units external` with `--restore` as today. The post-backup event count runs in
A1's plan as `mosaic` while the bus still holds the store (the `heldcount` read, :1074), and again in A3
on the stopped store. A difference refuses `post-backup-events`, as `run_steps` does (:1013).
### Backup
A3's `backup_store` is today's. It copies the stopped store into `$DATA/backups/stack/<ACT>`, so
`rollback --restore <ACT>` and row 181's guard find it. The r2 tar is gone: it ran with the unit up, and
it landed where neither reader looks. As on dragon-lin, the config dir isn't in the backup, and the
credential isn't either. PBS backups of VM 1008 cover the disk (P4). A restore swaps only `bus/` and
leaves `secrets/claude-session.env` in place (N6, in the run sheet).
The install's adopt (2c') takes no backup. The store holds only what the acceptance wrote, and the first
re-pin's A3 takes the first backup.
### The log
Every line goes to `$DATA/state/stack-activation-log.jsonl`, as today, with `units external` added. So
`from_log previous` reads .53's history the same way it reads dragon-lin's. A3 writes `started`. A5 or
R4 writes the result. If a run dies between them, the log keeps `started` with no result, and the next
run's `interrupted` check reports it.
### Who
Mos or Sage plans each re-pin. ops-01 runs `case1-repin.sh` on Jason's or Mos's go, and a STATE report
follows. The gate and the in-run rollback have one owner, the root command, as they do on dragon-lin.
### dragon-lin's path
`--units external` is a new value. Without it, `stack-release.sh` and `stack-copy.sh check` run as they
do today. `confirm` and `revert` refuse without it. Section 7 names the tests that show this.
### Not taken
- Row 190's user units under `mosaic`, with root running `loginctl enable-linger mosaic` once. Every
existing re-pin tool would work unchanged and root would be needed only once. But the unit form
would differ from Case 2, and a user manager would run as `mosaic`.
- A sudoers rule that lets `mosaic` start and stop the unit. P1 and C2-13's precheck require that
`mosaic` has no sudo.
## 5. Acceptance checks
@@ -142,55 +261,98 @@ Functional checks. Any failure fails the row:
| Id | Check | How |
|---|---|---|
| F1 | the unit runs as `mosaic` | `systemctl show mosaic-stack.service -p User -p MainPID`, then the MainPID's uid from `/proc/<pid>/status` is `mosaic`'s, and the unit is active |
| F1 | the units run as `mosaic` | `systemctl show <unit> -p User -p MainPID` for each of UNITS; each MainPID's uid in `/proc/<pid>/status` is `mosaic`'s, and each unit is active |
| F2 | the Console listens on 127.0.0.1 on .53 and answers there | `curl -fsS http://127.0.0.1:7332/healthz` on .53 succeeds, and the listener table shows 7332 bound to 127.0.0.1 only. Jason reaches it with `ssh -L 7332:127.0.0.1:7332 localadmin@10.1.1.53`. A LAN listener waits for Q175 SSO (sage-1451 Q3) |
| F3 | one tester message gets a reply | the tester listener on 127.0.0.1:7333 sends one marked message to the chief, and a stored reply appears within 5 minutes (`talk`, then `follow`; inbox alone shows decisions only) |
| F3 | one tester message gets a reply | as `mosaic`: `mosaic talk chief <marker> --tester`, then `mosaic follow --tester`, with a 300 s limit. A stored reply to the marked message passes. The inbox alone shows decisions only, so it isn't used (N3) |
| F4 | the precheck passes | `case2-precheck.sh --profile factory` exits 0 as `mosaic` |
| F5 | a role session runs under bwrap | while F3's reply is pending, a `bwrap` process owned by `mosaic` has a claude process under it. If `launches list --json` records the sandbox, the build reads that instead, since a short session can end before `ps` sees it |
| F6 | claude is the pinned version | `/srv/mosaic/.local/bin/claude --version` as `mosaic`, after F3, equals the run sheet's pin |
Privilege and host paths. These report and never fail the row:
| Id | Path | Mark | Evidence the script prints |
|---|---|---|---|
| P1 | `mosaic` has sudo | closed when `sudo -l -U mosaic` grants nothing | the `sudo -l` result |
| P1 | `mosaic` has sudo | closed only when `sudo -l -U mosaic` prints "is not allowed to run sudo" (N2); any other output is unnamed | the `sudo -l` result |
| P2 | `mosaic` has supplementary groups or a key | closed when `id -G mosaic` is one entry and `~mosaic/.ssh/authorized_keys` is absent or empty | `id`, file state |
| P3 | `localadmin` NOPASSWD sudo, reached with the dragon-lin key | accepted Q183 (agents keep ssh to .53; localadmin from #423 step 1) | `sudo -l -U localadmin` |
| P4 | root on pve over VM 1008, and PBS backups of VM 1008, which hold `/srv/mosaic` including the credential | accepted Q182 and Q183 (agents keep root on pve and PBS) | a fixed line; the script can't see pve from inside |
| P5 | `jarvis`, which runs Hermes, can reach `/srv/mosaic` through sudo, or through the docker or lxd groups, which are root-equivalent | unnamed. Q183 accepts the jarvis key's reach to pve, not local root over the `mosaic` home. It ends at #423 step 4 (jarvis sudo), which waits until Case 1 is live. Sage passed it to Mos (sage-1451) | `id jarvis` |
| P6 | other users in sudo, docker, lxd | closed when the only members are `localadmin` and `jarvis`; any other is unnamed | `getent group` lines |
| P5 | `jarvis`, which runs Hermes, can reach `/srv/mosaic` through sudo, or through the docker or lxd groups, which are root-equivalent | closed when `jarvis` is in none of sudo, docker and lxd; unnamed otherwise. Q183 accepts the jarvis key's reach to pve, not local root over the `mosaic` home. #423 step 4 (jarvis sudo) lands before the install (mos-1091 4c). Whether it also removes docker and lxd I don't know, and the check reports what it finds | `id jarvis` |
| P6 | other members of sudo, docker, lxd | closed when sudo holds only `localadmin` and docker and lxd are empty. Every other member is its own unnamed line, `jarvis` included (N1) | `getent group` lines |
| P7 | a file under `/etc/sudoers.d` naming `mosaic` | closed when none | the file names |
| P8 | the Console or tester reachable from the LAN | closed when 7331-7333 are bound to 127.0.0.1 only | listener table |
| P9 | `/usr/bin/bwrap` gives any local user a user namespace | unnamed: the residual of mos-1091 4b, which Duckworth reviews with the profile | `aa-status` line for `mosaic-bwrap`, the sysctl value |
Tests, in `packages/cli/tests/case1-accept.test.mjs`. The script takes a fake root (`--root DIR`) for
`/proc`, `/etc` and `getent` output, and fake `systemctl`, `curl`, `ss` and `sudo` on PATH. Cases:
`/proc`, `/etc` and `getent` output, and fake `systemctl`, `curl`, `ss`, `sudo`, `bwrap`, `aa-status` and
`claude` on PATH. Cases:
- each F check failing gives a non-zero exit with its line;
- each P check in each of its marks;
- each P check in each of its marks, including P1 with output that grants nothing but lacks the refusal
text (unnamed);
- a run with every P check `unnamed` still exits 0;
- the summary counts match the lines.
Mutants, one per F check (the check made to pass on failure), and one that maps P5 to `closed`.
Mutants: one per F check (the check made to pass on failure), one that maps P5 to `closed`, and one that
closes P1 on any output without a rule line.
Nothing in the tests reaches .53 or dragon-lin's live state.
## 6. Rulings (sage-1451)
## 6. Rulings
- Q1, unit form: the brief's system unit. C2-13's `ExecStart=` check accepts a varying copy id. If that
changes C2-13, it goes to Gosalyn and my manifest names it (section 4).
- Q2, bwrap and user namespaces. Ubuntu 24.04 with `apparmor_restrict_unprivileged_userns = 1` can refuse
bwrap's namespace. Two host security changes are needed: installing `bubblewrap` through apt, and the
user namespace fix, either an AppArmor profile that allows `userns` for `/usr/bin/bwrap` or the sysctl
turned off. Sage sends both to Mos for Jason. This design names them and stops there. Nothing runs on
.53 until they're ruled. .54 is likely the same, so Gosalyn needs the answer too.
sage-1451:
- Q1, unit form: the brief's system unit. C2-13's `ExecStart=` check accepts a varying copy id, and
C2-13 took that (gosalyn-0034 item 3).
- Q3, the Console: it listens on 127.0.0.1 on .53 (`packages/webui/src/serve.mjs:466`; `cli.mjs` passes no
host), and Jason reaches it with `ssh -L`. F2 says so. A LAN listener waits for Q175 SSO.
- Q4, the date: if C2-13's candidate is late, I build on C2-13's latest commit and rebase (Q177). Under
the #426(a) push rules (mos-1086) a rebase goes to a new branch name.
- P5 keeps the mark "not named in Q182 or Q183", and Sage passed it to Mos.
mos-1091 (none goes to Jason):
- Q2 4a: installing bubblewrap is an install step, run by ops-01 as root under R27. Rollback is
`apt-get remove`.
- Q2 4b: a per-binary AppArmor profile for `/usr/bin/bwrap` with `userns`, the sysctl left at 1. The
profile text, load, test and rollback are in section 1. Duckworth reviews the profile.
- 4c: P5 ends at #423 step 4, which Mos sent to Duckworth so it lands in Jason's VM 1008 boot session,
before the install. The P5 check stays.
## 7. Manifest (planned)
- `scripts/case1-accept.sh` (new) and `packages/cli/tests/case1-accept.test.mjs` (new).
- The `--profile factory` change, the two precheck lines and the copy id in `case2-unit.sh`'s `ExecStart=`
check, in C2-13's files, either as Gosalyn's C2-13 change or as named changes here (sections 2 and 4).
- Docs: the Case 1 run sheet `docs/guides/case1-install.md` (new): the window's steps in order, each with
who runs it and its go.
- `scripts/case1-repin.sh` (new) and `packages/cli/tests/case1-repin.test.mjs` (new). Fake `systemctl`,
`sudo` and `case2-unit.sh` on PATH record their argv, with a scratch home standing in for `/srv/mosaic`.
Cases:
- a pass runs A1-A5 in order;
- a failure at A3, A4 or A5 runs R1-R4 and exits 1;
- a failure at R2 or R4 exits 2 with UNITS stopped;
- a HEAD mismatch, or an ID that fails the copy-id rule, refuses with exit 3 before any stop;
- every exec of a path under the home goes through `sudo -u mosaic -H` (from the recorded argv);
- R3 renders OLD, which comes from the unit file, not from anything the `mosaic` steps print.
- `scripts/stack-release.sh`: `--units external` for `adopt`, `activate` and `rollback`, and the new
`confirm` and `revert` verbs, which refuse without it. `scripts/stack-copy.sh`: `check --units
external`, which reads the system units. Tests in `packages/cli/tests/stack-release-external.test.mjs`
(new), against a fake system-scope `systemctl`:
- `units-running` refuses while any unit is active;
- activate leaves the pointer as it was, writes `PENDING`, and writes a backup of the stopped store
under `$DATA/backups/stack/<ACT>`;
- `confirm` on a pass writes the pointer and logs `ok`; on a failure it logs `gate-failed ...
rollback pending` and leaves the pointer;
- `revert` puts the wrapper back, and restores TAKEN only after a migration;
- `confirm --rollback` logs `rollback ok` or `rollback-failed`;
- `rollback --restore` finds a backup that an external activate took;
- `confirm` and `revert` refuse without `--units external`.
- dragon-lin unchanged. `stack-release.test.mjs` and `stack-copy.test.mjs` run unchanged and green. A
new case in `stack-release-external.test.mjs` runs a default activate, rollback and check against a
fake `systemctl` that records argv, and every call carries `--user`. A default `activate --plan`
prints the same bytes on the candidate as on its base, with the base output as a fixture.
- Mutants for the re-pin: `confirm` writes the pointer on a gate failure; `case1-repin.sh` skips R1-R4;
`case1-repin.sh` runs `mosaic`'s `stack-release.sh` without `sudo -u mosaic`; the default path drops
`--user` from one `systemctl` call.
- `scripts/case1-accept.sh` (new) and `packages/cli/tests/case1-accept.test.mjs` (new), section 5.
- C2-13. `--profile`, the node and bwrap precheck lines and the copy-id rule are in its brief
(gosalyn-0034). Still open with Gosalyn: what UNITS is (section 4), and `DISABLE_AUTOUPDATER=1` as a
fixed `Environment=` line if the session allowlist passes it (section 1). Any of these that C2-13's
candidate lacks becomes a named change here.
- Docs: the Case 1 run sheet `docs/guides/case1-install.md` (new). It lists the window's steps in order,
each with who runs it and its go, the pinned node sha256 and claude version, the AppArmor profile text
with its load, test and rollback, and the line that a restore leaves the credential in place.
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, dragon-lin's units or `~/.mosaic`.