Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e3c4767c8 | ||
|
|
37171f65ba | ||
|
|
ddf7dce6c6 | ||
|
|
08578991c7 | ||
|
|
ca500082af |
@@ -0,0 +1,161 @@
|
||||
# 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/<id>.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 <instance> run <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/<MainPID>/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-<pin>`;
|
||||
- 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=<node dir>:/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.
|
||||
|
||||
## Correction to deviation 6 (follow-up, 2026-10-21)
|
||||
|
||||
Deviation 6 says the node F9 runs is root's own. At 37171f65b the script didn't check that. F9 ran
|
||||
`--node` as root before F10's path check, so the header's "root itself only reads" was false
|
||||
(Duckworth's N1, `agents/duckworth/work/row195/REVIEW-m1.md` at 6afcee2a5). A host installed as designed
|
||||
was safe, because `case2-unit.sh` refused a non-root-owned `--node`. The fix and its tests are in
|
||||
`FOLLOWUP.md` next to this file.
|
||||
@@ -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-<pin>/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 `<configDir>/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 | `<configDir>/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-<v>/bin/node`, `<v>` 24 or later | the go names `<v>` and its sha256 |
|
||||
| claude | `/opt/mosaic-stack/claude-<pin>/node_modules/@anthropic-ai/claude-code/bin/claude.exe`, `<pin>` 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-<v>/bin/node
|
||||
--claude
|
||||
/opt/mosaic-stack/claude-<pin>/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-<v>/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 `<dataRoot>/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/<sha>.bundle --sha <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 <copy> [--restore <ACT> [--events N]]` | ops-01 | Mos per run. Mos or Sage picks `<copy>` 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 `<dataRoot>/backups/stack/<ACT>`, 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 <pin> --node-pin <v>
|
||||
```
|
||||
|
||||
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=<node dir>:/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, `<id> <path> <mark>
|
||||
<evidence>`, 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 `[email protected]` and `mosaic-webui.service`: active, `User=mosaic`, and the uid in `/proc/<MainPID>/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 [email protected]`. A LAN listener waits for Q175 SSO (sage-1451 Q3) |
|
||||
| F3 | one tester message gets a reply | as `mosaic`: `mosaic talk chief <marker> --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/<pid>/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-<pin>/`, and its `--version` as `mosaic` prints `<pin> (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-<v>/bin/node` with `<v>` the `--node-pin`, and its `--version` prints `<v>`. 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@<business>.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-<pin>`;
|
||||
- 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=<node dir>:...`, 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 [email protected]` 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 `<sha>`, `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 `[email protected]`;
|
||||
- the business file name;
|
||||
- `case1-accept.sh --business`.
|
||||
|
||||
The landed boot unit `packages/cli/systemd/mosaic-hostcheck.service` has
|
||||
`[email protected]`. 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.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Row 195 follow-up: Duckworth's N1-N3 on `scripts/case1-accept.sh`
|
||||
|
||||
Duckworth approved row 195's manifest round 1 (duckworth-0088, `agents/duckworth/work/row195/REVIEW-m1.md`
|
||||
at 6afcee2a5) with three notes that don't block. Sage asked for this follow-up in sage-1876 and opens the
|
||||
row after 195 lands. The branch is `darkwing/row195-n1`, on 37171f65b.
|
||||
|
||||
## What changes
|
||||
|
||||
N1. F9 ran `$ROOT$NODE` as root (`case1-accept.sh:298` at 37171f65b) before F10 checked that `--node` is
|
||||
the pinned path. Duckworth offered two fixes: F9's parse through `AS_USER`, or F10's path check first. I
|
||||
took the path check, plus one more:
|
||||
- F9 now runs node only when `--node` equals `/opt/mosaic-stack/node-<--node-pin>/bin/node`.
|
||||
- Then `not_root_only` walks from that file up to `/`. Each entry must be the running user's (root's on the
|
||||
host) with no group or other write, the rule `case2-unit.sh` `root_only` applied when it rendered the
|
||||
units. A symlink fails it, because its mode is 777. The first entry that breaks the rule goes in the
|
||||
evidence.
|
||||
|
||||
I didn't run the parse through `AS_USER`. That would hand a check about a root file to the stack's user,
|
||||
and a process running as `mosaic` could change what that node prints. Rechecking ownership costs one
|
||||
loop and doesn't depend on how the host was installed.
|
||||
|
||||
N2. The header (:9-15) and the `docs/TOOLS.md` entry now list what root runs: `systemctl show` and
|
||||
`is-enabled` (F1, P11), `curl` on the loopback healthz (F2), `sudo -l` (P1, P3), `sudo -u jarvis bwrap
|
||||
--version` and `hostcheck.sh --check` (P9), and node for F9 under the rule above.
|
||||
|
||||
N3. F6 matched `--claude` by prefix, so `/opt/mosaic-stack/claude-<pin>/../claude-2.0.9/...` passed the
|
||||
path check. F6 now fails on any `..` segment, before it runs `--version`. F10 compares for equality, so
|
||||
it needed no change.
|
||||
|
||||
BUILD.md gets a correction entry for deviation 6. DESIGN.md is unchanged. Gosalyn's Q4 answer makes the
|
||||
"either works" wording at `DESIGN.md:336-338` stale, but an approved design changes only with Sage's
|
||||
authorization, so it's left for Sage to rule on.
|
||||
|
||||
## Tests
|
||||
|
||||
`packages/cli/tests/case1-accept.test.mjs`:
|
||||
- The node stub touches `node-as-root` in the state directory when something runs it outside the fake
|
||||
`sudo` (which sets HOME to the stack's home). `host()` sets the node file's directories and the scratch
|
||||
root to 0755, so the umask can't fail F9.
|
||||
- "every check passing" also asserts that root ran the pinned node.
|
||||
- New: "F9 won't run --node as root off the pin path or with a writable directory above it". Another node
|
||||
path fails F9 and F10, with no root run. A group-writable `bin/` and a symlinked node each fail F9
|
||||
alone, with the bad path in the evidence and no root run.
|
||||
- The F6 test gains a `..` case: a path under `claude-<pin>/` by prefix that names 2.0.9's binary, which
|
||||
prints the pin. F6 alone fails, with "has a .. segment".
|
||||
|
||||
That makes 23 tests. None has run. Every run waits for Sage's grant.
|
||||
|
||||
## Mutants
|
||||
|
||||
The twelve from row 195 still apply, unchanged. These four are new, each one exact replacement:
|
||||
|
||||
| # | Mutant | Expected killer |
|
||||
|---|---|---|
|
||||
| 13 | F9 runs any `--node` (the pin check becomes `true`) | F9 won't run (another node) |
|
||||
| 14 | F9 skips the ownership walk (`bad=""`) | F9 won't run (writable `bin/`, symlink) |
|
||||
| 15 | `not_root_only` checks only the file, not the directories above it | F9 won't run (writable `bin/`) |
|
||||
| 16 | F6 accepts a `..` segment | F6 stub, path and `..` |
|
||||
|
||||
## Slot plan
|
||||
|
||||
Same shape as row 195's steps, on the follow-up's tip: the single test file, then the sixteen mutants,
|
||||
then the gate (the cli suite and every `scripts/test-*.sh`), each behind the busy checks and chained
|
||||
with `&&`. The scripts are ready in `/mnt/storage/scratch/darkwing-195n-out/`.
|
||||
@@ -893,6 +893,43 @@ 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=<node dir>:/usr/local/bin:/usr/bin:/bin` from `/`.
|
||||
Root itself reads `ss`, `/proc`, the account database and file metadata. It
|
||||
runs `systemctl show` and `is-enabled`, `curl` on the Console's loopback
|
||||
`/healthz`, `sudo -l`, `sudo -u jarvis bwrap --version`, `hostcheck.sh
|
||||
--check`, and node to parse `managed-settings.json` for F9. F9 runs node only
|
||||
when `--node` is the `--node-pin` path, and the file and every directory above
|
||||
it are root's with no group or other write. The one write is F3's message and
|
||||
its reply, and it never opens `claude-session.env`.
|
||||
|
||||
It prints one line per check, `<id> <path> <mark> <evidence>`, then
|
||||
`count <mark> 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 <ruling>` (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
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
// 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, renameSync, 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. Node marks a run outside
|
||||
// the fake sudo, which sets HOME to the stack's home, so a test can tell whether root ran it.
|
||||
const NODE_STUB = `[ "$HOME" = "$ROOT_DIR/srv/mosaic" ] || touch "$HC/node-as-root"
|
||||
[ "\${1:-}" = --version ] && { cat "$HC/node-version"; exit 0; }
|
||||
exec "$REAL_NODE" "$@"`;
|
||||
const TREE = {
|
||||
[NODE]: NODE_STUB,
|
||||
[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 = {
|
||||
"[email protected]": "ActiveState=active\nUser=mosaic\nMainPID=100\n",
|
||||
"show-mosaic-webui.service": "ActiveState=active\nUser=mosaic\nMainPID=200\n",
|
||||
"show-mosaic-hostcheck.service": "[email protected] 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);
|
||||
// F9 runs node only when it and each directory above it have no group or other write, whatever the umask.
|
||||
for (let d = dirname(NODE); d !== "/"; d = dirname(d)) chmodSync(join(root, d), 0o755);
|
||||
chmodSync(root, 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));
|
||||
assert.ok(existsSync(join(h.hc, "node-as-root")), "F9 runs the pinned, root-only node as root");
|
||||
});
|
||||
|
||||
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, on a path outside claude-<pin> and on a .. segment", (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);
|
||||
// Under claude-<pin>/ by prefix, but the binary it names is 2.0.9's, which answers with the pin.
|
||||
h = host(t);
|
||||
const dotdot = CLAUDE.replace(`claude-${CLAUDE_PIN}/`, `claude-${CLAUDE_PIN}/../claude-2.0.9/`);
|
||||
put(h.args, ARGS.map((a) => (a === CLAUDE ? dotdot : 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"], r.out);
|
||||
assert.match(r.out, /^F6 claude-pin FAIL --claude \S+\/claude-2\.1\.0\/\.\.\/claude-2\.0\.9\/\S+ has a \.\. segment$/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: F9 won't run --node as root off the pin path or with a writable directory above it", (t) => {
|
||||
// Another node that works for the stack's user: F9 refuses to run it, and F10 fails on the path.
|
||||
let h = host(t);
|
||||
const other = NODE.replace(NODE_PIN, "v24.0.9");
|
||||
put(h.args, ARGS.map((a) => (a === NODE ? other : a)).join("\n") + "\n", 0o644);
|
||||
put(join(h.root, other), `#!/usr/bin/env bash\n${NODE_STUB}\n`, 0o755);
|
||||
let r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F9", "F10"], r.out);
|
||||
assert.match(r.out, /^F9 autoupdater-off FAIL root won't run --node \/opt\/mosaic-stack\/node-v24\.0\.9\/bin\/node: it is not the --node-pin path$/m);
|
||||
assert.ok(!existsSync(join(h.hc, "node-as-root")));
|
||||
// The pinned path, under a group-writable directory and then a symlinked node: F9 alone fails, and
|
||||
// root doesn't run it.
|
||||
for (const [fault, bad] of [
|
||||
[(x) => chmodSync(join(x.root, dirname(NODE)), 0o775), "/opt/mosaic-stack/node-v24\\.1\\.0/bin \\(uid \\d+, mode 775\\)"],
|
||||
[(x) => { const f = join(x.root, NODE); renameSync(f, `${f}.real`); symlinkSync(`${f}.real`, f); }, "/opt/mosaic-stack/node-v24\\.1\\.0/bin/node \\(uid \\d+, mode 777\\)"],
|
||||
]) {
|
||||
h = host(t);
|
||||
fault(h);
|
||||
r = mustRun(h);
|
||||
assert.deepEqual(failed(r), ["F9"], r.out);
|
||||
assert.match(r.out, new RegExp(`^F9 autoupdater-off FAIL root won't run --node \\S+: ${bad} is not root's alone$`, "m"));
|
||||
assert.ok(!existsSync(join(h.hc, "node-as-root")));
|
||||
}
|
||||
});
|
||||
|
||||
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"), "[email protected] 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=<node dir>:...", (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"]);
|
||||
});
|
||||
Executable
+465
@@ -0,0 +1,465 @@
|
||||
#!/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=<node dir>:/usr/local/bin:/usr/bin:/bin` from /. Root itself reads ss,
|
||||
# /proc, the account database and file metadata, and runs these: systemctl show and is-enabled (F1,
|
||||
# P11), curl on the Console's loopback healthz (F2), sudo -l (P1, P3), sudo -u jarvis bwrap --version
|
||||
# and hostcheck.sh --check (P9), and node to parse managed-settings.json (F9). F9 runs node only when
|
||||
# --node is the pinned path and it and every directory above it are root's with no group or other
|
||||
# write. The script changes nothing on the host but the message F3 sends and its reply, and it never
|
||||
# opens claude-session.env.
|
||||
#
|
||||
# One line per check, `<id> <path> <mark> <evidence>`, then `count <mark> N` for each mark and a
|
||||
# result line. F checks are `pass` or `FAIL`. P checks are `closed`, `accepted <ruling>` 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; }
|
||||
[[ "$CLAUDE/" != */../* ]] || { ffail F6 claude-pin "--claude $CLAUDE has a .. segment"; 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
|
||||
}
|
||||
|
||||
# Empty when $1 and every directory above it, up to /, are the running user's (root's on the host) with
|
||||
# no group or other write: case2-unit.sh's root_only rule for --node, checked again before root runs it.
|
||||
# Otherwise the first path that breaks the rule. A symlink breaks it, because its mode is 777.
|
||||
not_root_only() {
|
||||
local p="$1" st
|
||||
while :; do
|
||||
st="$(stat -c '%u %a' -- "$ROOT$p" 2>/dev/null)" || { echo "$p (cannot stat)"; return; }
|
||||
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || { echo "$p (uid ${st% *}, mode ${st#* })"; return; }
|
||||
[ "$p" = / ] && return
|
||||
p="$(dirname -- "$p")"
|
||||
done
|
||||
}
|
||||
|
||||
check_f9() {
|
||||
local s v bad
|
||||
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; }
|
||||
# Root runs node here, so it runs only the pinned path, and only when nothing but root can replace it.
|
||||
[ "$NODE" = "/opt/mosaic-stack/node-$NODE_PIN/bin/node" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: it is not the --node-pin path"; return; }
|
||||
bad="$(not_root_only "$NODE")"
|
||||
[ -z "$bad" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: $bad is not root's alone"; 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
|
||||
Reference in New Issue
Block a user