From 4a51d392cafec5e1e43f863c2507eb0f115998e9 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 21 Oct 2026 02:37:08 -0500 Subject: [PATCH] row 195: case1-accept.sh, root's Case 1 acceptance (F1-F11, P1-P11), its test and TOOLS.md entry; DESIGN r7 doc fixes (#1693) Built to DESIGN r7 section 5, approved by Duckworth (duckworth-0087, 58d930f89), go sage-1817. BUILD.md holds the deviations and the twelve mutants. No test has run yet: singles, mutants and the gate wait for Sage's slot. DESIGN.md, doc-only per sage-1817: Duckworth's N1 (the human-id check is broker.mjs:321, not :245), and row 226's state at :21, :326 and :340 (assigned to Gosalyn, briefed, not started; the section 6 items were sent and acknowledged as gosalyn-0085). --- agents/darkwing/work/queue-195/BUILD.md | 122 ++++++ agents/darkwing/work/queue-195/DESIGN.md | 8 +- docs/TOOLS.md | 33 ++ packages/cli/tests/case1-accept.test.mjs | 487 +++++++++++++++++++++++ scripts/case1-accept.sh | 443 +++++++++++++++++++++ 5 files changed, 1089 insertions(+), 4 deletions(-) create mode 100644 agents/darkwing/work/queue-195/BUILD.md create mode 100644 packages/cli/tests/case1-accept.test.mjs create mode 100755 scripts/case1-accept.sh diff --git a/agents/darkwing/work/queue-195/BUILD.md b/agents/darkwing/work/queue-195/BUILD.md new file mode 100644 index 000000000..efbd815a8 --- /dev/null +++ b/agents/darkwing/work/queue-195/BUILD.md @@ -0,0 +1,122 @@ +# Row 195 build: `scripts/case1-accept.sh` + +Row 195 (#1693), the Case 1 install on 10.1.1.53. Design r7 at 5425ea2f3, approved by Duckworth +(duckworth-0087, `agents/duckworth/work/row195/REVIEW-r7.md` at 58d930f89; Gitea 29962). Sage's go to build +is sage-1817. + +## What this commit holds + +- `scripts/case1-accept.sh` (new, 100755): section 5's acceptance, F1-F11 and P1-P11. +- `packages/cli/tests/case1-accept.test.mjs` (new): section 5's cases. +- `docs/TOOLS.md`: the `case1-accept.sh` entry after the hostcheck entry. +- This file. +- `DESIGN.md`, doc-only fixes Sage named in sage-1817, with no new design round: + - Duckworth's N1. Section 1's human-id row cited `broker.mjs:245`; the check is `broker.mjs:321`. + - Row 226's state at :21 and :326. It said "unclaimed"; 226 is assigned to Gosalyn, briefed and not + started (sage-1814). + - :340 said I'd send the section 6 items to Gosalyn once 226 was claimed. I sent them on 2026-10-21 + and Gosalyn acknowledged (gosalyn-0085), so the line now says that. + +Nothing else changes. No `case2-*.sh`, `stack-upgrade.sh`, `stack-release.sh`, `case1-hostcheck.sh` or run +sheet edit, and nothing touches .53 (sage-1809). + +## Deviations from section 5, and choices it left open + +1. P4's mark is the single token `accepted Q182+Q183`. Section 5 lists `accepted Q182` and `accepted + Q183` as marks and gives P4 both. One line can carry one mark, and P4 is the only Q182 path, so the + count list has `accepted Q182+Q183` in place of `accepted Q182`. +2. The test seam replaces `getent`. Under `--root`, the script reads `DIR/etc/passwd` and `DIR/etc/group` + with awk. Without it, the same helper calls `getent`. The groups of a user are its primary gid, then + each group whose member list names it, which is what `id -G` prints. So there is no fake `id`, and + the script never calls `id` for another user. +3. P10 reads the port floor from `/proc/sys/net/ipv4/ip_unprivileged_port_start` rather than calling + `sysctl`, so the seam covers it the way hostcheck's item 7 is covered. There is no fake `sysctl`. +4. There is no fake `bwrap`, `claude`, `node` or `hostcheck.sh` on PATH. The script runs each by the + absolute path the args file or the install names, so the test puts stubs at those paths under + `--root`, and the fake `sudo` runs the stack's user's commands from the tree. +5. The args file must say `--profile factory` and name `--business`. Both are new refusals beyond + `stack-upgrade.sh`'s rules. A library file, or one for another business, would make F4 and F7 + check the wrong thing. After `--`, `--port` and `--tester-port` must be numeric. The args file is the + only source of the ports. +6. F8, F9 and F11 read file metadata as root: `stat`, `-L` and `find -maxdepth 1 -name '*.json'`. None + opens the credential. F9 parses `managed-settings.json` with the pinned node, run by root: that node + and that file are both root's own, installed by ops-01. +7. F7 reads `launch.by` from `$HOME/.config/mosaic-dev/businesses/.json` as the stack's user. That + path is `businessFilePath(id)` under the default config, which is what the unit uses. It doesn't load + the copy's `business.mjs` as `stack-release.sh`'s `plugins launchby` does. The user runs the whole + check and prints one line, and root accepts only `ok run ` (sage-1653: root doesn't + parse the user's output beyond a fixed pattern). +8. F5 names a process claude when its argv0's basename or its `comm` starts with `claude`. The window + confirms this against a real role session. If the bwrap'd claude shows another name, F5 fails closed + and the fix is this match, not the label check. +9. F3 and F5 run together. `talk` goes out in the background under `timeout 330`, and the `/proc` scan + repeats every half second until it finds a labelled pair or `talk` exits. F3's line prints first, then + F4, then F5, so the output keeps id order. F3 passes on exit 0 with the `sent` line and a reply line, + both counted on stdout only (`cli.mjs:395` and the `formatMessage` write); talk's error goes to stderr. +12. `--claude` must match `^/[A-Za-z0-9/._+@-]+$`. The `@` is for `node_modules/@anthropic-ai/...`; + `stack-upgrade.sh` doesn't check `--claude` at all. +10. F1 requires all four uids in `/proc//status` (real, effective, saved, filesystem) to be the + user's. +11. Evidence text the stack's user controls (talk's last line, the precheck's last line, F6's and F10's + `--version`) is cut to printable ASCII, one line, 200 bytes. + +## Tests + +`packages/cli/tests/case1-accept.test.mjs`, 22 tests. Every case builds a scratch host with every F check +passing and every P check closed or accepted, then changes one thing: +- all pass: exit 0, ids in order, each P mark, the count lines match the check lines, and the stand-in + credential's value appears nowhere; +- each F check failing alone exits 1 with only its line failing; +- F3 on exit 0 with only the `sent` line, and its evidence taking talk's stderr; +- F1 on a failed unit and on `User=root`; F2 on healthz failing and on another pid holding the port; +- F5 on `unpriv_bwrap (enforce)`, `bwrap//&unpriv_bwrap (complain)`, a trailing space on either label, + no bwrap of the user's, and passing on `comm` alone; +- F6 on a stub that prints nothing and on a path outside `claude-`; +- F7 on two live PM launches, a live one for another business, and no `launch.by`; +- F8 on 0640, on another owner and on a symlink, never printing the credential; +- F9 on a group-writable mode, another owner and a number for the flag; +- F11 on one `*.json` under `discord/`, and passing on an empty directory; +- each P check's unnamed mark, with the line count each fault makes: P6 one line per member, P9 one per + missing item; P2, P5, P10 and P11 in more than one way; +- every P check unnamed at once still exits 0; +- nine bad args files refuse with exit 2 before any command runs, each with its own refusal text; +- usage errors, and a user's run without `--root`, exit 2; +- every in-instance call goes through `sudo -u mosaic -H env PATH=:/usr/local/bin:/usr/bin:/bin`, + read from the fake `sudo`'s argv, and root's other `sudo` calls are exactly `-l -U mosaic`, + `-l -U localadmin` and the jarvis bwrap probe; +- the host check records `--check` and never `--enforce`. + +## Mutants + +Section 5's twelve, each one exact replacement in `scripts/case1-accept.sh`, run by +`/mnt/storage/scratch/darkwing-195-out/mutants.py` in Sage's slot. Results go here after the slot. + +| # | Mutant | Killed by | +|---|---|---| +| 1 | F1 passes on a MainPID owned by root | each F alone (F1) | +| 2 | F2 accepts a listener on 0.0.0.0 | each F alone (F2) | +| 3 | F3 passes without a reply | each F alone (F3), F3 sent only | +| 4 | F5 matches a substring | F5 labels (trailing space) | +| 5 | F6 skips the `--version` check | each F alone (F6), F6 stub | +| 6 | F8 skips the owner check | F8 another owner | +| 7 | F11 ignores `discord/` | each F alone (F11), F11 | +| 8 | P1 closes on any output | P unnamed (P1) | +| 9 | P5 maps to `closed` with jarvis in docker | P unnamed (P5), P5 names docker | +| 10 | P9 drops the jarvis item | P unnamed (P9 count 3), P9 each item | +| 11 | P11 accepts any `Before=` | P unnamed (P11) | +| 12 | a P check's `unnamed` sets the exit to 1 | P unnamed, every P unnamed | + +## 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 + +Pending Sage's slot: the single test file, the twelve mutants, then the gate (the cli suite and every +`scripts/test-*.sh`). No test ran before the slot. diff --git a/agents/darkwing/work/queue-195/DESIGN.md b/agents/darkwing/work/queue-195/DESIGN.md index 17dfbad95..3b0a405ba 100644 --- a/agents/darkwing/work/queue-195/DESIGN.md +++ b/agents/darkwing/work/queue-195/DESIGN.md @@ -18,7 +18,7 @@ values and the Case 1 acceptance". So r7 is mostly a cut. What r6 designed now s | 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` | briefed, unclaimed | +| 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: @@ -79,7 +79,7 @@ Instance `dev`, row 190's default (mos-1118 choice 4). `~` is `mosaic`'s home. | business | `mosaic-stack`, file `/businesses/mosaic-stack.json` | fixed (section 7) | | profile | `factory` for `case2-install.sh`, `case2-unit.sh` and `case2-precheck.sh` | fixed | | roles | the factory set from `roles/`, with a chief that has a harness var | the go names the set (section 2) | -| human id | Jason's, not `tester` (`broker.mjs:245`) | the go | +| human id | Jason's, not `tester` (`broker.mjs:321`) | the go | | model credential | `/secrets/claude-session.env`, `mosaic` 0600, one line `CLAUDE_CODE_OAUTH_TOKEN=...` | Jason places it (section 3) | | token slot path | none | section 2 | | node | `/opt/mosaic-stack/node-/bin/node`, `` 24 or later | the go names `` and its sha256 | @@ -323,7 +323,7 @@ Install rollback, any step before W13 passes. Stop and disable both units, remov `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 Gosalyn's and unclaimed, so I'm listing what the window +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 @@ -337,7 +337,7 @@ needs from its design, not designing it: fixed above. 5. Whether it runs `adopt`. If it doesn't, W12 does. -I'll send these to Gosalyn when the row is claimed, and the run sheet takes whichever answer 226 gives. +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 diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 31c0990ef..0329b22a7 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -892,6 +892,39 @@ apt hook runs it after every package run, and `mosaic-hostcheck.service` and exits 0, or 1 when the download fails. Exit 2 is usage or a refusal: root takes no `--root` (the test seam), and other users run only with it. +#### Root's acceptance run (`scripts/case1-accept.sh`, row 195) + +``` +case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N +``` + +Root runs it on the Case 1 host after the install window, from a root-owned +checkout. It reads the user, home, copy, node, claude and both ports from the +args file (`/etc/mosaic-stack/dev.args` on .53), which must pass +`stack-upgrade.sh`'s rules, say `--profile factory` and name `--business`. +Every command that runs the stack's code runs as +`sudo -u USER -H env PATH=:/usr/local/bin:/usr/bin:/bin` from `/`. +Root only reads systemd, `ss`, `/proc`, the account database and file +metadata. The one write is F3's message and its reply, and it never opens +`claude-session.env`. + +It prints one line per check, ` `, then +`count N` for each mark and a result line. The functional checks +F1-F11 are `pass` or `FAIL`: both units active as the user, the Console on +127.0.0.1 only, a tester message to chief answered, `case2-precheck.sh +--profile factory`, the bwrap labels of a live role session, the claude and +node pins, one live PM launch, the credential file's owner and mode, the +autoupdater off, no Discord binding. The host paths P1-P11 are `closed`, +`accepted ` (Q182, Q183, mos-1096, mos-1101) or `unnamed`, which means +no ruling names the path; each `unnamed` line goes to Mos. P9 runs +`hostcheck.sh --check`, never `--enforce`. + +Exit 0 when every F check passes, 1 when one fails, 2 on usage or a refusal: +a bad args file, or `--root` misused. A P check never changes the exit. +`--root DIR` is the test seam for `packages/cli/tests/case1-accept.test.mjs`; +root takes none, and other users run only with it. Design: row 195 r7 +section 5 (`agents/darkwing/work/queue-195/DESIGN.md`). + ## Console service (`scripts/webui-service.sh`) ```bash diff --git a/packages/cli/tests/case1-accept.test.mjs b/packages/cli/tests/case1-accept.test.mjs new file mode 100644 index 000000000..bdbb4ad66 --- /dev/null +++ b/packages/cli/tests/case1-accept.test.mjs @@ -0,0 +1,487 @@ +// Row 195 (#1693): scripts/case1-accept.sh, root's acceptance run on the Case 1 host. Design r7 section 5 +// (agents/darkwing/work/queue-195/DESIGN.md); deviations in agents/darkwing/work/queue-195/BUILD.md. +// Every case runs the script with --root on a scratch host tree: /etc, /proc, /srv/mosaic, /opt and the +// host check live under it. Fake systemctl, curl, ss, sudo and stat on PATH record their argv; the fake +// sudo runs the stack's user's commands from the tree. Nothing here reads or writes the real /etc, +// /proc or /srv, and the stand-in credential is not a credential. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmp } from "./helpers.mjs"; + +const REPO = fileURLToPath(new URL("../../../", import.meta.url)); +const SCRIPT = join(REPO, "scripts", "case1-accept.sh"); +const COPY = "0123456789abcdef0123456789abcdef01234567"; +const NODE_PIN = "v24.1.0", CLAUDE_PIN = "2.1.0"; +const NODE = `/opt/mosaic-stack/node-${NODE_PIN}/bin/node`; +const CLAUDE = `/opt/mosaic-stack/claude-${CLAUDE_PIN}/node_modules/@anthropic-ai/claude-code/bin/claude.exe`; +const HOME = "/srv/mosaic"; +const CLI = `${HOME}/.local/share/mosaic-stack/copies/${COPY}/scripts/mosaic`; +const PRECHECK = `${HOME}/.local/share/mosaic-stack/copies/${COPY}/scripts/case2-precheck.sh`; +const CRED = `${HOME}/.config/mosaic-dev/secrets/claude-session.env`; +const CRED_VALUE = "stand-in-not-a-credential-7f3e2a"; +const AS_MOSAIC = `-u mosaic -H env PATH=/opt/mosaic-stack/node-${NODE_PIN}/bin:/usr/local/bin:/usr/bin:/bin`; +const ARGS = ["--profile", "factory", "--user", "mosaic", "--home", HOME, "--business", "mosaic-stack", "--copy", COPY, + "--node", NODE, "--claude", CLAUDE, "--pm", "--", "--port", "732", "--business", "mosaic-stack", "--tester-port", "7333"]; +const OUTER = "bwrap (enforce)", CHILD = "bwrap//&unpriv_bwrap (enforce)"; +const MARKS = ["pass", "FAIL", "closed", "accepted Q182+Q183", "accepted Q183", "accepted mos-1096", "accepted mos-1101", "unnamed"]; +const P_PASS = { P1: "closed", P2: "closed", P3: "accepted Q183", P4: "accepted Q182+Q183", P5: "closed", P6: "closed", + P7: "closed", P8: "closed", P9: "accepted mos-1096", P10: "accepted mos-1101", P11: "closed" }; +const PASSWD = "root:x:0:0:root:/root:/bin/bash\nmosaic:x:997:997::/srv/mosaic:/usr/sbin/nologin\nlocaladmin:x:1000:1000::/home/localadmin:/bin/bash\njarvis:x:1001:1001::/home/jarvis:/bin/bash\n"; +const GROUP = "root:x:0:\nsudo:x:27:localadmin\nmosaic:x:997:\nlxd:x:998:\ndocker:x:999:\nlocaladmin:x:1000:\njarvis:x:1001:\n"; +const SS = "LISTEN 0 511 127.0.0.1:732 0.0.0.0:* users:((\"node\",pid=200,fd=21))\n" + + "LISTEN 0 511 127.0.0.1:7333 0.0.0.0:* users:((\"node\",pid=200,fd=22))\n" + + "LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=900,fd=3))\n"; +const HOSTCHECK_OK = [1, 2, 3, 4, 5, 6, 7].map((n) => `ok ${n} item ${n} holds`).join("\n") + "\nnote bubblewrap 0.9.0 is the install pin\n"; + +function put(file, text, mode) { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, text); + if (mode) chmodSync(file, mode); +} +const edit = (file, f) => writeFileSync(file, f(readFileSync(file, "utf8"))); +const log = (name) => `printf '%s' ${JSON.stringify(name)} >> "$HC/calls.log"; printf ' %s' "$@" >> "$HC/calls.log"; echo >> "$HC/calls.log"`; + +// Fakes on PATH. Each records its argv and answers from a file in the state directory. +const FAKES = { + systemctl: `case "$1" in + show) [ -f "$HC/show-$2" ] && cat "$HC/show-$2" ;; + is-enabled) cat "$HC/enabled-$2" 2>/dev/null || { echo disabled; exit 1; } ;; +esac`, + curl: `exit "$(cat "$HC/curl-rc")"`, + ss: `cat "$HC/ss"`, + // The credential and the settings file answer from the state directory, as root would see them, and + // only those calls are recorded; everything else is the real stat, which reads the args file. + stat: `case "\${@: -1}" in + *claude-session.env) ${log("stat")}; cat "$HC/stat-cred" ;; + *managed-settings.json) ${log("stat")}; cat "$HC/stat-settings" ;; + *) exec /usr/bin/stat "$@" ;; +esac`, + // -l -U USER prints the state file; -u jarvis is the bwrap probe; -u mosaic -H env PATH=... CMD runs + // CMD from the tree, with the PATH entries and each absolute argument that exists there moved under it. + sudo: `case "$1" in + -l) [ -f "$HC/sudo-l-$3" ] && cat "$HC/sudo-l-$3"; exit 0 ;; + -u) ;; + *) exit 97 ;; +esac +user="$2"; shift 2 +if [ "$user" = jarvis ]; then cat "$HC/jarvis-bwrap" >&2; exit "$(cat "$HC/jarvis-bwrap-rc")"; fi +[ "$user" = mosaic ] && [ "$1" = -H ] && [ "$2" = env ] && [[ "$3" == PATH=* ]] || exit 98 +p="\${3#PATH=}"; shift 3 +np=""; IFS=: read -ra parts <<< "$p"; for x in "\${parts[@]}"; do np+="$ROOT_DIR$x:"; done +cmd="$1"; shift +args=(); for a in "$@"; do if [[ "$a" == /* ]] && [ -e "$ROOT_DIR$a" ]; then args+=("$ROOT_DIR$a"); else args+=("$a"); fi; done +HOME="$ROOT_DIR/srv/mosaic" PATH="$np$PATH" exec "$ROOT_DIR$cmd" "\${args[@]}"`, +}; + +// Executables inside the tree, run by the fake sudo or by the script itself. +const TREE = { + [NODE]: `[ "\${1:-}" = --version ] && { cat "$HC/node-version"; exit 0; } +exec "$REAL_NODE" "$@"`, + [CLAUDE]: `[ "\${1:-}" = --version ] && cat "$HC/claude-version"; exit 0`, + [CLI]: `${log("mosaic")} +case "$1" in + talk) case "$(cat "$HC/talk-mode")" in + reply) echo "sent ab12cd34 to chief"; echo "[chief -> tester] seen" ;; + noreply) echo "sent ab12cd34 to chief"; echo "no reply from chief within 300 s" >&2; exit 1 ;; + sentonly) echo "sent ab12cd34 to chief" ;; + esac ;; + launches) cat "$HC/launches.json" ;; + *) exit 2 ;; +esac`, + [PRECHECK]: `${log("precheck")} +echo "precheck: done"; exit "$(cat "$HC/precheck-rc")"`, + "/usr/local/lib/mosaic-stack/hostcheck.sh": `${log("hostcheck")} +cat "$HC/hostcheck-out"`, +}; + +function proc(root, pid, { ppid, uid = 997, exe, argv0 = "x", comm = "x", label }) { + const d = join(root, "proc", String(pid)); + put(join(d, "status"), `Name:\t${comm}\nState:\tS (sleeping)\nPPid:\t${ppid}\nUid:\t${uid}\t${uid}\t${uid}\t${uid}\n`); + put(join(d, "cmdline"), `${argv0}\0--x\0`); + put(join(d, "comm"), `${comm}\n`); + rmSync(join(d, "exe"), { force: true }); + if (exe) symlinkSync(exe, join(d, "exe")); + if (label !== undefined) put(join(d, "attr", "current"), label); +} + +// A scratch Case 1 host where every F check passes and every P check is closed or accepted. +function host(t) { + const dir = tmp(t, "c1accept-"); + const root = join(dir, "root"), hc = join(dir, "hc"), bin = join(dir, "bin"); + const args = join(root, "etc", "mosaic-stack", "dev.args"); + put(args, ARGS.join("\n") + "\n", 0o644); + put(join(root, "etc", "passwd"), PASSWD); + put(join(root, "etc", "group"), GROUP); + put(join(root, "etc", "sudoers.d", "90-localadmin"), "localadmin ALL=(ALL) NOPASSWD: ALL\n"); + put(join(root, "etc", "claude-code", "managed-settings.json"), JSON.stringify({ env: { DISABLE_AUTOUPDATER: "1" } })); + put(join(root, CRED), `CLAUDE_CODE_OAUTH_TOKEN=${CRED_VALUE}\n`, 0o600); + put(join(root, HOME, ".config", "mosaic-dev", "businesses", "mosaic-stack.json"), JSON.stringify({ id: "mosaic-stack", launch: { by: "pm" } })); + put(join(root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "1024\n"); + proc(root, 1, { ppid: 0, uid: 0, exe: "/usr/lib/systemd/systemd", comm: "systemd" }); + proc(root, 100, { ppid: 1, exe: NODE, argv0: NODE, comm: "node" }); + proc(root, 200, { ppid: 1, exe: NODE, argv0: NODE, comm: "node" }); + proc(root, 300, { ppid: 100, exe: "/usr/bin/bwrap", argv0: "/usr/bin/bwrap", comm: "bwrap", label: `${OUTER}\n` }); + proc(root, 301, { ppid: 300, exe: NODE, argv0: NODE, comm: "node", label: `${CHILD}\n` }); + proc(root, 302, { ppid: 301, exe: CLAUDE, argv0: CLAUDE, comm: "claude.exe", label: `${CHILD}\n` }); + // A root bwrap with a mislabelled claude child, which F5 must pass over. + proc(root, 400, { ppid: 1, uid: 0, exe: "/usr/bin/bwrap", argv0: "/usr/bin/bwrap", comm: "bwrap", label: "unconfined\n" }); + proc(root, 401, { ppid: 400, uid: 0, argv0: "claude", comm: "claude", label: "unconfined\n" }); + const state = { + "show-mosaic-bus@mosaic-stack.service": "ActiveState=active\nUser=mosaic\nMainPID=100\n", + "show-mosaic-webui.service": "ActiveState=active\nUser=mosaic\nMainPID=200\n", + "show-mosaic-hostcheck.service": "Before=mosaic-bus@mosaic-stack.service mosaic-webui.service multi-user.target\n", + "enabled-mosaic-hostcheck.service": "enabled\n", + "curl-rc": "0", ss: SS, + "stat-cred": "mosaic 600 regular file\n", "stat-settings": "root:root 644 regular file\n", + "sudo-l-mosaic": "User mosaic is not allowed to run sudo on dev-53.\n", + "sudo-l-localadmin": "User localadmin may run the following commands on dev-53:\n (ALL : ALL) ALL\n (ALL) NOPASSWD: ALL\n", + "jarvis-bwrap": "sudo: unable to execute /usr/bin/bwrap: Permission denied\n", "jarvis-bwrap-rc": "1", + "node-version": `${NODE_PIN}\n`, "claude-version": `${CLAUDE_PIN} (Claude Code)\n`, + "talk-mode": "reply", "precheck-rc": "0", "hostcheck-out": HOSTCHECK_OK, + "launches.json": JSON.stringify([ + { instance: "pm", live: true, business: "mosaic-stack", run: "r-0002" }, + { instance: "pm", live: false, business: "mosaic-stack", run: "r-0001" }, + { instance: "dev-1", live: true, business: "mosaic-stack", run: "r-0003" }, + ]), + }; + for (const [name, text] of Object.entries(state)) put(join(hc, name), text); + for (const [name, body] of Object.entries(FAKES)) put(join(bin, name), `#!/usr/bin/env bash\n${name === "stat" ? "" : log(name)}\n${body}\n`, 0o755); + for (const [path, body] of Object.entries(TREE)) put(join(root, path), `#!/usr/bin/env bash\n${body}\n`, 0o755); + return { dir, root, hc, bin, args }; +} + +function run(h, extra = {}, argv = ["--business", "mosaic-stack", "--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", NODE_PIN, "--root", h.root]) { + const r = spawnSync(SCRIPT, argv, { + encoding: "utf8", + env: { ...process.env, PATH: `${h.bin}:${process.env.PATH}`, HC: h.hc, ROOT_DIR: h.root, REAL_NODE: process.execPath, TMPDIR: h.dir, ...extra }, + }); + const lines = r.stdout.split("\n").filter(Boolean); + // Each check line as { id, path, mark, evidence }. + const checks = lines.filter((l) => /^[FP][0-9]+ /.test(l)).map((l) => { + const [id, path, ...rest] = l.split(" "); + const tail = rest.join(" "); + const mark = MARKS.find((m) => tail === m || tail.startsWith(`${m} `)); + return { id, path, mark, evidence: tail.slice(mark?.length ?? 0).trim(), line: l }; + }); + return { status: r.status, out: r.stdout, err: r.stderr, lines, checks }; +} +const calls = (h, name) => (existsSync(join(h.hc, "calls.log")) ? readFileSync(join(h.hc, "calls.log"), "utf8") : "") + .split("\n").filter((l) => l === name || l.startsWith(`${name} `)).map((l) => l.slice(name.length + 1)); +const failed = (r) => r.checks.filter((c) => c.mark === "FAIL").map((c) => c.id); +const marksOf = (r, id) => r.checks.filter((c) => c.id === id).map((c) => c.mark); +const mustRun = (h, extra) => { const r = run(h, extra); assert.ok(r.status === 0 || r.status === 1, r.out + r.err); return r; }; + +// One fault per F check; each makes that check, and only that one, fail. +const F_FAULTS = { + F1: (h) => proc(h.root, 100, { ppid: 1, uid: 0, exe: NODE, comm: "node" }), + F2: (h) => put(join(h.hc, "ss"), SS.replace("127.0.0.1:732 ", "0.0.0.0:732 ")), + F3: (h) => put(join(h.hc, "talk-mode"), "noreply"), + F4: (h) => put(join(h.hc, "precheck-rc"), "1"), + F5: (h) => put(join(h.root, "proc", "302", "attr", "current"), "unconfined\n"), + F6: (h) => put(join(h.hc, "claude-version"), "2.0.9 (Claude Code)\n"), + F7: (h) => put(join(h.hc, "launches.json"), JSON.stringify([{ instance: "pm", live: false, business: "mosaic-stack", run: "r-1" }])), + F8: (h) => put(join(h.hc, "stat-cred"), "mosaic 640 regular file\n"), + F9: (h) => put(join(h.root, "etc", "claude-code", "managed-settings.json"), JSON.stringify({ env: { DISABLE_AUTOUPDATER: "0" } })), + F10: (h) => put(join(h.hc, "node-version"), "v24.0.0\n"), + F11: (h) => put(join(h.root, HOME, ".mosaic-dev", "discord", "binding.json"), "{}"), +}; + +test("row 195 accept: every check passing exits 0, with each P mark and counts that match the lines", (t) => { + const h = host(t); + const r = run(h); + assert.equal(r.status, 0, r.out + r.err); + assert.deepEqual(r.checks.map((c) => c.id), ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", + "P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "P9", "P10", "P11"]); + for (const c of r.checks.filter((x) => x.id.startsWith("F"))) assert.equal(c.mark, "pass", c.line); + for (const [id, mark] of Object.entries(P_PASS)) assert.deepEqual(marksOf(r, id), [mark], id); + const counts = r.lines.filter((l) => l.startsWith("count ")).map((l) => l.slice(6)); + assert.deepEqual(counts, MARKS.map((m) => `${m} ${r.checks.filter((c) => c.mark === m).length}`)); + assert.match(r.lines.at(-1), /^case1-accept: every F check passes/); + assert.match(r.out, /^F5 bwrap-label pass bwrap 300 reads 'bwrap \(enforce\)' and its claude descendant 302 reads/m); + assert.match(r.out, /^F7 pm-live pass one live launch: pm run r-0002$/m); + assert.ok(!(r.out + r.err).includes(CRED_VALUE)); +}); + +test("row 195 accept: exits 1 with each F check failing alone, and its line names it", (t) => { + for (const [id, fault] of Object.entries(F_FAULTS)) { + const h = host(t); + fault(h); + const r = mustRun(h); + assert.equal(r.status, 1, `${id}: ${r.out}`); + assert.deepEqual(failed(r), [id], `${id}: ${r.out}`); + assert.equal(r.lines.at(-1), "case1-accept: an F check failed, so the row fails"); + } +}); + +test("row 195 accept: F3 fails on exit 0 with no reply line, and its evidence takes talk's error", (t) => { + let h = host(t); + put(join(h.hc, "talk-mode"), "sentonly"); + let r = mustRun(h); + assert.deepEqual(failed(r), ["F3"]); + assert.match(r.out, /^F3 tester-reply FAIL talk exited 0 with 1 lines on stdout; last: sent ab12cd34 to chief$/m); + h = host(t); + put(join(h.hc, "talk-mode"), "noreply"); + r = mustRun(h); + assert.match(r.out, /^F3 tester-reply FAIL talk exited 1 with 1 lines on stdout; last: no reply from chief within 300 s$/m); +}); + +test("row 195 accept: F1 fails on a unit that isn't active or isn't User=mosaic", (t) => { + for (const show of ["ActiveState=failed\nUser=mosaic\nMainPID=0\n", "ActiveState=active\nUser=root\nMainPID=100\n"]) { + const h = host(t); + put(join(h.hc, "show-mosaic-webui.service"), show); + const r = mustRun(h); + assert.equal(r.status, 1); + assert.ok(failed(r).includes("F1"), r.out); + } +}); + +test("row 195 accept: F2 fails when healthz doesn't answer and when another pid holds the port", (t) => { + let h = host(t); + put(join(h.hc, "curl-rc"), "7"); + let r = mustRun(h); + assert.deepEqual(failed(r), ["F2"]); + assert.match(r.out, /^F2 console-loopback FAIL curl http:\/\/127\.0\.0\.1:732\/healthz exited 7$/m); + h = host(t); + put(join(h.hc, "ss"), SS.replace("pid=200,fd=21", "pid=555,fd=4")); + r = mustRun(h); + assert.deepEqual(failed(r), ["F2"]); +}); + +test("row 195 accept: F5 fails on unpriv_bwrap (enforce), on complain and on a trailing space", (t) => { + for (const label of ["unpriv_bwrap (enforce)\n", "bwrap//&unpriv_bwrap (complain)\n", `${CHILD} \n`]) { + const h = host(t); + put(join(h.root, "proc", "302", "attr", "current"), label); + const r = mustRun(h); + assert.deepEqual(failed(r), ["F5"], `${JSON.stringify(label)}: ${r.out}`); + } + const h = host(t); + put(join(h.root, "proc", "300", "attr", "current"), `${OUTER} \n`); + assert.deepEqual(failed(mustRun(h)), ["F5"]); +}); + +test("row 195 accept: F5 fails with no bwrap of mosaic's, and passes on comm alone", (t) => { + let h = host(t); + proc(h.root, 300, { ppid: 100, uid: 1001, exe: "/usr/bin/bwrap", comm: "bwrap", label: `${OUTER}\n` }); + let r = mustRun(h); + assert.deepEqual(failed(r), ["F5"]); + assert.match(r.out, /^F5 bwrap-label FAIL no \/usr\/bin\/bwrap process of mosaic/m); + h = host(t); + proc(h.root, 302, { ppid: 301, argv0: "/proc/self/fd/3", comm: "claude", label: `${CHILD}\n` }); + assert.deepEqual(failed(mustRun(h)), []); +}); + +test("row 195 accept: F6 fails on a stub that prints nothing and on a path outside claude-", (t) => { + let h = host(t); + put(join(h.hc, "claude-version"), ""); + let r = mustRun(h); + assert.deepEqual(failed(r), ["F6"]); + h = host(t); + const other = CLAUDE.replace(`claude-${CLAUDE_PIN}`, "claude-2.0.9"); + put(h.args, ARGS.map((a) => (a === CLAUDE ? other : a)).join("\n") + "\n", 0o644); + put(join(h.root, other), `#!/usr/bin/env bash\necho "${CLAUDE_PIN} (Claude Code)"\n`, 0o755); + r = mustRun(h); + assert.deepEqual(failed(r), ["F6"]); + assert.match(r.out, /^F6 claude-pin FAIL --claude \S+claude-2\.0\.9\S+ is not under \/opt\/mosaic-stack\/claude-2\.1\.0\/$/m); +}); + +test("row 195 accept: F7 fails on two live PM launches, one for another business, and no launch.by", (t) => { + const two = [{ instance: "pm", live: true, business: "mosaic-stack", run: "a" }, { instance: "pm", live: true, business: "mosaic-stack", run: "b" }]; + for (const [file, text] of [["launches.json", JSON.stringify(two)], ["launches.json", JSON.stringify([{ instance: "pm", live: true, business: "other", run: "a" }])]]) { + const h = host(t); + put(join(h.hc, file), text); + assert.deepEqual(failed(mustRun(h)), ["F7"]); + } + const h = host(t); + put(join(h.root, HOME, ".config", "mosaic-dev", "businesses", "mosaic-stack.json"), JSON.stringify({ id: "mosaic-stack" })); + const r = mustRun(h); + assert.deepEqual(failed(r), ["F7"]); + assert.match(r.out, /^F7 pm-live FAIL the business file has no launch\.by$/m); +}); + +test("row 195 accept: F8 fails on 0640, on another owner and on a symlink, and never prints the credential", (t) => { + for (const fault of [ + (h) => put(join(h.hc, "stat-cred"), "mosaic 640 regular file\n"), + (h) => put(join(h.hc, "stat-cred"), "root 600 regular file\n"), + (h) => { const f = join(h.root, CRED); rmSync(f); put(`${f}.real`, `X=${CRED_VALUE}\n`, 0o600); symlinkSync(`${f}.real`, f); }, + ]) { + const h = host(t); + fault(h); + const r = mustRun(h); + assert.deepEqual(failed(r), ["F8"], r.out); + assert.ok(!(r.out + r.err).includes(CRED_VALUE)); + } +}); + +test("row 195 accept: F9 fails on another owner, a group-writable mode and a number for the flag", (t) => { + for (const fault of [ + (h) => put(join(h.hc, "stat-settings"), "root:root 664 regular file\n"), + (h) => put(join(h.hc, "stat-settings"), "mosaic:root 644 regular file\n"), + (h) => put(join(h.root, "etc", "claude-code", "managed-settings.json"), JSON.stringify({ env: { DISABLE_AUTOUPDATER: 1 } })), + ]) { + const h = host(t); + fault(h); + assert.deepEqual(failed(mustRun(h)), ["F9"]); + } +}); + +test("row 195 accept: F11 fails on one *.json under discord/ and passes on an empty directory", (t) => { + let h = host(t); + put(join(h.root, HOME, ".mosaic-dev", "discord", "chief.json"), "{}"); + let r = mustRun(h); + assert.deepEqual(failed(r), ["F11"]); + assert.match(r.out, /^F11 no-discord FAIL \/srv\/mosaic\/\.mosaic-dev\/discord holds bindings: chief\.json$/m); + h = host(t); + mkdirSync(join(h.root, HOME, ".mosaic-dev", "discord"), { recursive: true }); + assert.deepEqual(failed(mustRun(h)), []); +}); + +// Each P check in its unnamed mark, with the line count each fault makes; none changes the exit. +const P_FAULTS = { + P1: [1, (h) => put(join(h.hc, "sudo-l-mosaic"), "User mosaic may run the following commands on dev-53:\n")], + P2: [1, (h) => put(join(h.root, HOME, ".ssh", "authorized_keys"), "ssh-ed25519 AAAA test\n")], + P5: [1, (h) => edit(join(h.root, "etc", "group"), (s) => s.replace("docker:x:999:", "docker:x:999:jarvis"))], + P6: [2, (h) => edit(join(h.root, "etc", "group"), (s) => s.replace("sudo:x:27:localadmin", "sudo:x:27:localadmin,bob").replace("lxd:x:998:", "lxd:x:998:carol"))], + P7: [1, (h) => put(join(h.root, "etc", "sudoers.d", "50-mosaic"), "mosaic ALL=(root) NOPASSWD: /usr/bin/systemctl\n")], + P8: [1, (h) => put(join(h.hc, "ss"), `${SS}LISTEN 0 511 0.0.0.0:7331 0.0.0.0:* users:(("node",pid=200,fd=23))\n`)], + P9: [3, (h) => { put(join(h.hc, "hostcheck-out"), HOSTCHECK_OK.replace("ok 2 ", "FAIL 2 ").replace("ok 5 ", "FAIL 5 ")); put(join(h.hc, "jarvis-bwrap-rc"), "0"); }], + P10: [1, (h) => put(join(h.root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "700\n")], + P11: [1, (h) => put(join(h.hc, "show-mosaic-hostcheck.service"), "Before=mosaic-bus@other.service multi-user.target\n")], +}; + +test("row 195 accept: each P check reports unnamed on its fault, and the exit stays 0", (t) => { + for (const [id, [n, fault]] of Object.entries(P_FAULTS)) { + const h = host(t); + fault(h); + const r = run(h); + assert.equal(r.status, 0, `${id}: ${r.out}`); + assert.deepEqual(marksOf(r, id), Array(n).fill("unnamed"), `${id}: ${r.out}`); + } +}); + +test("row 195 accept: P2 is unnamed with a supplementary group, and P5 names docker", (t) => { + let h = host(t); + put(join(h.root, "etc", "group"), GROUP.replace("lxd:x:998:", "lxd:x:998:mosaic")); + let r = run(h); + assert.equal(r.status, 0); + assert.deepEqual(marksOf(r, "P2"), ["unnamed"]); + assert.match(r.out, /^P2 mosaic-groups-keys unnamed id -G is '997 998'; authorized_keys is absent$/m); + h = host(t); + P_FAULTS.P5[1](h); + r = run(h); + assert.match(r.out, /^P5 jarvis-reach unnamed jarvis is in docker$/m); + assert.match(r.out, /^P6 group-docker unnamed jarvis is a member of docker$/m); +}); + +test("row 195 accept: P5 is closed with no jarvis account, and P2 with an empty authorized_keys", (t) => { + const h = host(t); + put(join(h.root, "etc", "passwd"), PASSWD.replace(/^jarvis:.*\n/m, "")); + put(join(h.root, HOME, ".ssh", "authorized_keys"), ""); + const r = run(h); + assert.deepEqual(marksOf(r, "P5"), ["closed"]); + assert.deepEqual(marksOf(r, "P2"), ["closed"]); +}); + +test("row 195 accept: P9 is unnamed once for each item missing", (t) => { + const items = { + hostcheck: (h) => put(join(h.hc, "hostcheck-out"), HOSTCHECK_OK.replace("ok 3 ", "FAIL 3 ")), + jarvis: (h) => { put(join(h.hc, "jarvis-bwrap-rc"), "0"); put(join(h.hc, "jarvis-bwrap"), ""); }, + member: (h) => put(join(h.root, "etc", "group"), GROUP.replace("mosaic:x:997:", "mosaic:x:997:jarvis")), + gid: (h) => put(join(h.root, "etc", "passwd"), `${PASSWD}hermes:x:1002:997::/home/hermes:/bin/bash\n`), + }; + for (const [name, fault] of Object.entries(items)) { + const h = host(t); + fault(h); + const r = run(h); + assert.equal(r.status, 0); + assert.deepEqual(marksOf(r, "P9"), ["unnamed"], `${name}: ${r.out}`); + } + const h = host(t); + put(join(h.hc, "hostcheck-out"), ""); + assert.deepEqual(marksOf(run(h), "P9"), Array(6).fill("unnamed")); +}); + +test("row 195 accept: P10 is unnamed with 7333 held by another pid; P11 with the unit disabled", (t) => { + let h = host(t); + put(join(h.hc, "ss"), SS.replace("pid=200,fd=22", "pid=555,fd=4")); + let r = run(h); + assert.equal(r.status, 0); + assert.deepEqual(marksOf(r, "P10"), ["unnamed"]); + h = host(t); + put(join(h.hc, "enabled-mosaic-hostcheck.service"), "disabled\n"); + r = run(h); + assert.deepEqual(marksOf(r, "P11"), ["unnamed"]); +}); + +test("row 195 accept: every P check unnamed together still exits 0", (t) => { + const h = host(t); + for (const [, fault] of Object.values(P_FAULTS)) fault(h); + const r = run(h); + assert.equal(r.status, 0, r.out + r.err); + assert.deepEqual(failed(r), []); + for (const id of ["P1", "P2", "P5", "P6", "P7", "P8", "P9", "P10", "P11"]) assert.ok(marksOf(r, id).every((m) => m === "unnamed"), id); + assert.match(r.lines.at(-1), /^case1-accept: every F check passes/); +}); + +test("row 195 accept: a bad args file refuses with exit 2 before any check, each for its own reason", (t) => { + const bad = { + "group write": [(h) => chmodSync(h.args, 0o664), /dev\.args must be \S+'s with no group or other write \(it is uid \d+, mode 664\)$/], + symlink: [(h) => { put(`${h.args}.real`, ARGS.join("\n") + "\n", 0o644); rmSync(h.args); symlinkSync(`${h.args}.real`, h.args); }, /no regular file \/etc\/mosaic-stack\/dev\.args$/], + "unknown flag": [(h) => put(h.args, ["--send", ...ARGS].join("\n") + "\n", 0o644), /has --send before --, which case2-unit\.sh does not take$/], + "two copies": [(h) => put(h.args, ["--copy", COPY, ...ARGS].join("\n") + "\n", 0o644), /needs exactly one --copy before --, not 2$/], + "library profile": [(h) => put(h.args, ARGS.map((a) => (a === "factory" ? "library" : a)).join("\n") + "\n", 0o644), /has --profile 'library', not factory$/], + "another business": [(h) => put(h.args, ARGS.map((a, i) => (a === "mosaic-stack" && i < 14 ? "acme" : a)).join("\n") + "\n", 0o644), /is for business acme, not mosaic-stack$/], + "no tester port": [(h) => put(h.args, ARGS.slice(0, -2).join("\n") + "\n", 0o644), /lacks a numeric --port or --tester-port after --$/], + "short copy": [(h) => put(h.args, ARGS.map((a) => (a === COPY ? "abc123" : a)).join("\n") + "\n", 0o644), /lacks a plain --user, --home, --business, --node, --claude or --copy$/], + "no file": [(h) => rmSync(h.args), /no regular file \/etc\/mosaic-stack\/dev\.args$/], + }; + for (const [name, [fault, why]] of Object.entries(bad)) { + const h = host(t); + fault(h); + const r = run(h); + assert.equal(r.status, 2, `${name}: ${r.out}${r.err}`); + assert.match(r.err.trimEnd(), /^case1-accept: refusing: /, name); + assert.match(r.err.trimEnd(), why, name); + assert.equal(r.out, "", name); + assert.ok(!existsSync(join(h.hc, "calls.log")), name); + } +}); + +test("row 195 accept: usage errors and a run without --root as a user exit 2", (t) => { + const h = host(t); + let r = run(h, {}, ["--business", "mosaic-stack", "--args", "/etc/mosaic-stack/dev.args", "--node-pin", NODE_PIN, "--root", h.root]); + assert.equal(r.status, 2); + assert.match(r.err, /^usage: /); + r = run(h, {}, ["--business", "mosaic-stack", "--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", "24.1.0", "--root", h.root]); + assert.equal(r.status, 2); + r = run(h, {}, ["--business", "mosaic-stack", "--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", NODE_PIN]); + assert.equal(r.status, 2); + assert.equal(r.err, "case1-accept: refusing: run it as root\n"); + assert.ok(!existsSync(join(h.hc, "calls.log"))); +}); + +test("row 195 accept: every in-instance call goes through sudo -u mosaic -H env PATH=:...", (t) => { + const h = host(t); + const r = run(h); + assert.equal(r.status, 0, r.out + r.err); + const sudo = calls(h, "sudo"); + const asMosaic = sudo.filter((a) => a.startsWith("-u mosaic")); + for (const a of asMosaic) assert.ok(a.startsWith(`${AS_MOSAIC} /`), a); + const cmds = asMosaic.map((a) => a.slice(AS_MOSAIC.length + 1).split(" ")[0]); + assert.deepEqual([...new Set(cmds)].sort(), [CLI, PRECHECK, CLAUDE, NODE].sort()); + assert.deepEqual(sudo.filter((a) => !a.startsWith("-u mosaic")).sort(), + ["-l -U localadmin", "-l -U mosaic", "-u jarvis /usr/bin/bwrap --version"]); + const talk = calls(h, "mosaic").filter((a) => a.startsWith("talk ")); + assert.equal(talk.length, 1); + assert.match(talk[0], /^talk chief case1-accept-[0-9a-f]{12}: .* --business mosaic-stack --tester --wait 300$/); + assert.deepEqual(calls(h, "precheck"), ["--business mosaic-stack --profile factory"]); +}); + +test("row 195 accept: the host check runs with --check and never --enforce", (t) => { + const h = host(t); + run(h); + assert.deepEqual(calls(h, "hostcheck"), ["--check"]); +}); diff --git a/scripts/case1-accept.sh b/scripts/case1-accept.sh new file mode 100755 index 000000000..d1c14fd45 --- /dev/null +++ b/scripts/case1-accept.sh @@ -0,0 +1,443 @@ +#!/usr/bin/env bash +# Row 195 (#1693): the Case 1 acceptance on 10.1.1.53, design r7 section 5 +# (agents/darkwing/work/queue-195/DESIGN.md). Deviations and decisions: agents/darkwing/work/queue-195/BUILD.md. +# +# case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N +# +# Root runs it on the Case 1 host after the install window, from a root-owned checkout. It reads the +# user, home, copy, node, claude and both ports from the args file, which must pass stack-upgrade.sh's +# rules and be a factory file for --business. Every command that runs the stack's code runs as +# `sudo -u USER -H env PATH=:/usr/local/bin:/usr/bin:/bin` from /. Root itself only reads +# systemd, ss, /proc, the account database and file metadata. It changes nothing on the host but the +# message F3 sends and its reply, and it never opens claude-session.env. +# +# One line per check, ` `, then `count N` for each mark and a +# result line. F checks are `pass` or `FAIL`. P checks are `closed`, `accepted ` or +# `unnamed`, which means no Q182, Q183 or Mos ruling names the path; each unnamed line goes to Mos. +# +# Exit 0 when every F check passes, 1 when one fails, 2 on usage or a refusal (a bad args file, or +# --root misused). A P check never changes the exit. +# +# Test seam, for packages/cli/tests/case1-accept.test.mjs only. Root takes none: +# --root DIR prefixes the args file, /etc, /proc, the stack's home, the host check and node, and +# reads the account database from DIR/etc/passwd and DIR/etc/group instead of getent +set -uo pipefail + +usage() { echo "usage: case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N" >&2; exit 2; } +refuse() { echo "case1-accept: refusing: $*" >&2; exit 2; } +BUSINESS="" ARGS_FILE="" CLAUDE_PIN="" NODE_PIN="" ROOT="" +while [ $# -gt 0 ]; do + case "$1" in + --business) [ $# -ge 2 ] && [[ "$2" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage; BUSINESS="$2"; shift 2 ;; + --args) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] || usage; ARGS_FILE="$2"; shift 2 ;; + --claude-pin) [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; CLAUDE_PIN="$2"; shift 2 ;; + --node-pin) [ $# -ge 2 ] && [[ "$2" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; NODE_PIN="$2"; shift 2 ;; + --root) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [ -d "$2" ] || usage; ROOT="${2%/}"; shift 2 ;; + *) usage ;; + esac +done +[ -n "$BUSINESS" ] && [ -n "$ARGS_FILE" ] && [ -n "$CLAUDE_PIN" ] && [ -n "$NODE_PIN" ] || usage +if [ "$(id -u)" = 0 ]; then + [ -z "$ROOT" ] || refuse "--root is a test seam, and root takes none" + PATH=/usr/sbin:/usr/bin:/sbin:/bin +else + [ -n "$ROOT" ] || refuse "run it as root" +fi +cd / || refuse "cannot cd to /" + +# --- The args file, by stack-upgrade.sh's read_args rules, plus the serve ports --- + +f="$ROOT$ARGS_FILE" +[ -f "$f" ] && [ ! -L "$f" ] || refuse "no regular file $ARGS_FILE" +st="$(stat -c '%u %a' -- "$f")" || refuse "cannot stat $ARGS_FILE" +[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || + refuse "$ARGS_FILE must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })" +mapfile -t ARGS < "$f" || refuse "cannot read $ARGS_FILE" +S_USER="" S_HOME="" A_BUSINESS="" NODE="" COPY_ID="" CLAUDE="" PROFILE="" PORT="" TESTER_PORT="" +copies=0 past="" +for ((i = 0; i < ${#ARGS[@]}; i++)); do + a="${ARGS[i]}" v="${ARGS[i + 1]:-}" + if [ -n "$past" ]; then + case "$a" in --port) PORT="$v"; i=$((i + 1)) ;; --tester-port) TESTER_PORT="$v"; i=$((i + 1)) ;; esac + continue + fi + case "$a" in + --) past=1 ;; + --pm) ;; + --user) S_USER="$v"; i=$((i + 1)) ;; + --home) S_HOME="$v"; i=$((i + 1)) ;; + --business) A_BUSINESS="$v"; i=$((i + 1)) ;; + --node) NODE="$v"; i=$((i + 1)) ;; + --claude) CLAUDE="$v"; i=$((i + 1)) ;; + --profile) PROFILE="$v"; i=$((i + 1)) ;; + --copy) COPY_ID="$v"; copies=$((copies + 1)); i=$((i + 1)) ;; + *) refuse "$ARGS_FILE has $a before --, which case2-unit.sh does not take" ;; + esac +done +[ "$copies" = 1 ] || refuse "$ARGS_FILE needs exactly one --copy before --, not $copies" +[[ "$S_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] && [[ "$S_HOME" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$A_BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] && + [[ "$NODE" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] && [[ "$CLAUDE" =~ ^/[A-Za-z0-9/._+@-]+$ ]] || + refuse "$ARGS_FILE lacks a plain --user, --home, --business, --node, --claude or --copy" +[ "$PROFILE" = factory ] || refuse "$ARGS_FILE has --profile '${PROFILE}', not factory" +[ "$A_BUSINESS" = "$BUSINESS" ] || refuse "$ARGS_FILE is for business $A_BUSINESS, not $BUSINESS" +[[ "$PORT" =~ ^[1-9][0-9]{0,4}$ ]] && [[ "$TESTER_PORT" =~ ^[1-9][0-9]{0,4}$ ]] || + refuse "$ARGS_FILE lacks a numeric --port or --tester-port after --" + +COPY="$S_HOME/.local/share/mosaic-stack/copies/$COPY_ID" +DATA_ROOT="$S_HOME/.mosaic-dev" +CRED="$S_HOME/.config/mosaic-dev/secrets/claude-session.env" +BUSINESS_FILE="$S_HOME/.config/mosaic-dev/businesses/$BUSINESS.json" +BUS_UNIT="mosaic-bus@$BUSINESS.service" CON_UNIT=mosaic-webui.service +HOSTCHECK=/usr/local/lib/mosaic-stack/hostcheck.sh +SETTINGS=/etc/claude-code/managed-settings.json +LABEL_OUTER='bwrap (enforce)' LABEL_CHILD='bwrap//&unpriv_bwrap (enforce)' +AS_USER=(sudo -u "$S_USER" -H env "PATH=$(dirname "$NODE"):/usr/local/bin:/usr/bin:/bin") +WORK="$(mktemp -d)" || refuse "cannot make a temp dir" +trap 'rm -rf -- "$WORK"' EXIT + +# --- Output --- + +MARKS=(pass FAIL closed "accepted Q182+Q183" "accepted Q183" "accepted mos-1096" "accepted mos-1101" unnamed) +declare -A COUNT=() +F_FAILED=0 +# Text the stack's user wrote, made safe to print: printable ASCII, one line, at most 200 bytes. +clean() { tr -c '[:print:]' ' ' <<< "$1" | cut -c1-200 | sed 's/ *$//'; } +line() { local id="$1" path="$2" mark="$3"; shift 3; echo "$id $path $mark $*"; COUNT[$mark]=$(( ${COUNT[$mark]:-0} + 1 )); } +fpass() { line "$1" "$2" pass "${@:3}"; } +ffail() { line "$1" "$2" FAIL "${@:3}"; F_FAILED=1; } + +# --- The account database: getent on the host, the seam's files under --root --- + +db() { + if [ -n "$ROOT" ]; then + if [ $# -gt 1 ]; then awk -F: -v k="$2" '$1 == k' "$ROOT/etc/$1"; else cat "$ROOT/etc/$1"; fi + else + getent "$@" + fi +} +uid_of() { db passwd "$1" | cut -d: -f3; } +gid_of() { db passwd "$1" | cut -d: -f4; } +# The group ids USER belongs to, as `id -G USER` prints them: the primary gid, then each group +# whose member list names USER. +groups_of() { + local g; g="$(gid_of "$1")" + [ -n "$g" ] || return 0 + { echo "$g"; db group | awk -F: -v u="$1" '{ n = split($4, m, ","); for (i = 1; i <= n; i++) if (m[i] == u) print $3 }'; } | awk '!seen[$0]++' +} +names_of_groups() { local g; for g in "$@"; do db group | awk -F: -v g="$g" '$3 == g { print $1 }'; done; } +MOSAIC_UID="$(uid_of "$S_USER")" + +# --- systemd, /proc and listeners --- + +# KEY=VALUE lines for UNIT's properties. +unit_show() { local u="$1"; shift; systemctl show "$u" "${@/#/-p}" 2>/dev/null; } +prop() { sed -n "s/^$2=//p" <<< "$1" | tail -n 1; } +# The real, effective, saved and filesystem uids of PID, space-separated. +proc_uids() { awk '$1 == "Uid:" { print $2, $3, $4, $5 }' "$ROOT/proc/$1/status" 2>/dev/null; } +proc_label() { local l; l="$(cat "$ROOT/proc/$1/attr/current" 2>/dev/null)" || return 1; printf '%s' "${l%$'\n'}"; } +# One `ADDRESS PIDS` line per TCP listener on port $1, PIDS comma-separated. +listeners() { + ss -Hltnp 2>/dev/null | awk -v p="$1" '{ + n = split($4, a, ":"); if (a[n] != p) next + addr = substr($4, 1, length($4) - length(p) - 1); pids = ""; s = $0 + while (match(s, /pid=[0-9]+/)) { pids = pids (pids == "" ? "" : ",") substr(s, RSTART + 4, RLENGTH - 4); s = substr(s, RSTART + RLENGTH) } + print addr, pids + }' +} +# 0 when port $1 has a listener and every one is on 127.0.0.1, held by pid $2 alone. +loopback_by() { + local addr pids p any=0 + [ -n "$2" ] && [ "$2" != 0 ] || return 1 + while read -r addr pids; do + [ -n "$addr" ] || continue + any=1 + [ "$addr" = 127.0.0.1 ] || return 1 + for p in ${pids//,/ }; do [ "$p" = "$2" ] || return 1; done + [ -n "$pids" ] || return 1 + done <<< "$(listeners "$1")" + [ "$any" = 1 ] +} +listener_text() { local t; t="$(listeners "$1" | tr '\n' ';')"; echo "${t:-none}"; } + +BUS_SHOW="$(unit_show "$BUS_UNIT" ActiveState User MainPID)" +CON_SHOW="$(unit_show "$CON_UNIT" ActiveState User MainPID)" +CON_PID="$(prop "$CON_SHOW" MainPID)" + +# --- F checks --- + +check_f1() { + local u show state user pid uids why="" + for u in "$BUS_UNIT" "$CON_UNIT"; do + if [ "$u" = "$BUS_UNIT" ]; then show="$BUS_SHOW"; else show="$CON_SHOW"; fi + state="$(prop "$show" ActiveState)" user="$(prop "$show" User)" pid="$(prop "$show" MainPID)" + uids="$(proc_uids "${pid:-0}")" + [ "$state" = active ] || { why+="$u is ${state:-unknown}; "; continue; } + [ "$user" = "$S_USER" ] || { why+="$u has User=${user:-unset}; "; continue; } + [ -n "$MOSAIC_UID" ] && [ "$uids" = "$MOSAIC_UID $MOSAIC_UID $MOSAIC_UID $MOSAIC_UID" ] || + why+="$u MainPID ${pid:-none} has uids '${uids:-unreadable}', not $S_USER's ${MOSAIC_UID:-unknown}; " + done + if [ -z "$why" ]; then fpass F1 units-user "$BUS_UNIT and $CON_UNIT are active as $S_USER (uid $MOSAIC_UID)" + else ffail F1 units-user "${why%; }"; fi +} + +check_f2() { + local rc=0 + curl -fsS --max-time 10 -o /dev/null "http://127.0.0.1:$PORT/healthz" 2>/dev/null || rc=$? + if [ "$rc" != 0 ]; then ffail F2 console-loopback "curl http://127.0.0.1:$PORT/healthz exited $rc" + elif ! loopback_by "$PORT" "$CON_PID"; then ffail F2 console-loopback "port $PORT listeners are '$(listener_text "$PORT")', not 127.0.0.1 by the Console's MainPID ${CON_PID:-none}" + else fpass F2 console-loopback "healthz answers, and $PORT is bound to 127.0.0.1 only, by MainPID $CON_PID"; fi +} + +# F5's scan: an outer /usr/bin/bwrap owned by the stack's user with a claude descendant. Sets F5_OK +# when one pair has both labels exactly, F5_WHY to the last mismatch otherwise. +F5_OK="" F5_WHY="no /usr/bin/bwrap process of $S_USER with a claude descendant was seen while the reply was pending" +scan_f5() { + local pid ppid uids kids q c argv0 comm lo lc + local -A parent=() children=() bw=() + # One read of every PPid line and one of every exe link, so a scan stays short on a busy host. + while read -r pid ppid; do + parent[$pid]="$ppid" children[$ppid]+=" $pid" + done < <(grep -H '^PPid:' "$ROOT"/proc/[0-9]*/status 2>/dev/null | sed -n 's#^.*/proc/\([0-9]*\)/status:PPid:[[:space:]]*\([0-9]*\)$#\1 \2#p') + while read -r pid; do + [[ "$pid" =~ ^[0-9]+$ ]] && [ -n "${parent[$pid]:-}" ] && bw[$pid]=1 + done < <(find "$ROOT/proc" -mindepth 2 -maxdepth 2 -name exe -lname /usr/bin/bwrap -printf '%h\n' 2>/dev/null | sed 's#.*/##') + for pid in "${!bw[@]}"; do + [ -z "${bw[${parent[$pid]}]:-}" ] || continue + uids="$(proc_uids "$pid")" + [ -n "$MOSAIC_UID" ] && [ "${uids%% *}" = "$MOSAIC_UID" ] || continue + # Every descendant, breadth first. + kids=() q=("$pid") + while [ ${#q[@]} -gt 0 ]; do + for c in ${children[${q[0]}]:-}; do kids+=("$c") q+=("$c"); done + q=("${q[@]:1}") + done + for c in "${kids[@]}"; do + argv0="$(tr '\0' '\n' < "$ROOT/proc/$c/cmdline" 2>/dev/null | sed -n 1p)" + comm="$(cat "$ROOT/proc/$c/comm" 2>/dev/null)" + [[ "${argv0##*/}" == claude* ]] || [[ "$comm" == claude* ]] || continue + lo="$(proc_label "$pid")" lc="$(proc_label "$c")" + if [ "$lo" = "$LABEL_OUTER" ] && [ "$lc" = "$LABEL_CHILD" ]; then + F5_OK="bwrap $pid reads '$lo' and its claude descendant $c reads '$lc'"; return 0 + fi + F5_WHY="bwrap $pid reads '$lo' and its claude descendant $c reads '$lc', not '$LABEL_OUTER' and '$LABEL_CHILD'" + done + done + return 1 +} + +# F3 and F5 together: the message goes out in the background and the scan runs until it is answered. +check_f3_f5() { + local marker text rc=0 out err n tpid + marker="case1-accept-$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')" + text="$marker: this is the Case 1 acceptance; reply with one short line." + timeout 330 "${AS_USER[@]}" "$COPY/scripts/mosaic" talk chief "$text" --business "$BUSINESS" --tester --wait 300 > "$WORK/talk.out" 2> "$WORK/talk.err" & + tpid=$! + while :; do + scan_f5 && break + kill -0 "$tpid" 2>/dev/null || break + sleep 0.5 + done + wait "$tpid" || rc=$? + # Both the sent line and the reply are on stdout; an error is on stderr. + out="$(cat "$WORK/talk.out")" err="$(cat "$WORK/talk.err")" + n="$(grep -c . <<< "$out")" + if [ "$rc" = 0 ] && grep -q "^sent [^ ]* to chief" <<< "$out" && [ "$n" -ge 2 ]; then + fpass F3 tester-reply "chief replied to $marker" + else + ffail F3 tester-reply "talk exited $rc with $n lines on stdout; last: $(clean "$(tail -n 1 <<< "${err:-$out}")")" + fi + if [ -n "$F5_OK" ]; then F5_LINE=(fpass "$F5_OK"); else F5_LINE=(ffail "$F5_WHY"); fi +} + +check_f4() { + local rc=0 out + out="$("${AS_USER[@]}" "$COPY/scripts/case2-precheck.sh" --business "$BUSINESS" --profile factory 2>&1)" || rc=$? + if [ "$rc" = 0 ]; then fpass F4 precheck "case2-precheck.sh --profile factory exits 0" + else ffail F4 precheck "case2-precheck.sh --profile factory exited $rc; last: $(clean "$(tail -n 1 <<< "$out")")"; fi +} + +check_f6() { + local want="/opt/mosaic-stack/claude-$CLAUDE_PIN/" v + [[ "$CLAUDE" == "$want"* ]] || { ffail F6 claude-pin "--claude $CLAUDE is not under $want"; return; } + v="$("${AS_USER[@]}" "$CLAUDE" --version 2>/dev/null | sed -n 1p)" + if [ "$v" = "$CLAUDE_PIN (Claude Code)" ]; then fpass F6 claude-pin "$CLAUDE prints '$v'" + else ffail F6 claude-pin "$CLAUDE --version prints '$(clean "$v")', not '$CLAUDE_PIN (Claude Code)'"; fi +} + +# The PM check confirm runs (stack-release.sh pm_external): exactly one live launches list entry for +# launch.by, for this business. The stack's user runs the whole check and prints one result line. +F7_JS=' +const {readFileSync}=require("node:fs");const {spawnSync}=require("node:child_process"); +const [file,cli,b]=process.argv.slice(1); +let by; +try{by=JSON.parse(readFileSync(file,"utf8")).launch?.by}catch(e){console.log(`fail cannot read ${file}: ${e.code??"not JSON"}`);process.exit(0)} +if(typeof by!=="string"||!by){console.log("fail the business file has no launch.by");process.exit(0)} +const r=spawnSync(cli,["launches","list","--json"],{encoding:"utf8"}); +if(r.status!==0){console.log(`fail launches list exited ${r.status}`);process.exit(0)} +let l;try{l=JSON.parse(r.stdout)}catch{console.log("fail launches list printed no JSON");process.exit(0)} +const live=Array.isArray(l)?l.filter((x)=>x&&x.instance===by&&x.live===true):[]; +console.log(live.length!==1?`fail ${live.length} live launches of ${by}`:live[0].business!==b?`fail the live launch of ${by} is for ${live[0].business}`:`ok ${by} run ${live[0].run}`);' +check_f7() { + local out + out="$("${AS_USER[@]}" "$NODE" -e "$F7_JS" -- "$BUSINESS_FILE" "$COPY/scripts/mosaic" "$BUSINESS" 2>/dev/null | sed -n 1p)" + if [[ "$out" =~ ^ok\ [a-z0-9][a-z0-9-]*\ run\ [A-Za-z0-9._-]+$ ]]; then fpass F7 pm-live "one live launch: ${out#ok }" + else ffail F7 pm-live "$(clean "${out#fail }")"; fi +} + +check_f8() { + local s + s="$(stat -c '%U %a %F' -- "$ROOT$CRED" 2>/dev/null)" || s="missing" + if [ ! -L "$ROOT$CRED" ] && [ "$s" = "$S_USER 600 regular file" ]; then fpass F8 credential-file "$CRED is '$s'" + else ffail F8 credential-file "$CRED is '$s', not '$S_USER 600 regular file'"; fi +} + +check_f9() { + local s v + s="$(stat -c '%U:%G %a %F' -- "$ROOT$SETTINGS" 2>/dev/null)" || s="missing" + [ ! -L "$ROOT$SETTINGS" ] && [ "$s" = "root:root 644 regular file" ] || { ffail F9 autoupdater-off "$SETTINGS is '$s', not 'root:root 644 regular file'"; return; } + v="$("$ROOT$NODE" -e 'const s=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(JSON.stringify(s?.env?.DISABLE_AUTOUPDATER??null))' -- "$ROOT$SETTINGS" 2>/dev/null)" || v="unreadable" + if [ "$v" = '"1"' ]; then fpass F9 autoupdater-off "$SETTINGS is root:root 0644 with env.DISABLE_AUTOUPDATER \"1\"" + else ffail F9 autoupdater-off "$SETTINGS env.DISABLE_AUTOUPDATER is $v, not \"1\""; fi +} + +check_f10() { + local want="/opt/mosaic-stack/node-$NODE_PIN/bin/node" v + [ "$NODE" = "$want" ] || { ffail F10 node-pin "--node $NODE is not $want"; return; } + v="$("${AS_USER[@]}" "$NODE" --version 2>/dev/null | sed -n 1p)" + if [ "$v" = "$NODE_PIN" ]; then fpass F10 node-pin "$NODE prints $v" + else ffail F10 node-pin "$NODE --version prints '$(clean "$v")', not $NODE_PIN"; fi +} + +check_f11() { + local d="$ROOT$DATA_ROOT/discord" found + if [ -L "$d" ]; then ffail F11 no-discord "$DATA_ROOT/discord is a symlink"; return; fi + [ -d "$d" ] || { fpass F11 no-discord "$DATA_ROOT/discord is absent"; return; } + found="$(find "$d" -maxdepth 1 -name '*.json' -printf '%f ' 2>/dev/null)" + if [ -z "$found" ]; then fpass F11 no-discord "$DATA_ROOT/discord holds no binding" + else ffail F11 no-discord "$DATA_ROOT/discord holds bindings: ${found% }"; fi +} + +# --- P checks --- + +check_p1() { + local out + out="$(sudo -l -U "$S_USER" 2>&1)" + if grep -q "is not allowed to run sudo" <<< "$out"; then line P1 "$S_USER-sudo" closed "sudo -l: $(clean "$(sed -n 1p <<< "$out")")" + else line P1 "$S_USER-sudo" unnamed "sudo -l: $(clean "$(tr '\n' ' ' <<< "$out")")"; fi +} + +check_p2() { + local gs keys="$ROOT$S_HOME/.ssh/authorized_keys" kstate n + gs="$(groups_of "$S_USER" | tr '\n' ' ')"; gs="${gs% }" + n="$(wc -w <<< "$gs")" + if [ ! -e "$keys" ] && [ ! -L "$keys" ]; then kstate=absent + elif [ -f "$keys" ] && [ ! -L "$keys" ] && [ ! -s "$keys" ]; then kstate=empty + else kstate=present; fi + if [ "$n" = 1 ] && [ "$kstate" != present ]; then line P2 "$S_USER-groups-keys" closed "id -G is '$gs'; authorized_keys is $kstate" + else line P2 "$S_USER-groups-keys" unnamed "id -G is '$gs'; authorized_keys is $kstate"; fi +} + +check_p3() { + local n + n="$(sudo -l -U localadmin 2>&1 | grep -c NOPASSWD)" + line P3 localadmin-sudo "accepted Q183" "sudo -l -U localadmin lists $n NOPASSWD lines" +} + +check_p4() { line P4 pve-root-pbs "accepted Q182+Q183" "root on pve over VM 1008, and PBS backups that hold $S_HOME with the credential; this script can't see pve"; } + +check_p5() { + local gs reach + if [ -z "$(uid_of jarvis)" ]; then line P5 jarvis-reach closed "no jarvis account"; return; fi + gs="$(names_of_groups $(groups_of jarvis) | tr '\n' ' ')" + reach="$(tr ' ' '\n' <<< "$gs" | grep -xE 'sudo|docker|lxd' | tr '\n' ' ')" + if [ -z "$reach" ]; then line P5 jarvis-reach closed "jarvis is in none of sudo, docker and lxd (groups: ${gs% })" + else line P5 jarvis-reach unnamed "jarvis is in ${reach% }"; fi +} + +check_p6() { + local g m members bad=0 + for g in sudo docker lxd; do + members="$(db group "$g" | cut -d: -f4)" + for m in ${members//,/ }; do + [ "$g" = sudo ] && [ "$m" = localadmin ] && continue + line P6 "group-$g" unnamed "$m is a member of $g"; bad=1 + done + done + [ "$bad" = 1 ] || line P6 group-members closed "sudo holds only localadmin, and docker and lxd have no members" +} + +check_p7() { + local found + found="$(grep -rlw -- "$S_USER" "$ROOT/etc/sudoers.d" 2>/dev/null | sed "s#^$ROOT##" | tr '\n' ' ')" + if [ -z "$found" ]; then line P7 sudoers-d closed "no file under /etc/sudoers.d names $S_USER" + else line P7 sudoers-d unnamed "files naming $S_USER: ${found% }"; fi +} + +check_p8() { + local other + other="$(listeners 7331)" + if loopback_by "$PORT" "$CON_PID" && loopback_by "$TESTER_PORT" "$CON_PID" && [ -z "$other" ]; then + line P8 lan-listeners closed "$PORT and $TESTER_PORT are on 127.0.0.1 only, by MainPID $CON_PID; nothing on 7331" + else + line P8 lan-listeners unnamed "$PORT: $(listener_text "$PORT"); $TESTER_PORT: $(listener_text "$TESTER_PORT"); 7331: $(listener_text 7331); Console MainPID ${CON_PID:-none}" + fi +} + +check_p9() { + local out n missing=() rc=0 err mg others + out="$("$ROOT$HOSTCHECK" --check 2>/dev/null)" + for n in 1 2 3 4 5 6; do grep -q "^ok $n " <<< "$out" || missing+=("hostcheck item $n has no ok line") + done + err="$(sudo -u jarvis /usr/bin/bwrap --version 2>&1 >/dev/null)" || rc=$? + [ "$rc" != 0 ] && grep -q "Permission denied" <<< "$err" || missing+=("sudo -u jarvis /usr/bin/bwrap --version exited $rc without Permission denied") + mg="$(gid_of "$S_USER")" + [ -n "$mg" ] && [ -z "$(db group "$S_USER" | cut -d: -f4)" ] || missing+=("group $S_USER lists members, or $S_USER has no group") + others="$(db passwd | awk -F: -v g="$mg" -v u="$S_USER" '$4 == g && $1 != u { print $1 }' | tr '\n' ' ')" + [ -z "$others" ] || missing+=("other accounts have $S_USER's gid $mg: ${others% }") + if [ ${#missing[@]} = 0 ]; then + line P9 bwrap-userns "accepted mos-1096" "hostcheck items 1-6 ok, jarvis is denied bwrap, and group $S_USER has no other member" + else + for n in "${missing[@]}"; do line P9 bwrap-userns unnamed "$n"; done + fi +} + +check_p10() { + local floor + floor="$(cat "$ROOT/proc/sys/net/ipv4/ip_unprivileged_port_start" 2>/dev/null)" + if [[ "$floor" =~ ^[0-9]+$ ]] && [ "$floor" -gt "$PORT" ] && loopback_by "$TESTER_PORT" "$CON_PID"; then + line P10 tester-port-squat "accepted mos-1101" "the port floor $floor is above $PORT, and MainPID $CON_PID holds $TESTER_PORT" + else + line P10 tester-port-squat unnamed "the port floor is '${floor:-unreadable}' against $PORT; $TESTER_PORT: $(listener_text "$TESTER_PORT"); Console MainPID ${CON_PID:-none}" + fi +} + +check_p11() { + local en before + en="$(systemctl is-enabled mosaic-hostcheck.service 2>/dev/null)" + before="$(prop "$(unit_show mosaic-hostcheck.service Before)" Before)" + if [ "$en" = enabled ] && grep -qxF -- "$BUS_UNIT" <<< "${before// /$'\n'}"; then + line P11 hostcheck-before-bus closed "mosaic-hostcheck.service is enabled, and its Before= names $BUS_UNIT" + else + line P11 hostcheck-before-bus unnamed "mosaic-hostcheck.service is '${en:-unknown}', Before='$before'; it needs enabled and $BUS_UNIT" + fi +} + +check_f1 +check_f2 +F5_LINE=() +check_f3_f5 +check_f4 +"${F5_LINE[0]}" F5 bwrap-label "${F5_LINE[@]:1}" +check_f6 +check_f7 +check_f8 +check_f9 +check_f10 +check_f11 +check_p1; check_p2; check_p3; check_p4; check_p5; check_p6 +check_p7; check_p8; check_p9; check_p10; check_p11 + +for m in "${MARKS[@]}"; do echo "count $m ${COUNT[$m]:-0}"; done +if [ "$F_FAILED" = 0 ]; then echo "case1-accept: every F check passes; send the whole output to Duckworth, and each unnamed line to Mos"; exit 0; fi +echo "case1-accept: an F check failed, so the row fails" +exit 1