From b650146479afb18caa379e60d71c20938680eb40 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 21 Oct 2026 04:58:17 -0500 Subject: [PATCH] row 195: scripts/case1-accept.sh, root's Case 1 acceptance (F1-F11, P1-P11), its test, TOOLS.md entry (#1693) Lands Darkwing's manifest round 1 candidate, 37171f65b (base 4ced5e63d), approved by Duckworth (duckworth-0088, REVIEW-m1 6afcee2a5, #1693 comment 29988, rev 2758). Five paths: scripts/case1-accept.sh (100755), packages/cli/tests/case1-accept.test.mjs, docs/TOOLS.md, agents/darkwing/work/queue-195/DESIGN.md (r7, approved duckworth-0087) and BUILD.md. No 195 path changed between 4ced5e63d and this commit's parent. Patch-id 656e43ff25de, the same as 4ced5e63d..37171f65b. Evidence: Darkwing's slot runs: single 22/22 (sage-1829), 12/12 mutants killed and gate cli 662/0 plus selftests 429/0 on ddf7dce6c (sage-1865, sage-1870). Gate on my slot (sage-r195land, base 38b83ea5c plus 195's 5 and 213's 39 paths, 09:45:52-09:57:52Z): business 200/0, bus 542/0, harness 140/0, cli 663/0; scripts auth 15/0, conductor 17/0, config 31/0, discord 78/0, extension-package 18/0, foundation 44/0, land-work 86/0, queue 156/0 plus 28/0, release 14/0, task 98/0; the models mutant L1 killed (launch-settings 15/1), restore matched. Between the gate base 38b83ea5c and 4364dddb0 only docs/plans/2026-10-20_host-credentials.md changed (01e1fe512, 4364dddb0), which no test reads. Duckworth's notes N1-N3 go to docs/plans/DEFERRED.md in the next commit. --- agents/darkwing/work/queue-195/BUILD.md | 153 +++++++ agents/darkwing/work/queue-195/DESIGN.md | 388 ++++++++++++++++++ docs/TOOLS.md | 33 ++ packages/cli/tests/case1-accept.test.mjs | 487 +++++++++++++++++++++++ scripts/case1-accept.sh | 443 +++++++++++++++++++++ 5 files changed, 1504 insertions(+) create mode 100644 agents/darkwing/work/queue-195/BUILD.md create mode 100644 agents/darkwing/work/queue-195/DESIGN.md create mode 100644 packages/cli/tests/case1-accept.test.mjs create mode 100755 scripts/case1-accept.sh diff --git a/agents/darkwing/work/queue-195/BUILD.md b/agents/darkwing/work/queue-195/BUILD.md new file mode 100644 index 000000000..317d6adef --- /dev/null +++ b/agents/darkwing/work/queue-195/BUILD.md @@ -0,0 +1,153 @@ +# Row 195 build: `scripts/case1-accept.sh` + +Row 195 (#1693), the Case 1 install on 10.1.1.53. Design r7 at 5425ea2f3, approved by Duckworth +(duckworth-0087, `agents/duckworth/work/row195/REVIEW-r7.md` at 58d930f89; Gitea 29962). Sage's go to build +is sage-1817. + +## What this commit holds + +- `scripts/case1-accept.sh` (new, 100755): section 5's acceptance, F1-F11 and P1-P11. +- `packages/cli/tests/case1-accept.test.mjs` (new): section 5's cases. +- `docs/TOOLS.md`: the `case1-accept.sh` entry after the hostcheck entry. +- This file. +- `DESIGN.md`, doc-only fixes Sage named in sage-1817, with no new design round: + - Duckworth's N1. Section 1's human-id row cited `broker.mjs:245`; the check is `broker.mjs:321`. + - Row 226's state at :21 and :326. It said "unclaimed"; 226 is assigned to Gosalyn, briefed and not + started (sage-1814). + - :340 said I'd send the section 6 items to Gosalyn once 226 was claimed. I sent them on 2026-10-21 + and Gosalyn acknowledged (gosalyn-0085), so the line now says that. + +Nothing else changes. No `case2-*.sh`, `stack-upgrade.sh`, `stack-release.sh`, `case1-hostcheck.sh` or run +sheet edit, and nothing touches .53 (sage-1809). + +## Deviations from section 5, and choices it left open + +1. P4's mark is the single token `accepted Q182+Q183`. Section 5 lists `accepted Q182` and `accepted + Q183` as marks and gives P4 both. One line can carry one mark, and P4 is the only Q182 path, so the + count list has `accepted Q182+Q183` in place of `accepted Q182`. +2. The test seam replaces `getent`. Under `--root`, the script reads `DIR/etc/passwd` and `DIR/etc/group` + with awk. Without it, the same helper calls `getent`. The groups of a user are its primary gid, then + each group whose member list names it, which is what `id -G` prints. So there is no fake `id`, and + the script never calls `id` for another user. +3. P10 reads the port floor from `/proc/sys/net/ipv4/ip_unprivileged_port_start` rather than calling + `sysctl`, so the seam covers it the way hostcheck's item 7 is covered. There is no fake `sysctl`. +4. There is no fake `bwrap`, `claude`, `node` or `hostcheck.sh` on PATH. The script runs each by the + absolute path the args file or the install names, so the test puts stubs at those paths under + `--root`, and the fake `sudo` runs the stack's user's commands from the tree. +5. The args file must say `--profile factory` and name `--business`. Both are new refusals beyond + `stack-upgrade.sh`'s rules. A library file, or one for another business, would make F4 and F7 + check the wrong thing. After `--`, `--port` and `--tester-port` must be numeric. The args file is the + only source of the ports. +6. F8, F9 and F11 read file metadata as root: `stat`, `-L` and `find -maxdepth 1 -name '*.json'`. None + opens the credential. F9 parses `managed-settings.json` with the pinned node, run by root: that node + and that file are both root's own, installed by ops-01. +7. F7 reads `launch.by` from `$HOME/.config/mosaic-dev/businesses/.json` as the stack's user. That + path is `businessFilePath(id)` under the default config, which is what the unit uses. It doesn't load + the copy's `business.mjs` as `stack-release.sh`'s `plugins launchby` does. The user runs the whole + check and prints one line, and root accepts only `ok run ` (sage-1653: root doesn't + parse the user's output beyond a fixed pattern). +8. F5 names a process claude when its argv0's basename or its `comm` starts with `claude`. The window + confirms this against a real role session. If the bwrap'd claude shows another name, F5 fails closed + and the fix is this match, not the label check. +9. F3 and F5 run together. `talk` goes out in the background under `timeout 330`, and the `/proc` scan + repeats every half second until it finds a labelled pair or `talk` exits. F3's line prints first, then + F4, then F5, so the output keeps id order. F3 passes on exit 0 with the `sent` line and a reply line, + both counted on stdout only (`cli.mjs:395` and the `formatMessage` write); talk's error goes to stderr. +12. `--claude` must match `^/[A-Za-z0-9/._+@-]+$`. The `@` is for `node_modules/@anthropic-ai/...`; + `stack-upgrade.sh` doesn't check `--claude` at all. +10. F1 requires all four uids in `/proc//status` (real, effective, saved, filesystem) to be the + user's. +11. Evidence text the stack's user controls (talk's last line, the precheck's last line, F6's and F10's + `--version`) is cut to printable ASCII, one line, 200 bytes. + +## Tests + +`packages/cli/tests/case1-accept.test.mjs`, 22 tests. Every case builds a scratch host with every F check +passing and every P check closed or accepted, then changes one thing: +- all pass: exit 0, ids in order, each P mark, the count lines match the check lines, and the stand-in + credential's value appears nowhere; +- each F check failing alone exits 1 with only its line failing; +- F3 on exit 0 with only the `sent` line, and its evidence taking talk's stderr; +- F1 on a failed unit and on `User=root`; F2 on healthz failing and on another pid holding the port; +- F5 on `unpriv_bwrap (enforce)`, `bwrap//&unpriv_bwrap (complain)`, a trailing space on either label, + no bwrap of the user's, and passing on `comm` alone; +- F6 on a stub that prints nothing and on a path outside `claude-`; +- F7 on two live PM launches, a live one for another business, and no `launch.by`; +- F8 on 0640, on another owner and on a symlink, never printing the credential; +- F9 on a group-writable mode, another owner and a number for the flag; +- F11 on one `*.json` under `discord/`, and passing on an empty directory; +- each P check's unnamed mark, with the line count each fault makes: P6 one line per member, P9 one per + missing item; P2, P5, P10 and P11 in more than one way; +- every P check unnamed at once still exits 0; +- nine bad args files refuse with exit 2 before any command runs, each with its own refusal text; +- usage errors, and a user's run without `--root`, exit 2; +- every in-instance call goes through `sudo -u mosaic -H env PATH=:/usr/local/bin:/usr/bin:/bin`, + read from the fake `sudo`'s argv, and root's other `sudo` calls are exactly `-l -U mosaic`, + `-l -U localadmin` and the jarvis bwrap probe; +- the host check records `--check` and never `--enforce`. + +## Mutants + +Section 5's twelve, each one exact replacement in `scripts/case1-accept.sh`, run by +`/mnt/storage/scratch/darkwing-195-out/mutants.py` in Sage's slot (sage-1865). All twelve were killed. +After each one the script's bytes were restored, and the final sha256 matched (`d434f15ff638...`). The +killers below are the tests that failed in each mutant's saved log. + +| # | Mutant | Killed by | +|---|---|---| +| 1 | F1 passes on a MainPID owned by root | each F alone | +| 2 | F2 accepts a listener on 0.0.0.0 | each F alone | +| 3 | F3 passes without a reply | each F alone; F3 exit 0 with no reply line | +| 4 | F5 matches a substring | F5 labels (the trailing space) | +| 5 | F6 skips the `--version` check | each F alone; F6 stub and path | +| 6 | F8 skips the owner check | F8 0640, owner and symlink | +| 7 | F11 ignores `discord/` | each F alone; F11 `*.json` | +| 8 | P1 closes on any output | P unnamed; every P unnamed | +| 9 | P5 maps to `closed` with jarvis in docker | P unnamed; every P unnamed; P2 and P5 names docker | +| 10 | P9 drops the jarvis item | P unnamed; P9 per item | +| 11 | P11 accepts any `Before=` | P unnamed; every P unnamed | +| 12 | a P check's `unnamed` sets the exit to 1 | P unnamed; every P unnamed; P10 and P11; P2 and P5; P9 per item | + +Mutant 1 survived the F1 variants test (a failed unit, `User=root`), because those faults fail F1 on the +unit's state and `User=`, not on the uid line. Each F alone covers the uid line. + +## Pre-slot read review + +Since every test run is slot work, a read-only subagent traced each case through the script before the +first slot run, executing nothing. It found four defects, all fixed in this commit: +- The `--claude` regex had no `@`, so every run refused the args file, the real .53 one included. +- The fake `stat` logged the args-file call, so the bad-args test's "no command ran" check would fail. +- The P5 and P6 faults each rewrote `etc/group` from the constant, so applying both lost P5's change. +- F3 counted stderr into its line count, so a mutant dropping the exit check survived. F3 now counts + stdout only, and a `sent`-only case covers the line count. + +## Run environment + +The build commit ddf7dce6c sits on base 4ced5e63d (origin/refactor when it was pushed). It was first built +on c515273db as 4a51d392c. Row 238 then changed `docs/TOOLS.md`, so I cherry-picked the three commits onto +4ced5e63d as `darkwing/row195-b`. `git range-diff` shows all three identical. node v26.8.1 on dragon-lin, +every run behind my busy check and Sage's, chained with `&&`. + +- Step 1, sage-1829 (07:41:54Z to 07:42:05Z), TMPDIR `/tmp/dw195`: + `node --test packages/cli/tests/case1-accept.test.mjs`, 22 pass, 0 fail, 11.0 s. +- Step 2, sage-1865 (08:34:27Z to 08:36:21Z), TMPDIR `/mnt/storage/scratch/tmp/r195`: the twelve mutants + above, all killed. +- Step 3, the gate (08:36:21Z to 08:47:06Z), same TMPDIR, `COMPOSE_PROJECT_NAME=darkwing-r195`: + +| Suite | Result | +|---|---| +| packages/cli (`node --test tests/*.test.mjs`) | 662 pass, 0 fail, 0 skipped | +| test-auth.sh | 15 passed, 0 failed | +| test-conductor.sh | 17 passed, 0 failed | +| test-config.sh | 31 passed, 0 failed | +| test-discord.sh | 78 passed, 0 failed | +| test-extension-package.sh | 18 passed, 0 failed | +| test-foundation.sh | 44 passed, 0 failed | +| test-land-work.sh | 86 passed, 0 failed | +| test-queue.sh | 156 node pass, 0 fail; 28 passed, 0 failed | +| test-release.sh | 14 passed, 0 failed | +| test-task.sh | 98 passed, 0 failed | + +Summed: 818 node tests passed with 0 failed, and 429 selftests passed with 0 failed. The compose network +was removed afterwards, and the sweep found no process left in the worktree, the output directory or the +TMPDIR. This file's results are a doc-only commit on top of the build, so nothing that ran has changed. diff --git a/agents/darkwing/work/queue-195/DESIGN.md b/agents/darkwing/work/queue-195/DESIGN.md new file mode 100644 index 000000000..3b0a405ba --- /dev/null +++ b/agents/darkwing/work/queue-195/DESIGN.md @@ -0,0 +1,388 @@ +# Row 195 design: the Case 1 install on 10.1.1.53 (r7) + +Darkwing, 2026-10-21. Brief: `docs/plans/2026-10-20_hub-own-uid.md`, blob 45be7f09afc1, section "Brief: the +Case 1 install on 10.1.1.53 (row 195)". Gitea #1693. Reviewer Duckworth, gate owner Sage. Branch +`darkwing/row195-r7` on c515273db. r6 (b7239bb66 on `darkwing/row195`, approved duckworth-0055) is the base, +carried unchanged as 7398b7dd7. The r6 text is in that commit and in b7239bb66. + +## Changes from r6 + +Q187 (mos-1108) made install and upgrade product commands, and mos-1118 split r6 into the installer rows +(`docs/plans/2026-10-20_installer-rows.md`). That brief says row 195 "shrinks to the factory profile's +values and the Case 1 acceptance". So r7 is mostly a cut. What r6 designed now ships elsewhere: + +| r6 piece | Now | State | +|---|---|---| +| `stack-release.sh --units external`: `confirm`, `revert`, `gate`, `rollback --to`, external `adopt` and `status`; `stack-copy.sh check --units external` and bundle copies | row 232, I1 | landed | +| `case1-repin.sh` (A1-A5, A2f, R1-R4) | `scripts/stack-upgrade.sh`, row 233, I3 | landed | +| the host steps 2a, 2a', 2a'' and the window's root steps | `docs/guides/host-run-sheet.md` sections 1-10, row 233 | landed | +| `case1-hostcheck.sh`, `case1-pins.sh`, `mosaic-hostcheck.service` (the drift stop, r6 B2) | row 240, I4 | landed c515273db | +| the unit render, `--profile factory`, `--pm`, `--claude`, the factory serve flags, the precheck | row 230, C2-13a, I2 | landed | +| the install as `mosaic`: bootstrap, business file, copy | row 226, C2-13b, I2, `scripts/case2-install.sh` | assigned to Gosalyn, briefed, not started | +| `docs/guides/case1-install.md` | the host run sheet, plus section 6 of this file | | + +Other changes: + +- Claude moves under `/opt` (sage-1522, run sheet section 3). `--claude` is + `/opt/mosaic-stack/claude-/node_modules/@anthropic-ai/claude-code/bin/claude.exe`, root's, and root + installs and upgrades it. r6's `/srv/mosaic/.local/bin/claude` is gone. F6 reads the new path. +- The args file is `/etc/mosaic-stack/dev.args` (run sheet section 7), not `case1-unit.args`. It holds + `--copy` too, and `stack-upgrade.sh` rewrites only that line, after confirm passes. +- Both units are `WantedBy=multi-user.target` and start at boot (Q189, mos-1122). r6's "the Console has no + `[Install]`, root starts it by hand after a reboot" no longer holds. +- The business id is stated, with the DEFERRED 240 N1 reasoning (sage-1809, sage-1813). Section 7. +- The window can't run until row 226 lands. Sage added 226 to row 195's after list at rev 2718 + (sage-1813). Section 6 lists what row 195 needs from 226's install. +- The acceptance runs as root through ops-01, not as `localadmin`. `aa-status`, `sudo -l -U`, + `systemd-run --uid` and the hostcheck all need root. It gains four functional checks for items the run + sheet marks "precheck line missing" (F8-F11) and one host line (P11). Section 5. +- The Discord row in section 2 changed. The precheck's "no Discord binding" line is C2-13b's and is + library-only, so for the factory it's F11 in the acceptance. + +What row 195 still builds: `scripts/case1-accept.sh` and its test (section 5). The rest of this file is +inputs, the window order and the record the reviewer checks. + +## 0. What I checked on the host + +Read only, as `localadmin` over ssh, 2026-10-20 between 20:40Z and 21:51Z. Nothing in r7 touched .53 +(sage-1809). The window's first step re-reads these, since they can drift in a day: unattended-upgrades +runs daily. + +| Item | 10.1.1.53 on 2026-10-20 | Needed | +|---|---|---| +| OS | Ubuntu 24.04.5, kernel 6.8.0-124, x86_64, 12 cores, 94 GiB, 217 GiB free on `/` | fits | +| `mosaic` user, group, `/srv/mosaic` | absent | run sheet section 1 | +| node | `/usr/local/bin/node` v22.22.2, root-owned | 24 or later, under `/opt` (run sheet section 2) | +| claude | not on localadmin's PATH | under `/opt` (run sheet section 3) | +| bwrap | absent; apt candidate `bubblewrap 0.9.0-1ubuntu0.3` | run sheet section 4 | +| user namespaces | `kernel.apparmor_restrict_unprivileged_userns = 1`, apparmor 4.0.1, no bwrap profile loaded | run sheet section 4 | +| profiles that name bwrap | none (`grep -rlw bwrap /etc/apparmor.d` exits 1) | hostcheck item 3 | +| `fs.protected_hardlinks` | 1 | hostcheck item 6 | +| `net.ipv4.ip_unprivileged_port_start` | 1024 | run sheet section 5 | +| listeners below 1024 | 22 (sshd) and 53 (the resolver); 732 is free | the Console's port | +| ports 7330-7333 | free | 7333 is the tester port | +| `localadmin` | NOPASSWD sudo (#423 step 1, mos-1066, mos-1077) | ops-01's root path | +| `jarvis` | groups sudo, docker, lxd, adm | #423 step 4 removes them (P5) | +| unattended-upgrades | on, with the security pocket, which carries bubblewrap | the drift stop (row 240) | +| journald | `SplitMode=uid` | the PM check reads `launches list`, not the journal (row 232) | + +## 1. The factory profile's values + +Instance `dev`, row 190's default (mos-1118 choice 4). `~` is `mosaic`'s home. + +| Input | Value | Who gives it | +|---|---|---| +| user | `mosaic`, primary group `mosaic` with no other member | fixed | +| home | `/srv/mosaic`, 0700 | fixed | +| config dir | `/srv/mosaic/.config/mosaic-dev` | row 190 | +| data root | `/srv/mosaic/.mosaic-dev`, marker `{"instanceVersion":1,"name":"dev"}` | row 190 | +| business | `mosaic-stack`, file `/businesses/mosaic-stack.json` | fixed (section 7) | +| profile | `factory` for `case2-install.sh`, `case2-unit.sh` and `case2-precheck.sh` | fixed | +| roles | the factory set from `roles/`, with a chief that has a harness var | the go names the set (section 2) | +| human id | Jason's, not `tester` (`broker.mjs:321`) | the go | +| model credential | `/secrets/claude-session.env`, `mosaic` 0600, one line `CLAUDE_CODE_OAUTH_TOKEN=...` | Jason places it (section 3) | +| token slot path | none | section 2 | +| node | `/opt/mosaic-stack/node-/bin/node`, `` 24 or later | the go names `` and its sha256 | +| claude | `/opt/mosaic-stack/claude-/node_modules/@anthropic-ai/claude-code/bin/claude.exe`, `` 2.1.304 unless the go names a newer one | the go | +| PM | on (`--pm`) | fixed | +| Console | `--port 732` on 127.0.0.1, with the unit's `CAP_NET_BIND_SERVICE` | fixed | +| tester | `--tester-port 7333` on 127.0.0.1 (P10) | fixed | +| board | none | fixed | +| release | a git bundle of `refs/heads/refactor` and the 40-hex commit (run sheet section 6) | the go names both and the bundle's sha256 | + +The args file. `/etc/mosaic-stack/dev.args`, `root:root` 0644, one argument per line (run sheet section 7): + +``` +--profile +factory +--user +mosaic +--home +/srv/mosaic +--business +mosaic-stack +--copy +<40-hex commit> +--node +/opt/mosaic-stack/node-/bin/node +--claude +/opt/mosaic-stack/claude-/node_modules/@anthropic-ai/claude-code/bin/claude.exe +--pm +-- +--port +732 +--business +mosaic-stack +--tester-port +7333 +``` + +`case2-unit.sh` takes exactly these before `--` (`scripts/case2-unit.sh:54`), and `stack-upgrade.sh` +refuses `args-unreadable` on anything else (`scripts/stack-upgrade.sh:126-148`). The factory profile +refuses the preview trio, `--send` and the proof pair (`case2-unit.sh:169`). .53 has no proxy, and Jason +reaches the Console with `ssh -L` (sage-1451 Q3). Changing a serve flag is a re-install with its own go, +not an upgrade. + +Why the port is 732 (r6, mos-1101 item 5). Role sessions share the host's network namespace +(`packages/seat/src/sandbox.mjs:67`), so while the Console is down a session could bind a port above the +floor and answer in its place. Below 1024 only the Console unit can bind, through its capability. The +tester port can't go below 1024 (`packages/webui/src/cli.mjs:33`), so P10 records it. + +PATH for `mosaic`'s steps. `sudo -u mosaic -H` resets PATH to sudo's `secure_path`, which finds +`/usr/local/bin/node`, v22. So every `mosaic` step runs as +`sudo -u mosaic -H env PATH=/opt/mosaic-stack/node-/bin:/usr/local/bin:/usr/bin:/bin ...`, the list +`case2-unit.sh` renders as the units' PATH. `stack-upgrade.sh` builds it from the args file's `--node`. + +## 2. Where Case 1 differs from Case 2 + +| Case 2 (library) | Case 1 (factory) | Reason | +|---|---|---| +| business file with the library mapping | `mosaic-stack` with no `library` key. The precheck refuses a library project under `factory` (`profile-library-business`, `case2-precheck.sh:127-128`) | the factory has no books repo | +| `bean`, `chet`, persona vars and files, specialists | the factory roles from `roles/`, with a chief that has a harness var | the go names the set. I haven't read Jason's live business file and won't. The acceptance needs only the chief (F3); a chief with no harness var answers `no-route`. The human adds roles later 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) | +| `bus.tester` off | on; the precheck refuses it off under `factory` (`case2-precheck.sh:135`) | the acceptance and the Q173 test system need the tester | +| persona modes and digests, `library.sync`, `library.status` | skipped | no persona files, no library (C2-13b brief, `factory` profile) | +| `ZAI_API_KEY` in `claude-session.env` | not checked; the file holds `CLAUDE_CODE_OAUTH_TOKEN` | no instance has provider `zai` (run sheet section 9) | +| no Discord binding (C2-13b, library only) | none either. F11 checks that `/discord/` holds no binding | the factory's connector stays on dragon-lin. Two connectors on one bot token would split its events | +| the claude marker prompt | runs, since the chief's harness is claude-code | | +| serve flags: preview trio, `--send`, proof pair | `--port 732`, `--business mosaic-stack`, `--tester-port 7333` | no proxy on .53 | +| bus: no `--pm`, no `MOSAIC_CLAUDE_BIN` | `--pm`, and `Environment=MOSAIC_CLAUDE_BIN=<--claude>` | the PM dispatches the factory's work; without the path the bus finds no claude on the unit's PATH | +| Console proof file (run sheet section 9, .54) | none | no proof pair | + +All of these are inputs to the landed scripts. Row 230 implemented the four factory items r6 asked for +(serve flags, `--pm`, `--claude`, row 190's tokens), and nothing in them hardcodes a Case 1 value. + +## 3. Model access + +Q185: Case 1 uses the account the factory uses today, the Claude subscription behind dragon-lin's +`claude-session.env`. No script copies that file and none reads its value. In the window, Jason, or the +person he names under the Q173 secrets rule, makes a credential for .53 from that account and writes it +to `/srv/mosaic/.config/mosaic-dev/secrets/claude-session.env` as `mosaic`, mode 0600 (run sheet +section 9). Nobody prints it. The check is `stat -c '%U %a %F' PATH` only (F8). + +The bus unit reads it through `EnvironmentFile=-` (`case2-unit.sh:243`). The leading `-` means a missing +file doesn't stop the bus, so the chief's first launch would fail instead. The window places the file +before the units start (section 6, W9), and the precheck's claude marker prompt then proves it works +without printing it. + +Quota: the factory on dragon-lin and on .53 share one subscription. Q185's "use existing" accepts that. +The pi harness stays off on .53 until P19 #383 settles (mos-0865). The business file names claude only. + +## 4. Upgrade, rollback and backup on .53 + +All three are `scripts/stack-upgrade.sh` (row 233), run as root by ops-01 from `/root/mosaic-stack` at the +reviewed commit. Usage, exits and the step log are in `docs/TOOLS.md`, "Root's upgrade driver", and run +sheet section 6. + +| Action | Command | Who | Go | +|---|---|---|---| +| upgrade | `stack-upgrade.sh upgrade --instance dev --bundle /srv/mosaic/incoming/.bundle --sha ` after run sheet section 6's per-upgrade lines | ops-01 | Mos per run; `--migrate` also needs Jason present (decision 96) | +| rollback | `stack-upgrade.sh rollback --instance dev --to [--restore [--events N]]` | ops-01 | Mos per run. Mos or Sage picks `` from `stack-release.sh status mosaic-stack --units external`, read as `mosaic`. The plan refuses unless it is the previous distinct copy | +| backup of the store | A3's `backup_store` into `/backups/stack/`, inside every upgrade | the driver | the upgrade's | +| rehearsal | `stack-release.sh rehearse` against the log's newest backup, before anything stops, on `--rehearse-port`, default 7339, which section 0 found free | the driver | the upgrade's | +| backup of the VM | a PBS backup of VM 1008 before the install window and before any `--migrate` | ops-01 | the window's go (Q182, Q183: agents keep PBS) | + +Three .53 specifics: + +1. The first upgrade after install needs `--no-rehearsal` in its go. The install's adopt takes no backup, + so the log records none, the rehearsal exits 3 (`no-backup`), and the driver goes on only with the + flag (`stack-upgrade.sh:323-324`). Every later upgrade refuses the flag (`rehearsal-required`). +2. Exit 5 (`args-failed`): the new stack is live and confirmed, but `dev.args` still names the old copy. + ops-01 moves the file the result line names into place with `mv -f`, and nothing else (run sheet + section 6). +3. Exit 2 leaves both units stopped and the activation open. ops-01 sends Mos the exit and the last + lines of `/var/log/mosaic-stack/upgrade-dev.log`, and Jason or Mos settles it with `confirm` or `revert` + as on dragon-lin. A unit that doesn't stop within `--timeout` ends here on purpose (A2f's + `stale-start`, duckworth-0055 N1, now in run sheet section 6). + +The store backup doesn't hold the config dir or the credential. PBS backups of VM 1008 hold the whole disk, +the credential included (P4). A restore swaps only the store and leaves `claude-session.env` in place. + +Re-install, for a serve flag or role change, is not an upgrade. It needs its own go and runs: stop the +Console, then the bus; rewrite `dev.args` with the same `--copy`; `case2-unit.sh` with it; both units start +again from the render. Row 230 N1 (webby-0046) holds here: a re-render with the units running leaves each on +its old copy, so ops-01 stops both first. `stack-upgrade.sh` already stops both before it renders. + +## 5. Acceptance: `scripts/case1-accept.sh` + +New in this row. ops-01 runs it as root on .53, from `/root/mosaic-stack`: + +``` +case1-accept.sh --business mosaic-stack --args /etc/mosaic-stack/dev.args --claude-pin --node-pin +``` + +It reads the user, home, node, claude and ports from the args file and refuses (exit 2) when that file +fails `stack-upgrade.sh`'s rules. Every check inside the instance runs as +`sudo -u mosaic -H env PATH=:/usr/local/bin:/usr/bin:/bin`. It changes nothing on the host +except what F3's message writes to the store. It prints one line per check, ` +`, then a count of each mark. Marks: `closed`, `accepted Q182`, `accepted Q183`, `accepted +mos-1096`, `accepted mos-1101`, and `unnamed`, which means "not named in Q182, Q183 or a Mos ruling". +It exits 1 when any F check fails and 0 otherwise. A P check never changes the exit. Each `unnamed` +line goes to Mos, who takes it to Jason (R8 N1). Duckworth reads the whole output. + +It prints no value from `claude-session.env` and never opens it. + +Functional checks. Any failure fails the row: + +| Id | Check | How | +|---|---|---| +| F1 | the units run as `mosaic` | for `mosaic-bus@mosaic-stack.service` and `mosaic-webui.service`: active, `User=mosaic`, and the uid in `/proc//status` is `mosaic`'s | +| F2 | the Console answers on 127.0.0.1:732 only | `curl -fsS http://127.0.0.1:732/healthz` succeeds, and `ss -Hltnp` shows 732 bound to 127.0.0.1 only, by the Console's MainPID. Jason reaches it with `ssh -L 7332:127.0.0.1:732 localadmin@10.1.1.53`. A LAN listener waits for Q175 SSO (sage-1451 Q3) | +| F3 | one tester message gets a reply | as `mosaic`: `mosaic talk chief --business mosaic-stack --tester --wait 300`. A stored reply to the marked message passes. The inbox shows decisions only, so it isn't used | +| F4 | the precheck passes | `case2-precheck.sh --business mosaic-stack --profile factory` as `mosaic` exits 0 | +| F5 | a role session runs under bwrap with the profile | while F3's reply is pending: a `/usr/bin/bwrap` process owned by `mosaic` has a claude child. Its `/proc//attr/current` equals `bwrap (enforce)` and the child's equals `bwrap//&unpriv_bwrap (enforce)`, exact strings after the trailing newline is cut | +| F6 | claude is the pin | the args file's `--claude` path sits under `/opt/mosaic-stack/claude-/`, and its `--version` as `mosaic` prints ` (Claude Code)` | +| F7 | the PM run is live | `launches list --json` as `mosaic` has exactly one live entry for the PM's instance (`launch.by`), the check `confirm` runs | +| F8 | the credential's file | `stat -c '%U %a %F'` on `claude-session.env` reads `mosaic 600 regular file`, and it isn't a symlink. Run sheet section 9 marks this "precheck line missing" | +| F9 | the autoupdater is off | `/etc/claude-code/managed-settings.json` is `root:root` 0644 and its `env.DISABLE_AUTOUPDATER` is `"1"`. Run sheet section 3 marks this "precheck line missing" | +| F10 | node is the pin | the args file's `--node` is `/opt/mosaic-stack/node-/bin/node` with `` the `--node-pin`, and its `--version` prints ``. Run sheet section 2 marks this "precheck line missing" | +| F11 | no Discord binding | `/srv/mosaic/.mosaic-dev/discord/` is absent or holds no `*.json` (section 2) | + +Host paths. These report and never fail the row: + +| Id | Path | Mark | Evidence | +|---|---|---|---| +| P1 | `mosaic` has sudo | closed only when `sudo -l -U mosaic` prints "is not allowed to run sudo"; 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 | `sudo -l -U localadmin` | +| P4 | root on pve over VM 1008, and PBS backups that hold `/srv/mosaic` with the credential | accepted Q182 and Q183 | a fixed line; the script can't see pve | +| P5 | `jarvis`, which runs Hermes, reaches `/srv/mosaic` through sudo, docker or lxd | closed when `jarvis` is in none of the three; unnamed otherwise. #423 step 4 removes all four groups before the install (mos-1091 4c) | `id jarvis` | +| P6 | other members of sudo, docker, lxd | closed when sudo holds only `localadmin` and docker and lxd are empty; each other member is its own unnamed line | `getent group` lines | +| P7 | a file under `/etc/sudoers.d` naming `mosaic` | closed when none | file names | +| P8 | the Console or tester reachable from the LAN | closed when 732 and 7333 are bound to 127.0.0.1 only, each by the Console's MainPID, and nothing listens on 7331 | listener table | +| P9 | a user namespace through `/usr/bin/bwrap` | accepted mos-1096 only while all eight hold, unnamed otherwise: hostcheck items 1 to 6, read from `/usr/local/lib/mosaic-stack/hostcheck.sh --check`'s own `ok N` lines (item 7 is P10's floor); `sudo -u jarvis /usr/bin/bwrap --version` fails with "Permission denied"; `getent group mosaic` lists no member and no other account has its gid. F5 adds the label of a real role session | the hostcheck lines and each item | +| P10 | a session binds the tester port 7333 while the Console is down | accepted mos-1101 while the port floor is above 732 and 7333 is held by the Console's MainPID | the floor, the owner pid | +| P11 | the boot check runs before the bus | closed when `mosaic-hostcheck.service` is enabled and `systemctl show -p Before` on it names `mosaic-bus@.service` for the args file's business; unnamed otherwise (section 7) | the `Before=` value | + +P9 reads hostcheck's output, which root's own script prints, by matching `^ok [1-6] ` lines. It runs `--check` only, +never `--enforce`, so the acceptance can't close bwrap. + +Tests, in `packages/cli/tests/case1-accept.test.mjs`. The script takes a test seam, `--root DIR`, for +`/proc`, `/etc`, `/srv/mosaic` and `getent`, and refuses it when run as root, as the hostcheck does. Fake +`systemctl`, `curl`, `ss`, `sudo`, `bwrap`, `sysctl`, `stat`, `claude`, `node` and `hostcheck.sh` on PATH. +Nothing reaches .53 or dragon-lin's live state, and no test reads a real credential. Cases: +- every check passing exits 0, with the counts matching the lines; +- each F check failing alone exits 1 with its line; +- F5 fails on `unpriv_bwrap (enforce)`, `bwrap//&unpriv_bwrap (complain)` and a trailing space; +- F6 fails on a stub `claude.exe` that prints nothing and on a path outside `claude-`; +- F8 fails on mode 0640, on another owner and on a symlink, and the stand-in credential's value never + appears in the output; +- F11 fails on one `*.json` under `discord/`; +- each P check in each of its marks: P1 with output that grants nothing but lacks the refusal text + (unnamed), P9 unnamed once for each item missing, P10 unnamed with the floor at 700 and with 7333 held + by another pid, P11 unnamed with `Before=` naming another business; +- every P check `unnamed` still exits 0; +- a bad args file refuses with exit 2 before any check; +- every in-instance call goes through `sudo -u mosaic -H env PATH=:...`, read from the fake + `sudo`'s recorded argv; +- the hostcheck fake records `--check` and never `--enforce`. + +Mutants, each killed by a named case: +1. F1 passes on a MainPID owned by root. +2. F2 accepts a listener on 0.0.0.0. +3. F3 passes without a reply. +4. F5 matches a substring. +5. F6 skips the `--version` check. +6. F8 skips the owner check. +7. F11 ignores `discord/`. +8. P1 closes on any output. +9. P5 maps to `closed` with `jarvis` in docker. +10. P9 drops the jarvis item. +11. P11 accepts any `Before=`. +12. A P check's `unnamed` sets the exit to 1. + +## 6. The .53 window + +Nothing in this row touches .53 (sage-1809). Every root step below is ops-01's, over +`ssh localadmin@10.1.1.53` with `sudo`, and waits on a Jason decision (mos-1086). The window needs: +- row 226 landed; +- Jason's or Mos's go naming the commit, the bundle's sha256, the node and claude pins, the role set and + Jason's human id; +- a backup first; +- a STATE report after. + +| # | Step | Runs as | Source | +|---|---|---|---| +| W0 | re-read section 0's items; stop on any change and report it | `localadmin`, read only | section 0 | +| W1 | PBS backup of VM 1008 | ops-01 on pve | section 4 | +| W2 | the stack's user and home | root | run sheet 1 | +| W3 | node under `/opt` | root | run sheet 2 | +| W4 | claude under `/opt`, managed settings | root | run sheet 3 | +| W5 | bubblewrap, the profile, the statoverride, the no-new-privs test, the drift stop; `hostcheck.sh --check` exits 0 with item 7 at 1023 | root | run sheet 4 | +| W6 | the port floor (read) | root | run sheet 5 | +| W7 | the bundle, root's checkout at ``, `mosaic`'s clone | root, then `sudo -u mosaic -H` | run sheet 6 | +| W8 | `case2-install.sh --profile factory` with section 1's inputs: bootstrap, the business file, roles, human id, the copy | `sudo -u mosaic -H env PATH=...` | row 226 | +| W9 | place `claude-session.env` | Jason, or who he names | run sheet 9, section 3 | +| W10 | write `dev.args` (section 1); `case2-unit.sh` from it, which enables both units and starts the bus, then the Console | root | run sheet 7, 10 | +| W11 | `case2-precheck.sh --business mosaic-stack --profile factory` exits 0; `hostcheck.sh --check` exits 0 with item 7 at 732 | `mosaic`; root | row 230, 240 | +| W12 | `stack-release.sh adopt mosaic-stack --units external`, unless row 226's install does it | `sudo -u mosaic -H` | row 232 | +| W13 | `case1-accept.sh` (section 5); the output to Duckworth, then Mos confirms | root | this row | +| W14 | STATE report to Mos | ops-01 | brief | + +Install rollback, any step before W13 passes. Stop and disable both units, remove their files and +`dev.args`, then the run sheet's rollbacks for sections 4, 3 and 2 in that order. Removing the user and +home (`userdel -r mosaic`) needs its own Mos go, since it deletes the home. W1's backup covers the rest. + +What row 195 needs from row 226. Row 226 is assigned to Gosalyn, briefed and not started, so I'm listing what the window +needs from its design, not designing it: + +1. `--profile factory` writes the business file with `bus.tester` on and no `library` key, and the roles + the go names. +2. It writes no token slot and no persona files (its brief says so). +3. It runs before the unit render, since the copy must exist for `case2-unit.sh --copy`, and it starts no + unit. +4. Who writes `dev.args`. The C2-13b brief says the install doesn't write unit files, and the run sheet + says "I2's install writes it". `mosaic` can't write `/etc/mosaic-stack`. So either root's part of I2 + writes it, or ops-01 writes it from section 1 at W10. Either works for row 195. The file's content is + fixed above. +5. Whether it runs `adopt`. If it doesn't, W12 does. + +I sent these to Gosalyn on 2026-10-21 (gosalyn-0085 acknowledged), and the run sheet takes whichever answer 226 gives. + +## 7. The business id + +.53's business id is `mosaic-stack` (sage-1813). It appears in: +- the args file, twice; +- the bus unit `mosaic-bus@mosaic-stack.service`; +- the business file name; +- `case1-accept.sh --business`. + +The landed boot unit `packages/cli/systemd/mosaic-hostcheck.service` has +`Before=mosaic-bus@mosaic-stack.service`. So at boot the drift stop runs before .53's bus, and DEFERRED 240 +N1 (da3051ccc: `After=mosaic-hostcheck.service` in `mosaic-bus.service.in`) doesn't bind this row. + +If a go ever names another id for .53, the window stops before W8, and the N1 fix lands first as its own +reviewed row. P11 is the check on the host. It reads `unnamed` when the boot unit's `Before=` doesn't +name the installed bus. + +DEFERRED 240 N2 (the apt hook goes in before the first `--check`) is a run sheet order note. This row +doesn't edit the run sheet, so it stays deferred. + +## 8. Rulings this design rests on + +- sage-1451: Q1 system units; Q3 the Console on 127.0.0.1 with `ssh -L`; Q4 a rebase goes to a new branch + name (#426(a), mos-1086). +- mos-1091, mos-1096, mos-1098, mos-1101: the bwrap path, its profile and P9; the port squat and P10; .53 + runs the PM. Their r6 sections stand, and rows 232, 233, 240 and 230 implemented them. +- mos-1108 (Q187) and mos-1118: the installer rows and the scope cut. mos-1122 (Q189): both units start at + boot. +- sage-1522: claude under `/opt`, root-owned. +- sage-1809: no step on .53, root steps named and left to ops-01, the business id stated. +- sage-1813: 226 in the after list (rev 2718); the id is `mosaic-stack` and 240 N1 doesn't apply. +- Q185: the factory's model account. Q173: credentials placed by Jason or his named person. + +## 9. Manifest (planned) + +- `scripts/case1-accept.sh` (new), mode 100755. +- `packages/cli/tests/case1-accept.test.mjs` (new). Section 5's cases and mutants. +- `docs/TOOLS.md`: a `case1-accept.sh` entry with usage, marks and exits, beside the hostcheck entry. +- `agents/darkwing/work/queue-195/BUILD.md` (new): run environment, the mutant results, deviations. +- No change to `case2-*.sh`, `stack-upgrade.sh`, `stack-release.sh`, `case1-hostcheck.sh` or the run + sheet. Two follow-ups stay out of this row: + - row 230 r2 N1. The "still on copy" line reads the id from the unit file. On .53 neither the install + nor `stack-upgrade.sh` reaches that line, since both start from stopped units. + - DEFERRED 240 N2. +- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, dragon-lin's units or `~/.mosaic`. + +Gate: Duckworth approves this design, then the manifest; the cli suite and every `scripts/test-*.sh` +green on Sage's gate; section 5's twelve mutants killed in a slot Sage grants. diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 0109ce0a8..94a1b3791 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -893,6 +893,39 @@ apt hook runs it after every package run, and `mosaic-hostcheck.service` 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. +#### Root's acceptance run (`scripts/case1-accept.sh`, row 195) + +``` +case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N +``` + +Root runs it on the Case 1 host after the install window, from a root-owned +checkout. It reads the user, home, copy, node, claude and both ports from the +args file (`/etc/mosaic-stack/dev.args` on .53), which must pass +`stack-upgrade.sh`'s rules, say `--profile factory` and name `--business`. +Every command that runs the stack's code runs as +`sudo -u USER -H env PATH=:/usr/local/bin:/usr/bin:/bin` from `/`. +Root only reads systemd, `ss`, `/proc`, the account database and file +metadata. The one write is F3's message and its reply, and it never opens +`claude-session.env`. + +It prints one line per check, ` `, then +`count N` for each mark and a result line. The functional checks +F1-F11 are `pass` or `FAIL`: both units active as the user, the Console on +127.0.0.1 only, a tester message to chief answered, `case2-precheck.sh +--profile factory`, the bwrap labels of a live role session, the claude and +node pins, one live PM launch, the credential file's owner and mode, the +autoupdater off, no Discord binding. The host paths P1-P11 are `closed`, +`accepted ` (Q182, Q183, mos-1096, mos-1101) or `unnamed`, which means +no ruling names the path; each `unnamed` line goes to Mos. P9 runs +`hostcheck.sh --check`, never `--enforce`. + +Exit 0 when every F check passes, 1 when one fails, 2 on usage or a refusal: +a bad args file, or `--root` misused. A P check never changes the exit. +`--root DIR` is the test seam for `packages/cli/tests/case1-accept.test.mjs`; +root takes none, and other users run only with it. Design: row 195 r7 +section 5 (`agents/darkwing/work/queue-195/DESIGN.md`). + ## Console service (`scripts/webui-service.sh`) ```bash diff --git a/packages/cli/tests/case1-accept.test.mjs b/packages/cli/tests/case1-accept.test.mjs new file mode 100644 index 000000000..bdbb4ad66 --- /dev/null +++ b/packages/cli/tests/case1-accept.test.mjs @@ -0,0 +1,487 @@ +// Row 195 (#1693): scripts/case1-accept.sh, root's acceptance run on the Case 1 host. Design r7 section 5 +// (agents/darkwing/work/queue-195/DESIGN.md); deviations in agents/darkwing/work/queue-195/BUILD.md. +// Every case runs the script with --root on a scratch host tree: /etc, /proc, /srv/mosaic, /opt and the +// host check live under it. Fake systemctl, curl, ss, sudo and stat on PATH record their argv; the fake +// sudo runs the stack's user's commands from the tree. Nothing here reads or writes the real /etc, +// /proc or /srv, and the stand-in credential is not a credential. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, 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-accept.sh"); +const COPY = "0123456789abcdef0123456789abcdef01234567"; +const NODE_PIN = "v24.1.0", CLAUDE_PIN = "2.1.0"; +const NODE = `/opt/mosaic-stack/node-${NODE_PIN}/bin/node`; +const CLAUDE = `/opt/mosaic-stack/claude-${CLAUDE_PIN}/node_modules/@anthropic-ai/claude-code/bin/claude.exe`; +const HOME = "/srv/mosaic"; +const CLI = `${HOME}/.local/share/mosaic-stack/copies/${COPY}/scripts/mosaic`; +const PRECHECK = `${HOME}/.local/share/mosaic-stack/copies/${COPY}/scripts/case2-precheck.sh`; +const CRED = `${HOME}/.config/mosaic-dev/secrets/claude-session.env`; +const CRED_VALUE = "stand-in-not-a-credential-7f3e2a"; +const AS_MOSAIC = `-u mosaic -H env PATH=/opt/mosaic-stack/node-${NODE_PIN}/bin:/usr/local/bin:/usr/bin:/bin`; +const ARGS = ["--profile", "factory", "--user", "mosaic", "--home", HOME, "--business", "mosaic-stack", "--copy", COPY, + "--node", NODE, "--claude", CLAUDE, "--pm", "--", "--port", "732", "--business", "mosaic-stack", "--tester-port", "7333"]; +const OUTER = "bwrap (enforce)", CHILD = "bwrap//&unpriv_bwrap (enforce)"; +const MARKS = ["pass", "FAIL", "closed", "accepted Q182+Q183", "accepted Q183", "accepted mos-1096", "accepted mos-1101", "unnamed"]; +const P_PASS = { P1: "closed", P2: "closed", P3: "accepted Q183", P4: "accepted Q182+Q183", P5: "closed", P6: "closed", + P7: "closed", P8: "closed", P9: "accepted mos-1096", P10: "accepted mos-1101", P11: "closed" }; +const PASSWD = "root:x:0:0:root:/root:/bin/bash\nmosaic:x:997:997::/srv/mosaic:/usr/sbin/nologin\nlocaladmin:x:1000:1000::/home/localadmin:/bin/bash\njarvis:x:1001:1001::/home/jarvis:/bin/bash\n"; +const GROUP = "root:x:0:\nsudo:x:27:localadmin\nmosaic:x:997:\nlxd:x:998:\ndocker:x:999:\nlocaladmin:x:1000:\njarvis:x:1001:\n"; +const SS = "LISTEN 0 511 127.0.0.1:732 0.0.0.0:* users:((\"node\",pid=200,fd=21))\n" + + "LISTEN 0 511 127.0.0.1:7333 0.0.0.0:* users:((\"node\",pid=200,fd=22))\n" + + "LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=900,fd=3))\n"; +const HOSTCHECK_OK = [1, 2, 3, 4, 5, 6, 7].map((n) => `ok ${n} item ${n} holds`).join("\n") + "\nnote bubblewrap 0.9.0 is the install pin\n"; + +function put(file, text, mode) { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, text); + if (mode) chmodSync(file, mode); +} +const edit = (file, f) => writeFileSync(file, f(readFileSync(file, "utf8"))); +const log = (name) => `printf '%s' ${JSON.stringify(name)} >> "$HC/calls.log"; printf ' %s' "$@" >> "$HC/calls.log"; echo >> "$HC/calls.log"`; + +// Fakes on PATH. Each records its argv and answers from a file in the state directory. +const FAKES = { + systemctl: `case "$1" in + show) [ -f "$HC/show-$2" ] && cat "$HC/show-$2" ;; + is-enabled) cat "$HC/enabled-$2" 2>/dev/null || { echo disabled; exit 1; } ;; +esac`, + curl: `exit "$(cat "$HC/curl-rc")"`, + ss: `cat "$HC/ss"`, + // The credential and the settings file answer from the state directory, as root would see them, and + // only those calls are recorded; everything else is the real stat, which reads the args file. + stat: `case "\${@: -1}" in + *claude-session.env) ${log("stat")}; cat "$HC/stat-cred" ;; + *managed-settings.json) ${log("stat")}; cat "$HC/stat-settings" ;; + *) exec /usr/bin/stat "$@" ;; +esac`, + // -l -U USER prints the state file; -u jarvis is the bwrap probe; -u mosaic -H env PATH=... CMD runs + // CMD from the tree, with the PATH entries and each absolute argument that exists there moved under it. + sudo: `case "$1" in + -l) [ -f "$HC/sudo-l-$3" ] && cat "$HC/sudo-l-$3"; exit 0 ;; + -u) ;; + *) exit 97 ;; +esac +user="$2"; shift 2 +if [ "$user" = jarvis ]; then cat "$HC/jarvis-bwrap" >&2; exit "$(cat "$HC/jarvis-bwrap-rc")"; fi +[ "$user" = mosaic ] && [ "$1" = -H ] && [ "$2" = env ] && [[ "$3" == PATH=* ]] || exit 98 +p="\${3#PATH=}"; shift 3 +np=""; IFS=: read -ra parts <<< "$p"; for x in "\${parts[@]}"; do np+="$ROOT_DIR$x:"; done +cmd="$1"; shift +args=(); for a in "$@"; do if [[ "$a" == /* ]] && [ -e "$ROOT_DIR$a" ]; then args+=("$ROOT_DIR$a"); else args+=("$a"); fi; done +HOME="$ROOT_DIR/srv/mosaic" PATH="$np$PATH" exec "$ROOT_DIR$cmd" "\${args[@]}"`, +}; + +// Executables inside the tree, run by the fake sudo or by the script itself. +const TREE = { + [NODE]: `[ "\${1:-}" = --version ] && { cat "$HC/node-version"; exit 0; } +exec "$REAL_NODE" "$@"`, + [CLAUDE]: `[ "\${1:-}" = --version ] && cat "$HC/claude-version"; exit 0`, + [CLI]: `${log("mosaic")} +case "$1" in + talk) case "$(cat "$HC/talk-mode")" in + reply) echo "sent ab12cd34 to chief"; echo "[chief -> tester] seen" ;; + noreply) echo "sent ab12cd34 to chief"; echo "no reply from chief within 300 s" >&2; exit 1 ;; + sentonly) echo "sent ab12cd34 to chief" ;; + esac ;; + launches) cat "$HC/launches.json" ;; + *) exit 2 ;; +esac`, + [PRECHECK]: `${log("precheck")} +echo "precheck: done"; exit "$(cat "$HC/precheck-rc")"`, + "/usr/local/lib/mosaic-stack/hostcheck.sh": `${log("hostcheck")} +cat "$HC/hostcheck-out"`, +}; + +function proc(root, pid, { ppid, uid = 997, exe, argv0 = "x", comm = "x", label }) { + const d = join(root, "proc", String(pid)); + put(join(d, "status"), `Name:\t${comm}\nState:\tS (sleeping)\nPPid:\t${ppid}\nUid:\t${uid}\t${uid}\t${uid}\t${uid}\n`); + put(join(d, "cmdline"), `${argv0}\0--x\0`); + put(join(d, "comm"), `${comm}\n`); + rmSync(join(d, "exe"), { force: true }); + if (exe) symlinkSync(exe, join(d, "exe")); + if (label !== undefined) put(join(d, "attr", "current"), label); +} + +// A scratch Case 1 host where every F check passes and every P check is closed or accepted. +function host(t) { + const dir = tmp(t, "c1accept-"); + const root = join(dir, "root"), hc = join(dir, "hc"), bin = join(dir, "bin"); + const args = join(root, "etc", "mosaic-stack", "dev.args"); + put(args, ARGS.join("\n") + "\n", 0o644); + put(join(root, "etc", "passwd"), PASSWD); + put(join(root, "etc", "group"), GROUP); + put(join(root, "etc", "sudoers.d", "90-localadmin"), "localadmin ALL=(ALL) NOPASSWD: ALL\n"); + put(join(root, "etc", "claude-code", "managed-settings.json"), JSON.stringify({ env: { DISABLE_AUTOUPDATER: "1" } })); + put(join(root, CRED), `CLAUDE_CODE_OAUTH_TOKEN=${CRED_VALUE}\n`, 0o600); + put(join(root, HOME, ".config", "mosaic-dev", "businesses", "mosaic-stack.json"), JSON.stringify({ id: "mosaic-stack", launch: { by: "pm" } })); + put(join(root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "1024\n"); + proc(root, 1, { ppid: 0, uid: 0, exe: "/usr/lib/systemd/systemd", comm: "systemd" }); + proc(root, 100, { ppid: 1, exe: NODE, argv0: NODE, comm: "node" }); + proc(root, 200, { ppid: 1, exe: NODE, argv0: NODE, comm: "node" }); + proc(root, 300, { ppid: 100, exe: "/usr/bin/bwrap", argv0: "/usr/bin/bwrap", comm: "bwrap", label: `${OUTER}\n` }); + proc(root, 301, { ppid: 300, exe: NODE, argv0: NODE, comm: "node", label: `${CHILD}\n` }); + proc(root, 302, { ppid: 301, exe: CLAUDE, argv0: CLAUDE, comm: "claude.exe", label: `${CHILD}\n` }); + // A root bwrap with a mislabelled claude child, which F5 must pass over. + proc(root, 400, { ppid: 1, uid: 0, exe: "/usr/bin/bwrap", argv0: "/usr/bin/bwrap", comm: "bwrap", label: "unconfined\n" }); + proc(root, 401, { ppid: 400, uid: 0, argv0: "claude", comm: "claude", label: "unconfined\n" }); + const state = { + "show-mosaic-bus@mosaic-stack.service": "ActiveState=active\nUser=mosaic\nMainPID=100\n", + "show-mosaic-webui.service": "ActiveState=active\nUser=mosaic\nMainPID=200\n", + "show-mosaic-hostcheck.service": "Before=mosaic-bus@mosaic-stack.service mosaic-webui.service multi-user.target\n", + "enabled-mosaic-hostcheck.service": "enabled\n", + "curl-rc": "0", ss: SS, + "stat-cred": "mosaic 600 regular file\n", "stat-settings": "root:root 644 regular file\n", + "sudo-l-mosaic": "User mosaic is not allowed to run sudo on dev-53.\n", + "sudo-l-localadmin": "User localadmin may run the following commands on dev-53:\n (ALL : ALL) ALL\n (ALL) NOPASSWD: ALL\n", + "jarvis-bwrap": "sudo: unable to execute /usr/bin/bwrap: Permission denied\n", "jarvis-bwrap-rc": "1", + "node-version": `${NODE_PIN}\n`, "claude-version": `${CLAUDE_PIN} (Claude Code)\n`, + "talk-mode": "reply", "precheck-rc": "0", "hostcheck-out": HOSTCHECK_OK, + "launches.json": JSON.stringify([ + { instance: "pm", live: true, business: "mosaic-stack", run: "r-0002" }, + { instance: "pm", live: false, business: "mosaic-stack", run: "r-0001" }, + { instance: "dev-1", live: true, business: "mosaic-stack", run: "r-0003" }, + ]), + }; + for (const [name, text] of Object.entries(state)) put(join(hc, name), text); + for (const [name, body] of Object.entries(FAKES)) put(join(bin, name), `#!/usr/bin/env bash\n${name === "stat" ? "" : log(name)}\n${body}\n`, 0o755); + for (const [path, body] of Object.entries(TREE)) put(join(root, path), `#!/usr/bin/env bash\n${body}\n`, 0o755); + return { dir, root, hc, bin, args }; +} + +function run(h, extra = {}, argv = ["--business", "mosaic-stack", "--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", NODE_PIN, "--root", h.root]) { + const r = spawnSync(SCRIPT, argv, { + encoding: "utf8", + env: { ...process.env, PATH: `${h.bin}:${process.env.PATH}`, HC: h.hc, ROOT_DIR: h.root, REAL_NODE: process.execPath, TMPDIR: h.dir, ...extra }, + }); + const lines = r.stdout.split("\n").filter(Boolean); + // Each check line as { id, path, mark, evidence }. + const checks = lines.filter((l) => /^[FP][0-9]+ /.test(l)).map((l) => { + const [id, path, ...rest] = l.split(" "); + const tail = rest.join(" "); + const mark = MARKS.find((m) => tail === m || tail.startsWith(`${m} `)); + return { id, path, mark, evidence: tail.slice(mark?.length ?? 0).trim(), line: l }; + }); + return { status: r.status, out: r.stdout, err: r.stderr, lines, checks }; +} +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 failed = (r) => r.checks.filter((c) => c.mark === "FAIL").map((c) => c.id); +const marksOf = (r, id) => r.checks.filter((c) => c.id === id).map((c) => c.mark); +const mustRun = (h, extra) => { const r = run(h, extra); assert.ok(r.status === 0 || r.status === 1, r.out + r.err); return r; }; + +// One fault per F check; each makes that check, and only that one, fail. +const F_FAULTS = { + F1: (h) => proc(h.root, 100, { ppid: 1, uid: 0, exe: NODE, comm: "node" }), + F2: (h) => put(join(h.hc, "ss"), SS.replace("127.0.0.1:732 ", "0.0.0.0:732 ")), + F3: (h) => put(join(h.hc, "talk-mode"), "noreply"), + F4: (h) => put(join(h.hc, "precheck-rc"), "1"), + F5: (h) => put(join(h.root, "proc", "302", "attr", "current"), "unconfined\n"), + F6: (h) => put(join(h.hc, "claude-version"), "2.0.9 (Claude Code)\n"), + F7: (h) => put(join(h.hc, "launches.json"), JSON.stringify([{ instance: "pm", live: false, business: "mosaic-stack", run: "r-1" }])), + F8: (h) => put(join(h.hc, "stat-cred"), "mosaic 640 regular file\n"), + F9: (h) => put(join(h.root, "etc", "claude-code", "managed-settings.json"), JSON.stringify({ env: { DISABLE_AUTOUPDATER: "0" } })), + F10: (h) => put(join(h.hc, "node-version"), "v24.0.0\n"), + F11: (h) => put(join(h.root, HOME, ".mosaic-dev", "discord", "binding.json"), "{}"), +}; + +test("row 195 accept: every check passing exits 0, with each P mark and counts that match the lines", (t) => { + const h = host(t); + const r = run(h); + assert.equal(r.status, 0, r.out + r.err); + assert.deepEqual(r.checks.map((c) => c.id), ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", + "P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "P9", "P10", "P11"]); + for (const c of r.checks.filter((x) => x.id.startsWith("F"))) assert.equal(c.mark, "pass", c.line); + for (const [id, mark] of Object.entries(P_PASS)) assert.deepEqual(marksOf(r, id), [mark], id); + const counts = r.lines.filter((l) => l.startsWith("count ")).map((l) => l.slice(6)); + assert.deepEqual(counts, MARKS.map((m) => `${m} ${r.checks.filter((c) => c.mark === m).length}`)); + assert.match(r.lines.at(-1), /^case1-accept: every F check passes/); + assert.match(r.out, /^F5 bwrap-label pass bwrap 300 reads 'bwrap \(enforce\)' and its claude descendant 302 reads/m); + assert.match(r.out, /^F7 pm-live pass one live launch: pm run r-0002$/m); + assert.ok(!(r.out + r.err).includes(CRED_VALUE)); +}); + +test("row 195 accept: exits 1 with each F check failing alone, and its line names it", (t) => { + for (const [id, fault] of Object.entries(F_FAULTS)) { + const h = host(t); + fault(h); + const r = mustRun(h); + assert.equal(r.status, 1, `${id}: ${r.out}`); + assert.deepEqual(failed(r), [id], `${id}: ${r.out}`); + assert.equal(r.lines.at(-1), "case1-accept: an F check failed, so the row fails"); + } +}); + +test("row 195 accept: F3 fails on exit 0 with no reply line, and its evidence takes talk's error", (t) => { + let h = host(t); + put(join(h.hc, "talk-mode"), "sentonly"); + let r = mustRun(h); + assert.deepEqual(failed(r), ["F3"]); + assert.match(r.out, /^F3 tester-reply FAIL talk exited 0 with 1 lines on stdout; last: sent ab12cd34 to chief$/m); + h = host(t); + put(join(h.hc, "talk-mode"), "noreply"); + r = mustRun(h); + assert.match(r.out, /^F3 tester-reply FAIL talk exited 1 with 1 lines on stdout; last: no reply from chief within 300 s$/m); +}); + +test("row 195 accept: F1 fails on a unit that isn't active or isn't User=mosaic", (t) => { + for (const show of ["ActiveState=failed\nUser=mosaic\nMainPID=0\n", "ActiveState=active\nUser=root\nMainPID=100\n"]) { + const h = host(t); + put(join(h.hc, "show-mosaic-webui.service"), show); + const r = mustRun(h); + assert.equal(r.status, 1); + assert.ok(failed(r).includes("F1"), r.out); + } +}); + +test("row 195 accept: F2 fails when healthz doesn't answer and when another pid holds the port", (t) => { + let h = host(t); + put(join(h.hc, "curl-rc"), "7"); + let r = mustRun(h); + assert.deepEqual(failed(r), ["F2"]); + assert.match(r.out, /^F2 console-loopback FAIL curl http:\/\/127\.0\.0\.1:732\/healthz exited 7$/m); + h = host(t); + put(join(h.hc, "ss"), SS.replace("pid=200,fd=21", "pid=555,fd=4")); + r = mustRun(h); + assert.deepEqual(failed(r), ["F2"]); +}); + +test("row 195 accept: F5 fails on unpriv_bwrap (enforce), on complain and on a trailing space", (t) => { + for (const label of ["unpriv_bwrap (enforce)\n", "bwrap//&unpriv_bwrap (complain)\n", `${CHILD} \n`]) { + const h = host(t); + put(join(h.root, "proc", "302", "attr", "current"), label); + const r = mustRun(h); + assert.deepEqual(failed(r), ["F5"], `${JSON.stringify(label)}: ${r.out}`); + } + const h = host(t); + put(join(h.root, "proc", "300", "attr", "current"), `${OUTER} \n`); + assert.deepEqual(failed(mustRun(h)), ["F5"]); +}); + +test("row 195 accept: F5 fails with no bwrap of mosaic's, and passes on comm alone", (t) => { + let h = host(t); + proc(h.root, 300, { ppid: 100, uid: 1001, exe: "/usr/bin/bwrap", comm: "bwrap", label: `${OUTER}\n` }); + let r = mustRun(h); + assert.deepEqual(failed(r), ["F5"]); + assert.match(r.out, /^F5 bwrap-label FAIL no \/usr\/bin\/bwrap process of mosaic/m); + h = host(t); + proc(h.root, 302, { ppid: 301, argv0: "/proc/self/fd/3", comm: "claude", label: `${CHILD}\n` }); + assert.deepEqual(failed(mustRun(h)), []); +}); + +test("row 195 accept: F6 fails on a stub that prints nothing and on a path outside claude-", (t) => { + let h = host(t); + put(join(h.hc, "claude-version"), ""); + let r = mustRun(h); + assert.deepEqual(failed(r), ["F6"]); + h = host(t); + const other = CLAUDE.replace(`claude-${CLAUDE_PIN}`, "claude-2.0.9"); + put(h.args, ARGS.map((a) => (a === CLAUDE ? other : a)).join("\n") + "\n", 0o644); + put(join(h.root, other), `#!/usr/bin/env bash\necho "${CLAUDE_PIN} (Claude Code)"\n`, 0o755); + r = mustRun(h); + assert.deepEqual(failed(r), ["F6"]); + assert.match(r.out, /^F6 claude-pin FAIL --claude \S+claude-2\.0\.9\S+ is not under \/opt\/mosaic-stack\/claude-2\.1\.0\/$/m); +}); + +test("row 195 accept: F7 fails on two live PM launches, one for another business, and no launch.by", (t) => { + const two = [{ instance: "pm", live: true, business: "mosaic-stack", run: "a" }, { instance: "pm", live: true, business: "mosaic-stack", run: "b" }]; + for (const [file, text] of [["launches.json", JSON.stringify(two)], ["launches.json", JSON.stringify([{ instance: "pm", live: true, business: "other", run: "a" }])]]) { + const h = host(t); + put(join(h.hc, file), text); + assert.deepEqual(failed(mustRun(h)), ["F7"]); + } + const h = host(t); + put(join(h.root, HOME, ".config", "mosaic-dev", "businesses", "mosaic-stack.json"), JSON.stringify({ id: "mosaic-stack" })); + const r = mustRun(h); + assert.deepEqual(failed(r), ["F7"]); + assert.match(r.out, /^F7 pm-live FAIL the business file has no launch\.by$/m); +}); + +test("row 195 accept: F8 fails on 0640, on another owner and on a symlink, and never prints the credential", (t) => { + for (const fault of [ + (h) => put(join(h.hc, "stat-cred"), "mosaic 640 regular file\n"), + (h) => put(join(h.hc, "stat-cred"), "root 600 regular file\n"), + (h) => { const f = join(h.root, CRED); rmSync(f); put(`${f}.real`, `X=${CRED_VALUE}\n`, 0o600); symlinkSync(`${f}.real`, f); }, + ]) { + const h = host(t); + fault(h); + const r = mustRun(h); + assert.deepEqual(failed(r), ["F8"], r.out); + assert.ok(!(r.out + r.err).includes(CRED_VALUE)); + } +}); + +test("row 195 accept: F9 fails on another owner, a group-writable mode and a number for the flag", (t) => { + for (const fault of [ + (h) => put(join(h.hc, "stat-settings"), "root:root 664 regular file\n"), + (h) => put(join(h.hc, "stat-settings"), "mosaic:root 644 regular file\n"), + (h) => put(join(h.root, "etc", "claude-code", "managed-settings.json"), JSON.stringify({ env: { DISABLE_AUTOUPDATER: 1 } })), + ]) { + const h = host(t); + fault(h); + assert.deepEqual(failed(mustRun(h)), ["F9"]); + } +}); + +test("row 195 accept: F11 fails on one *.json under discord/ and passes on an empty directory", (t) => { + let h = host(t); + put(join(h.root, HOME, ".mosaic-dev", "discord", "chief.json"), "{}"); + let r = mustRun(h); + assert.deepEqual(failed(r), ["F11"]); + assert.match(r.out, /^F11 no-discord FAIL \/srv\/mosaic\/\.mosaic-dev\/discord holds bindings: chief\.json$/m); + h = host(t); + mkdirSync(join(h.root, HOME, ".mosaic-dev", "discord"), { recursive: true }); + assert.deepEqual(failed(mustRun(h)), []); +}); + +// Each P check in its unnamed mark, with the line count each fault makes; none changes the exit. +const P_FAULTS = { + P1: [1, (h) => put(join(h.hc, "sudo-l-mosaic"), "User mosaic may run the following commands on dev-53:\n")], + P2: [1, (h) => put(join(h.root, HOME, ".ssh", "authorized_keys"), "ssh-ed25519 AAAA test\n")], + P5: [1, (h) => edit(join(h.root, "etc", "group"), (s) => s.replace("docker:x:999:", "docker:x:999:jarvis"))], + P6: [2, (h) => edit(join(h.root, "etc", "group"), (s) => s.replace("sudo:x:27:localadmin", "sudo:x:27:localadmin,bob").replace("lxd:x:998:", "lxd:x:998:carol"))], + P7: [1, (h) => put(join(h.root, "etc", "sudoers.d", "50-mosaic"), "mosaic ALL=(root) NOPASSWD: /usr/bin/systemctl\n")], + P8: [1, (h) => put(join(h.hc, "ss"), `${SS}LISTEN 0 511 0.0.0.0:7331 0.0.0.0:* users:(("node",pid=200,fd=23))\n`)], + P9: [3, (h) => { put(join(h.hc, "hostcheck-out"), HOSTCHECK_OK.replace("ok 2 ", "FAIL 2 ").replace("ok 5 ", "FAIL 5 ")); put(join(h.hc, "jarvis-bwrap-rc"), "0"); }], + P10: [1, (h) => put(join(h.root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "700\n")], + P11: [1, (h) => put(join(h.hc, "show-mosaic-hostcheck.service"), "Before=mosaic-bus@other.service multi-user.target\n")], +}; + +test("row 195 accept: each P check reports unnamed on its fault, and the exit stays 0", (t) => { + for (const [id, [n, fault]] of Object.entries(P_FAULTS)) { + const h = host(t); + fault(h); + const r = run(h); + assert.equal(r.status, 0, `${id}: ${r.out}`); + assert.deepEqual(marksOf(r, id), Array(n).fill("unnamed"), `${id}: ${r.out}`); + } +}); + +test("row 195 accept: P2 is unnamed with a supplementary group, and P5 names docker", (t) => { + let h = host(t); + put(join(h.root, "etc", "group"), GROUP.replace("lxd:x:998:", "lxd:x:998:mosaic")); + let r = run(h); + assert.equal(r.status, 0); + assert.deepEqual(marksOf(r, "P2"), ["unnamed"]); + assert.match(r.out, /^P2 mosaic-groups-keys unnamed id -G is '997 998'; authorized_keys is absent$/m); + h = host(t); + P_FAULTS.P5[1](h); + r = run(h); + assert.match(r.out, /^P5 jarvis-reach unnamed jarvis is in docker$/m); + assert.match(r.out, /^P6 group-docker unnamed jarvis is a member of docker$/m); +}); + +test("row 195 accept: P5 is closed with no jarvis account, and P2 with an empty authorized_keys", (t) => { + const h = host(t); + put(join(h.root, "etc", "passwd"), PASSWD.replace(/^jarvis:.*\n/m, "")); + put(join(h.root, HOME, ".ssh", "authorized_keys"), ""); + const r = run(h); + assert.deepEqual(marksOf(r, "P5"), ["closed"]); + assert.deepEqual(marksOf(r, "P2"), ["closed"]); +}); + +test("row 195 accept: P9 is unnamed once for each item missing", (t) => { + const items = { + hostcheck: (h) => put(join(h.hc, "hostcheck-out"), HOSTCHECK_OK.replace("ok 3 ", "FAIL 3 ")), + jarvis: (h) => { put(join(h.hc, "jarvis-bwrap-rc"), "0"); put(join(h.hc, "jarvis-bwrap"), ""); }, + member: (h) => put(join(h.root, "etc", "group"), GROUP.replace("mosaic:x:997:", "mosaic:x:997:jarvis")), + gid: (h) => put(join(h.root, "etc", "passwd"), `${PASSWD}hermes:x:1002:997::/home/hermes:/bin/bash\n`), + }; + for (const [name, fault] of Object.entries(items)) { + const h = host(t); + fault(h); + const r = run(h); + assert.equal(r.status, 0); + assert.deepEqual(marksOf(r, "P9"), ["unnamed"], `${name}: ${r.out}`); + } + const h = host(t); + put(join(h.hc, "hostcheck-out"), ""); + assert.deepEqual(marksOf(run(h), "P9"), Array(6).fill("unnamed")); +}); + +test("row 195 accept: P10 is unnamed with 7333 held by another pid; P11 with the unit disabled", (t) => { + let h = host(t); + put(join(h.hc, "ss"), SS.replace("pid=200,fd=22", "pid=555,fd=4")); + let r = run(h); + assert.equal(r.status, 0); + assert.deepEqual(marksOf(r, "P10"), ["unnamed"]); + h = host(t); + put(join(h.hc, "enabled-mosaic-hostcheck.service"), "disabled\n"); + r = run(h); + assert.deepEqual(marksOf(r, "P11"), ["unnamed"]); +}); + +test("row 195 accept: every P check unnamed together still exits 0", (t) => { + const h = host(t); + for (const [, fault] of Object.values(P_FAULTS)) fault(h); + const r = run(h); + assert.equal(r.status, 0, r.out + r.err); + assert.deepEqual(failed(r), []); + for (const id of ["P1", "P2", "P5", "P6", "P7", "P8", "P9", "P10", "P11"]) assert.ok(marksOf(r, id).every((m) => m === "unnamed"), id); + assert.match(r.lines.at(-1), /^case1-accept: every F check passes/); +}); + +test("row 195 accept: a bad args file refuses with exit 2 before any check, each for its own reason", (t) => { + const bad = { + "group write": [(h) => chmodSync(h.args, 0o664), /dev\.args must be \S+'s with no group or other write \(it is uid \d+, mode 664\)$/], + symlink: [(h) => { put(`${h.args}.real`, ARGS.join("\n") + "\n", 0o644); rmSync(h.args); symlinkSync(`${h.args}.real`, h.args); }, /no regular file \/etc\/mosaic-stack\/dev\.args$/], + "unknown flag": [(h) => put(h.args, ["--send", ...ARGS].join("\n") + "\n", 0o644), /has --send before --, which case2-unit\.sh does not take$/], + "two copies": [(h) => put(h.args, ["--copy", COPY, ...ARGS].join("\n") + "\n", 0o644), /needs exactly one --copy before --, not 2$/], + "library profile": [(h) => put(h.args, ARGS.map((a) => (a === "factory" ? "library" : a)).join("\n") + "\n", 0o644), /has --profile 'library', not factory$/], + "another business": [(h) => put(h.args, ARGS.map((a, i) => (a === "mosaic-stack" && i < 14 ? "acme" : a)).join("\n") + "\n", 0o644), /is for business acme, not mosaic-stack$/], + "no tester port": [(h) => put(h.args, ARGS.slice(0, -2).join("\n") + "\n", 0o644), /lacks a numeric --port or --tester-port after --$/], + "short copy": [(h) => put(h.args, ARGS.map((a) => (a === COPY ? "abc123" : a)).join("\n") + "\n", 0o644), /lacks a plain --user, --home, --business, --node, --claude or --copy$/], + "no file": [(h) => rmSync(h.args), /no regular file \/etc\/mosaic-stack\/dev\.args$/], + }; + for (const [name, [fault, why]] of Object.entries(bad)) { + const h = host(t); + fault(h); + const r = run(h); + assert.equal(r.status, 2, `${name}: ${r.out}${r.err}`); + assert.match(r.err.trimEnd(), /^case1-accept: refusing: /, name); + assert.match(r.err.trimEnd(), why, name); + assert.equal(r.out, "", name); + assert.ok(!existsSync(join(h.hc, "calls.log")), name); + } +}); + +test("row 195 accept: usage errors and a run without --root as a user exit 2", (t) => { + const h = host(t); + let r = run(h, {}, ["--business", "mosaic-stack", "--args", "/etc/mosaic-stack/dev.args", "--node-pin", NODE_PIN, "--root", h.root]); + assert.equal(r.status, 2); + assert.match(r.err, /^usage: /); + r = run(h, {}, ["--business", "mosaic-stack", "--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", "24.1.0", "--root", h.root]); + assert.equal(r.status, 2); + r = run(h, {}, ["--business", "mosaic-stack", "--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", NODE_PIN]); + assert.equal(r.status, 2); + assert.equal(r.err, "case1-accept: refusing: run it as root\n"); + assert.ok(!existsSync(join(h.hc, "calls.log"))); +}); + +test("row 195 accept: every in-instance call goes through sudo -u mosaic -H env PATH=:...", (t) => { + const h = host(t); + const r = run(h); + assert.equal(r.status, 0, r.out + r.err); + const sudo = calls(h, "sudo"); + const asMosaic = sudo.filter((a) => a.startsWith("-u mosaic")); + for (const a of asMosaic) assert.ok(a.startsWith(`${AS_MOSAIC} /`), a); + const cmds = asMosaic.map((a) => a.slice(AS_MOSAIC.length + 1).split(" ")[0]); + assert.deepEqual([...new Set(cmds)].sort(), [CLI, PRECHECK, CLAUDE, NODE].sort()); + assert.deepEqual(sudo.filter((a) => !a.startsWith("-u mosaic")).sort(), + ["-l -U localadmin", "-l -U mosaic", "-u jarvis /usr/bin/bwrap --version"]); + const talk = calls(h, "mosaic").filter((a) => a.startsWith("talk ")); + assert.equal(talk.length, 1); + assert.match(talk[0], /^talk chief case1-accept-[0-9a-f]{12}: .* --business mosaic-stack --tester --wait 300$/); + assert.deepEqual(calls(h, "precheck"), ["--business mosaic-stack --profile factory"]); +}); + +test("row 195 accept: the host check runs with --check and never --enforce", (t) => { + const h = host(t); + run(h); + assert.deepEqual(calls(h, "hostcheck"), ["--check"]); +}); diff --git a/scripts/case1-accept.sh b/scripts/case1-accept.sh new file mode 100755 index 000000000..d1c14fd45 --- /dev/null +++ b/scripts/case1-accept.sh @@ -0,0 +1,443 @@ +#!/usr/bin/env bash +# Row 195 (#1693): the Case 1 acceptance on 10.1.1.53, design r7 section 5 +# (agents/darkwing/work/queue-195/DESIGN.md). Deviations and decisions: agents/darkwing/work/queue-195/BUILD.md. +# +# case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N +# +# Root runs it on the Case 1 host after the install window, from a root-owned checkout. It reads the +# user, home, copy, node, claude and both ports from the args file, which must pass stack-upgrade.sh's +# rules and be a factory file for --business. Every command that runs the stack's code runs as +# `sudo -u USER -H env PATH=:/usr/local/bin:/usr/bin:/bin` from /. Root itself only reads +# systemd, ss, /proc, the account database and file metadata. It changes nothing on the host but the +# message F3 sends and its reply, and it never opens claude-session.env. +# +# One line per check, ` `, then `count N` for each mark and a +# result line. F checks are `pass` or `FAIL`. P checks are `closed`, `accepted ` or +# `unnamed`, which means no Q182, Q183 or Mos ruling names the path; each unnamed line goes to Mos. +# +# Exit 0 when every F check passes, 1 when one fails, 2 on usage or a refusal (a bad args file, or +# --root misused). A P check never changes the exit. +# +# Test seam, for packages/cli/tests/case1-accept.test.mjs only. Root takes none: +# --root DIR prefixes the args file, /etc, /proc, the stack's home, the host check and node, and +# reads the account database from DIR/etc/passwd and DIR/etc/group instead of getent +set -uo pipefail + +usage() { echo "usage: case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N" >&2; exit 2; } +refuse() { echo "case1-accept: refusing: $*" >&2; exit 2; } +BUSINESS="" ARGS_FILE="" CLAUDE_PIN="" NODE_PIN="" ROOT="" +while [ $# -gt 0 ]; do + case "$1" in + --business) [ $# -ge 2 ] && [[ "$2" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage; BUSINESS="$2"; shift 2 ;; + --args) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] || usage; ARGS_FILE="$2"; shift 2 ;; + --claude-pin) [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; CLAUDE_PIN="$2"; shift 2 ;; + --node-pin) [ $# -ge 2 ] && [[ "$2" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; NODE_PIN="$2"; shift 2 ;; + --root) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [ -d "$2" ] || usage; ROOT="${2%/}"; shift 2 ;; + *) usage ;; + esac +done +[ -n "$BUSINESS" ] && [ -n "$ARGS_FILE" ] && [ -n "$CLAUDE_PIN" ] && [ -n "$NODE_PIN" ] || usage +if [ "$(id -u)" = 0 ]; then + [ -z "$ROOT" ] || refuse "--root is a test seam, and root takes none" + PATH=/usr/sbin:/usr/bin:/sbin:/bin +else + [ -n "$ROOT" ] || refuse "run it as root" +fi +cd / || refuse "cannot cd to /" + +# --- The args file, by stack-upgrade.sh's read_args rules, plus the serve ports --- + +f="$ROOT$ARGS_FILE" +[ -f "$f" ] && [ ! -L "$f" ] || refuse "no regular file $ARGS_FILE" +st="$(stat -c '%u %a' -- "$f")" || refuse "cannot stat $ARGS_FILE" +[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || + refuse "$ARGS_FILE must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })" +mapfile -t ARGS < "$f" || refuse "cannot read $ARGS_FILE" +S_USER="" S_HOME="" A_BUSINESS="" NODE="" COPY_ID="" CLAUDE="" PROFILE="" PORT="" TESTER_PORT="" +copies=0 past="" +for ((i = 0; i < ${#ARGS[@]}; i++)); do + a="${ARGS[i]}" v="${ARGS[i + 1]:-}" + if [ -n "$past" ]; then + case "$a" in --port) PORT="$v"; i=$((i + 1)) ;; --tester-port) TESTER_PORT="$v"; i=$((i + 1)) ;; esac + continue + fi + case "$a" in + --) past=1 ;; + --pm) ;; + --user) S_USER="$v"; i=$((i + 1)) ;; + --home) S_HOME="$v"; i=$((i + 1)) ;; + --business) A_BUSINESS="$v"; i=$((i + 1)) ;; + --node) NODE="$v"; i=$((i + 1)) ;; + --claude) CLAUDE="$v"; i=$((i + 1)) ;; + --profile) PROFILE="$v"; i=$((i + 1)) ;; + --copy) COPY_ID="$v"; copies=$((copies + 1)); i=$((i + 1)) ;; + *) refuse "$ARGS_FILE has $a before --, which case2-unit.sh does not take" ;; + esac +done +[ "$copies" = 1 ] || refuse "$ARGS_FILE needs exactly one --copy before --, not $copies" +[[ "$S_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] && [[ "$S_HOME" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$A_BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] && + [[ "$NODE" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] && [[ "$CLAUDE" =~ ^/[A-Za-z0-9/._+@-]+$ ]] || + refuse "$ARGS_FILE lacks a plain --user, --home, --business, --node, --claude or --copy" +[ "$PROFILE" = factory ] || refuse "$ARGS_FILE has --profile '${PROFILE}', not factory" +[ "$A_BUSINESS" = "$BUSINESS" ] || refuse "$ARGS_FILE is for business $A_BUSINESS, not $BUSINESS" +[[ "$PORT" =~ ^[1-9][0-9]{0,4}$ ]] && [[ "$TESTER_PORT" =~ ^[1-9][0-9]{0,4}$ ]] || + refuse "$ARGS_FILE lacks a numeric --port or --tester-port after --" + +COPY="$S_HOME/.local/share/mosaic-stack/copies/$COPY_ID" +DATA_ROOT="$S_HOME/.mosaic-dev" +CRED="$S_HOME/.config/mosaic-dev/secrets/claude-session.env" +BUSINESS_FILE="$S_HOME/.config/mosaic-dev/businesses/$BUSINESS.json" +BUS_UNIT="mosaic-bus@$BUSINESS.service" CON_UNIT=mosaic-webui.service +HOSTCHECK=/usr/local/lib/mosaic-stack/hostcheck.sh +SETTINGS=/etc/claude-code/managed-settings.json +LABEL_OUTER='bwrap (enforce)' LABEL_CHILD='bwrap//&unpriv_bwrap (enforce)' +AS_USER=(sudo -u "$S_USER" -H env "PATH=$(dirname "$NODE"):/usr/local/bin:/usr/bin:/bin") +WORK="$(mktemp -d)" || refuse "cannot make a temp dir" +trap 'rm -rf -- "$WORK"' EXIT + +# --- Output --- + +MARKS=(pass FAIL closed "accepted Q182+Q183" "accepted Q183" "accepted mos-1096" "accepted mos-1101" unnamed) +declare -A COUNT=() +F_FAILED=0 +# Text the stack's user wrote, made safe to print: printable ASCII, one line, at most 200 bytes. +clean() { tr -c '[:print:]' ' ' <<< "$1" | cut -c1-200 | sed 's/ *$//'; } +line() { local id="$1" path="$2" mark="$3"; shift 3; echo "$id $path $mark $*"; COUNT[$mark]=$(( ${COUNT[$mark]:-0} + 1 )); } +fpass() { line "$1" "$2" pass "${@:3}"; } +ffail() { line "$1" "$2" FAIL "${@:3}"; F_FAILED=1; } + +# --- The account database: getent on the host, the seam's files under --root --- + +db() { + if [ -n "$ROOT" ]; then + if [ $# -gt 1 ]; then awk -F: -v k="$2" '$1 == k' "$ROOT/etc/$1"; else cat "$ROOT/etc/$1"; fi + else + getent "$@" + fi +} +uid_of() { db passwd "$1" | cut -d: -f3; } +gid_of() { db passwd "$1" | cut -d: -f4; } +# The group ids USER belongs to, as `id -G USER` prints them: the primary gid, then each group +# whose member list names USER. +groups_of() { + local g; g="$(gid_of "$1")" + [ -n "$g" ] || return 0 + { echo "$g"; db group | awk -F: -v u="$1" '{ n = split($4, m, ","); for (i = 1; i <= n; i++) if (m[i] == u) print $3 }'; } | awk '!seen[$0]++' +} +names_of_groups() { local g; for g in "$@"; do db group | awk -F: -v g="$g" '$3 == g { print $1 }'; done; } +MOSAIC_UID="$(uid_of "$S_USER")" + +# --- systemd, /proc and listeners --- + +# KEY=VALUE lines for UNIT's properties. +unit_show() { local u="$1"; shift; systemctl show "$u" "${@/#/-p}" 2>/dev/null; } +prop() { sed -n "s/^$2=//p" <<< "$1" | tail -n 1; } +# The real, effective, saved and filesystem uids of PID, space-separated. +proc_uids() { awk '$1 == "Uid:" { print $2, $3, $4, $5 }' "$ROOT/proc/$1/status" 2>/dev/null; } +proc_label() { local l; l="$(cat "$ROOT/proc/$1/attr/current" 2>/dev/null)" || return 1; printf '%s' "${l%$'\n'}"; } +# One `ADDRESS PIDS` line per TCP listener on port $1, PIDS comma-separated. +listeners() { + ss -Hltnp 2>/dev/null | awk -v p="$1" '{ + n = split($4, a, ":"); if (a[n] != p) next + addr = substr($4, 1, length($4) - length(p) - 1); pids = ""; s = $0 + while (match(s, /pid=[0-9]+/)) { pids = pids (pids == "" ? "" : ",") substr(s, RSTART + 4, RLENGTH - 4); s = substr(s, RSTART + RLENGTH) } + print addr, pids + }' +} +# 0 when port $1 has a listener and every one is on 127.0.0.1, held by pid $2 alone. +loopback_by() { + local addr pids p any=0 + [ -n "$2" ] && [ "$2" != 0 ] || return 1 + while read -r addr pids; do + [ -n "$addr" ] || continue + any=1 + [ "$addr" = 127.0.0.1 ] || return 1 + for p in ${pids//,/ }; do [ "$p" = "$2" ] || return 1; done + [ -n "$pids" ] || return 1 + done <<< "$(listeners "$1")" + [ "$any" = 1 ] +} +listener_text() { local t; t="$(listeners "$1" | tr '\n' ';')"; echo "${t:-none}"; } + +BUS_SHOW="$(unit_show "$BUS_UNIT" ActiveState User MainPID)" +CON_SHOW="$(unit_show "$CON_UNIT" ActiveState User MainPID)" +CON_PID="$(prop "$CON_SHOW" MainPID)" + +# --- F checks --- + +check_f1() { + local u show state user pid uids why="" + for u in "$BUS_UNIT" "$CON_UNIT"; do + if [ "$u" = "$BUS_UNIT" ]; then show="$BUS_SHOW"; else show="$CON_SHOW"; fi + state="$(prop "$show" ActiveState)" user="$(prop "$show" User)" pid="$(prop "$show" MainPID)" + uids="$(proc_uids "${pid:-0}")" + [ "$state" = active ] || { why+="$u is ${state:-unknown}; "; continue; } + [ "$user" = "$S_USER" ] || { why+="$u has User=${user:-unset}; "; continue; } + [ -n "$MOSAIC_UID" ] && [ "$uids" = "$MOSAIC_UID $MOSAIC_UID $MOSAIC_UID $MOSAIC_UID" ] || + why+="$u MainPID ${pid:-none} has uids '${uids:-unreadable}', not $S_USER's ${MOSAIC_UID:-unknown}; " + done + if [ -z "$why" ]; then fpass F1 units-user "$BUS_UNIT and $CON_UNIT are active as $S_USER (uid $MOSAIC_UID)" + else ffail F1 units-user "${why%; }"; fi +} + +check_f2() { + local rc=0 + curl -fsS --max-time 10 -o /dev/null "http://127.0.0.1:$PORT/healthz" 2>/dev/null || rc=$? + if [ "$rc" != 0 ]; then ffail F2 console-loopback "curl http://127.0.0.1:$PORT/healthz exited $rc" + elif ! loopback_by "$PORT" "$CON_PID"; then ffail F2 console-loopback "port $PORT listeners are '$(listener_text "$PORT")', not 127.0.0.1 by the Console's MainPID ${CON_PID:-none}" + else fpass F2 console-loopback "healthz answers, and $PORT is bound to 127.0.0.1 only, by MainPID $CON_PID"; fi +} + +# F5's scan: an outer /usr/bin/bwrap owned by the stack's user with a claude descendant. Sets F5_OK +# when one pair has both labels exactly, F5_WHY to the last mismatch otherwise. +F5_OK="" F5_WHY="no /usr/bin/bwrap process of $S_USER with a claude descendant was seen while the reply was pending" +scan_f5() { + local pid ppid uids kids q c argv0 comm lo lc + local -A parent=() children=() bw=() + # One read of every PPid line and one of every exe link, so a scan stays short on a busy host. + while read -r pid ppid; do + parent[$pid]="$ppid" children[$ppid]+=" $pid" + done < <(grep -H '^PPid:' "$ROOT"/proc/[0-9]*/status 2>/dev/null | sed -n 's#^.*/proc/\([0-9]*\)/status:PPid:[[:space:]]*\([0-9]*\)$#\1 \2#p') + while read -r pid; do + [[ "$pid" =~ ^[0-9]+$ ]] && [ -n "${parent[$pid]:-}" ] && bw[$pid]=1 + done < <(find "$ROOT/proc" -mindepth 2 -maxdepth 2 -name exe -lname /usr/bin/bwrap -printf '%h\n' 2>/dev/null | sed 's#.*/##') + for pid in "${!bw[@]}"; do + [ -z "${bw[${parent[$pid]}]:-}" ] || continue + uids="$(proc_uids "$pid")" + [ -n "$MOSAIC_UID" ] && [ "${uids%% *}" = "$MOSAIC_UID" ] || continue + # Every descendant, breadth first. + kids=() q=("$pid") + while [ ${#q[@]} -gt 0 ]; do + for c in ${children[${q[0]}]:-}; do kids+=("$c") q+=("$c"); done + q=("${q[@]:1}") + done + for c in "${kids[@]}"; do + argv0="$(tr '\0' '\n' < "$ROOT/proc/$c/cmdline" 2>/dev/null | sed -n 1p)" + comm="$(cat "$ROOT/proc/$c/comm" 2>/dev/null)" + [[ "${argv0##*/}" == claude* ]] || [[ "$comm" == claude* ]] || continue + lo="$(proc_label "$pid")" lc="$(proc_label "$c")" + if [ "$lo" = "$LABEL_OUTER" ] && [ "$lc" = "$LABEL_CHILD" ]; then + F5_OK="bwrap $pid reads '$lo' and its claude descendant $c reads '$lc'"; return 0 + fi + F5_WHY="bwrap $pid reads '$lo' and its claude descendant $c reads '$lc', not '$LABEL_OUTER' and '$LABEL_CHILD'" + done + done + return 1 +} + +# F3 and F5 together: the message goes out in the background and the scan runs until it is answered. +check_f3_f5() { + local marker text rc=0 out err n tpid + marker="case1-accept-$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')" + text="$marker: this is the Case 1 acceptance; reply with one short line." + timeout 330 "${AS_USER[@]}" "$COPY/scripts/mosaic" talk chief "$text" --business "$BUSINESS" --tester --wait 300 > "$WORK/talk.out" 2> "$WORK/talk.err" & + tpid=$! + while :; do + scan_f5 && break + kill -0 "$tpid" 2>/dev/null || break + sleep 0.5 + done + wait "$tpid" || rc=$? + # Both the sent line and the reply are on stdout; an error is on stderr. + out="$(cat "$WORK/talk.out")" err="$(cat "$WORK/talk.err")" + n="$(grep -c . <<< "$out")" + if [ "$rc" = 0 ] && grep -q "^sent [^ ]* to chief" <<< "$out" && [ "$n" -ge 2 ]; then + fpass F3 tester-reply "chief replied to $marker" + else + ffail F3 tester-reply "talk exited $rc with $n lines on stdout; last: $(clean "$(tail -n 1 <<< "${err:-$out}")")" + fi + if [ -n "$F5_OK" ]; then F5_LINE=(fpass "$F5_OK"); else F5_LINE=(ffail "$F5_WHY"); fi +} + +check_f4() { + local rc=0 out + out="$("${AS_USER[@]}" "$COPY/scripts/case2-precheck.sh" --business "$BUSINESS" --profile factory 2>&1)" || rc=$? + if [ "$rc" = 0 ]; then fpass F4 precheck "case2-precheck.sh --profile factory exits 0" + else ffail F4 precheck "case2-precheck.sh --profile factory exited $rc; last: $(clean "$(tail -n 1 <<< "$out")")"; fi +} + +check_f6() { + local want="/opt/mosaic-stack/claude-$CLAUDE_PIN/" v + [[ "$CLAUDE" == "$want"* ]] || { ffail F6 claude-pin "--claude $CLAUDE is not under $want"; return; } + v="$("${AS_USER[@]}" "$CLAUDE" --version 2>/dev/null | sed -n 1p)" + if [ "$v" = "$CLAUDE_PIN (Claude Code)" ]; then fpass F6 claude-pin "$CLAUDE prints '$v'" + else ffail F6 claude-pin "$CLAUDE --version prints '$(clean "$v")', not '$CLAUDE_PIN (Claude Code)'"; fi +} + +# The PM check confirm runs (stack-release.sh pm_external): exactly one live launches list entry for +# launch.by, for this business. The stack's user runs the whole check and prints one result line. +F7_JS=' +const {readFileSync}=require("node:fs");const {spawnSync}=require("node:child_process"); +const [file,cli,b]=process.argv.slice(1); +let by; +try{by=JSON.parse(readFileSync(file,"utf8")).launch?.by}catch(e){console.log(`fail cannot read ${file}: ${e.code??"not JSON"}`);process.exit(0)} +if(typeof by!=="string"||!by){console.log("fail the business file has no launch.by");process.exit(0)} +const r=spawnSync(cli,["launches","list","--json"],{encoding:"utf8"}); +if(r.status!==0){console.log(`fail launches list exited ${r.status}`);process.exit(0)} +let l;try{l=JSON.parse(r.stdout)}catch{console.log("fail launches list printed no JSON");process.exit(0)} +const live=Array.isArray(l)?l.filter((x)=>x&&x.instance===by&&x.live===true):[]; +console.log(live.length!==1?`fail ${live.length} live launches of ${by}`:live[0].business!==b?`fail the live launch of ${by} is for ${live[0].business}`:`ok ${by} run ${live[0].run}`);' +check_f7() { + local out + out="$("${AS_USER[@]}" "$NODE" -e "$F7_JS" -- "$BUSINESS_FILE" "$COPY/scripts/mosaic" "$BUSINESS" 2>/dev/null | sed -n 1p)" + if [[ "$out" =~ ^ok\ [a-z0-9][a-z0-9-]*\ run\ [A-Za-z0-9._-]+$ ]]; then fpass F7 pm-live "one live launch: ${out#ok }" + else ffail F7 pm-live "$(clean "${out#fail }")"; fi +} + +check_f8() { + local s + s="$(stat -c '%U %a %F' -- "$ROOT$CRED" 2>/dev/null)" || s="missing" + if [ ! -L "$ROOT$CRED" ] && [ "$s" = "$S_USER 600 regular file" ]; then fpass F8 credential-file "$CRED is '$s'" + else ffail F8 credential-file "$CRED is '$s', not '$S_USER 600 regular file'"; fi +} + +check_f9() { + local s v + s="$(stat -c '%U:%G %a %F' -- "$ROOT$SETTINGS" 2>/dev/null)" || s="missing" + [ ! -L "$ROOT$SETTINGS" ] && [ "$s" = "root:root 644 regular file" ] || { ffail F9 autoupdater-off "$SETTINGS is '$s', not 'root:root 644 regular file'"; return; } + v="$("$ROOT$NODE" -e 'const s=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(JSON.stringify(s?.env?.DISABLE_AUTOUPDATER??null))' -- "$ROOT$SETTINGS" 2>/dev/null)" || v="unreadable" + if [ "$v" = '"1"' ]; then fpass F9 autoupdater-off "$SETTINGS is root:root 0644 with env.DISABLE_AUTOUPDATER \"1\"" + else ffail F9 autoupdater-off "$SETTINGS env.DISABLE_AUTOUPDATER is $v, not \"1\""; fi +} + +check_f10() { + local want="/opt/mosaic-stack/node-$NODE_PIN/bin/node" v + [ "$NODE" = "$want" ] || { ffail F10 node-pin "--node $NODE is not $want"; return; } + v="$("${AS_USER[@]}" "$NODE" --version 2>/dev/null | sed -n 1p)" + if [ "$v" = "$NODE_PIN" ]; then fpass F10 node-pin "$NODE prints $v" + else ffail F10 node-pin "$NODE --version prints '$(clean "$v")', not $NODE_PIN"; fi +} + +check_f11() { + local d="$ROOT$DATA_ROOT/discord" found + if [ -L "$d" ]; then ffail F11 no-discord "$DATA_ROOT/discord is a symlink"; return; fi + [ -d "$d" ] || { fpass F11 no-discord "$DATA_ROOT/discord is absent"; return; } + found="$(find "$d" -maxdepth 1 -name '*.json' -printf '%f ' 2>/dev/null)" + if [ -z "$found" ]; then fpass F11 no-discord "$DATA_ROOT/discord holds no binding" + else ffail F11 no-discord "$DATA_ROOT/discord holds bindings: ${found% }"; fi +} + +# --- P checks --- + +check_p1() { + local out + out="$(sudo -l -U "$S_USER" 2>&1)" + if grep -q "is not allowed to run sudo" <<< "$out"; then line P1 "$S_USER-sudo" closed "sudo -l: $(clean "$(sed -n 1p <<< "$out")")" + else line P1 "$S_USER-sudo" unnamed "sudo -l: $(clean "$(tr '\n' ' ' <<< "$out")")"; fi +} + +check_p2() { + local gs keys="$ROOT$S_HOME/.ssh/authorized_keys" kstate n + gs="$(groups_of "$S_USER" | tr '\n' ' ')"; gs="${gs% }" + n="$(wc -w <<< "$gs")" + if [ ! -e "$keys" ] && [ ! -L "$keys" ]; then kstate=absent + elif [ -f "$keys" ] && [ ! -L "$keys" ] && [ ! -s "$keys" ]; then kstate=empty + else kstate=present; fi + if [ "$n" = 1 ] && [ "$kstate" != present ]; then line P2 "$S_USER-groups-keys" closed "id -G is '$gs'; authorized_keys is $kstate" + else line P2 "$S_USER-groups-keys" unnamed "id -G is '$gs'; authorized_keys is $kstate"; fi +} + +check_p3() { + local n + n="$(sudo -l -U localadmin 2>&1 | grep -c NOPASSWD)" + line P3 localadmin-sudo "accepted Q183" "sudo -l -U localadmin lists $n NOPASSWD lines" +} + +check_p4() { line P4 pve-root-pbs "accepted Q182+Q183" "root on pve over VM 1008, and PBS backups that hold $S_HOME with the credential; this script can't see pve"; } + +check_p5() { + local gs reach + if [ -z "$(uid_of jarvis)" ]; then line P5 jarvis-reach closed "no jarvis account"; return; fi + gs="$(names_of_groups $(groups_of jarvis) | tr '\n' ' ')" + reach="$(tr ' ' '\n' <<< "$gs" | grep -xE 'sudo|docker|lxd' | tr '\n' ' ')" + if [ -z "$reach" ]; then line P5 jarvis-reach closed "jarvis is in none of sudo, docker and lxd (groups: ${gs% })" + else line P5 jarvis-reach unnamed "jarvis is in ${reach% }"; fi +} + +check_p6() { + local g m members bad=0 + for g in sudo docker lxd; do + members="$(db group "$g" | cut -d: -f4)" + for m in ${members//,/ }; do + [ "$g" = sudo ] && [ "$m" = localadmin ] && continue + line P6 "group-$g" unnamed "$m is a member of $g"; bad=1 + done + done + [ "$bad" = 1 ] || line P6 group-members closed "sudo holds only localadmin, and docker and lxd have no members" +} + +check_p7() { + local found + found="$(grep -rlw -- "$S_USER" "$ROOT/etc/sudoers.d" 2>/dev/null | sed "s#^$ROOT##" | tr '\n' ' ')" + if [ -z "$found" ]; then line P7 sudoers-d closed "no file under /etc/sudoers.d names $S_USER" + else line P7 sudoers-d unnamed "files naming $S_USER: ${found% }"; fi +} + +check_p8() { + local other + other="$(listeners 7331)" + if loopback_by "$PORT" "$CON_PID" && loopback_by "$TESTER_PORT" "$CON_PID" && [ -z "$other" ]; then + line P8 lan-listeners closed "$PORT and $TESTER_PORT are on 127.0.0.1 only, by MainPID $CON_PID; nothing on 7331" + else + line P8 lan-listeners unnamed "$PORT: $(listener_text "$PORT"); $TESTER_PORT: $(listener_text "$TESTER_PORT"); 7331: $(listener_text 7331); Console MainPID ${CON_PID:-none}" + fi +} + +check_p9() { + local out n missing=() rc=0 err mg others + out="$("$ROOT$HOSTCHECK" --check 2>/dev/null)" + for n in 1 2 3 4 5 6; do grep -q "^ok $n " <<< "$out" || missing+=("hostcheck item $n has no ok line") + done + err="$(sudo -u jarvis /usr/bin/bwrap --version 2>&1 >/dev/null)" || rc=$? + [ "$rc" != 0 ] && grep -q "Permission denied" <<< "$err" || missing+=("sudo -u jarvis /usr/bin/bwrap --version exited $rc without Permission denied") + mg="$(gid_of "$S_USER")" + [ -n "$mg" ] && [ -z "$(db group "$S_USER" | cut -d: -f4)" ] || missing+=("group $S_USER lists members, or $S_USER has no group") + others="$(db passwd | awk -F: -v g="$mg" -v u="$S_USER" '$4 == g && $1 != u { print $1 }' | tr '\n' ' ')" + [ -z "$others" ] || missing+=("other accounts have $S_USER's gid $mg: ${others% }") + if [ ${#missing[@]} = 0 ]; then + line P9 bwrap-userns "accepted mos-1096" "hostcheck items 1-6 ok, jarvis is denied bwrap, and group $S_USER has no other member" + else + for n in "${missing[@]}"; do line P9 bwrap-userns unnamed "$n"; done + fi +} + +check_p10() { + local floor + floor="$(cat "$ROOT/proc/sys/net/ipv4/ip_unprivileged_port_start" 2>/dev/null)" + if [[ "$floor" =~ ^[0-9]+$ ]] && [ "$floor" -gt "$PORT" ] && loopback_by "$TESTER_PORT" "$CON_PID"; then + line P10 tester-port-squat "accepted mos-1101" "the port floor $floor is above $PORT, and MainPID $CON_PID holds $TESTER_PORT" + else + line P10 tester-port-squat unnamed "the port floor is '${floor:-unreadable}' against $PORT; $TESTER_PORT: $(listener_text "$TESTER_PORT"); Console MainPID ${CON_PID:-none}" + fi +} + +check_p11() { + local en before + en="$(systemctl is-enabled mosaic-hostcheck.service 2>/dev/null)" + before="$(prop "$(unit_show mosaic-hostcheck.service Before)" Before)" + if [ "$en" = enabled ] && grep -qxF -- "$BUS_UNIT" <<< "${before// /$'\n'}"; then + line P11 hostcheck-before-bus closed "mosaic-hostcheck.service is enabled, and its Before= names $BUS_UNIT" + else + line P11 hostcheck-before-bus unnamed "mosaic-hostcheck.service is '${en:-unknown}', Before='$before'; it needs enabled and $BUS_UNIT" + fi +} + +check_f1 +check_f2 +F5_LINE=() +check_f3_f5 +check_f4 +"${F5_LINE[0]}" F5 bwrap-label "${F5_LINE[@]:1}" +check_f6 +check_f7 +check_f8 +check_f9 +check_f10 +check_f11 +check_p1; check_p2; check_p3; check_p4; check_p5; check_p6 +check_p7; check_p8; check_p9; check_p10; check_p11 + +for m in "${MARKS[@]}"; do echo "count $m ${COUNT[$m]:-0}"; done +if [ "$F_FAILED" = 0 ]; then echo "case1-accept: every F check passes; send the whole output to Duckworth, and each unnamed line to Mos"; exit 0; fi +echo "case1-accept: an F check failed, so the row fails" +exit 1