Compare commits

...
Author SHA1 Message Date
jason.woltje f6c70f9441 sessions: Darkwing, row 195 build in review (37171f65b, rev 2756), slot green 22/22, 12/12 mutants, gate 662/0 + 429/0 2026-10-21 03:52:15 -05:00
jason.woltje b3b2374eaf sessions: Filbert, row 215 manifest r2 approve 2026-10-21 03:37:47 -05:00
jason.woltje 696255f883 sessions: Filbert, row 215 manifest review, changes requested (B1-B3) 2026-10-21 03:29:39 -05:00
jason.woltje 059577886c deferred: gate runs mount the host pi auth file (Webby N4 on the row 197 proof driver, traced by Herb) 2026-10-21 03:28:33 -05:00
jason.woltje 4ced5e63d4 sessions: Sage, rows 240, 228 and 238 landed (c515273db, 86249ac55, d49b1f2bd), 197 r5 rebuild, Gosalyn 212 slot (sage-1788..1826) 2026-10-21 02:29:11 -05:00
jason.woltje e7078e6a85 deferred: Webby's row 238 N1, the sso-config first-step hint has no business (#1744) 2026-10-21 02:27:43 -05:00
jason.woltje d49b1f2bd3 row 238: stack-release's sso-config first step carries --instance NAME on a named instance (#1744)
Lands Launchpad's round 3 candidate, manifest 1bf831f3 (4c90099ea on launchpad/row238-r4, base
c515273db), approved by Webby (29956, rev 2717). Three paths: scripts/stack-release.sh, packages/cli/tests/
stack-release.test.mjs and docs/TOOLS.md. It is row 200's follow-up N1. Round 3 only merges docs/TOOLS.md
onto the row 240 landing: the landed blob plus the one hunk at :599. The 3-path patch-id, b9d0bb0ee3c7,
is the same in all three rounds, and stack-release.sh stays 100755.

Evidence: Launchpad's slot run on round 2, 2afbfbb01: stack-release.test.mjs 196/0 (sage-1794). Sage's
combined gate with row 228 on c515273db plus both rows' paths (07:11:30Z to 07:23:34Z): packages/seat 165 pass, 0 fail; packages/cli 640 pass, 0 fail; all ten scripts/test-*.sh 585 pass, 0 fail (out/, run.log). Row 238's mutant, the sso-config first step dropping --instance NAME, was killed (2 fails), survived: none, and stack-release.sh was restored to its blob.
`queue review verify-commit 238` matches the manifest on all 3 paths. Round 1's N1 stays on the #1744
carry list.
2026-10-21 02:24:25 -05:00
jason.woltje 86249ac552 row 228: ZAI_API_KEY in the session env allowlist (#1729)
Lands Researcher's round 3 candidate, manifest 82cf98c6 (cf18c42b9 on researcher/row228-r3, base
c771a36f4), approved by Webby (29949, rev 2708). Three paths: packages/seat/src/session.mjs (ZAI_API_KEY
in ENV_ALLOW), packages/seat/tests/session.test.mjs (the pass-through test) and packages/cli/README.md
(the line, with the round 1 and round 2 N1 trims at :711 and :718). Round 3 is the round 2 rebase onto
the row 234 landing; the two seat files carry the blobs approved in rounds 1 and 2.

Evidence: Researcher's slot run on cf18c42b9: session.test.mjs 13/0, host.test.mjs 42/0 (sage-1796).
Round 2's seat and cli runs, 94/0, and its killed mutant stand, since the seat blobs are unchanged.
Sage's combined gate with row 238 on c515273db plus both rows' paths (07:11:30Z to 07:23:34Z):
packages/seat 165 pass, 0 fail; packages/cli 640 pass, 0 fail; all ten scripts/test-*.sh 585 pass, 0 fail (out/, run.log). bus runtime.mjs and webui reads.mjs import only readSessions from session.mjs, which this
change doesn't touch. `queue review verify-commit 228` matches the manifest on all 3 paths.
2026-10-21 02:24:25 -05:00
jason.woltje 59f817c321 sessions: Darkwing, row 195 DESIGN r7 pushed (5425ea2f3), Duckworth review requested (Gitea 29960), row 226 items to Gosalyn 2026-10-21 02:15:49 -05:00
jason.woltje c515273db7 row 240: installer I4, root's host check, its pins and the boot unit; I3's N1, N4 and N5 (#1746, Vikunja #432)
Lands Darkwing's round 1 candidate, manifest 573ece95a4a9 (ad1b23ab2 on darkwing/row240-r3, base
c771a36f4), approved by Filbert (29945, rev 2696). Nine paths: scripts/case1-hostcheck.sh (new),
scripts/case1-pins.sh (new), packages/cli/systemd/mosaic-hostcheck.service (new),
packages/cli/tests/case1-hostcheck.test.mjs (new), scripts/stack-upgrade.sh,
packages/cli/tests/stack-upgrade.test.mjs, docs/guides/host-run-sheet.md, docs/TOOLS.md and
agents/darkwing/work/queue-240/BUILD.md. File modes match the candidate. case1-hostcheck.sh and
stack-upgrade.sh are 100755. case1-pins.sh is 100644, because the host check sources it.

Evidence: Darkwing's first slot run failed one test, the N1 test's after hook (ENOENT once the
fixture had removed its root). The fix b45852daa is test-only. The second run on b45852daa was green:
singles 37/0, mutants M1-M8 8 of 8 killed, cli 639/0, scripts 585/0. Since c771a36f4 no commit
touches the nine paths. Sage's gate on 060a17b54 plus the nine paths (06:45:05Z to 06:56:21Z):
packages/cli 639 pass, 0 fail, and all ten scripts/test-*.sh 585 pass, 0 fail (out/, run.log). `queue review verify-commit 240` matches the manifest on all 9 paths.

Filbert's notes: N1 (the boot unit orders only mosaic-bus@mosaic-stack) and N2 (the run sheet writes
the apt hook before the first --check) are in docs/plans/DEFERRED.md. N3, repeated DRIFT lines after
bwrap is closed, is by design and is noted with N2.
2026-10-21 01:56:47 -05:00
jason.woltje da3051ccc6 deferred: Filbert's row 240 N1 and N2 (29945, #1746); 10-24 window draft Q1 takes the mos-1161 ruling 2026-10-21 01:50:34 -05:00
jason.woltje 060a17b549 sessions: Filbert, row 240 round 1 approve (29945, rev 2696) 2026-10-21 01:41:13 -05:00
jason.woltje ffd90e7fc4 sessions: Darkwing, row 240 in review round 1 (ad1b23ab2, manifest 573ece95, revs 2694 and 2695), with its BUILD-LOG entry 2026-10-21 01:37:57 -05:00
jason.woltje 02709125a6 sessions: Sage, row 234 landing c771a36f4, 235 briefed, rebuilt-row slot order, 10-24 draft NEW (sage-1766..1787)
Doc only. The 10-24 window draft 0 now names NEW c771a36f4 and adds row 233 and the T46 untimed tables.
2026-10-21 01:20:30 -05:00
18 changed files with 957 additions and 35 deletions
+1
View File
@@ -5176,3 +5176,4 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
2026-10-20 05:10:34 UTC | Row 178 #1676 round 2 verified | Before: C1 render refusals lacked killing tests and row 181 base was pending. Rebased onto 931651f2d, kept all COUNTED/count code and tests, appended T178 tail; added independent owner/collision/absolute/charset/header and empty-loopback proof tests. Four files singly 156/156, all 24 mutants killed, metadata and sandbox fixture probes pass; scratch sweep empty. Packet agents/rocko/work/queue-178/r2/, candidate 9300a670, inventory d0dcde7b (59/59), scan 58 files no findings; in-review revs 2166-2167, expected no-token request failure. No live actions or commits.
2026-10-21T01:28:05Z | Darkwing | row 232 #1734 installer I1 build, round 1 in review | Before: stack-release.sh managed only user units (systemctl --user), and stack-copy.sh had no bundle copy. After: --units external (adopt, activate, rollback --to, status, confirm, revert, gate --since, AWAITING-UNITS, stale-start, external-pm, --plan --expect-from) and stack-copy check --units external, copy --bundle FILE SHA, wrapper ID --create; user mode byte-identical to the base by test. First build 0af0c73c6 on 00d4cc1f6; rebased onto row 200 (cb5fdb064) as a0ffabc3f on darkwing/row232-r1; --sso refused under external (D9), row 200 config_checks run under external (D10). Gate on Sage's slot (sage-1618): cli 561/0, every scripts/test-*.sh green; 17/17 mutants killed. Results commit 84f1a91e6 (pushed, seat branch only). Manifest 7a3b4ea1 (9 paths), queue revs 2595-2596, request comment 29825, reviewer Filbert. Packet agents/darkwing/work/queue-232/BUILD.md.
2026-10-21T04:50:44Z | Darkwing | row 233 #1735 installer I3 build, round 1 in review | Before: root had no upgrade driver for root-owned units and no host run sheet; stack-release.sh had no rehearse --backup last and no external plan refusals. After: scripts/stack-upgrade.sh upgrade and rollback (r6 A1-A5, A2f, R1-R4) with 20 tests, docs/guides/host-run-sheet.md, TOOLS.md lines, and on Sage's rulings stack-release.sh --backup last, BACKUP_STORE and the --plan refusals open-activation and migrate-required (3 tests). Base e65a3ab4d (row 232's landing). Gate on Sage's slot (sage-1704) on d99166ade: cli 616/0, every scripts/test-*.sh green, 1201/0 summed by awk; m1-m8 8/8 killed. Correction: the process sweep after the gate found an orphaned sleep 60 from the lock test's flock holder (N1); 08db6ac3f fixes it test only, Sage ruled the gate stands (sage-1707), rerun c0 20/0 and m1 killed with 0 left (sage-1708). Tip 1a1dc80ca (pushed, seat branch only). Manifest 464ce9eb (7 paths), queue revs 2644 and 2647, request comment 29901, reviewer Filbert. Packet agents/darkwing/work/queue-233/BUILD.md.
2026-10-21T06:37:41Z | Darkwing | row 240 #1746 installer I4 build, round 1 in review | Before: root had no host check for the bwrap AppArmor profile, its pins or a boot unit, and stack-upgrade.sh exited 1 args-failed when the args move after A5 failed, while exit 1 means the old copy runs. After: scripts/case1-hostcheck.sh (seven items, --check, --enforce, --upstream), scripts/case1-pins.sh, mosaic-hostcheck.service and 16 tests; the args move failure exits 5 args-failed, documented in TOOLS.md and run sheet section 6 and tested; run sheet section 3 puts the pinned npm on PATH and names the bin/claude.exe --claude path; sections 8 and 10 have precheck lines. Verification: slot run r1 on 47ec9fe2f failed at the singles (the new N1 test's after hook threw ENOENT after the fixture cleanup removed the root); b45852daa drops the hook; r2 on b45852daa green: singles 16/0 and 21/0, c0 plus M1-M8 8/8 killed, cli 639/0, scripts 585/0. Candidate darkwing/row240-r3 ad1b23ab2 on c771a36f4, manifest 573ece95 (9 paths), revs 2694 and 2695, comment 29943.
+173
View File
@@ -0,0 +1,173 @@
# Row 240 (I4): root's host check, its pins and the boot unit
Brief: `docs/plans/2026-10-20_installer-rows.md` section I4, blob `4a5a65020f00` (commit b00d7d94d, rev 2665,
the pin row 240 carries; the I3 carry items came in with it). Sage split it out of I3 (sage-1650). Gitea #1746, Vikunja P19 #432. Owner Darkwing, reviewer Filbert, gate owner Sage.
Design: row 195 DESIGN r6 B2, `agents/darkwing/work/queue-195/DESIGN.md` at b7239bb66, :262-303 (the script,
the three callers) and :839-860 (files, test cases, mutants). Where the brief and r6 differ, the brief wins.
## Files
Drafted on `darkwing/row240-r1` from d99166ade (row 233's candidate) while row 233's gate ran, then applied
as one patch onto row 233's landing, 57f59e0c3, on `darkwing/row240-r2` (sage-1753). It applied cleanly; between
the two bases the only change on this row's paths is 233's N1 test fix in `stack-upgrade.test.mjs`. Row 234
then landed as c771a36f4 on 57f59e0c3. Sage preferred that the gate run with it in the tree (sage-1773), so both
commits were picked onto c771a36f4 as `darkwing/row240-r3`. That was a clean pick: the 42 paths changed from
57f59e0c3 to c771a36f4 share none with this row's 9.
- `scripts/case1-pins.sh` (new, 0644): `PIN_PROFILES_VERSION`, `PIN_PROFILE_SHA256`, `PIN_BUBBLEWRAP_VERSION`,
r6 section 0's values.
- `scripts/case1-hostcheck.sh` (new, 0755): the seven items and `--check`, `--enforce`, `--upstream`.
- `packages/cli/systemd/mosaic-hostcheck.service` (new): the boot oneshot.
- `packages/cli/tests/case1-hostcheck.test.mjs` (new, 16 tests). The cli suite's `tests/*.test.mjs` glob picks
it up.
- `docs/guides/host-run-sheet.md` section 4: the install lines that replace "Install them from row 240's run
sheet lines once it lands", plus rollback, drift recovery and `--upstream` before a re-pin window. Sections
3, 6, 8 and 10 for the I3 carry items below.
- `scripts/stack-upgrade.sh` and `packages/cli/tests/stack-upgrade.test.mjs`: I3 N1 below.
- `docs/TOOLS.md`: a new subsection "Root's host check (`scripts/case1-hostcheck.sh`, row 240)" after the
upgrade driver's.
Brief line 257 said I3's `stack-upgrade.sh` doesn't change. Sage's ruling sage-1727 widened the row to it for
N1 alone. Its default `STACK_UPGRADE_HOSTCHECK=/usr/local/lib/mosaic-stack/hostcheck.sh` is where the run
sheet installs this script.
## Carried from I3
Filbert's row 233 approve (Gitea 29922) left five notes. Sage ruled N1, N4 and N5 into this row and N2 and N3
to DEFERRED.md (sage-1727).
| Note | Change |
|---|---|
| N1: `stack-upgrade.sh` exits 1 `args-failed` when the args move after A5 fails, but exit 1 means the old copy runs | The move's failure exits 5 `args-failed`. The header, TOOLS.md's step 5 and exit line, and run sheet section 6's exit list name exit 5 and what root does: move the new file the result line names into place. Test `row 240: I3 N1: a failed args move after A5 exits 5 ...` makes `/etc/mosaic-stack` read-only at confirm and checks exit 5, the step names, `args rc=1`, the result line, both units on the new copy, the old args file's bytes and the new file beside it. The step log regex takes `exit=5`. Mutant M8. |
| N4: section 3's `npm install` has no npm on root's PATH, and the `--claude` path is unnamed while `node_modules/.bin/claude` is a symlink `case2-unit.sh:140` refuses | The install line runs with section 2's pinned node first on PATH (the postinstall runs `node` too). The `--claude` path is `node_modules/@anthropic-ai/claude-code/bin/claude.exe`, and a `--version` line catches the postinstall's 500-byte stub. |
| N5: sections 8 and 10 lack precheck lines | Each gets "Precheck line: none" and its missing line. |
Exit 5 is new rather than 2 because 2 means both units are stopped, and here both run. Exit 4 stays usage.
The `claude.exe` path comes from the 2.1.304 package itself: `npm view` gives `bin.claude` as `bin/claude.exe`,
and its `install.cjs` hardlinks the platform binary over that placeholder, or copies it across devices, then
sets 0755. I downloaded the tarball into `/mnt/storage/scratch/tmp/r240/cc304` and read it; nothing ran. The
run sheet says a newer pin needs the same check.
## The brief, item by item
| Brief line | Where |
|---|---|
| 247 `case1-pins.sh` holds the three pins | `scripts/case1-pins.sh`; the pins test checks the values against r6's |
| 248-249 seven items, `--check` exits 0 or 1 | `items()` and the final dispatch; the check tests |
| 250-251 `--enforce`: `apparmor_parser -R`, `root:root 0700`, `DRIFT`, a log line, exit 0 | `enforce()` and `stop_bwrap()`; the four enforce tests |
| 252 `--upstream` compares the current deb to the pin | `upstream()`; the upstream test |
| 253 the boot oneshot | `mosaic-hostcheck.service`; the boot unit test |
| 254-255 fakes for every host tool, r6's seven case groups, seven mutants | the test file; Tests and Mutants below |
| 257 the I3 run sheet item points here | run sheet section 4 |
## Tests
Fakes on PATH, each logging its argv to `$HC/calls.log`: `aa-status`, `dpkg`, `dpkg-statoverride`,
`apparmor_parser`, `systemd-run`, `stat`, `chown`, `chmod`, `apt-get`, `dpkg-deb`. `host(t)` builds a scratch
root (`/etc`, `/proc/sys`, `/usr/bin/bwrap`, `/var/log`) and a lib dir with a copy of the script and fixture
pins that hold the fixture profile's sha256. Every run passes `--root`.
r6's case groups (:843-856) against the tests:
| r6 case | Tests |
|---|---|
| `--check` exits 0 with all seven, 1 with each failing alone, its line naming it | `check: exits 0 ...`, `check: exits 1 with each item failing alone ...`, plus one test per item for the item's edge cases |
| item 3: a second file naming `bwrap` as a word, `/{usr/,}bin/bwrap` included; passes with the profile alone | `check: item 3 ...` |
| item 4: `unpriv_bwrap (enforce)` or a trailing space fails; the argv carries `-p NoNewPrivileges=yes` and the 2a' properties | `check: item 4 ...` checks the whole argv |
| item 7: the floor at 732 fails | `check: item 6 ...; item 7 ...` (733 passes as the control) |
| `--enforce` exits 0 on a pass and every failure; unload, close, `DRIFT`, one log line; `DRIFT` present with every item passing still closes | `enforce: a pass ...`, `enforce: each failure ...`, `enforce: with DRIFT present ...` |
| `--enforce` never calls `dpkg-statoverride` with `--update`, `--add` or `--remove` | `enforce: no unload ...; never writes the statoverride` (every call is `--list /usr/bin/bwrap`) |
| a bubblewrap version other than the pin is a line, not a failure | `check: a bubblewrap version other than the install pin ...` |
| `--upstream` against a differing deb prints the difference, exits 0 | `upstream: ...` (also: a match, a deb with no profile, a failed download) |
| the unit: `Type=oneshot`, `After=apparmor.service`, `[email protected]`, runs `--enforce` | `boot unit: ...` |
The last test also checks that the repo script reads `scripts/case1-pins.sh` beside it, that bad usage exits
2, and that a non-root run without `--root` refuses.
Singles are the two files that run a changed script: `case1-hostcheck.test.mjs` and `stack-upgrade.test.mjs`.
Sage asked (sage-1759) whether `stack-release.test.mjs` or `stack-release-external.test.mjs`, which share
`stack-release-fixture.mjs`, run `stack-upgrade.sh` or `case1-pins.sh`. They don't. `grep -cF` for
`stack-upgrade`, `case1-pins`, `case1-hostcheck` and `hostcheck` gives 0 in all three files; the control
`stack-release.sh` gives 2 in the fixture and 8 in `stack-release.test.mjs`. `scripts/stack-release.sh` names
`stack-upgrade.sh` once, in a comment (:2061). The gate's cli suite runs both files anyway.
## Mutants
r6's seven (:858-860) and one for I3 N1, scripted in `~/darkwing-scratch/r240/mutants.sh` with r233's method: a checked backup,
an exact replacement that must match once, `bash -n`, the named killer by `--test-name-pattern`, the restore
and a sha256 check. A dry run matched all eight once each. Results below.
| Mutant | Change | Killer |
|---|---|---|
| M1 | `--enforce` exits 1 after the stop | `enforce: each failure exits 0 ...` |
| M2 | `[ ! -e "$DRIFT" ] \|\| had=1` becomes `true \|\| had=1` | `enforce: with DRIFT present ...` |
| M3 | item 1's `dpkg -S` becomes `owner=""; rc=1` | `check: item 1 fails when a package owns ...` |
| M4 | item 3's `grep -rlw bwrap` becomes `grep -rlF /usr/bin/bwrap` | `check: item 3 ...` |
| M5 | item 4's `[ "$label" = "$LABEL" ]` becomes a substring match | `check: item 4 ...` |
| M6 | item 4 drops `-p NoNewPrivileges=yes` | `check: item 4 ...` (argv) |
| M7 | item 7's `-gt` becomes `-ge` | `check: item 6 ...; item 7 fails with the floor at 732 ...` |
| M8 | `stack-upgrade.sh`'s `finish 5 args-failed` becomes `finish 1 args-failed` (I3 N1) | `row 240: I3 N1: a failed args move ...` |
## Deviations
- Repo name `scripts/case1-hostcheck.sh` (r6), installed as `/usr/local/lib/mosaic-stack/hostcheck.sh`, the
name in brief line 239 and I3's `stack-upgrade.sh` default. r6's apt hook line says `case1-hostcheck.sh`;
the run sheet's hook uses the installed name.
- `stat` is a fake too. r6 lists seven fakes; a test can't make a root-owned file, so item 5's `stat` reads a
fixture.
- Item 7 with no Console unit installed reads the port as 1023 and says so ("1023, because no Console unit
is installed"). Until I2 installs the unit the floor must be above 1023. A unit with no `--port` fails.
- Item 4 runs under `timeout 60`. A hung `systemd-run` would otherwise hang the apt hook and the boot unit.
## Decisions
- Fail closed where r6 is silent:
- pins with no 64-hex sha fail item 1;
- `dpkg -S` exit 1 (no owner) passes, exit 0 fails with the owner, any other exit fails with the code;
- a failed `aa-status` fails item 2 and counts as loaded, so `--enforce` tries the unload.
- `--root DIR` is the test seam (r6 :840). Root refuses it; a non-root user needs it. Both exit 2. Root runs
with `PATH=/usr/sbin:/usr/bin:/sbin:/bin`.
- `DRIFT` is append-only (`<stamp> <failed line>`), and `--enforce` never removes it. The log line is written
only when the stop runs: `<stamp> enforce stop items=<ids or none> drift-before=<0|1> unload=<rc=N|no-file|skipped> chown=N chmod=N`.
- `--upstream` exits 1 when the download or the unpack fails, since it then compared nothing. A deb without
the profile file is a difference (exit 0), per r6 :302-303.
- The bubblewrap note reads the version from `dpkg -s bubblewrap`.
- The run sheet's recovery steps (`chown root:mosaic`, `chmod 0750`, `apparmor_parser -r`, then `--check`
before root removes `DRIFT`) are mine. r6 says only that root removes `DRIFT` in a reviewed step.
## Run environment
Worktree `/mnt/storage/scratch/darkwing-r240b`, branch `darkwing/row240-r3` on c771a36f4, with `node_modules`
linked to the canonical checkout's. Node v26.8.1, kernel 7.2.2-1-cachyos, 16 CPUs, TMPDIR
`/mnt/storage/scratch/tmp/r240`, compose project `darkwing-r240b`. Sage's slot (sage-1773, 06:16Z). Each run is
one chain behind `busy.sh`, launched from `/`: `~/darkwing-scratch/r240/run-all.sh` checks the HEAD pin and a
clean tree, runs the singles, then the mutants (8 killed, none surviving, the tree clean after), then the gate
(the cli suite and every `scripts/test-*.sh`), each stopping the chain on failure, then sweeps `/proc` cwd links
for anything left in the r240 trees.
## Results
Run r1, 06:17:10Z on 47ec9fe2f, failed at the singles. `case1-hostcheck.test.mjs` passed 16/0/0, and
`stack-upgrade.test.mjs` went 20 pass, 1 fail. The failure was the N1 test's own `t.after` hook, which ran
`chmodSync(e.etc, 0o755)` after the fixture's cleanup had already removed the scratch root, so it threw ENOENT.
Every assertion in the test body had passed, exit 5 included. The fixture's cleanup runs `chmod -R u+w` on the
root before `rmSync`, so the 0555 dir never needed the hook. b45852daa drops it and changes nothing else. The
mutants and the gate didn't run. The sweep found nothing left. Sage counted r1 as this slot's one failure
(sage-1788).
Run r2, 06:20:41Z to 06:33:28Z on b45852daa, passed. The counts come from awk over the .out files
(`~/darkwing-scratch/r233/sum.sh`):
| Step | Result |
|---|---|
| `case1-hostcheck.test.mjs` | 16/0/0 |
| `stack-upgrade.test.mjs` | 21/0/0 |
| c0 control | exit 0 |
| M1-M8 | 8/8 killed, none survived, every restore's sha256 matched, tree clean after |
| `packages/cli` suite | 639/0/0 |
| `scripts/test-*.sh` | auth 15, conductor 17, config 31, discord 78, extension-package 18, foundation 44, land-work 86, queue 156 node + 28 selftest, release 14, task 98; 585 pass, 0 fail |
| Gate total | 11 suites, 1224 pass, 0 fail |
| Sweep | nothing left in the r240 trees; the compose network was removed |
This commit adds only these BUILD.md lines. No test reads BUILD.md.
@@ -20,12 +20,12 @@ so Mos doesn't check this draft. Draft 1 names NEW and goes to Mos for the P0 ch
- OLD, live: `4429a104f6d65ccf165f6c83b5d1dce3147dfde0`, schema 3i, pointer `pm: true` (record 99a406ed3).
P1 re-reads the activation id and the Console flags from `status`. I don't copy them from the last window.
- NEW: open. It's the row 234 landing, or a later code landing if Mos rules. Candidates in landing order:
194, 234 (210, 237, 232, 241 and 242 have landed).
- NEW: `c771a36f4bd0b6c359282425c668996a90072f93`, the row 234 landing (2026-10-21 06:16Z), unless Mos rules a
later code landing. 194 is the open candidate. 210, 237, 232, 241, 242, 233 and 234 have landed.
- REPO, `P`, `WIN=agents/sage/work/window-repin-<NEW12>/`, `SNAP`: as in `repin-4429a104f.md`, with the
sha checks at P0 and before S1, S2 and any D3.
## What NEW carries over OLD (so far, refactor 04ce8dfd5)
## What NEW carries over OLD (refactor c771a36f4)
`git log --first-parent 4429a104f..HEAD`, path-limited to packages, scripts, roles, contracts, adapters, skills
and the package files:
@@ -44,7 +44,8 @@ and the package files:
| 232 | e65a3ab4d | Installer I1: `stack-release.sh --units external` and `stack-copy.sh copy --bundle`, `wrapper --create`. Both are opt-in, and this window uses neither. The default units path must render the same units, so the P2 unit diff covers it with row 190. P0 checks that no window command line carries `--units external`. |
| 241 | 5d4a61776 | Test only (bus land.test.mjs). No live effect. |
| 242 | 5fb736b92 | Bus publish hold: after publish-timeout the bus holds the decision until its push ends, then records a late push. The live business has no publish in flight at W1, and P3 shows none open. Its follow-up fc1cb856b is comments only (git-socket.mjs). |
| 234 | open | Machine grant row a and the 3i to 3j schema step. `--plan` must say `migration: 3i -> 3j`. |
| 233 | 57f59e0c3 | Installer I3: new tool `scripts/stack-upgrade.sh` and a `stack-release.sh` change. This window doesn't run `stack-upgrade.sh`. The P2 unit diff covers the `stack-release.sh` change with rows 190 and 232. |
| 234 | c771a36f4 | Machine grant row a and the 3i to 3j schema step (new tables `hosts` and `host_grants`). `--plan` must say `migration: 3i -> 3j`. |
| PRD 1.7+1.8 | 0adf8ddfe | `docs/prd/mosaic-stack.md` and `packages/tasks/requirements.json` move to version 1.8 (mos-1154, mos-1156, DECISIONS Q192). The live pm cites 1.6 until this window. Check after: the pm's requirements read answers version 1.8. |
Service script drift OLD to HEAD is 415+/91- over the four scripts, mostly row 190. P5 holds them at NEW.
@@ -56,7 +57,8 @@ Service script drift OLD to HEAD is 415+/91- over the four scripts, mostly row 1
- W0b: the business file copy, as in the last window.
- Rollback after a migration isn't the plain rollback. OLD is 3i and the store is 3j, so going back needs
`--restore <W5 activation>`, and that loses the events written after W5. The row 181 restore guard counts
11 tables, and the untimed ones need the D3 snapshot. Mos decides any restore past W5, as before.
11 tables, and the untimed ones need the D3 snapshot. Row 234 adds `hosts` and `host_grants` to the untimed
list (T46), so at 3j the D3 snapshot covers them too. Draft 1 recounts the list from NEW. Mos decides any restore past W5, as before.
- Grant look-alikes (row 234 A22b, gizmoduck-0107). `3i-3j.sql` adds `events_grant_actor` but doesn't check
rows already in the store, so after the migration only the actor filter in `GRANT_EVIDENCE` (actor_role NULL,
actor_run 'grant') ignores an old session-written row. P3, and again just before `--migrate`: on the
@@ -74,8 +76,11 @@ history unless the window fails.
## Open before draft 1
- Q1: NEW, once 234 lands. Do 194 and 237 go in if they land first? I recommend yes for 237 (small, bus
only) and asking Mos about 194, since 194 changes the lifecycle verbs and roles/chief-of-staff.*.
- Q1, ruled by mos-1161: draft 1 doesn't wait for 194. NEW is the latest code landing when I cut draft 1. If
194 has landed with a green full gate (15 packages, ten script suites), NEW is the 194 landing and P3 also runs
the lifecycle verbs once on the backup copy and gets one pm reply under the new roles/chief-of-staff files. A
P3 failure that traces to 194 drops NEW back to c771a36f4 before anything touches live, and the window goes on.
Otherwise NEW is c771a36f4 and 194 rides a later window.
- Q2, settled by mos-1148: phase 2 is its own window on 10-23. Draft 1 takes OLD and the Console flags,
`--sso` included, from `status` after that window.
- Q3: who operates. If Darkwing is free, Darkwing operates and I check. Otherwise I operate as in the last
+8
View File
@@ -1399,3 +1399,11 @@ are never rewritten or removed; corrections are new entries.
2026-10-21T04:50:44Z | Darkwing (Opus 5.5, 7525c8e4) | row 233 I3 gate, mutants and review request (sage-1704, sage-1707, sage-1708) | gate green on d99166ade (1201/0, cli 616/0) and m1-m8 8/8 killed; N1 flock-holder orphan fixed test-only in 08db6ac3f, rerun c0 20/0 + m1 killed, sweep 0 left; in review round 1, manifest 464ce9eb at 1a1dc80ca on darkwing/row233-r1, comment 29901, revs 2644 and 2647 (lock race resolved); Filbert woken
2026-10-21T04:58:01Z | Filbert (Opus 5.5, 048f3c63) | row 233 (I3, #1735) review round 1 on manifest 464ce9eb177a (1a1dc80ca) | APPROVE, Gitea 29922, rev 2659 (left for Sage); static, no slot; 5 non-blocking notes N1-N5 (args-failed exit 1, REPO ownership, signals, run sheet npm/claude path, sections 8/10 precheck)
2026-10-21T05:47:45Z | Sage (Opus 5.5, 1ef1e4f8) | rows 233+234 landing gate, row 233 landing, 240 brief, drift sweep (sage-1742..1758) | combined gate stopped on two row 234 failures (bus schema.test.mjs:522 counts, cli stack-release T46 restore-guard lists; mos-0823); 233 landed alone 57f59e0c3 (patch-id b16ac924, verify-commit ok, cli 617/0, scripts 585/0), pushed with b00d7d94d, 5465fd52d and riders 642a9b87d, 16019b80e; #1735 closed 29931, done rev 2675, Vikunja 432 comment 436; 240 briefed rev 2676; 234 r3 c783d5e39 rebuilt on the landing in Gizmoduck's slot; 197 and 238 drifted, rebase sent (sage-1754, 1755, 1757 stash fix)
2026-10-21T06:20:30Z | Sage (Opus 5.5, 1ef1e4f8) | row 234 r3 landing gate (2890/0 packages, 585/0 scripts) and landing c771a36f4, pushed 06:16Z; #1740 closed 29938, Vikunja 436 c437; 235 briefed rev 2686, rotateBy ruling; 238/228/197 rebuilt on 234, slot order Darkwing 240 then 238, 228, 197; 10-24 draft NEW c771a36f4 (sage-1766..1787) | landed
2026-10-21T06:37:41Z | Darkwing (Opus 5.5, 7525c8e4) | row 240 I4 build, slot run and review request (sage-1759, sage-1773, sage-1788, sage-1795) | r1 on 47ec9fe2f stopped at singles (stack-upgrade 20/1: the N1 test's own after hook threw ENOENT once the fixture removed the root), b45852daa drops it; r2 green: singles 37/0, M1-M8 8/8 killed, cli 639/0, scripts 585/0; darkwing/row240-r3 pushed at ad1b23ab2 on c771a36f4, manifest 573ece95 (9 paths), in-review revs 2694 and 2695, request comment 29943 on #1746, Filbert woken | in review
2026-10-21T06:41:13Z | Filbert (Opus 5.5, 048f3c63) | row 240 (I4, #1746) round 1 review on manifest 573ece95 (ad1b23ab2) | approve, Gitea 29945, rev 2696; 233 carries N1/N4/N5 present; notes N1 boot unit Before= names one business, N2 doc-only, N3 info
2026-10-21T07:15:48Z | Darkwing (Opus 5.5, 7525c8e4) | row 195 (#1693) DESIGN r7, Case 1 install on .53 after the installer split | darkwing/row195-r7 5425ea2f3 pushed (r6 carried as 7398b7dd7 on c515273db), DESIGN.md blob 17dfbad95ba5; business id mosaic-stack named in §7, so DEFERRED 240 N1 doesn't apply (sage-1813); root steps left to ops-01, nothing run on .53 (sage-1809); Duckworth review requested 07:14Z, Gitea 29960; row 226 items (dev.args writer, adopt) sent to Gosalyn and copied to Sage per sage-1814; r7 :21 and :326 still say 226 is unclaimed, to fix next revision
2026-10-21T07:29:11Z | Sage (Opus 5.5, 1ef1e4f8) | row 240 landing gate (cli 639/0, scripts 585/0) and landing c515273db with DEFERRED da3051ccc; 238 r3/r4 and 228 r3 requests posted as jarvis; combined 228+238 gate (seat 165/0, cli 640/0, scripts 585/0, 238 mutant killed), landings 86249ac55 and d49b1f2bd, DEFERRED e7078e6a8; #1746, #1729, #1744 closed; 197 r2 request posted (29961), r5 rebuild on 238; 195 after 226, Duckworth r7 note posted (29962); slots Herb 197, Gosalyn 212 (sage-1788..1826) | landed
2026-10-21T08:29:39Z | Filbert (Opus 5.5, 048f3c63) | row 215 (C2-04) manifest review, herb/row215 6a508804f | CHANGES REQUESTED, static: B1 cli config.mjs:61 trackerFor projectFilePath skip, B2 refuse publish naming a library entry, B3 sync-to-read window in the shared state contract; N1-N3; sent to Herb and Sage 08:29Z
2026-10-21T08:37:47Z | Filbert (Opus 5.5, 048f3c63) | row 215 (C2-04) manifest r2 review, herb/row215 2522025af | APPROVE, static: B1-B3 and N1-N2 resolved per sage-1860/1862; build notes on lock file creation, mirror metadata allowlist and flock dependency; sent to Herb and Sage 08:37Z
2026-10-21T08:52:06Z | Darkwing (Opus 5.5, 7525c8e4) | row 195 (#1693) build and manifest round 1 in review (rev 2756, request comment 29985, results 29987) | darkwing/row195-b 37171f65b on 4ced5e63d (ddf7dce6c build: scripts/case1-accept.sh, case1-accept.test.mjs, TOOLS.md entry, DESIGN r7 doc fixes per sage-1817; 37171f65b BUILD.md results); slot: single 22/22 (sage-1829), 12/12 mutants killed and gate cli 662/0 plus scripts 429/0 selftests (sage-1865), green per sage-1870; Duckworth reviews (sage-1872); Gosalyn 0087 Q4/Q5 match W10/W12; flock goes to DEFERRED with 215 (sage-1871); nothing touched .53
+38 -4
View File
@@ -599,7 +599,8 @@ error. Before anything stops, a run whose Console flags hold `--sso` refuses
`sso-target` for a target Console without it, `sso-config` when the target's own
`checkConsole` refuses the config's `console` key (or there is none, in which
case, from another copy, it names the first step: activate the target without
`--sso`, then write `console.sso`), and `sso-slot` for each provider's slot file
`--sso`, then write `console.sso`; on a named instance both commands carry
`--instance NAME`), and `sso-slot` for each provider's slot file
that is missing, a symlink, not a regular file, not owned by this uid, has group
or other bits, resolves inside the repository or the copies, or is empty. It
uses `lstat`, `stat` and `realpath` on the slot and never reads it. `--plan`
@@ -837,8 +838,9 @@ user's clone `<home>/src/mosaic-stack`:
`--copy` (which also starts the bus, then the Console), A5 `confirm`.
The driver writes the new args file beside the old one before A2
(`args-failed`, exit 3) and moves it into place after A5. A failed move
exits 1 `args-failed`: the new copy is live and confirmed, and root puts
the file in place by hand.
exits 5 `args-failed`: the new copy is live and confirmed, the args file
still names the old one, and root moves the new file (the result line
names it) into place by hand.
A failure in A2 or a stage that exits 3 starts the old units again and gates
them (A2f). A later failure stops both units, runs `revert`, renders the old
@@ -854,11 +856,43 @@ against a fixed pattern (`BACKUP_STORE none` otherwise). The step log
`<time> result exit=N <code> <text>` line.
Exit 0 ok, 1 failed with the old stack running and gated, 2 rollback-failed
with both units stopped, 3 refused with nothing stopped, 4 usage. A refusal
with both units stopped, 3 refused with nothing stopped, 4 usage, 5
args-failed with the new stack live and confirmed. A refusal
after step 2 leaves the user's clone fast-forwarded and the new copy made. After exit
2 the activation stays open, so the next run's plan refuses until root
finishes it.
#### Root's host check (`scripts/case1-hostcheck.sh`, row 240)
```
/usr/local/lib/mosaic-stack/hostcheck.sh --check | --enforce | --upstream
```
Installed from `scripts/case1-hostcheck.sh` as `hostcheck.sh`, with
`scripts/case1-pins.sh` beside it (`docs/guides/host-run-sheet.md` section 4).
The pins are the profile's sha256, the `apparmor-profiles` version it came
from and the bubblewrap install pin; changing one is a reviewed commit.
Seven items, one `ok N` or `FAIL N` line each: the profile file matches the
pin and no package owns it; `aa-status` has `bwrap` and `unpriv_bwrap` in
enforce mode and no `mosaic-bwrap`; no other file under `/etc/apparmor.d`
names `bwrap`; a bwrap child under the units' no-new-privs reads
`bwrap//&unpriv_bwrap (enforce)`; `/usr/bin/bwrap` is `root mosaic 0750` by
the statoverride and by `stat`; the user-namespace restriction and
`fs.protected_hardlinks` are 1; `net.ipv4.ip_unprivileged_port_start` is
above the Console unit's `--port`, or above 1023 with no unit. A `note` line
says whether bubblewrap is the install pin and never fails.
`--check` exits 0 or 1; `stack-upgrade.sh` refuses `profile-drift` on 1.
`--enforce` exits 0. On a failure, or while `/etc/mosaic-stack/DRIFT` exists,
it unloads the profile if loaded, sets `/usr/bin/bwrap` to `root:root` 0700,
appends the failed items to `DRIFT` and one line to
`/var/log/mosaic-stack/hostcheck.log`. It never writes the statoverride. The
apt hook runs it after every package run, and `mosaic-hostcheck.service`
(`packages/cli/systemd/`) at boot before the bus. `--upstream` compares the
`apparmor-profiles` candidate's profile to the pin, prints any difference
and exits 0, or 1 when the download fails. Exit 2 is usage or a refusal:
root takes no `--root` (the test seam), and other users run only with it.
## Console service (`scripts/webui-service.sh`)
```bash
+51 -10
View File
@@ -66,8 +66,10 @@ The pin is 2.1.304 unless the window names a newer one.
```sh
install -d -o root -g root -m 0755 /opt/mosaic-stack/claude-<pin> /etc/claude-code
npm install --prefix /opt/mosaic-stack/claude-<pin> @anthropic-ai/claude-code@<pin>
PATH=/opt/mosaic-stack/node-<v>/bin:/usr/sbin:/usr/bin:/sbin:/bin \
npm install --prefix /opt/mosaic-stack/claude-<pin> @anthropic-ai/claude-code@<pin>
chown -R root:root /opt/mosaic-stack/claude-<pin>
/opt/mosaic-stack/claude-<pin>/node_modules/@anthropic-ai/claude-code/bin/claude.exe --version
printf '%s\n' '{"env":{"DISABLE_AUTOUPDATER":"1"}}' > /etc/claude-code/managed-settings.json
chown root:root /etc/claude-code/managed-settings.json
chmod 0644 /etc/claude-code/managed-settings.json
@@ -75,8 +77,16 @@ chmod 0644 /etc/claude-code/managed-settings.json
Rollback: `rm -r /opt/mosaic-stack/claude-<pin> /etc/claude-code/managed-settings.json`.
The args file's `--claude` names the binary under `/opt/mosaic-stack/claude-<pin>`. `case2-unit.sh`
refuses one that isn't a root-owned regular file outside the home.
Root's PATH has no npm, so the install line puts section 2's pinned node first; the package's postinstall
also runs `node`. The `--version` line must print `<pin> (Claude Code)`. Anything else means the postinstall
left its 500-byte stub in `bin/claude.exe` (no native package for the platform, or scripts were off): stop and
roll back.
The args file's `--claude` is
`/opt/mosaic-stack/claude-<pin>/node_modules/@anthropic-ai/claude-code/bin/claude.exe`, the regular file the
postinstall hardlinks or copies the native binary to. Not `node_modules/.bin/claude`: that is npm's symlink
to it, and `case2-unit.sh` refuses a symlink, as it does anything that isn't a root-owned regular file outside
the home. Checked against the 2.1.304 package's `install.cjs`; a newer pin needs the same check.
Precheck lines:
- `ok claude replaces its prompt with --system-prompt-file (<n> input tokens)`. It runs the claude the bus
@@ -120,17 +130,41 @@ lists no member, and `sysctl fs.protected_hardlinks` prints 1. `sysctl
kernel.apparmor_restrict_unprivileged_userns` prints 1 too (Ubuntu 24.04's default); leave it, so nothing
else on the host gains user namespaces.
The drift stop is row 240's (I4, #1746): `hostcheck.sh` and its pins in `/usr/local/lib/mosaic-stack/`, the
apt hook `/etc/apt/apt.conf.d/80mosaic-hostcheck`
(`DPkg::Post-Invoke { "/usr/local/lib/mosaic-stack/hostcheck.sh --enforce || true"; };`) and the boot unit
`mosaic-hostcheck.service`. Install them from row 240's run sheet lines once it lands. Until then
`stack-upgrade.sh` refuses `profile-drift` on every run, because it runs `hostcheck.sh --check` and a
missing file refuses.
The drift stop (row 240, I4, #1746) goes in last, after the profile and the statoverride. As root, from
the root-owned checkout of the reviewed commit:
```sh
install -d -o root -g root -m 0755 /usr/local/lib/mosaic-stack
install -o root -g root -m 0755 scripts/case1-hostcheck.sh /usr/local/lib/mosaic-stack/hostcheck.sh
install -o root -g root -m 0644 scripts/case1-pins.sh /usr/local/lib/mosaic-stack/case1-pins.sh
printf '%s\n' 'DPkg::Post-Invoke { "/usr/local/lib/mosaic-stack/hostcheck.sh --enforce || true"; };' \
> /etc/apt/apt.conf.d/80mosaic-hostcheck
install -o root -g root -m 0644 packages/cli/systemd/mosaic-hostcheck.service /etc/systemd/system/
systemctl daemon-reload && systemctl enable mosaic-hostcheck.service
/usr/local/lib/mosaic-stack/hostcheck.sh --check
```
`--check` prints one `ok` line for each of its seven items and exits 0. Item 7 reads 1023 as the port
until I2 installs the Console unit (section 10), then the unit's `--port`. `stack-upgrade.sh` runs `--check`
before its plan and refuses `profile-drift` on any exit but 0, a missing file included. Rollback:
`systemctl disable mosaic-hostcheck.service`, then `rm` the unit, the hook file and
`/usr/local/lib/mosaic-stack`.
On drift, `--enforce` (the apt hook after every package run, and the unit at boot) unloads the profile,
closes `/usr/bin/bwrap` to `root:root` 0700, appends the failed items to `/etc/mosaic-stack/DRIFT` and logs
a line in `/var/log/mosaic-stack/hostcheck.log`. Every role session then refuses to start. ops-01 reads the
log in each STATE report and sends any `DRIFT` to Mos. Only root removes `DRIFT`, in a reviewed step with
a new pin or a Mos go. After that, `chown root:mosaic /usr/bin/bwrap`, `chmod 0750 /usr/bin/bwrap` and
`apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict` put bwrap back, and `--check` must exit 0.
Before each re-pin window, ops-01 runs `/usr/local/lib/mosaic-stack/hostcheck.sh --upstream`. It
downloads the `apparmor-profiles` candidate and compares its `bwrap-userns-restrict` to the pin. A
`DIFFERS` line goes to Mos as a new-pin request; it refuses nothing.
Precheck lines:
- `ok bwrap --unshare-all runs`
- The AppArmor profile, the statoverride, the no-new-privs result and the P9 checks: precheck line missing.
A line that runs `/usr/local/lib/mosaic-stack/hostcheck.sh --check` would cover them once row 240 lands.
`/usr/local/lib/mosaic-stack/hostcheck.sh --check` covers them (row 240).
## 5. The port floor
@@ -187,6 +221,9 @@ When a run doesn't exit 0, send Mos the exit and the last lines of `/var/log/mos
it is finished with `confirm` or `revert`. One way here: a unit that doesn't stop within `--timeout`
keeps its old start time, A2f's gate fails it `stale-start`, and the driver stops both (duckworth-0055
N1). That is on purpose; a unit that won't stop is a reason to stop.
- Exit 5: `args-failed`. The new copy is live and passed confirm, but `/etc/mosaic-stack/dev.args` still names
the old copy. The next run refuses `args-differ`, and a render from that file would put the old copy back.
Move the file the result line names into place with `mv -f`. The two differ only in the `--copy` value.
Precheck lines: none. Root's checkout at `<sha>` and the stack's user's clone: precheck line missing.
After install, `stack-copy.sh check --units external` reads the copy the units run.
@@ -209,6 +246,8 @@ their sudoers line and the sshd drop-in `/etc/ssh/sshd_config.d/<nn>-mosaic-keys
it (`hosts.enroll`, the `host-enroll` decision) are rows 234 and 235, not yet on refactor. This item has no
commands until they land.
Precheck line: none. The enrolled host's key and its `mosaic-keys` sudoers line: precheck line missing.
## 9. Credential slots
Jason places these, all in one sitting per host. No value appears here or in any message. Check a placed
@@ -238,3 +277,5 @@ leaves.
None by hand. I2's `case2-unit.sh` enables both units, starts the bus, then the Console (Q189, mos-1122).
`stack-upgrade.sh` stops and starts both units itself.
Precheck line: none. Both units active after the render: precheck line missing.
+46
View File
@@ -238,6 +238,52 @@ at every gate. Started 2026-09-12 during the control board MVP.
lands, as a follow-up row or in the next row that edits role.mjs.
Webby N1 on row 213 r3 (REVIEW-r3.md, 3ab3c010b), mos-1160.
(2026-10-21)
- **The boot host check orders only one business's bus.**
`mosaic-hostcheck.service` (row 240) has
`[email protected]`. `case2-unit.sh` enables the
bus as `mosaic-bus@<business>.service`, so on a host whose business id
isn't `mosaic-stack` nothing orders the two. The bus can start before
the boot stop runs, and after a boot-time drift role sessions can use
bwrap until it does. The fix: `After=mosaic-hostcheck.service` in
`packages/cli/systemd/mosaic-bus.service.in`, which orders every
instance and does nothing where the unit isn't installed. It goes in
before the first install whose business id isn't `mosaic-stack`.
Filbert N1 on row 240 (29945, #1746). (2026-10-21)
- **The run sheet writes the apt hook before the first `--check`.**
Section 4 installs `80mosaic-hostcheck`, then runs
`hostcheck.sh --check`. If that check fails, the next package run
closes bwrap before anyone has read the failure. It fails closed, but
it surprises an installer. The fix: run `--check` first, or say what a
failure at that step means. Doc-only, for the next row that edits
`docs/guides/host-run-sheet.md`. Filbert N2 on row 240 (29945, #1746).
Filbert's N3, that a closed bwrap appends to `DRIFT` on every later
apt run and boot, is by design; ops-01's STATE read should expect the
repeats. (2026-10-21)
- **The sso-config first-step hint has no business.** When the target
copy has no `console.sso`, `stack-release.sh` (`:938`) refuses with
"activate $TO without --sso first, then ... activate $TO --sso". The
usage line is `activate ID BUSINESS` (`:47`), so pasting either command
as printed gives a usage error, not the first step. It fails safe,
since the usage error changes nothing. The fix: add `$BUSINESS`
(set at `:2138`) to both commands, and the business (`acme`)
to the three expected strings in `stack-release.test.mjs`
(`:1538`, `:1689`, `:1691`). For a later row that edits
`stack-release.sh`. Webby N1 on row 238 r1 (REVIEW-r1.md, 433a95292,
#1744). (2026-10-21)
- **Gate runs mount the host's pi auth file.** `compose.yaml:43` mounts
`${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}` read-only into
the agent container. No suite sets `PI_AUTH_FILE`, so the
`test-task.sh` cases that call `scripts/run-task.sh`, and
`test-release.sh` through `release.sh` `health_check` (`:41-54`), run
Compose with the host file. Row 197's lane `cleanEnv`
(`scripts/testing/gate-lane.mjs:109`) keeps the default too: it neither
sets nor passes `PI_AUTH_FILE`. The mount is read-only, and the legacy
gate already worked this way before row 197. `test-auth.sh` uses its
own fixtures but calls `auth.sh`, not Compose. The fix: a fixture
`PI_AUTH_FILE` for the Compose-reaching cases, set by the suites or by
the lane's `cleanEnv`, and a test that no gate step mounts the host
default. It needs its own row. Webby N4 on the row 197 proof driver
(bba870ee9), traced by Herb (herb-0051). (2026-10-21)
## Queue
+10 -7
View File
@@ -432,9 +432,11 @@ A Claude Code session logs in with `CLAUDE_CODE_OAUTH_TOKEN`,
on. The bus unit loads it ("Unit" below). Nothing from the host's
`~/.claude` reaches a session, whose `HOME` is its own. With none of them
set, a Claude Code launch refuses `no-session-credential` before anything
starts. A Pi session gets the same variables through the allowlist. A
shell the harness opens, Claude Code's `Bash` or Pi's `bash`, can read
them.
starts. A Pi session gets the same variables through the allowlist. A Pi
session on provider `zai` logs in with `ZAI_API_KEY`, which the allowlist
passes. Like every allowlist variable, it reaches every session the host
starts, Claude Code ones included. A shell the harness opens, Claude Code's
`Bash` or Pi's `bash`, can read all of these variables.
A Pi session logs in only through the variables `ENV_ALLOW` passes. Its
`HOME` is the run's own, so Pi never reads the operator's `~/.pi/agent`
@@ -706,13 +708,14 @@ instead and sessions run `claude` from the unit's `PATH`, which in the
sandbox finds only a `claude` under `/usr`. A running host
keeps the environment it started with until it restarts.
The session credential comes from
The session credentials come from
`EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`, the
one place the path is set. The file holds one line, for example
`CLAUDE_CODE_OAUTH_TOKEN=<token>`, mode 0600. The operator writes it;
one place the path is set. The file holds one line per variable, for
example `CLAUDE_CODE_OAUTH_TOKEN=<token>`, mode 0600. It holds every session
provider key, not only Claude's. The operator writes it;
nothing in the repository creates it, and only the Case 2 precheck reads
it, printing variable names, never values. No session sees the file
itself, only the variable. The leading `-` lets the unit start
itself, only the variables. The leading `-` lets the unit start
without it, so Pi sessions still launch and a Claude Code launch refuses
`no-session-credential`. To change the credential, edit or swap the file
(or this line and this paragraph), then restart the unit; a running host
@@ -0,0 +1,16 @@
# Mosaic Case 1 host check at boot (row 240, #1746; r6 B2). Root installs it as
# /etc/systemd/system/mosaic-hostcheck.service, root:root 0644, and enables it
# (docs/guides/host-run-sheet.md). It runs the drift stop before the bus starts, which covers a kernel
# or parser change that takes effect at boot. --enforce always exits 0; on drift it closes bwrap to
# root, and every role session then refuses to start.
[Unit]
Description=Mosaic Case 1 host check: the bwrap profile, the statoverride and the sysctls
After=apparmor.service
Before=[email protected]
[Service]
Type=oneshot
ExecStart=/usr/local/lib/mosaic-stack/hostcheck.sh --enforce
[Install]
WantedBy=multi-user.target
+335
View File
@@ -0,0 +1,335 @@
// Row 240 (I4, #1746): scripts/case1-hostcheck.sh, the Case 1 host's drift stop for bwrap, and its
// pins and boot unit. Brief: docs/plans/2026-10-20_installer-rows.md "I4 hostcheck"; design r6 B2
// (agents/darkwing/work/queue-195/DESIGN.md at b7239bb66). Every case copies the script into a scratch
// lib directory with a fixture case1-pins.sh beside it and runs it with --root on a scratch host tree.
// Fake aa-status, dpkg, dpkg-statoverride, apparmor_parser, systemd-run, stat, chown, chmod, apt-get
// and dpkg-deb on PATH record their argv. Nothing here reads or writes the real /etc, /proc or /usr.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { tmp } from "./helpers.mjs";
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
const SCRIPT = join(REPO, "scripts", "case1-hostcheck.sh");
const PINS = join(REPO, "scripts", "case1-pins.sh");
const UNIT = join(REPO, "packages", "cli", "systemd", "mosaic-hostcheck.service");
const PROFILE = "/etc/apparmor.d/bwrap-userns-restrict";
const LABEL = "bwrap//&unpriv_bwrap (enforce)";
// A made-up profile; the fixture pins carry its sha256.
const PROFILE_TEXT = "abi <abi/4.0>,\ninclude <tunables/global>\nprofile bwrap /usr/bin/bwrap flags=(attach_disconnected,mediate_deleted) {\n allow capability,\n change_profile /** -> &unpriv_bwrap,\n}\nprofile unpriv_bwrap flags=(attach_disconnected,mediate_deleted) {\n audit deny capability,\n}\n";
const sha256 = (s) => createHash("sha256").update(s).digest("hex");
const AA_OK = { version: "2", profiles: { "/usr/bin/man": "enforce", bwrap: "enforce", unpriv_bwrap: "enforce", "lsb_release": "enforce" }, processes: {} };
const SYSTEMD_RUN = ["--wait", "--pipe", "--uid=mosaic", "--gid=mosaic", "-p", "NoNewPrivileges=yes", "-p", "ProtectSystem=strict",
"-p", "ProtectHome=yes", "-p", "PrivateTmp=yes", "/usr/bin/bwrap", "--unshare-user", "--unshare-pid", "--ro-bind", "/", "/",
"cat", "/proc/self/attr/current"];
function put(file, text, mode) {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, text);
if (mode) chmodSync(file, mode);
}
// Each fake appends `name argv...` to calls.log and answers from a file in the state directory.
const FAKES = {
"aa-status": `[ -f "$HC/aa-rc" ] && exit "$(cat "$HC/aa-rc")"; cat "$HC/aa-status.json"`,
dpkg: `case "$1" in
-S) [ -f "$HC/owner" ] && cat "$HC/owner"; exit "$(cat "$HC/dpkg-S-rc")" ;;
-s) [ "$2" = bubblewrap ] && printf 'Package: bubblewrap\\nStatus: install ok installed\\nVersion: %s\\n' "$(cat "$HC/bwrap-version")" ;;
*) exit 2 ;;
esac`,
"dpkg-statoverride": `[ "$1" = --list ] && cat "$HC/statoverride"`,
apparmor_parser: `exit 0`,
"systemd-run": `cat "$HC/label"`,
stat: `cat "$HC/stat"`,
chown: `exit 0`,
chmod: `exit 0`,
"apt-get": `[ -f "$HC/apt-rc" ] && exit "$(cat "$HC/apt-rc")"; [ "$1 $2" = "download apparmor-profiles" ] && cp "$HC/deb" "./apparmor-profiles_9.9_all.deb"`,
"dpkg-deb": `case "$1" in
-f) [ "$3" = Version ] && echo 9.9-upstream ;;
-x) mkdir -p "$3/usr/share/apparmor/extra-profiles"; [ ! -f "$HC/upstream-profile" ] || cp "$HC/upstream-profile" "$3/usr/share/apparmor/extra-profiles/bwrap-userns-restrict" ;;
esac`,
};
// A scratch host where all seven items hold.
function host(t) {
const dir = tmp(t, "case1-hostcheck-");
const root = join(dir, "root"), hc = join(dir, "hc"), bin = join(dir, "bin"), lib = join(dir, "lib");
put(join(root, PROFILE), PROFILE_TEXT);
put(join(root, "etc", "apparmor.d", "abstractions", "base"), " /etc/ld.so.cache r,\n");
put(join(root, "etc", "apparmor.d", "tunables", "global"), "@{HOME}=/home/*/\n");
put(join(root, "proc", "sys", "kernel", "apparmor_restrict_unprivileged_userns"), "1\n");
put(join(root, "proc", "sys", "fs", "protected_hardlinks"), "1\n");
put(join(root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "1024\n");
put(join(root, "etc", "systemd", "system", "mosaic-webui.service"),
"[Service]\nExecStart=/opt/mosaic-stack/node/bin/node /srv/mosaic/copies/x/packages/webui/src/cli.mjs serve --port 732 --business mosaic-stack --tester-port 7333\n");
put(join(root, "usr", "bin", "bwrap"), "", 0o755);
put(join(hc, "aa-status.json"), JSON.stringify(AA_OK));
put(join(hc, "dpkg-S-rc"), "1");
put(join(hc, "bwrap-version"), "0.9.0-1ubuntu0.3");
put(join(hc, "statoverride"), "root mosaic 0750 /usr/bin/bwrap\n");
put(join(hc, "label"), `${LABEL}\n`);
put(join(hc, "stat"), "root mosaic 750\n");
put(join(hc, "deb"), "fake deb\n");
put(join(hc, "upstream-profile"), PROFILE_TEXT);
for (const [name, body] of Object.entries(FAKES)) {
put(join(bin, name), `#!/usr/bin/env bash\nprintf '%s' ${JSON.stringify(name)} >> "$HC/calls.log"; printf ' %s' "$@" >> "$HC/calls.log"; echo >> "$HC/calls.log"\n${body}\n`, 0o755);
}
mkdirSync(lib);
copyFileSync(SCRIPT, join(lib, "hostcheck.sh"));
chmodSync(join(lib, "hostcheck.sh"), 0o755);
put(join(lib, "case1-pins.sh"), `PIN_PROFILES_VERSION=4.0.1-fixture\nPIN_PROFILE_SHA256=${sha256(PROFILE_TEXT)}\nPIN_BUBBLEWRAP_VERSION=0.9.0-1ubuntu0.3\n`);
return { dir, root, hc, bin, lib, script: join(lib, "hostcheck.sh") };
}
function run(h, ...args) {
const r = spawnSync(h.script, [...args, "--root", h.root], {
encoding: "utf8",
env: { ...process.env, PATH: `${h.bin}:${process.env.PATH}`, HC: h.hc, TMPDIR: h.dir },
});
return { status: r.status, out: r.stdout, err: r.stderr, lines: r.stdout.split("\n").filter(Boolean) };
}
const calls = (h, name) => (existsSync(join(h.hc, "calls.log")) ? readFileSync(join(h.hc, "calls.log"), "utf8") : "")
.split("\n").filter((l) => l === name || l.startsWith(`${name} `)).map((l) => l.slice(name.length + 1));
const fails = (r) => r.lines.filter((l) => l.startsWith("FAIL "));
const drift = (h) => join(h.root, "etc", "mosaic-stack", "DRIFT");
const hlog = (h) => join(h.root, "var", "log", "mosaic-stack", "hostcheck.log");
const aa = (h, profiles) => put(join(h.hc, "aa-status.json"), JSON.stringify({ version: "2", profiles, processes: {} }));
// One fault per item; each makes that item, and only that one, fail.
const FAULTS = {
1: (h) => put(join(h.root, PROFILE), `${PROFILE_TEXT}# drift\n`),
2: (h) => aa(h, { ...AA_OK.profiles, bwrap: "complain" }),
3: (h) => put(join(h.root, "etc", "apparmor.d", "usr.bin.other"), "profile other /usr/bin/other {\n /usr/bin/bwrap ix,\n}\n"),
4: (h) => put(join(h.hc, "label"), "unconfined\n"),
5: (h) => put(join(h.hc, "statoverride"), "root root 0755 /usr/bin/bwrap\n"),
6: (h) => put(join(h.root, "proc", "sys", "kernel", "apparmor_restrict_unprivileged_userns"), "0\n"),
7: (h) => put(join(h.root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "700\n"),
};
test("row 240 check: exits 0 with all seven items and a note that bubblewrap is the install pin", (t) => {
const h = host(t);
const r = run(h, "--check");
assert.equal(r.status, 0, r.out + r.err);
assert.deepEqual(r.lines.map((l) => l.split(" ").slice(0, 2).join(" ")), ["ok 1", "ok 2", "ok 3", "ok 4", "ok 5", "ok 6", "ok 7", "note bubblewrap"]);
assert.match(r.lines[6], /is 1024, above the Console's --port 732$/);
assert.equal(r.lines[7], "note bubblewrap 0.9.0-1ubuntu0.3 is the install pin");
});
test("row 240 check: exits 1 with each item failing alone, and its line names it", (t) => {
for (const [n, fault] of Object.entries(FAULTS)) {
const h = host(t);
fault(h);
const r = run(h, "--check");
assert.equal(r.status, 1, `item ${n}: ${r.out}`);
assert.deepEqual(fails(r).map((l) => l.split(" ")[1]), [n], `item ${n}: ${r.out}`);
}
});
test("row 240 check: item 1 fails when a package owns the profile, when dpkg -S errs, or with no pin", (t) => {
let h = host(t);
put(join(h.hc, "dpkg-S-rc"), "0");
put(join(h.hc, "owner"), `apparmor-profiles: ${PROFILE}\n`);
let r = run(h, "--check");
assert.equal(r.status, 1);
assert.deepEqual(fails(r), [`FAIL 1 ${PROFILE} is owned by a package: apparmor-profiles: ${PROFILE}`]);
assert.deepEqual(calls(h, "dpkg").filter((a) => a.startsWith("-S")), [`-S ${PROFILE}`]);
h = host(t);
put(join(h.hc, "dpkg-S-rc"), "2");
r = run(h, "--check");
assert.deepEqual(fails(r), [`FAIL 1 dpkg -S ${PROFILE} exited 2`]);
h = host(t);
rmSync(join(h.lib, "case1-pins.sh"));
r = run(h, "--check");
assert.equal(r.status, 1);
assert.deepEqual(fails(r), [`FAIL 1 no profile pin in ${h.lib}/case1-pins.sh`]);
});
test("row 240 check: item 2 fails on a loaded mosaic-bwrap, a missing unpriv_bwrap or a failed aa-status", (t) => {
for (const [fault, text] of [
[(h) => aa(h, { ...AA_OK.profiles, "mosaic-bwrap": "enforce" }), "a mosaic-bwrap profile"],
[(h) => aa(h, { bwrap: "enforce" }), "no unpriv_bwrap profile in enforce mode"],
[(h) => put(join(h.hc, "aa-rc"), "1"), "aa-status --json failed"],
]) {
const h = host(t);
fault(h);
const r = run(h, "--check");
assert.equal(r.status, 1);
assert.equal(fails(r).length, 1, r.out);
assert.ok(fails(r)[0].startsWith("FAIL 2 ") && fails(r)[0].includes(text), r.out);
}
});
test("row 240 check: item 3 fails on a second file naming bwrap as a word, /{usr/,}bin/bwrap included", (t) => {
for (const text of ["profile x /{usr/,}bin/bwrap {\n}\n", "# runs bwrap\n", "/usr/bin/bwrap ix,\n"]) {
const h = host(t);
put(join(h.root, "etc", "apparmor.d", "local", "extra"), text);
const r = run(h, "--check");
assert.equal(r.status, 1, text);
assert.deepEqual(fails(r).map((l) => l.split(" ")[1]), ["3"], text);
assert.ok(fails(r)[0].includes(join(h.root, "etc", "apparmor.d", "local", "extra")), r.out);
}
const h = host(t);
put(join(h.root, "etc", "apparmor.d", "local", "extra"), "# bwrapper and unpriv-bwrapx are other words\n");
assert.equal(run(h, "--check").status, 0);
});
test("row 240 check: item 4 reads the label under the units' no-new-privs and matches it exactly", (t) => {
let h = host(t);
assert.equal(run(h, "--check").status, 0);
assert.deepEqual(calls(h, "systemd-run"), [SYSTEMD_RUN.join(" ")]);
for (const label of ["unpriv_bwrap (enforce)", `${LABEL} `, `x${LABEL}`]) {
h = host(t);
put(join(h.hc, "label"), `${label}\n`);
const r = run(h, "--check");
assert.equal(r.status, 1, JSON.stringify(label));
assert.deepEqual(fails(r), [`FAIL 4 a bwrap child under no-new-privs has the label '${label}', not '${LABEL}'`]);
}
});
test("row 240 check: item 5 needs the statoverride and stat to agree", (t) => {
const h = host(t);
put(join(h.hc, "stat"), "root root 700\n");
const r = run(h, "--check");
assert.deepEqual(fails(r), ["FAIL 5 /usr/bin/bwrap is 'root root 700', not 'root mosaic 750'"]);
assert.deepEqual(calls(h, "dpkg-statoverride"), ["--list /usr/bin/bwrap"]);
assert.deepEqual(calls(h, "stat"), [`-c %U %G %a ${h.root}/usr/bin/bwrap`]);
});
test("row 240 check: item 6 fails on protected_hardlinks 0; item 7 fails with the floor at 732 and reads 1023 with no unit", (t) => {
let h = host(t);
put(join(h.root, "proc", "sys", "fs", "protected_hardlinks"), "0\n");
assert.deepEqual(fails(run(h, "--check")).map((l) => l.split(" ")[1]), ["6"]);
h = host(t);
put(join(h.root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "732\n");
assert.deepEqual(fails(run(h, "--check")), ["FAIL 7 net.ipv4.ip_unprivileged_port_start is 732, not above the Console's --port 732"]);
put(join(h.root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "733\n");
assert.equal(run(h, "--check").status, 0);
rmSync(join(h.root, "etc", "systemd", "system", "mosaic-webui.service"));
assert.deepEqual(fails(run(h, "--check")), ["FAIL 7 net.ipv4.ip_unprivileged_port_start is 733, not above 1023, because no Console unit is installed"]);
put(join(h.root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "1024\n");
assert.equal(run(h, "--check").status, 0);
put(join(h.root, "etc", "systemd", "system", "mosaic-webui.service"), "[Service]\nExecStart=/x serve --business mosaic-stack --tester-port 7333\n");
assert.deepEqual(fails(run(h, "--check")), ["FAIL 7 /etc/systemd/system/mosaic-webui.service has no --port"]);
});
test("row 240 check: a bubblewrap version other than the install pin is a note, not a failure", (t) => {
const h = host(t);
put(join(h.hc, "bwrap-version"), "0.9.0-1ubuntu0.4");
const r = run(h, "--check");
assert.equal(r.status, 0);
assert.equal(r.lines.at(-1), "note bubblewrap 0.9.0-1ubuntu0.4 is not the install pin 0.9.0-1ubuntu0.3, which is not a drift item");
});
test("row 240 enforce: a pass with no DRIFT changes nothing and exits 0", (t) => {
const h = host(t);
const r = run(h, "--enforce");
assert.equal(r.status, 0);
assert.equal(r.lines.at(-1), "hostcheck: all seven hold");
assert.deepEqual([calls(h, "apparmor_parser"), calls(h, "chown"), calls(h, "chmod")], [[], [], []]);
assert.equal(existsSync(drift(h)) || existsSync(hlog(h)), false);
});
test("row 240 enforce: each failure exits 0, unloads the profile, closes bwrap, writes DRIFT and one log line", (t) => {
for (const [n, fault] of Object.entries(FAULTS)) {
const h = host(t);
fault(h);
const r = run(h, "--enforce");
assert.equal(r.status, 0, `item ${n}: ${r.out}${r.err}`);
assert.deepEqual(calls(h, "apparmor_parser"), [`-R ${h.root}${PROFILE}`], `item ${n}`);
assert.deepEqual(calls(h, "chown"), [`root:root ${h.root}/usr/bin/bwrap`], `item ${n}`);
assert.deepEqual(calls(h, "chmod"), [`0700 ${h.root}/usr/bin/bwrap`], `item ${n}`);
const d = readFileSync(drift(h), "utf8").split("\n").filter(Boolean);
assert.equal(d.length, 1, `item ${n}`);
assert.match(d[0], new RegExp(`^\\d{4}-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ ${n} `), `item ${n}`);
const l = readFileSync(hlog(h), "utf8").split("\n").filter(Boolean);
assert.equal(l.length, 1, `item ${n}`);
assert.match(l[0], new RegExp(`^\\d{4}-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ enforce stop items=${n} drift-before=0 unload=rc=0 chown=0 chmod=0$`), `item ${n}`);
}
});
test("row 240 enforce: with DRIFT present and every item passing, it still closes bwrap", (t) => {
const h = host(t);
put(drift(h), "2026-10-20T00:00:00Z 1 an earlier drift\n");
const r = run(h, "--enforce");
assert.equal(r.status, 0);
assert.equal(fails(r).length, 0);
assert.deepEqual(calls(h, "chown"), [`root:root ${h.root}/usr/bin/bwrap`]);
assert.deepEqual(calls(h, "chmod"), [`0700 ${h.root}/usr/bin/bwrap`]);
assert.equal(readFileSync(drift(h), "utf8"), "2026-10-20T00:00:00Z 1 an earlier drift\n");
assert.match(readFileSync(hlog(h), "utf8"), /Z enforce stop items=none drift-before=1 unload=rc=0 chown=0 chmod=0\n$/);
});
test("row 240 enforce: no unload when aa-status lists neither bwrap profile; never writes the statoverride", (t) => {
const h = host(t);
aa(h, { "/usr/bin/man": "enforce" });
const r = run(h, "--enforce");
assert.equal(r.status, 0);
assert.deepEqual(calls(h, "apparmor_parser"), []);
assert.deepEqual(calls(h, "chmod"), [`0700 ${h.root}/usr/bin/bwrap`]);
assert.match(readFileSync(hlog(h), "utf8"), /Z enforce stop items=2 drift-before=0 unload=skipped chown=0 chmod=0\n$/);
for (const [n, fault] of Object.entries(FAULTS)) {
const g = host(t);
fault(g);
run(g, "--enforce");
for (const a of calls(g, "dpkg-statoverride")) assert.doesNotMatch(a, /--(update|add|remove)/, `item ${n}`);
assert.deepEqual(calls(g, "dpkg-statoverride"), ["--list /usr/bin/bwrap"], `item ${n}`);
}
});
test("row 240 upstream: a candidate whose profile differs prints the difference and exits 0", (t) => {
let h = host(t);
put(join(h.hc, "upstream-profile"), `${PROFILE_TEXT} # upstream change\n`);
let r = run(h, "--upstream");
assert.equal(r.status, 0, r.err);
assert.match(r.out, new RegExp(`^upstream: DIFFERS: apparmor-profiles 9.9-upstream's bwrap-userns-restrict has sha256 ${sha256(`${PROFILE_TEXT} # upstream change\n`)}; the pin is ${sha256(PROFILE_TEXT)} from 4.0.1-fixture\\.`));
assert.match(r.out, /^\+ {2}# upstream change$/m);
assert.deepEqual(calls(h, "apt-get"), ["download apparmor-profiles"]);
assert.deepEqual([calls(h, "chown"), calls(h, "chmod"), existsSync(drift(h))], [[], [], false]);
h = host(t);
r = run(h, "--upstream");
assert.equal(r.status, 0);
assert.equal(r.out, "upstream: apparmor-profiles 9.9-upstream's bwrap-userns-restrict matches the pin from 4.0.1-fixture\n");
h = host(t);
rmSync(join(h.hc, "upstream-profile"));
r = run(h, "--upstream");
assert.equal(r.status, 0);
assert.match(r.out, /^upstream: DIFFERS: apparmor-profiles 9.9-upstream has no bwrap-userns-restrict;/);
h = host(t);
put(join(h.hc, "apt-rc"), "100");
r = run(h, "--upstream");
assert.equal(r.status, 1);
assert.equal(r.err, "hostcheck: upstream: apt-get download apparmor-profiles failed\n");
});
test("row 240 boot unit: a oneshot after apparmor and before the bus that runs --enforce", () => {
const text = readFileSync(UNIT, "utf8");
const lines = text.split("\n").filter((l) => l && !l.startsWith("#"));
assert.deepEqual(lines.filter((l) => /^(Type|After|Before|ExecStart|WantedBy)=/.test(l)), [
"After=apparmor.service",
"[email protected]",
"Type=oneshot",
"ExecStart=/usr/local/lib/mosaic-stack/hostcheck.sh --enforce",
"WantedBy=multi-user.target",
]);
});
test("row 240 pins and usage: the shipped pins are r6's, the script reads the pins beside it, and bad usage exits 2", (t) => {
assert.equal(readFileSync(PINS, "utf8").split("\n").filter((l) => /^PIN_/.test(l)).join("\n"),
"PIN_PROFILES_VERSION=4.0.1really4.0.1-0ubuntu0.24.04.9\nPIN_PROFILE_SHA256=11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9\nPIN_BUBBLEWRAP_VERSION=0.9.0-1ubuntu0.3");
const h = host(t);
const r = run({ ...h, script: SCRIPT }, "--check");
assert.equal(r.status, 1);
assert.deepEqual(fails(r), [`FAIL 1 ${PROFILE} sha256 is ${sha256(PROFILE_TEXT)}, not the pin 11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9`]);
for (const args of [[], ["--check", "--enforce"], ["--check", "--root", "relative"], ["--status"]]) {
const u = spawnSync(h.script, args, { encoding: "utf8", env: { ...process.env, PATH: `${h.bin}:${process.env.PATH}`, HC: h.hc } });
assert.equal(u.status, 2, args.join(" "));
}
if (process.getuid() !== 0) {
const u = spawnSync(h.script, ["--check"], { encoding: "utf8", env: { ...process.env, PATH: `${h.bin}:${process.env.PATH}`, HC: h.hc } });
assert.deepEqual([u.status, u.stderr], [2, "hostcheck: refusing: run it as root\n"]);
}
});
@@ -1683,6 +1683,14 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
assert.ok(stepText(a).includes(`Z drop --sso; ${B}'s Console does not support it, so the Console flags come from ${set}/${show}\n`));
await noLeak(a);
});
await t.test('row 238 (#1744): on a named instance the sso-config first step names --instance NAME in both commands',async(t)=>{
const e=await mk(t),a=await named(e,'alpha');await seed(a);
assert.equal(JSON.parse(readFileSync(a.cfg,'utf8')).console,undefined);
await refused(a,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C1} --instance alpha without --sso first, then write console.sso, then run activate ${C1} --instance alpha --sso`);
const out=await planOnly(a,3,'activate',C1,'acme','--sso');
assert.ok(out.includes(`refuses sso-config: ${C1}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C1} --instance alpha without --sso first, then write console.sso, then run activate ${C1} --instance alpha --sso`),out.join('\n'));
await noLeak(a);
});
});
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
+16 -1
View File
@@ -140,7 +140,7 @@ const arm=(e,key,text)=>writeFileSync(join(e.sd,'arm',key),text);
// The step log: every line `<time> <step> rc=N ...` or the result line, root:root 0640 (here the
// test user's), and nothing the stack's user printed. Returns the lines without the time.
const STEP=/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ (start|fetch|ff|copy|hostcheck|units|plan|rehearse|rehearse-store|rehearsal-skipped|stop-console|stop-bus|wait-down|stage|render|confirm|args|revert|render-old|confirm-rollback|gate|start-bus|start-console|stop-units) rc=\d+ |^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ result exit=[0-3] [a-z-]+ /;
const STEP=/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ (start|fetch|ff|copy|hostcheck|units|plan|rehearse|rehearse-store|rehearsal-skipped|stop-console|stop-bus|wait-down|stage|render|confirm|args|revert|render-old|confirm-rollback|gate|start-bus|start-console|stop-units) rc=\d+ |^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ result exit=[0-35] [a-z-]+ /;
function slog(e){
const l=lines(e.slog);
for(const x of l){assert.match(x,STEP);assert.ok(!/stack-release:|fixture rehearse|PASS |FAIL |REHEARSAL/.test(x),x)}
@@ -292,6 +292,21 @@ test('row 233: sequence 7: two upgrades and a rollback --to the previous copy wi
assert.equal(lines(join(e.sd,'root-systemctl.log')).length,sc);assert.equal(argsCopy(e),C1);
});
test('row 240: I3 N1: a failed args move after A5 exits 5 args-failed, the new copy live and confirmed and both args files kept',async(t)=>{
const e=await useed(t);
// A read-only /etc/mosaic-stack at confirm: the stage and the render are done, and the move fails.
// The fixture's cleanup runs chmod -R u+w before it removes the root, so the 0555 dir needs no hook here.
arm(e,'confirm.sh',`chmod 0555 ${e.etc}\n`);
const l=await upgrade(e,C1);assert.equal(l.code,5,l.all);
assert.deepEqual(names(e),[...PRE,...A2,'stage','render','confirm','args','result']);
assert.ok(slog(e).includes(`args rc=1 copy: ${C1}`));
assert.match(result(e),new RegExp(`^result exit=5 args-failed ${C1} is live and confirmed, but ${e.args} still names ${B}; put ${e.etc}/\\.dev\\.args\\.\\w{6} in its place$`));
assert.deepEqual(open(e),[]);assert.deepEqual(live(e),['active','active',C1,C1]);
assert.equal(readFileSync(e.args,'utf8'),e.argsText);
const left=readdirSync(e.etc).filter((n)=>n!=='dev.args');assert.equal(left.length,1);
assert.equal(readFileSync(join(e.etc,left[0]),'utf8'),e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`));
});
test('row 233: sequence 8: a migration that writes and fails: the driver reverts to the backup byte for byte, --cause A3: exit 1',async(t)=>{
const e=await useed(t);
const s0=sha(e.store);
+1 -1
View File
@@ -83,7 +83,7 @@ export function threadStore(dataRoot, business, thread) {
export const ENV_ALLOW = Object.freeze([
"HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "TZ", "TERM",
"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_OAUTH_TOKEN",
"OPENAI_API_KEY", "OPENROUTER_API_KEY",
"OPENAI_API_KEY", "OPENROUTER_API_KEY", "ZAI_API_KEY",
"MOSAIC_CLAUDE_BIN",
]);
+7
View File
@@ -58,6 +58,13 @@ test("sessionEnv passes only the allowlist, the repo's bin on PATH, and the run
assert.equal(sessionEnv({ MOSAIC_CLAUDE_BIN: "/opt/claude" }, "r0123456789ab").MOSAIC_CLAUDE_BIN, "/opt/claude");
});
test("ZAI_API_KEY passes the session allowlist, and ZAI_TOKEN does not", () => {
assert.ok(ENV_ALLOW.includes("ZAI_API_KEY"));
const env = sessionEnv({ ZAI_API_KEY: "fixture-zai", ZAI_TOKEN: "t" }, "r0123456789ab");
assert.equal(env.ZAI_API_KEY, "fixture-zai");
assert.equal(Object.hasOwn(env, "ZAI_TOKEN"), false);
});
test("sessionExit gives a runner's signal death back as the signal; a session credential is one whole set of nonempty variables", () => {
assert.deepEqual(sessionExit(0, null), { code: 0, signal: null });
assert.deepEqual(sessionExit(23, null), { code: 23, signal: null });
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# Row 240 (I4, #1746): the Case 1 host's drift stop for bwrap, r6 B2
# (agents/darkwing/work/queue-195/DESIGN.md at b7239bb66, as docs/plans/2026-10-20_installer-rows.md
# "I4 hostcheck" ships it). Deviations and decisions: agents/darkwing/work/queue-240/BUILD.md.
#
# case1-hostcheck.sh --check | --enforce | --upstream
#
# Installed as /usr/local/lib/mosaic-stack/hostcheck.sh, root:root 0755, with case1-pins.sh beside it,
# root:root 0644. Three callers: stack-upgrade.sh A1 runs --check and refuses profile-drift on any
# exit but 0; the apt hook /etc/apt/apt.conf.d/80mosaic-hostcheck runs --enforce after every dpkg run;
# mosaic-hostcheck.service runs --enforce at boot, before the bus.
#
# Seven items, one line each, `ok N ...` or `FAIL N ...`:
# 1. /etc/apparmor.d/bwrap-userns-restrict has the pinned sha256, and `dpkg -S` finds no owner.
# 2. `aa-status --json` lists bwrap and unpriv_bwrap in enforce mode, and no mosaic-bwrap.
# 3. `grep -rlw bwrap /etc/apparmor.d` lists that one file and nothing else.
# 4. A bwrap child started as mosaic under the units' no-new-privs reads the label
# `bwrap//&unpriv_bwrap (enforce)`, exactly.
# 5. The statoverride on /usr/bin/bwrap reads `root mosaic 0750`, and stat agrees.
# 6. kernel.apparmor_restrict_unprivileged_userns and fs.protected_hardlinks are 1.
# 7. net.ipv4.ip_unprivileged_port_start is above the Console's --port in the installed unit, or
# above 1023, the highest --port case2-unit.sh takes, while no unit is installed.
# A `note` line says whether bubblewrap is the install pin; it is never a failure.
#
# --check exits 0 when all seven hold and 1 otherwise. --enforce runs the same items and exits 0. On
# a failure, or while /etc/mosaic-stack/DRIFT exists, it stops bwrap: `apparmor_parser -R` on the
# profile if it is loaded, then root:root 0700 on /usr/bin/bwrap. It appends the failed items to DRIFT
# and one line to /var/log/mosaic-stack/hostcheck.log. Only root removes DRIFT, in a reviewed step.
# It never writes the statoverride, because dpkg's lock may be held under apt's (r6).
# --upstream downloads the apparmor-profiles candidate into a temp dir and compares its
# bwrap-userns-restrict to the pin. A difference is printed and exits 0, because it refuses nothing;
# it exits 1 when the download or the unpack fails.
#
# Exit 2 on usage or a refusal. Root takes no --root; without root it runs only with --root.
#
# Test seam, for packages/cli/tests/case1-hostcheck.test.mjs only:
# --root DIR prefixes /etc, /proc/sys, /var/log and /usr/bin/bwrap
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
usage() { echo "usage: case1-hostcheck.sh --check | --enforce | --upstream" >&2; exit 2; }
MODE="" ROOT=""
while [ $# -gt 0 ]; do
case "$1" in
--check|--enforce|--upstream) [ -z "$MODE" ] || usage; MODE="${1#--}"; shift ;;
--root) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [ -d "$2" ] || usage; ROOT="${2%/}"; shift 2 ;;
*) usage ;;
esac
done
[ -n "$MODE" ] || usage
if [ "$(id -u)" = 0 ]; then
[ -z "$ROOT" ] || { echo "hostcheck: refusing: --root is a test seam, and root takes none" >&2; exit 2; }
PATH=/usr/sbin:/usr/bin:/sbin:/bin
else
[ -n "$ROOT" ] || { echo "hostcheck: refusing: run it as root" >&2; exit 2; }
fi
PIN_PROFILES_VERSION="" PIN_PROFILE_SHA256="" PIN_BUBBLEWRAP_VERSION=""
# shellcheck source=case1-pins.sh
[ -f "$HERE/case1-pins.sh" ] && . "$HERE/case1-pins.sh"
PROFILE=/etc/apparmor.d/bwrap-userns-restrict
BWRAP=/usr/bin/bwrap
DRIFT="$ROOT/etc/mosaic-stack/DRIFT"
LOGF="$ROOT/var/log/mosaic-stack/hostcheck.log"
LABEL='bwrap//&unpriv_bwrap (enforce)'
stamp() { date -u +%Y-%m-%dT%H:%M:%SZ; }
FAILED=() LOADED=1
pass() { echo "ok $*"; }
fail() { echo "FAIL $*"; FAILED+=("$*"); }
item1() {
local sum owner rc
[[ "$PIN_PROFILE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { fail "1 no profile pin in $HERE/case1-pins.sh"; return; }
sum="$(sha256sum < "$ROOT$PROFILE" 2>/dev/null | cut -d' ' -f1)"
[ "$sum" = "$PIN_PROFILE_SHA256" ] || { fail "1 $PROFILE sha256 is ${sum:-unreadable}, not the pin $PIN_PROFILE_SHA256"; return; }
owner="$(dpkg -S "$PROFILE" 2>/dev/null)"; rc=$?
case "$rc" in
1) pass "1 $PROFILE matches the pin from apparmor-profiles $PIN_PROFILES_VERSION, and no package owns it" ;;
0) fail "1 $PROFILE is owned by a package: $owner" ;;
*) fail "1 dpkg -S $PROFILE exited $rc" ;;
esac
}
item2() {
local json list
if ! json="$(aa-status --json 2>/dev/null)"; then fail "2 aa-status --json failed"; return; fi
# The profiles object holds name-to-mode pairs and nothing nested; one pair per line.
list="$(sed -n 's/.*"profiles": *{\([^}]*\)}.*/\1/p' <<< "$json" | tr ',' '\n' | sed 's/^ *//; s/ *$//')"
grep -Eq '^"(bwrap|unpriv_bwrap)":' <<< "$list" || LOADED=0
grep -qxE '"bwrap": *"enforce"' <<< "$list" || { fail "2 aa-status lists no bwrap profile in enforce mode"; return; }
grep -qxE '"unpriv_bwrap": *"enforce"' <<< "$list" || { fail "2 aa-status lists no unpriv_bwrap profile in enforce mode"; return; }
! grep -qE '^"mosaic-bwrap":' <<< "$list" || { fail "2 aa-status lists a mosaic-bwrap profile"; return; }
pass "2 bwrap and unpriv_bwrap are in enforce mode, and no mosaic-bwrap is loaded"
}
item3() {
local found
found="$(grep -rlw bwrap "$ROOT/etc/apparmor.d" 2>/dev/null | sort)"
[ "$found" = "$ROOT$PROFILE" ] || { fail "3 the files under /etc/apparmor.d that name bwrap are not $PROFILE alone: $(tr '\n' ' ' <<< "$found")"; return; }
pass "3 $PROFILE is the only file under /etc/apparmor.d that names bwrap"
}
item4() {
local label
label="$(timeout 60 systemd-run --wait --pipe --uid=mosaic --gid=mosaic -p NoNewPrivileges=yes \
-p ProtectSystem=strict -p ProtectHome=yes -p PrivateTmp=yes \
"$BWRAP" --unshare-user --unshare-pid --ro-bind / / cat /proc/self/attr/current 2>/dev/null)"
[ "$label" = "$LABEL" ] || { fail "4 a bwrap child under no-new-privs has the label '$label', not '$LABEL'"; return; }
pass "4 a bwrap child under no-new-privs has the label '$LABEL'"
}
item5() {
local so st
so="$(dpkg-statoverride --list "$BWRAP" 2>/dev/null)"
st="$(stat -c '%U %G %a' "$ROOT$BWRAP" 2>/dev/null)"
[ "$so" = "root mosaic 0750 $BWRAP" ] || { fail "5 the statoverride on $BWRAP reads '$so', not 'root mosaic 0750'"; return; }
[ "$st" = "root mosaic 750" ] || { fail "5 $BWRAP is '$st', not 'root mosaic 750'"; return; }
pass "5 $BWRAP is root mosaic 0750, by the statoverride and by stat"
}
item6() {
local userns links
userns="$(cat "$ROOT/proc/sys/kernel/apparmor_restrict_unprivileged_userns" 2>/dev/null)"
links="$(cat "$ROOT/proc/sys/fs/protected_hardlinks" 2>/dev/null)"
[ "$userns" = 1 ] && [ "$links" = 1 ] ||
{ fail "6 kernel.apparmor_restrict_unprivileged_userns is '$userns' and fs.protected_hardlinks is '$links', not 1 and 1"; return; }
pass "6 kernel.apparmor_restrict_unprivileged_userns and fs.protected_hardlinks are 1"
}
item7() {
local floor unit="$ROOT/etc/systemd/system/mosaic-webui.service" ports port=0 p from
floor="$(cat "$ROOT/proc/sys/net/ipv4/ip_unprivileged_port_start" 2>/dev/null)"
[[ "$floor" =~ ^[0-9]{1,5}$ ]] || { fail "7 net.ipv4.ip_unprivileged_port_start is '$floor', not a number"; return; }
if [ -e "$unit" ]; then
ports="$(sed -n 's/^ExecStart=//p' "$unit" 2>/dev/null | grep -oE ' --port [0-9]{1,5}( |$)' | tr -dc '0-9\n')"
[ -n "$ports" ] || { fail "7 /etc/systemd/system/mosaic-webui.service has no --port"; return; }
for p in $ports; do [ "$((10#$p))" -le "$port" ] || port="$((10#$p))"; done
from="the Console's --port $port"
else
port=1023 from="1023, because no Console unit is installed"
fi
[ "$((10#$floor))" -gt "$port" ] || { fail "7 net.ipv4.ip_unprivileged_port_start is $floor, not above $from"; return; }
pass "7 net.ipv4.ip_unprivileged_port_start is $floor, above $from"
}
items() {
local v
item1; item2; item3; item4; item5; item6; item7
v="$(dpkg -s bubblewrap 2>/dev/null | sed -n 's/^Version: //p')"
if [ "$v" = "$PIN_BUBBLEWRAP_VERSION" ]; then
echo "note bubblewrap $v is the install pin"
else
echo "note bubblewrap ${v:-unknown} is not the install pin ${PIN_BUBBLEWRAP_VERSION:-unknown}, which is not a drift item"
fi
}
# The stop: unload the profile if it is loaded, then close bwrap to root. Prints the results.
stop_bwrap() {
local unload=skipped chown_rc=0 chmod_rc=0
if [ "$LOADED" = 1 ]; then
if [ -f "$ROOT$PROFILE" ]; then apparmor_parser -R "$ROOT$PROFILE" >/dev/null 2>&1; unload="rc=$?"; else unload=no-file; fi
fi
chown root:root "$ROOT$BWRAP" || chown_rc=$?
chmod 0700 "$ROOT$BWRAP" || chmod_rc=$?
echo "unload=$unload chown=$chown_rc chmod=$chmod_rc"
}
enforce() {
local had=0 now result f ids=""
[ ! -e "$DRIFT" ] || had=1
items
[ ${#FAILED[@]} -gt 0 ] || [ "$had" = 1 ] || { echo "hostcheck: all seven hold"; exit 0; }
umask 027
now="$(stamp)"
if [ ${#FAILED[@]} -gt 0 ]; then
mkdir -p "$(dirname "$DRIFT")" && for f in "${FAILED[@]}"; do echo "$now $f"; done >> "$DRIFT" ||
echo "hostcheck: cannot write $DRIFT" >&2
fi
for f in "${FAILED[@]}"; do ids+="${ids:+,}${f%% *}"; done
result="$(stop_bwrap)"
mkdir -p "$(dirname "$LOGF")" && echo "$now enforce stop items=${ids:-none} drift-before=$had $result" >> "$LOGF" ||
echo "hostcheck: cannot write $LOGF" >&2
echo "hostcheck: bwrap stopped ($result); only root removes $DRIFT"
exit 0
}
upstream() {
local tmp deb ver f sum
tmp="$(mktemp -d)" || exit 1
trap 'rm -rf "$tmp"' EXIT
(cd "$tmp" && apt-get download apparmor-profiles) >/dev/null 2>&1 || { echo "hostcheck: upstream: apt-get download apparmor-profiles failed" >&2; exit 1; }
deb=("$tmp"/apparmor-profiles_*.deb)
[ ${#deb[@]} = 1 ] && [ -f "${deb[0]}" ] || { echo "hostcheck: upstream: no single apparmor-profiles deb in the download" >&2; exit 1; }
ver="$(dpkg-deb -f "${deb[0]}" Version 2>/dev/null)" && dpkg-deb -x "${deb[0]}" "$tmp/x" >/dev/null 2>&1 ||
{ echo "hostcheck: upstream: cannot unpack ${deb[0]##*/}" >&2; exit 1; }
f="$tmp/x/usr/share/apparmor/extra-profiles/bwrap-userns-restrict"
if [ ! -f "$f" ]; then
echo "upstream: DIFFERS: apparmor-profiles $ver has no bwrap-userns-restrict; the pin is $PIN_PROFILE_SHA256 from $PIN_PROFILES_VERSION. Send it to Mos as a new-pin request."
exit 0
fi
sum="$(sha256sum < "$f" | cut -d' ' -f1)"
if [ "$sum" = "$PIN_PROFILE_SHA256" ]; then
echo "upstream: apparmor-profiles $ver's bwrap-userns-restrict matches the pin from $PIN_PROFILES_VERSION"
else
echo "upstream: DIFFERS: apparmor-profiles $ver's bwrap-userns-restrict has sha256 $sum; the pin is $PIN_PROFILE_SHA256 from $PIN_PROFILES_VERSION. Send it to Mos as a new-pin request."
diff -u --label "installed $PROFILE" --label "apparmor-profiles $ver" "$ROOT$PROFILE" "$f" || true
fi
exit 0
}
case "$MODE" in
check) items; [ ${#FAILED[@]} -eq 0 ] || exit 1; exit 0 ;;
enforce) enforce ;;
upstream) upstream ;;
esac
+12
View File
@@ -0,0 +1,12 @@
# Row 240 (I4, #1746): the Case 1 host pins, sourced by case1-hostcheck.sh, which is installed as
# /usr/local/lib/mosaic-stack/hostcheck.sh with this file beside it. r6 B2
# (agents/darkwing/work/queue-195/DESIGN.md at b7239bb66). Changing a pin is a reviewed commit and
# its own round.
#
# The AppArmor profile 2a' installs as /etc/apparmor.d/bwrap-userns-restrict, taken from
# /usr/share/apparmor/extra-profiles/bwrap-userns-restrict in this apparmor-profiles version.
PIN_PROFILES_VERSION=4.0.1really4.0.1-0ubuntu0.24.04.9
PIN_PROFILE_SHA256=11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9
# The bubblewrap version 2a' installs. An install pin, not a drift item: a security update keeps the
# statoverride, and the profile attaches by path. hostcheck prints a line when they differ.
PIN_BUBBLEWRAP_VERSION=0.9.0-1ubuntu0.3
+4 -3
View File
@@ -929,12 +929,13 @@ sso_checks() {
sso_supported || { check_refuse sso-target "$TO's Console does not support --sso"; return 0; }
local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out id slot path why
# N4 (webby-0019, sage-1403): a pre-phase-1 loader refuses a console key, so with the key not yet
# written and the stack on another copy, the first step is the re-pin without --sso.
local first=""
# written and the stack on another copy, the first step is the re-pin without --sso. On a named
# instance both commands carry --instance NAME (row 238).
local first="" inst="${INSTANCE_FLAGS[*]:+ ${INSTANCE_FLAGS[*]}}"
if ! out="$(node -e "$SSO_JS" -- "$COPIES/$TO" "$cfg" 2>&1)"; then
out="${out%%$'\n'*}"
[ "$out" != "there is no console.sso in the system config" ] || [ "$P_COPY" = "$TO" ] ||
first="; activate $TO without --sso first, then write console.sso, then run activate $TO --sso"
first="; activate $TO$inst without --sso first, then write console.sso, then run activate $TO$inst --sso"
check_refuse sso-config "$TO's Console would refuse its --sso start: $out$first"
return 0
fi
+3 -2
View File
@@ -27,7 +27,8 @@
# <argv>`, and a last `<time> result exit=N <code> <text>` line.
#
# Exit 0 ok, 1 failed with the old stack running, 2 rollback-failed with the units stopped,
# 3 refused with nothing stopped, 4 usage.
# 3 refused with nothing stopped, 4 usage, 5 args-failed: the new stack is live and confirmed, but
# the args file still names the old copy.
#
# Test seams, for packages/cli/tests/stack-upgrade.test.mjs only. Run as root, it refuses any:
# STACK_UPGRADE_ETC default /etc/mosaic-stack
@@ -278,7 +279,7 @@ run_steps() {
step confirm "${AS_USER[@]}" "$SR" confirm "$id" "$BUSINESS" --units external --copies "$COPIES_DIR" --timeout "$TIMEOUT" ||
rollback_to_old "$id" A5
if mv -f "$NEW_ARGS" "$ETC/$INSTANCE.args"; then logl "args rc=0 copy: $id"
else logl "args rc=1 copy: $id"; finish 1 args-failed "$id is live and confirmed, but $ETC/$INSTANCE.args still names $OLD; put $NEW_ARGS in its place"
else logl "args rc=1 copy: $id"; finish 5 args-failed "$id is live and confirmed, but $ETC/$INSTANCE.args still names $OLD; put $NEW_ARGS in its place"
fi
}