From 436d58111839d405749740a9fb9c20976d2d56e6 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 12 Oct 2026 13:39:36 -0500 Subject: [PATCH] rocko: row 77 (#1571) packet, round 1; BUILD-LOG and SESSIONS appends Rocko's work committed by Sage (Rocko has no git authority). Candidate manifest sha256 4927de66, base 27a1a2bb. Co-Authored-By: Claude Opus 5.5 --- BUILD-LOG.md | 6 + agents/rocko/work/queue-77/BUILD.md | 203 +++ agents/rocko/work/queue-77/base.txt | 1 + agents/rocko/work/queue-77/build.patch | 1184 +++++++++++++++++ agents/rocko/work/queue-77/byte-check.sh | 36 + .../work/queue-77/candidate-manifest.sha256 | 10 + agents/rocko/work/queue-77/files.txt | 10 + agents/rocko/work/queue-77/mutants.py | 37 + agents/rocko/work/queue-77/out/byte-check.txt | 10 + .../work/queue-77/out/byte-check/base-1.txt | 20 + .../work/queue-77/out/byte-check/base-2.txt | 20 + .../work/queue-77/out/byte-check/base-3.txt | 20 + .../work/queue-77/out/byte-check/base-4.txt | 20 + .../work/queue-77/out/byte-check/base-5.txt | 20 + .../work/queue-77/out/byte-check/base-6.txt | 20 + .../work/queue-77/out/byte-check/base-7.txt | 1 + .../work/queue-77/out/byte-check/base-8.txt | 20 + .../work/queue-77/out/byte-check/cand-1.txt | 20 + .../work/queue-77/out/byte-check/cand-2.txt | 20 + .../work/queue-77/out/byte-check/cand-3.txt | 20 + .../work/queue-77/out/byte-check/cand-4.txt | 20 + .../work/queue-77/out/byte-check/cand-5.txt | 20 + .../work/queue-77/out/byte-check/cand-6.txt | 20 + .../work/queue-77/out/byte-check/cand-7.txt | 1 + .../work/queue-77/out/byte-check/cand-8.txt | 20 + .../out/byte-check/template-body.diff | 0 agents/rocko/work/queue-77/out/gate.sh | 14 + .../work/queue-77/out/gate/node-webui.txt | 103 ++ .../rocko/work/queue-77/out/gate/summary.txt | 39 + .../work/queue-77/out/gate/test-auth.txt | 17 + .../work/queue-77/out/gate/test-conductor.txt | 55 + .../work/queue-77/out/gate/test-config.txt | 26 + .../work/queue-77/out/gate/test-discord.txt | 70 + .../out/gate/test-extension-package.txt | 21 + .../queue-77/out/gate/test-foundation.txt | 53 + .../work/queue-77/out/gate/test-queue.txt | 35 + .../work/queue-77/out/gate/test-release.txt | 16 + .../work/queue-77/out/gate/test-task.txt | 101 ++ agents/rocko/work/queue-77/out/mutants.txt | 18 + .../work/queue-77/packet-manifest.sha256 | 38 + docs/SESSIONS.md | 1 + 41 files changed, 2386 insertions(+) create mode 100644 agents/rocko/work/queue-77/BUILD.md create mode 100644 agents/rocko/work/queue-77/base.txt create mode 100644 agents/rocko/work/queue-77/build.patch create mode 100755 agents/rocko/work/queue-77/byte-check.sh create mode 100644 agents/rocko/work/queue-77/candidate-manifest.sha256 create mode 100644 agents/rocko/work/queue-77/files.txt create mode 100644 agents/rocko/work/queue-77/mutants.py create mode 100644 agents/rocko/work/queue-77/out/byte-check.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/base-1.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/base-2.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/base-3.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/base-4.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/base-5.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/base-6.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/base-7.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/base-8.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/cand-1.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/cand-2.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/cand-3.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/cand-4.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/cand-5.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/cand-6.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/cand-7.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/cand-8.txt create mode 100644 agents/rocko/work/queue-77/out/byte-check/template-body.diff create mode 100755 agents/rocko/work/queue-77/out/gate.sh create mode 100644 agents/rocko/work/queue-77/out/gate/node-webui.txt create mode 100644 agents/rocko/work/queue-77/out/gate/summary.txt create mode 100644 agents/rocko/work/queue-77/out/gate/test-auth.txt create mode 100644 agents/rocko/work/queue-77/out/gate/test-conductor.txt create mode 100644 agents/rocko/work/queue-77/out/gate/test-config.txt create mode 100644 agents/rocko/work/queue-77/out/gate/test-discord.txt create mode 100644 agents/rocko/work/queue-77/out/gate/test-extension-package.txt create mode 100644 agents/rocko/work/queue-77/out/gate/test-foundation.txt create mode 100644 agents/rocko/work/queue-77/out/gate/test-queue.txt create mode 100644 agents/rocko/work/queue-77/out/gate/test-release.txt create mode 100644 agents/rocko/work/queue-77/out/gate/test-task.txt create mode 100644 agents/rocko/work/queue-77/out/mutants.txt create mode 100644 agents/rocko/work/queue-77/packet-manifest.sha256 diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 94b086cd7..a5e0cc781 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -4394,3 +4394,9 @@ After: Jason gave the go at 17:21:09Z. Mos reports that ops-01 switched Argo `mo Before: the follow-ups from rows 76, 83, 67 and the I1 review existed only as lists in BUILD-LOG entries, the standup and decision 109. Mos had filed #146 (decision 109's pinned Console copy) and #147 (decision 101's uid reinstall) in Vikunja project 19, both due 2026-10-13. After: Jason ruled "Always use Vikunja for task tracking" (Q109, relayed by Mos in sage-0081). At 17:37:55Z Sage filed nine tasks in project 19 on tasks.woltje.com with the pm token slot, each read back with the sync key: #153 to #155 (the I1, M1 and row 77 briefs, due 2026-10-13) and #156 to #161 (the open follow-ups). Evidence: `agents/sage/work/vikunja-setup/2026-10-12_followups.json` and `file-followups.mjs`. The follow-up lists in the row 83 and cutover entries above are now tracked as #159, #160 and #146. Later entries cite Vikunja ids instead of listing follow-ups. + +### 2026-10-12 — Rocko, row 77 (#1571) candidate, round 1: Console pinned copy, update step and service hardening + +Before: `mosaic-webui.service` ran from the canonical checkout, so every landing there changed the live Console (decision 109). `install` and `uninstall --no-reload` didn't check whether the unit was running, a failed state read wasn't reported, `--allow-user` could start with `-`, and the uid header took any user name (Vikunja #146, #147, #155; brief `docs/plans/2026-10-12_console-pinned-copy.md`, decision 112). + +After (candidate, uncommitted, base 27a1a2bb, packet `agents/rocko/work/queue-77/`, candidate manifest sha256 4927de66): `scripts/webui-service.sh copy COMMIT` writes a read-only `git archive` of the Console's paths to `~/.local/share/mosaic-console//` (only from the canonical checkout and only for commits on refactor). `install --copy ID` points the unit at that copy, and serve's new `--checkout` keeps the queue read in the checkout. `status` shows the copy id and whether refactor has moved past it. `install` and `uninstall` refuse while the unit is in any state but inactive or failed, with or without `--no-reload`; a failed state read refuses with one line. Preview values must start with a letter or digit, and the uid header takes only `^[0-9a-f]{64}$`. The template carries decision 102's wording. Two choices are stricter than the brief: files are made read-only before the rename, and the running check covers every state but inactive or failed. Evidence: 28 service and pinned-copy tests; 18 mutants, all killed; a byte check showing `render` without `--copy` matches the base 8 of 8; and the gate (webui node 94/0 and all nine `scripts/test-*.sh` green, kept in `out/gate/`). Moved to in-review at rev 676; the review request failed at rev 677, as expected without a credential, so Sage sends it as jarvis. Nothing touched the live unit. diff --git a/agents/rocko/work/queue-77/BUILD.md b/agents/rocko/work/queue-77/BUILD.md new file mode 100644 index 000000000..178d493fc --- /dev/null +++ b/agents/rocko/work/queue-77/BUILD.md @@ -0,0 +1,203 @@ +# Row 77 (#1571): Console pinned copy, update step and service hardening, candidate packet, round 1 + +Author: Rocko. Reviewers: Darkwing and Filbert. Gate: Sage. Brief: +`docs/plans/2026-10-12_console-pinned-copy.md`, section "Console pinned +copy, update step and service hardening" (lead decision 112). Base: +`27a1a2bb` (`base.txt`); `refactor` has not touched any of the files below +since (`git log 27a1a2bb..1d4ba6bb` on them is empty). The packet is +uncommitted. I made no commits, pushes or Gitea calls, read no token or +private binding, and never started, stopped, restarted or installed the +live unit. Every test run uses `--dir`, a stand-in `systemctl` and +fixture copies. + +## Files (`files.txt`, `candidate-manifest.sha256`) + +| File | Change | +|---|---| +| `scripts/webui-service.sh` | `copy` verb; `install`/`render --copy ID [--copies DIR]`; `--allow-worktree`; N1 to N5; the uid rule; `status` copy and drift lines | +| `packages/webui/systemd/mosaic-webui.service.in` | header comment only: `@REPO@` is the copy, `--checkout`, and decision 102's wording (Jason or Mos) in lines 6-9 | +| `packages/webui/src/cli.mjs` | `serve --checkout PATH` (absolute directory, else exit 2), passed to `consoleReads` as `queueRoot`; a `Queue:` start line | +| `packages/webui/src/reads.mjs` | `consoleReads({ root, queueRoot = root })`; the queue reader takes `queueRoot`, nothing else changes | +| `packages/webui/tests/service.test.mjs` | existing tests moved to `--copy`, the uid and the state read; +7 tests | +| `packages/webui/tests/pinned-copy.test.mjs` | new, 6 tests | +| `packages/webui/README.md` | one paragraph at the end of the service section | +| `docs/TOOLS.md` | the "Console service" entry only | +| `agents/rocko/work/queue-77/mutants.py` | the mutant rerun: `python3 mutants.py ` | +| `agents/rocko/work/queue-77/byte-check.sh` | render without `--copy`, base script against candidate: `byte-check.sh ` | + +## The copy + +`copy COMMIT [--copies DIR]`: + +- Refuses outside the canonical checkout (`$REPO/.git` must be a + directory) unless `--allow-worktree` (tests only; the help text says so). +- COMMIT must start with a letter or digit and use only `A-Za-z0-9._/-`; + it resolves with `rev-parse --verify "$commit^{commit}"` to the full id, + and `merge-base --is-ancestor` against `refs/heads/refactor` refuses + anything off the branch. `--copies` must be absolute, in the unit's + character set. +- An existing `DIR/` prints `exists:` and exits 0, untouched. +- Otherwise `git archive -- COPY_PATHS | tar -x` into + `mktemp -d DIR/..XXXXXX` (removed by a trap on any failure), + `chmod a-w` on every file, `chmod 0755` on the temp directory, then + `mv -T` to `DIR/`. A rename that loses a race to another `copy` + prints `exists:`. + +Two choices against the brief's wording, both stricter: + +- **chmod before the rename.** The brief says rename, then remove write + permission. I do it the other way round, so the final path is never + seen whole-but-writable, and a crash between the steps leaves only a + temp directory, not a writable copy at the id. +- **Files read-only, directories not.** Directories keep their write bit, + so the copy can still be removed with plain `rm -rf` ("copies are kept + until someone removes them"). A file can't be edited in place; a new + file could be added to a directory. Making directories read-only too is + a one-line change if the reviewers want it. + +`COPY_PATHS` (in the script, with a comment): the `src` of bus, business, +cli, conversation, discord, harness, seat and webui, `packages/webui/README.md`, +`scripts/mosaic-config.mjs`, `roles` and `RELEASE`. `packages/queue` is +deliberately left out: the queue read runs in the checkout. + +## The unit + +- `install` and `render` take `--copy ID` (40 lower-case hex) and + `--copies DIR`. The copy must hold `packages/webui/src/cli.mjs`, else + "no copy ... make it with: scripts/webui-service.sh copy ID". `@REPO@` + becomes the copy path, and the flags get `--checkout ` in front. +- `install` refuses without `--copy`. `render` without `--copy` is the + base unit byte for byte (byte check below), and `render` takes no + `--allow-worktree` (it only prints). +- `status` reads `WorkingDirectory`. If its last part is 40 hex it prints + `copy: ` and one of `refactor: , the copy's commit`, + `refactor: , another commit; the copy is `, or + `refactor: unknown; ...`. Otherwise `copy: none; the unit runs from `. + +## N1 to N5 and the uid rule + +- **N1, broader than the brief.** `running()` reads + `systemctl --user list-units --no-legend --plain mosaic-webui.service` + and treats any ACTIVE state but `inactive` or `failed` as running, so + `reloading` (and any state systemd adds) refuses too, not only the + brief's three. `install` refuses with "refusing: mosaic-webui.service is + running (STATE); Jason or Mos stops it first (systemctl --user stop + mosaic-webui), then install, then start it", and nothing is written. +- **N2.** `copy` and `install` call `canonical()`. +- **N3, I chose "always checks".** `uninstall` reads the state with or + without `--no-reload`; `--no-reload` skips only `daemon-reload`. + `install` does the same. The docs say so. +- **N4.** A failed state read dies with one line: "refusing: systemctl + --user list-units --no-legend --plain mosaic-webui.service failed, so + the unit's state is unknown; nothing was changed". systemctl's own + stderr is dropped so the line stays one line. +- **N5.** `--public-host` and `--auth-header` already had leading-character + checks that no test covered; `--allow-user` gains one ("allowed user must + start with a letter or digit"). Tests cover `-` and `.` on all three. + This also retires the case row 83 documented, `--allow-user --send` + without sending: it is now refused, and the old test that installed it + now asserts the refusal. +- **uid rule.** With `--auth-header` equal to `x-authentik-uid` in any + case (`${AUTH_HEADER,,}`), `--allow-user` must match `^[0-9a-f]{64}$`, + or render and install refuse naming the rule and decision 101. + +Order in `install`: flags, canonical, `--copy` present, render (all value +checks), then the state read, then the write. A refused value never +reaches systemctl. + +## Tests + +`node --test tests/service.test.mjs tests/pinned-copy.test.mjs`: 28 pass. + +service.test.mjs: every run now gets a stand-in `systemctl` (a quiet one +that lists nothing by default), since `install` reads the unit's state; no +run can reach the real systemd. New tests: install refuses in four +running states with and without `--no-reload` and goes ahead for +inactive, failed, another unit and nothing; a failed state read refuses +install and uninstall with one exact line; `--copy` refusals, `--copies`, +and the worktree refusal (the suite runs in a worktree, so it asserts the +refusal when `.git` is a file); `render --copy` byte for byte; the uid +rule in four header spellings, six bad values, render and install, and +the username header still taking a username; leading `-` and `.`; status +copy, drift and `copy: none`. + +pinned-copy.test.mjs builds a fixture canonical repository on `refactor` +from this tree's packages, scripts and roles, with its own RELEASE. It +copies by short id, checks the top level, that every file is read-only, +`exists:` on repeats, the default XDG directory, and that the checkout is +untouched; refuses off-branch commits, bad names, bad copies directories +and a linked worktree, and accepts the worktree with `--allow-worktree`; +leaves the copies directory empty when `git archive` fails; serves from +the copy; refuses a bad `--checkout`; and checks `consoleReads` uses each +root. + +The serve test against the brief: it starts `cli.mjs serve` from the copy +and gets `/healthz` 200, `Bus: acme` (the config check, from the copy), +RELEASE `0.0.0-fixture` and the business read through the copy's roles, +and the queue read in the `--checkout` directory with its cwd there. +Without `--checkout` the copy's own queue read fails closed (503), since +the copy has no `packages/queue`. Two departures, stated plainly: there +is no literal stand-in socket, because `/healthz` and the reads above make +no bus call (serve only builds the caller at start); and the bus child, +`packages/bus/src/human-cli.mjs`, spawned per bus call from the copy's +`packages/cli/src/transport.mjs`, is not exercised, because tests never +spawn the real human-cli. Both files are inside `COPY_PATHS` +(`packages/bus/src`, `packages/cli/src`), and serve imports from both at +start, so a missing one fails the serve test. The `seat` mutant below +shows the test catches a package missing from the list. + +## Mutants (`mutants.py`, `out/mutants.txt`) + +18 mutants, each applied alone to the candidate tree, all killed: +failed counted as running; reloading not running; a failed state read +ignored; install skipping the state read; uninstall skipping it under +`--no-reload`; the uid header case-sensitive; upper-case hex accepted; +allowed user free to start with `-`; no `--checkout` in the unit; +`canonical()` always passing; install without `--copy`; a missing copy +accepted; status drift always "the copy's commit"; `packages/seat/src` +dropped from the list; files left writable; off-branch commits accepted; +the queue reader on `root`; a relative `--checkout` accepted. + +The last one first survived: the test's relative path didn't exist, so +`stat` refused it either way. The test now uses an existing relative +directory under the child's cwd, with a 10 s spawn timeout so a mutant +that really serves fails instead of hanging the suite (it hung once; I +killed my own fixture serve on port 0). + +## Byte check (`byte-check.sh`, `out/byte-check.txt`, `out/byte-check/`) + +The base script, placed beside the candidate for the run and removed +afterwards, and the candidate render eight flag sets without `--copy`, +including both preview headers, `--send`, a refused `--port 0` and a +dotted business id. With comment lines dropped (the template's header +changed by design), all 8 match, exit codes too, and the template's +non-comment body is identical. + +## Gate (`out/gate/`, `out/gate.sh`) + +Sequential, in the scratch worktree at `27a1a2bb` with the candidate and +`node_modules` linked, full output kept per suite, load average 20-33: + +| Suite | Result | +|---|---| +| webui node (`packages/webui/tests/*.test.mjs`) | 94 pass, 0 fail | +| test-auth | 15 passed, 0 failed | +| test-conductor | 17 passed, 0 failed | +| test-config | 24 passed, 0 failed | +| test-discord | 66 passed, 0 failed | +| test-extension-package | 18 passed, 0 failed | +| test-foundation | 44 passed, 0 failed | +| test-queue | 27 passed, 0 failed (the canonical-root checks skip in a worktree, as always) | +| test-release | 14 passed, 0 failed | +| test-task | 98 passed, 0 failed | + +No `✖` or `not ok` in any output file. `rocko-r77_default` was removed +after the Docker suites. + +## For the reviewers + +- The stale comment in `status` about `--allow-user --send` is updated: + the allowed user can no longer be `--send`. +- The live unit is untouched. Mos's reinstall after the landing is + `copy` at the landed commit, stop, `install --copy ID` with the uid, + start (brief, "Gate"). diff --git a/agents/rocko/work/queue-77/base.txt b/agents/rocko/work/queue-77/base.txt new file mode 100644 index 000000000..c3ae53649 --- /dev/null +++ b/agents/rocko/work/queue-77/base.txt @@ -0,0 +1 @@ +27a1a2bb162a96116e85c881b0061433741353fe diff --git a/agents/rocko/work/queue-77/build.patch b/agents/rocko/work/queue-77/build.patch new file mode 100644 index 000000000..9b6b5ca89 --- /dev/null +++ b/agents/rocko/work/queue-77/build.patch @@ -0,0 +1,1184 @@ +diff --git a/docs/TOOLS.md b/docs/TOOLS.md +index 89c85715..7e39d3a8 100644 +--- a/docs/TOOLS.md ++++ b/docs/TOOLS.md +@@ -241,8 +241,9 @@ Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input · + ## Console service (`scripts/webui-service.sh`) + + ```bash +-scripts/webui-service.sh render [FLAGS] # print the unit +-scripts/webui-service.sh install [--dir DIR] [--no-reload] [FLAGS] ++scripts/webui-service.sh copy COMMIT [--copies DIR] # make a pinned copy ++scripts/webui-service.sh render [--copy ID [--copies DIR]] [FLAGS] # print the unit ++scripts/webui-service.sh install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [FLAGS] + scripts/webui-service.sh uninstall [--dir DIR] [--no-reload] + scripts/webui-service.sh status [--dir DIR] + # FLAGS: [--port N] [--business ID] [--board URL] +@@ -257,19 +258,49 @@ preview set and is refused otherwise (exit 1, nothing written); ExecStart + then ends with `--send`, and the Console needs the system config at start + (`--send needs the bus configured`, exit 2). Without `--send` the unit is + read-only. The template is +-`packages/webui/systemd/mosaic-webui.service.in`. `install` writes a temp +-file, renames it and runs `systemctl --user daemon-reload`; `uninstall` +-refuses while the unit is active; `status` prints the unit state, whether +-the installed unit sends, and `GET /healthz` on the installed port. Each +-value is held to a plain +-character set the unit can carry (no space, `%`, `$`, quote, backslash, +-`&` or `|`) and refused otherwise. The unit sets no `MOSAIC_*`, `CLAUDE*`, +-`CODEX*` or `PI_*` variable. Serve's exit 2 (usage, config, or a port +-already in use) is not restarted. ++`packages/webui/systemd/mosaic-webui.service.in`. ++ ++The unit runs a pinned copy, not the working checkout (row 77, #1571). ++`copy COMMIT` resolves COMMIT to its full id, refuses one not reachable ++from `refs/heads/refactor`, and writes `git archive` of the paths the ++Console loads into `DIR//`, by default ++`${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-console//`. It writes a ++temp directory beside the target, makes every file read-only, then renames ++it, so the copy's path is always whole. A copy that exists is left alone ++(`exists:`). `install --copy ID` and `render --copy ID` point ++`WorkingDirectory` and ExecStart at the copy and pass serve ++`--checkout `, where the queue read still runs; `RELEASE`, ++the roles, the config check and the bus child come from the copy. `install` ++refuses without `--copy`, or when the copy is missing. `render` without ++`--copy` prints the unit for the checkout, as before. ++ ++`install` writes a temp file, renames it and runs ++`systemctl --user daemon-reload`. `install` and `uninstall` refuse while the ++unit is in any state but inactive or failed (`systemctl --user list-units`), ++and say Jason or Mos stops it first; `--no-reload` skips only the ++daemon-reload, never that check. If the state read itself fails they refuse ++with one line naming the call, and change nothing. `copy` and `install` ++refuse outside the canonical checkout (a `.git` directory, not a worktree's ++`.git` file); `--allow-worktree` overrides that for the tests only. `status` ++prints the unit state, the copy id it runs, whether `refactor` points at ++that commit or another, whether the installed unit sends, and ++`GET /healthz` on the installed port. It changes nothing. ++ ++Each value is held to a plain character set the unit can carry (no space, ++`%`, `$`, quote, backslash, `&` or `|`), and the preview values must start ++with a letter or digit; anything else is refused. With `--auth-header ++X-authentik-uid`, in any letter case, `--allow-user` must be the Authentik ++uid, `^[0-9a-f]{64}$` (lead decision 101). The unit sets no `MOSAIC_*`, ++`CLAUDE*`, `CODEX*` or `PI_*` variable. Serve's exit 2 (usage, config, or a ++port already in use) is not restarted. ++ ++The update step: `copy` the commit, stop the unit, `install --copy ID` with ++the same flags, start it. Rollback is the same steps with the earlier id. ++Copies are kept until someone removes them; nothing prunes them. + + There is no start, stop, enable or restart verb. The Console reads the bus + through the human transport, so whoever starts it holds the human's +-capability. Jason starts and restarts it ++capability. Jason starts, stops and restarts it + (`systemctl --user enable --now mosaic-webui`), and so may Mos under lead + decision 102, which accepts that sends from a Console Mos started enter the + trail as Jason (`human-cli`). Any other agent starting it, even through +diff --git a/packages/webui/README.md b/packages/webui/README.md +index 4a7cf535..74b3b5e7 100644 +--- a/packages/webui/README.md ++++ b/packages/webui/README.md +@@ -110,6 +110,17 @@ system config at start: without it serve refuses with `--send needs the bus + configured` and exits 2, which the unit doesn't restart. `status` says + whether the installed unit sends. + ++The unit runs a read-only pinned copy of the Console's paths, not the ++working checkout (row 77). `scripts/webui-service.sh copy COMMIT` makes one ++from a commit on `refactor`, and `install --copy ID` points the unit at it, ++with serve's `--checkout` naming the checkout, where the queue read still ++runs. The update step is `copy`, stop, `install --copy ID` with the same ++flags, start; rollback is the same with the earlier id, and copies are kept. ++The stop and start are Jason's or Mos's: `install` and `uninstall` refuse ++while the unit runs. With `--auth-header X-authentik-uid` the allowed user ++must be the 64-character Authentik uid (lead decision 101). `status` names ++the copy and whether `refactor` has moved past it. ++ + ## Message a role (row 76, #1570) + + ```sh +diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs +index 618a2cf1..de03a5ee 100644 +--- a/packages/webui/src/cli.mjs ++++ b/packages/webui/src/cli.mjs +@@ -5,9 +5,11 @@ import { views } from '../../bus/src/views.mjs'; + import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs'; + import { configDir } from '../../business/src/business.mjs'; + import { consoleReads, redactor } from './reads.mjs'; ++import { statSync } from 'node:fs'; ++import { isAbsolute } from 'node:path'; + import { ID_PATTERN } from '../../business/src/vocabulary.mjs'; + const args = process.argv.slice(2); +-const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send]]'; ++const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send]]'; + try { + if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); } + if (args.shift() !== 'serve') throw new Error(usage); +@@ -23,6 +25,14 @@ try { + if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535'); + options.port = Number(value); + } else if (flag === '--board') options.board = value; ++ // Row 77 (#1571): the canonical checkout the queue read runs from, when ++ // serve runs from a pinned copy; everything else loads from the copy. ++ else if (flag === '--checkout') { ++ let dir = false; ++ try { dir = isAbsolute(value) && statSync(value).isDirectory(); } catch { /* refused below */ } ++ if (!dir) throw new Error('--checkout must be the absolute path of a directory'); ++ options.checkout = value; ++ } + else if (flag === '--business') { + if (!ID_PATTERN.test(value)) throw new Error('business must be a business id'); + options.business = value; +@@ -54,10 +64,11 @@ try { + if (sendOn) throw new Error(`--send needs the bus configured (${why})`); + busLine = `Bus: not configured (${why})`; + } +- const reads = consoleReads({ root: REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null }); ++ const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null }); + const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send }); + const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : ''; +- console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${previewLine}\nCtrl-C stops this server.`); ++ const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : ''; ++ console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}\nCtrl-C stops this server.`); + for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close()); + } catch (err) { + console.error(`refused: ${err.message}`); +diff --git a/packages/webui/src/reads.mjs b/packages/webui/src/reads.mjs +index 01224d15..3d745232 100644 +--- a/packages/webui/src/reads.mjs ++++ b/packages/webui/src/reads.mjs +@@ -158,14 +158,16 @@ export function businessView(b, { system = null, now = Date.now() } = {}) { + } + + // --------------------------------------------------------------- all three +-// `system` is loadSystem() output or null. Returns { queue, row, business, ++// `system` is loadSystem() output or null. `queueRoot` is where the queue ++// read runs, `root` otherwise (RELEASE); serve --checkout sets it apart ++// when serve runs from a pinned copy (row 77, #1571). Returns { queue, row, business, + // settings }; each resolves to a redacted body or throws a ReadError whose + // message is redacted. +-export function consoleReads({ root, system = null, configDir = null, rolesDir, business = null, env = process.env, queueTimeoutMs = 30000, now = () => Date.now() }) { ++export function consoleReads({ root, queueRoot = root, system = null, configDir = null, rolesDir, business = null, env = process.env, queueTimeoutMs = 30000, now = () => Date.now() }) { + const dataRoot = system?.dataRoot ?? null; + const redact = redactor({ configDir, dataRoot }); + const fail = (code, message) => new ReadError(code, redact(message)); +- const queue = queueReader({ root, env, timeoutMs: queueTimeoutMs }); ++ const queue = queueReader({ root: queueRoot, env, timeoutMs: queueTimeoutMs }); + const readQueue = async () => { + try { return await queue(); } catch (err) { throw err instanceof ReadError ? fail(err.code, err.message) : fail('read-failed', 'the queue read failed'); } + }; +diff --git a/packages/webui/systemd/mosaic-webui.service.in b/packages/webui/systemd/mosaic-webui.service.in +index 081bd963..6049de0b 100644 +--- a/packages/webui/systemd/mosaic-webui.service.in ++++ b/packages/webui/systemd/mosaic-webui.service.in +@@ -1,13 +1,16 @@ + # Mosaic Console: `node packages/webui/src/cli.mjs serve`, one instance. + # Rendered by scripts/webui-service.sh from packages/webui/systemd/; @REPO@, + # @PATH@, @NODE@ and the serve flags below are filled in at install time. +-# Edit the template and reinstall; do not edit the installed copy. ++# @REPO@ is the read-only copy `install --copy ID` names, and the flags ++# pass the canonical checkout as --checkout for the queue read (row 77). ++# Edit the template and reinstall; do not edit the installed unit. + # + # The Console reads the bus through the human transport, so whoever starts +-# this unit holds the human's capability. Jason starts it from his own +-# shell (`systemctl --user enable --now mosaic-webui`); an agent starting +-# it works around the human proof (lead decisions 62 and 94). The unit sets +-# no MOSAIC_*, CLAUDE*, CODEX* or PI_* variable. ++# this unit holds the human's capability. Jason starts and restarts it ++# (`systemctl --user enable --now mosaic-webui`), and so may Mos under lead ++# decision 102, which accepts that sends then enter the trail as Jason. Any ++# other agent starting it works around the human proof (lead decisions 62 ++# and 94). The unit sets no MOSAIC_*, CLAUDE*, CODEX* or PI_* variable. + [Unit] + Description=Mosaic Console + Documentation=file://@REPO@/packages/webui/README.md +diff --git a/packages/webui/tests/pinned-copy.test.mjs b/packages/webui/tests/pinned-copy.test.mjs +new file mode 100644 +index 00000000..cab7f52b +--- /dev/null ++++ b/packages/webui/tests/pinned-copy.test.mjs +@@ -0,0 +1,208 @@ ++import { test } from 'node:test'; ++import assert from 'node:assert/strict'; ++import { execFileSync, spawn } from 'node:child_process'; ++import { once } from 'node:events'; ++import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; ++import { dirname, join } from 'node:path'; ++import { fileURLToPath } from 'node:url'; ++import { fixture, tmp } from '../../cli/tests/helpers.mjs'; ++import { consoleReads } from '../src/reads.mjs'; ++ ++// Row 77 (#1571, lead decision 112): `scripts/webui-service.sh copy` and ++// serve from the copy it writes. The copy comes from a fixture repository ++// holding this tree's packages, scripts and roles, so a path the Console ++// loads that the script's list leaves out fails the serve test here. ++// Nothing here reaches systemd: copy makes no systemctl call. ++const REPO = fileURLToPath(new URL('../../..', import.meta.url)).replace(/\/$/, ''); ++const NODE_DIR = dirname(process.execPath); ++const GIT = ['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null']; ++ ++const git = (cwd, ...args) => execFileSync('git', [...GIT, '-C', cwd, ...args], { encoding: 'utf8' }).trim(); ++ ++// A canonical checkout (a .git directory) on branch refactor, with this ++// tree's files, the uncommitted ones included, and a RELEASE of its own. ++function fixtureRepo(t) { ++ const dir = tmp(t, 'webui-copy-'); ++ const repo = join(dir, 'repo'); ++ const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean); ++ for (const f of files) if (existsSync(join(REPO, f))) cpSync(join(REPO, f), join(repo, f)); ++ writeFileSync(join(repo, 'RELEASE'), '0.0.0-fixture\n'); ++ git(dir, 'init', '-q', '-b', 'refactor', repo); ++ git(repo, 'add', '-A'); ++ git(repo, 'commit', '-q', '-m', 'fixture'); ++ return { dir, repo, script: join(repo, 'scripts', 'webui-service.sh'), head: git(repo, 'rev-parse', 'HEAD') }; ++} ++ ++async function run(dir, script, args) { ++ const env = { PATH: `${NODE_DIR}:/usr/bin:/bin`, HOME: dir, XDG_CONFIG_HOME: join(dir, 'config') }; ++ const child = spawn(script, args, { env }); ++ let stdout = '', stderr = ''; ++ child.stdout.on('data', (d) => { stdout += d; }); ++ child.stderr.on('data', (d) => { stderr += d; }); ++ const [status] = await once(child, 'close'); ++ return { status, stdout, stderr }; ++} ++ ++function filesUnder(root) { ++ const out = []; ++ for (const entry of readdirSync(root, { withFileTypes: true, recursive: true })) if (entry.isFile()) out.push(join(entry.parentPath, entry.name)); ++ return out; ++} ++ ++test('copy writes the Console paths at the full id, read-only, and leaves an existing copy alone', async (t) => { ++ const { dir, repo, script, head } = fixtureRepo(t); ++ const copies = join(dir, 'copies'); ++ const target = join(copies, head); ++ const r = await run(dir, script, ['copy', head.slice(0, 12), '--copies', copies]); ++ assert.equal(r.status, 0, r.stderr); ++ assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm')); ++ assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind'); ++ assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'packages', 'roles', 'scripts']); ++ assert.deepEqual(readdirSync(join(target, 'scripts')), ['mosaic-config.mjs']); ++ assert.ok(!existsSync(join(target, 'packages', 'queue')), 'the queue read runs from the checkout'); ++ assert.equal(readFileSync(join(target, 'RELEASE'), 'utf8'), '0.0.0-fixture\n'); ++ const files = filesUnder(target); ++ assert.ok(files.length > 50, String(files.length)); ++ for (const f of files) assert.equal(statSync(f).mode & 0o222, 0, `${f} is writable`); ++ // Already there: left as it is, by id or by branch name. ++ for (const commit of [head, 'refactor']) { ++ const again = await run(dir, script, ['copy', commit, '--copies', copies]); ++ assert.equal(again.status, 0, again.stderr); ++ assert.equal(again.stdout, `exists: ${target}\n`); ++ } ++ // The default directory is under XDG_DATA_HOME, else ~/.local/share. ++ const byDefault = await run(dir, script, ['copy', 'refactor']); ++ assert.equal(byDefault.status, 0, byDefault.stderr); ++ assert.ok(existsSync(join(dir, '.local', 'share', 'mosaic-console', head, 'packages', 'webui', 'src', 'cli.mjs'))); ++ assert.equal(git(repo, 'status', '--porcelain'), '', 'the checkout is untouched'); ++}); ++ ++test('copy refuses a commit off refactor, an unknown one, a worktree and a bad copies directory', async (t) => { ++ const { dir, repo, script } = fixtureRepo(t); ++ const copies = join(dir, 'copies'); ++ git(repo, 'checkout', '-q', '-b', 'side'); ++ writeFileSync(join(repo, 'side.txt'), 'side\n'); ++ git(repo, 'add', 'side.txt'); ++ git(repo, 'commit', '-q', '-m', 'side'); ++ const side = git(repo, 'rev-parse', 'HEAD'); ++ git(repo, 'checkout', '-q', 'refactor'); ++ for (const [commit, why] of [[side, new RegExp(`^webui-service: refusing: ${side} is not reachable from refs/heads/refactor$`, 'm')], ['side', /is not reachable from refs\/heads\/refactor/], ['nope', /^webui-service: not a commit: nope$/m], ['a b', /commit must be a commit id or branch name/], ['HEAD~1', /commit must be a commit id or branch name/]]) { ++ const r = await run(dir, script, ['copy', commit, '--copies', copies]); ++ assert.equal(r.status, 1, commit); ++ assert.match(r.stderr, why, commit); ++ assert.equal(r.stdout, '', commit); ++ } ++ for (const args of [['copy'], ['copy', '-x'], ['copy', 'refactor', '--copy', side], ['copy', 'refactor', '--port', '7332'], ['copy', 'refactor', '--dir', dir]]) { ++ assert.equal((await run(dir, script, args)).status, 4, args.join(' ')); ++ } ++ for (const bad of ['copies', `${dir}/a b`, `${dir}/a%b`]) { ++ const r = await run(dir, script, ['copy', 'refactor', '--copies', bad]); ++ assert.equal(r.status, 1, bad); ++ assert.match(r.stderr, /copies directory (must be an absolute path|contains a character the unit cannot carry)/, bad); ++ } ++ assert.ok(!existsSync(copies), 'no refusal writes anything'); ++ // N2: a linked worktree has a .git file; --allow-worktree is for tests. ++ const tree = join(dir, 'tree'); ++ git(repo, 'worktree', 'add', '-q', '--detach', tree, 'refactor'); ++ const fromTree = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies]); ++ assert.equal(fromTree.status, 1); ++ assert.match(fromTree.stderr, /^webui-service: refusing: \S+ is not the canonical checkout \(its \.git is not a directory\)/m); ++ assert.ok(!existsSync(copies)); ++ const allowed = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies, '--allow-worktree']); ++ assert.equal(allowed.status, 0, allowed.stderr); ++ assert.match(allowed.stdout, /^copied: /m); ++ assert.match((await run(dir, script, ['--help'])).stdout, /--allow-worktree is for the tests only/); ++}); ++ ++test('copy writes nothing when an archived path is missing at the commit', async (t) => { ++ const { dir, repo, script } = fixtureRepo(t); ++ const copies = join(dir, 'copies'); ++ git(repo, 'rm', '-q', 'scripts/mosaic-config.mjs'); ++ git(repo, 'commit', '-q', '-m', 'drop the config check'); ++ const r = await run(dir, script, ['copy', 'refactor', '--copies', copies]); ++ assert.equal(r.status, 1); ++ assert.match(r.stderr, /^webui-service: git archive of [0-9a-f]{40} failed; no copy written$/m); ++ assert.deepEqual(readdirSync(copies), [], 'the temp directory is removed'); ++}); ++ ++// serve from a copy: /healthz, the config check, roles and RELEASE from the ++// copy, and the queue read from --checkout or, without it, from the copy. ++async function serve(t, cli, args, env) { ++ const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); ++ t.after(() => child.kill()); ++ let out = '', err = ''; ++ child.stderr.on('data', (c) => { err += c; }); ++ const port = await new Promise((resolve, reject) => { ++ child.stdout.on('data', (c) => { out += c; const m = out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\/$/m); if (m && out.includes('Ctrl-C')) resolve(Number(m[1])); }); ++ child.on('exit', (code) => reject(new Error(`exited ${code}: ${out}${err}`))); ++ }); ++ const get = async (path) => { const res = await fetch(`http://127.0.0.1:${port}${path}`); return { status: res.status, body: await res.json() }; }; ++ return { out, get }; ++} ++ ++test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout', async (t) => { ++ const { dir, script, head } = fixtureRepo(t); ++ const copies = join(dir, 'copies'); ++ assert.equal((await run(dir, script, ['copy', 'refactor', '--copies', copies])).status, 0); ++ const cli = join(copies, head, 'packages', 'webui', 'src', 'cli.mjs'); ++ // A checkout whose queue read says where it ran. ++ const checkout = join(dir, 'checkout'); ++ mkdirSync(join(checkout, 'packages', 'webui', 'src'), { recursive: true }); ++ writeFileSync(join(checkout, 'packages', 'webui', 'src', 'queue-read.mjs'), `process.stdout.write(JSON.stringify({ rows: [], notes: [process.cwd() === ${JSON.stringify(checkout)} ? 'read in the checkout' : 'read elsewhere'] }));\n`); ++ const { env } = fixture(join(dir, 'home')); ++ delete env.MOSAIC_ROLES_DIR; ++ const { out, get } = await serve(t, cli, ['--business', 'acme', '--checkout', checkout], env); ++ assert.match(out, /^Bus: acme$/m, 'the config check ran from the copy'); ++ assert.match(out, /^Queue: read from the --checkout directory$/m); ++ assert.deepEqual(await get('/healthz'), { status: 200, body: { ok: true } }); ++ const settings = await get('/api/settings'); ++ assert.equal(settings.status, 200); ++ assert.equal(settings.body.release, '0.0.0-fixture', 'RELEASE from the copy'); ++ const business = await get('/api/business'); ++ assert.equal(business.status, 200, JSON.stringify(business.body)); ++ assert.equal(business.body.business.id, 'acme', 'roles from the copy'); ++ const queue = await get('/api/queue'); ++ assert.equal(queue.status, 200, JSON.stringify(queue.body)); ++ assert.deepEqual(queue.body.notes, ['read in the checkout']); ++ // Without --checkout the copy's own queue read runs, and the copy holds ++ // no packages/queue, so it fails closed. ++ const plain = await serve(t, cli, ['--business', 'acme'], env); ++ assert.doesNotMatch(plain.out, /^Queue:/m); ++ assert.equal((await plain.get('/healthz')).status, 200); ++ const none = await plain.get('/api/queue'); ++ assert.equal(none.status, 503); ++ assert.equal(none.body.error, 'read-failed'); ++}); ++ ++test('serve refuses a --checkout that is not an absolute directory path', async (t) => { ++ const dir = tmp(t, 'webui-checkout-'); ++ writeFileSync(join(dir, 'file'), ''); ++ // 'sub' is a directory under the child's cwd, so only its being relative refuses it. ++ mkdirSync(join(dir, 'sub')); ++ for (const value of ['sub', join(dir, 'missing'), join(dir, 'file')]) { ++ const child = spawn(process.execPath, [join(REPO, 'packages', 'webui', 'src', 'cli.mjs'), 'serve', '--port', '0', '--checkout', value], { cwd: dir, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] }); ++ let out = '', err = ''; ++ child.stdout.on('data', (c) => { out += c; }); ++ child.stderr.on('data', (c) => { err += c; }); ++ const [code] = await once(child, 'close'); ++ assert.equal(code, 2, value); ++ assert.equal(err, 'refused: --checkout must be the absolute path of a directory\n', value); ++ assert.equal(out, ''); ++ } ++}); ++ ++test('consoleReads runs the queue read under queueRoot and reads RELEASE under root', async (t) => { ++ const dir = tmp(t, 'webui-roots-'); ++ const [root, queueRoot] = [join(dir, 'copy'), join(dir, 'checkout')]; ++ for (const [base, said] of [[root, 'copy'], [queueRoot, 'checkout']]) { ++ mkdirSync(join(base, 'packages', 'webui', 'src'), { recursive: true }); ++ writeFileSync(join(base, 'packages', 'webui', 'src', 'queue-read.mjs'), `process.stdout.write(JSON.stringify({ rows: [], notes: ['${said}', process.cwd() === ${JSON.stringify(base)} ? 'cwd there' : 'cwd elsewhere'] }));\n`); ++ writeFileSync(join(base, 'RELEASE'), `${said}-release\n`); ++ } ++ const apart = consoleReads({ root, queueRoot, rolesDir: join(dir, 'roles') }); ++ assert.deepEqual((await apart.queue()).notes, ['checkout', 'cwd there'], 'the checkout ran it, with its cwd there'); ++ assert.equal((await apart.settings()).release, 'copy-release'); ++ const together = consoleReads({ root, rolesDir: join(dir, 'roles') }); ++ assert.deepEqual((await together.queue()).notes, ['copy', 'cwd there'], 'queueRoot defaults to root'); ++ assert.equal((await together.settings()).release, 'copy-release'); ++}); +diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs +index da440d6b..2f670c70 100644 +--- a/packages/webui/tests/service.test.mjs ++++ b/packages/webui/tests/service.test.mjs +@@ -11,22 +11,35 @@ import { fileURLToPath } from 'node:url'; + // scripts/webui-service.sh (row 75, #1569). Nothing here starts a unit or + // reaches the real systemd: install and uninstall run with --no-reload or a + // stand-in systemctl on PATH, and XDG_CONFIG_HOME points into a temp dir. +-// Row 83 (#1576) adds --send, taken only with the preview set. ++// Row 83 (#1576) adds --send, taken only with the preview set. Row 77 ++// (#1571) adds --copy, the running-unit refusals and the uid rule; every run ++// gets a stand-in systemctl, since install now reads the unit's state, and ++// install runs with --allow-worktree, since the suite runs in worktrees. + const REPO = fileURLToPath(new URL('../../..', import.meta.url)).replace(/\/$/, ''); + const SCRIPT = join(REPO, 'scripts', 'webui-service.sh'); + const NODE_DIR = dirname(process.execPath); +-const PREVIEW = ['--public-host', 'console.example.com', '--auth-header', 'X-authentik-uid', '--allow-user', 'jason']; +-const PREVIEW_FLAGS = '--public-host console.example.com --auth-header X-authentik-uid --allow-user jason'; ++const UID = '6f1c0e2a9b8d7c4e5f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e'; ++const PREVIEW = ['--public-host', 'console.example.com', '--auth-header', 'X-authentik-uid', '--allow-user', UID]; ++const PREVIEW_FLAGS = `--public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`; ++// A copy as `copy` leaves it, reduced to the file install looks for. ++const COPY_ID = '0123456789abcdef0123456789abcdef01234567'; ++const INSTALL = ['--copy', COPY_ID, '--allow-worktree']; ++const copyRoot = (dir) => join(dir, '.local', 'share', 'mosaic-console', COPY_ID); ++function fakeCopy(dir) { ++ mkdirSync(join(copyRoot(dir), 'packages', 'webui', 'src'), { recursive: true }); ++ writeFileSync(join(copyRoot(dir), 'packages', 'webui', 'src', 'cli.mjs'), ''); ++ return copyRoot(dir); ++} + const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\)$/m; + const TEMPLATE = readFileSync(join(REPO, 'packages', 'webui', 'systemd', 'mosaic-webui.service.in'), 'utf8'); + + // The unit as the base script renders it (row 75): the template with its four + // placeholders filled in, nothing else. Row 83 adds --send and must leave + // every unit rendered without it byte for byte as this. +-function filled(flags) { ++function filled(flags, root = REPO) { + const base = '/usr/local/bin:/usr/bin:/bin'; + const path = `:${base}:`.includes(`:${NODE_DIR}:`) ? base : `${NODE_DIR}:${base}`; +- return TEMPLATE.replaceAll('@REPO@', REPO).replaceAll('@PATH@', path).replaceAll('@NODE@', process.execPath).replaceAll('@FLAGS@', flags); ++ return TEMPLATE.replaceAll('@REPO@', root).replaceAll('@PATH@', path).replaceAll('@NODE@', process.execPath).replaceAll('@FLAGS@', flags); + } + + // A loopback port with nothing listening, so status's /healthz request +@@ -44,18 +57,20 @@ async function closedPort() { + function tmp(t) { + const dir = mkdtempSync(join(tmpdir(), 'webui-service-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); ++ fakeCopy(dir); + return dir; + } + +-// A stand-in systemctl that prints `lines` for list-units and logs every call. +-function standIn(dir, lines = '') { ++// A stand-in systemctl that prints `lines` for list-units and logs every ++// call. With `listFails`, list-units exits 1 after printing to stderr. ++function standIn(dir, lines = '', { listFails = false } = {}) { + const bin = join(dir, 'bin'); + mkdirSync(bin); + writeFileSync(join(dir, 'list-units.txt'), lines); + writeFileSync(join(bin, 'systemctl'), `#!/bin/sh + echo "$*" >> "${dir}/calls.txt" + case "$*" in +- *list-units*) cat "${dir}/list-units.txt" ;; ++ *list-units*) ${listFails ? 'echo "Failed to connect to bus" >&2; exit 1' : `cat "${dir}/list-units.txt"`} ;; + *is-enabled*) echo enabled ;; + *is-active*) echo active ;; + esac +@@ -65,9 +80,18 @@ exit 0 + return bin; + } + ++// Without a stand-in of its own, a run gets one that lists nothing (the ++// unit not loaded), so no run reaches the real systemd. ++function quiet(dir) { ++ const home = join(dir, '.quiet'); ++ if (!existsSync(home)) { mkdirSync(home); standIn(home); } ++ return join(home, 'bin'); ++} ++const otherCalls = (file) => readFileSync(file, 'utf8').split('\n').filter(Boolean).filter((c) => c !== '--user list-units --no-legend --plain mosaic-webui.service'); ++ + // Async, so an in-process server can answer the script's /healthz request. + async function run(dir, args, bin = null) { +- const env = { PATH: `${bin ? `${bin}:` : ''}${NODE_DIR}:/usr/bin:/bin`, HOME: dir, XDG_CONFIG_HOME: join(dir, 'config') }; ++ const env = { PATH: `${bin ?? quiet(dir)}:${NODE_DIR}:/usr/bin:/bin`, HOME: dir, XDG_CONFIG_HOME: join(dir, 'config') }; + const child = spawn(SCRIPT, args, { env }); + let stdout = '', stderr = ''; + child.stdout.on('data', (d) => { stdout += d; }); +@@ -95,7 +119,7 @@ test('render carries every serve flag, the preview set included', async (t) => { + const dir = tmp(t); + const r = await run(dir, ['render', '--port', '7340', '--business', 'mosaic.dev_1', '--board', 'http://127.0.0.1:7331', ...PREVIEW]); + assert.equal(r.status, 0, r.stderr); +- assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 --public-host console.example.com --auth-header X-authentik-uid --allow-user jason`); ++ assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`); + }); + + test('a partial preview set is refused', async (t) => { +@@ -153,8 +177,9 @@ test('install into --dir with --no-reload writes the file whole, then reports un + const dir = tmp(t); + const unitDir = join(dir, 'units'); + const bin = standIn(dir); +- const rendered = await run(dir, ['render', '--business', 'mosaic']); +- const r = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--business', 'mosaic'], bin); ++ const rendered = await run(dir, ['render', '--copy', COPY_ID, '--business', 'mosaic']); ++ assert.equal(rendered.stdout, filled(`--checkout ${REPO} --port 7332 --business mosaic`, copyRoot(dir))); ++ const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--business', 'mosaic'], bin); + assert.equal(r.status, 0, r.stderr); + const target = join(unitDir, 'mosaic-webui.service'); + assert.match(r.stdout, new RegExp(`^written: ${target}$`, 'm')); +@@ -162,41 +187,50 @@ test('install into --dir with --no-reload writes the file whole, then reports un + assert.equal(readFileSync(target, 'utf8'), rendered.stdout); + assert.equal(statSync(target).mode & 0o777, 0o644); + assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service'], 'no temp file left behind'); +- const again = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--business', 'mosaic'], bin); ++ const again = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--business', 'mosaic'], bin); + assert.equal(again.status, 0, again.stderr); + assert.match(again.stdout, new RegExp(`^unchanged: ${target}$`, 'm')); +- assert.ok(!existsSync(join(dir, 'calls.txt')), 'no systemctl call with --no-reload'); ++ assert.deepEqual(otherCalls(join(dir, 'calls.txt')), [], 'with --no-reload, only the state read'); + // A refused value writes nothing and leaves the installed unit alone. +- const bad = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--business', 'a b'], bin); ++ const bad = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--business', 'a b'], bin); + assert.equal(bad.status, 1); + assert.equal(readFileSync(target, 'utf8'), rendered.stdout); + assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']); + const fresh = join(dir, 'never'); +- assert.equal((await run(dir, ['install', '--dir', fresh, '--no-reload', '--port', '0'], bin)).status, 1); ++ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', fresh, '--no-reload', '--port', '0'], bin)).status, 1); + assert.ok(!existsSync(fresh), 'a refusal creates no directory'); + }); + + test('install without --no-reload runs daemon-reload and nothing that starts the unit', async (t) => { + const dir = tmp(t); + const bin = standIn(dir); +- const r = await run(dir, ['install', '--dir', join(dir, 'units')], bin); ++ const r = await run(dir, ['install', ...INSTALL, '--dir', join(dir, 'units')], bin); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^daemon-reload done$/m); +- assert.equal(readFileSync(join(dir, 'calls.txt'), 'utf8'), '--user daemon-reload\n'); ++ assert.equal(readFileSync(join(dir, 'calls.txt'), 'utf8'), '--user list-units --no-legend --plain mosaic-webui.service\n--user daemon-reload\n'); + }); + +-test('uninstall refuses while the unit is active, and removes it once it is not', async (t) => { ++test('uninstall refuses while the unit is running, with or without --no-reload, and removes it once it is not', async (t) => { + const dir = tmp(t); + const unitDir = join(dir, 'units'); + const target = join(unitDir, 'mosaic-webui.service'); +- for (const state of ['active', 'activating', 'deactivating']) { ++ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload'])).status, 0); ++ for (const state of ['active', 'activating', 'deactivating', 'reloading']) { + const bin = standIn(mkdtempSync(join(dir, 'si-')), `mosaic-webui.service loaded ${state} running Mosaic Console\n`); +- assert.equal((await run(dir, ['install', '--dir', unitDir, '--no-reload'])).status, 0); +- const r = await run(dir, ['uninstall', '--dir', unitDir], bin); +- assert.equal(r.status, 1, state); +- assert.match(r.stderr, /refusing: mosaic-webui\.service is still running/); +- assert.ok(existsSync(target), `still installed while ${state}`); ++ // N3: --no-reload skips only daemon-reload, never the state read. ++ for (const args of [['uninstall', '--dir', unitDir], ['uninstall', '--dir', unitDir, '--no-reload']]) { ++ const r = await run(dir, args, bin); ++ assert.equal(r.status, 1, `${state} ${args.join(' ')}`); ++ assert.equal(r.stderr, `webui-service: refusing: mosaic-webui.service is still running (${state}); Jason or Mos stops it first (systemctl --user stop mosaic-webui)\n`); ++ assert.equal(r.stdout, ''); ++ assert.ok(existsSync(target), `still installed while ${state}`); ++ } + } ++ // A failed unit is not running; uninstall goes ahead. ++ const failed = standIn(mkdtempSync(join(dir, 'si-')), 'mosaic-webui.service loaded failed failed Mosaic Console\n'); ++ assert.equal((await run(dir, ['uninstall', '--dir', unitDir, '--no-reload'], failed)).status, 0); ++ assert.ok(!existsSync(target)); ++ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload'])).status, 0); + const bin = standIn(mkdtempSync(join(dir, 'si-')), 'mosaic-webui.service loaded inactive dead Mosaic Console\n'); + const r = await run(dir, ['uninstall', '--dir', unitDir], bin); + assert.equal(r.status, 0, r.stderr); +@@ -220,9 +254,10 @@ test('status prints the unit state and GET /healthz on the installed port', asyn + await once(server, 'listening'); + t.after(() => server.close()); + const { port } = server.address(); +- assert.equal((await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port)])).status, 0); ++ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port)])).status, 0); + const r = await run(dir, ['status', '--dir', unitDir], bin); + assert.equal(r.status, 0, r.stderr); ++ assert.match(r.stdout, new RegExp(`^copy: ${COPY_ID}$`, 'm')); + assert.match(r.stdout, /^ {2}enabled: enabled$/m); + assert.match(r.stdout, /^ {2}active: {2}active$/m); + assert.match(r.stdout, new RegExp(`^healthz: http://127\\.0\\.0\\.1:${port}/healthz 200 \\{"ok":true\\}$`, 'm')); +@@ -237,7 +272,7 @@ test('status prints the unit state and GET /healthz on the installed port', asyn + + test('usage: an unknown verb or flag, and start or enable, exit 4', async (t) => { + const dir = tmp(t); +- for (const args of [[], ['start'], ['stop'], ['enable'], ['restart'], ['render', '--bogus'], ['render', '--port'], ['render', '--dir', dir], ['render', '--no-reload'], ['uninstall', '--port', '7332'], ['status', '--business', 'mosaic'], ['status', '--no-reload'], ['render', '--send', 'yes', ...PREVIEW]]) { ++ for (const args of [[], ['start'], ['stop'], ['enable'], ['restart'], ['render', '--bogus'], ['render', '--port'], ['render', '--dir', dir], ['render', '--no-reload'], ['uninstall', '--port', '7332'], ['status', '--business', 'mosaic'], ['status', '--no-reload'], ['render', '--send', 'yes', ...PREVIEW], ['render', '--allow-worktree'], ['render', '--copies', dir], ['uninstall', '--copy', COPY_ID], ['uninstall', '--allow-worktree'], ['status', '--copy', COPY_ID], ['status', '--allow-worktree'], ['install', '--copy']]) { + const r = await run(dir, args); + assert.equal(r.status, 4, args.join(' ')); + assert.match(r.stderr, /^usage: scripts\/webui-service\.sh/); +@@ -268,7 +303,7 @@ test('render --send with the preview set ends ExecStart with --send and changes + const r = await run(dir, ['render', ...args]); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, filled(`${flags} --send`), args.join(' ')); +- assert.ok(execStart(r.stdout).endsWith(' --allow-user jason --send'), execStart(r.stdout)); ++ assert.ok(execStart(r.stdout).endsWith(` --allow-user ${UID} --send`), execStart(r.stdout)); + const without = await run(dir, ['render', ...plain]); + assert.equal(without.status, 0, without.stderr); + const a = r.stdout.split('\n'), b = without.stdout.split('\n'); +@@ -290,20 +325,20 @@ test('--send without the whole preview set is refused and writes nothing', async + const bin = standIn(dir); + const fresh = join(dir, 'never'); + for (const args of partial) { +- const r = await run(dir, ['install', '--dir', fresh, '--no-reload', ...args, '--send'], bin); ++ const r = await run(dir, ['install', ...INSTALL, '--dir', fresh, '--no-reload', ...args, '--send'], bin); + assert.equal(r.status, 1, args.join(' ')); + assert.match(r.stderr, SEND_REFUSAL, args.join(' ')); + } + assert.ok(!existsSync(fresh), 'a refusal creates no directory'); + const unitDir = join(dir, 'units'); + const target = join(unitDir, 'mosaic-webui.service'); +- assert.equal((await run(dir, ['install', '--dir', unitDir, '--no-reload', ...PREVIEW], bin)).status, 0); ++ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', ...PREVIEW], bin)).status, 0); + const before = readFileSync(target, 'utf8'); +- const r = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--send', ...PREVIEW.slice(0, 4)], bin); ++ const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--send', ...PREVIEW.slice(0, 4)], bin); + assert.equal(r.status, 1); + assert.equal(readFileSync(target, 'utf8'), before, 'the installed unit is left alone'); + assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']); +- assert.ok(!existsSync(join(dir, 'calls.txt')), 'no systemctl call'); ++ assert.deepEqual(otherCalls(join(dir, 'calls.txt')), [], 'no systemctl call but the one state read'); + }); + + test('install --send into --dir with --no-reload writes the sending unit, and status says whether it sends', async (t) => { +@@ -312,13 +347,14 @@ test('install --send into --dir with --no-reload writes the sending unit, and st + const target = join(unitDir, 'mosaic-webui.service'); + const port = await closedPort(); + const flags = `--port ${port} ${PREVIEW_FLAGS}`; +- const r = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW, '--send']); ++ const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW, '--send']); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, new RegExp(`^written: ${target}$`, 'm')); + // Lead decision 102: Jason or Mos starts it, no other agent. + assert.match(r.stdout, /^this script starts nothing: .* Jason\nstarts and restarts it, or Mos under lead decision 102 .*\ntrail as Jason\); no other agent does:$/m); +- assert.match(r.stdout, new RegExp(`^serve: \\S+ ${REPO.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/packages/webui/src/cli\\.mjs serve ${flags} --send$`, 'm')); +- assert.equal(readFileSync(target, 'utf8'), filled(`${flags} --send`)); ++ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); ++ assert.match(r.stdout, new RegExp(`^serve: \\S+ ${esc(copyRoot(dir))}/packages/webui/src/cli\\.mjs serve --checkout ${esc(REPO)} ${flags} --send$`, 'm')); ++ assert.equal(readFileSync(target, 'utf8'), filled(`--checkout ${REPO} ${flags} --send`, copyRoot(dir))); + assert.equal(statSync(target).mode & 0o777, 0o644); + assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']); + const bin = standIn(dir); +@@ -326,34 +362,34 @@ test('install --send into --dir with --no-reload writes the sending unit, and st + assert.equal(sending.status, 0, sending.stderr); + assert.match(sending.stdout, /^send: on \(--send; human messages to roles\)$/m); + assert.match(sending.stdout, /^healthz: \S+ unreachable \(ECONNREFUSED\)$/m); +- const again = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port), '--send', ...PREVIEW], bin); ++ const again = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), '--send', ...PREVIEW], bin); + assert.match(again.stdout, new RegExp(`^unchanged: ${target}$`, 'm')); +- const readOnly = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW], bin); ++ const readOnly = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW], bin); + assert.match(readOnly.stdout, new RegExp(`^written: ${target}$`, 'm')); +- assert.equal(readFileSync(target, 'utf8'), filled(flags)); ++ assert.equal(readFileSync(target, 'utf8'), filled(`--checkout ${REPO} ${flags}`, copyRoot(dir))); + const off = await run(dir, ['status', '--dir', unitDir], bin); + assert.match(off.stdout, /^send: off \(read-only\)$/m); + assert.doesNotMatch(off.stdout, /^send: on/m); +- // The only systemctl calls are status's reads; install ran none. +- assert.deepEqual(readFileSync(join(dir, 'calls.txt'), 'utf8').split('\n').filter(Boolean).filter((c) => !/^--user (is-enabled|is-active|show) mosaic-webui\.service/.test(c)), []); ++ // The only systemctl calls are status's reads and install's state read. ++ assert.deepEqual(otherCalls(join(dir, 'calls.txt')).filter((c) => !/^--user (is-enabled|is-active|show) mosaic-webui\.service/.test(c)), []); + const none = await run(dir, ['status', '--dir', join(dir, 'empty')], bin); + assert.doesNotMatch(none.stdout, /^send:/m, 'no unit, no send line'); +- // serve takes --send as an allowed user, so ExecStart can end with +- // `--allow-user --send` on a unit that doesn't send. +- const oddUser = [...PREVIEW.slice(0, 4), '--allow-user', '--send']; +- for (const [args, ending, state] of [[oddUser, '--allow-user --send', 'off'], [[...oddUser, '--send'], '--allow-user --send --send', 'on']]) { +- const r = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port), ...args]); +- assert.equal(r.status, 0, r.stderr); +- assert.ok(execStart(readFileSync(target, 'utf8')).endsWith(` ${ending}`), args.join(' ')); +- assert.match((await run(dir, ['status', '--dir', unitDir], bin)).stdout, new RegExp(`^send: ${state} `, 'm'), args.join(' ')); ++ // serve would take --send as an allowed user; N5 refuses it, so no unit ++ // ends with `--allow-user --send` without sending. ++ const before = readFileSync(target, 'utf8'); ++ for (const args of [['--public-host', 'console.example.com', '--auth-header', 'X-authentik-username', '--allow-user', '--send'], ['--public-host', 'console.example.com', '--auth-header', 'X-authentik-username', '--allow-user', '--send', '--send']]) { ++ const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...args]); ++ assert.equal(r.status, 1, args.join(' ')); ++ assert.match(r.stderr, /^webui-service: allowed user must start with a letter or digit: --send$/m); + } ++ assert.equal(readFileSync(target, 'utf8'), before); + }); + + test('uninstall and status take no --send', async (t) => { + const dir = tmp(t); + const bin = standIn(dir); + const unitDir = join(dir, 'units'); +- assert.equal((await run(dir, ['install', '--dir', unitDir, '--no-reload', ...PREVIEW, '--send'])).status, 0); ++ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', ...PREVIEW, '--send'])).status, 0); + for (const args of [['uninstall', '--dir', unitDir, '--send'], ['uninstall', '--dir', unitDir, '--no-reload', '--send'], ['status', '--dir', unitDir, '--send']]) { + const r = await run(dir, args, bin); + assert.equal(r.status, 4, args.join(' ')); +@@ -363,3 +399,172 @@ test('uninstall and status take no --send', async (t) => { + assert.ok(existsSync(join(unitDir, 'mosaic-webui.service')), 'still installed'); + assert.ok(!existsSync(join(dir, 'calls.txt')), 'no systemctl call'); + }); ++ ++// Row 77 (#1571) from here on. ++const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); ++ ++test('install refuses while the unit is running, in any state but inactive or failed, and changes nothing', async (t) => { ++ const dir = tmp(t); ++ const unitDir = join(dir, 'units'); ++ const target = join(unitDir, 'mosaic-webui.service'); ++ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload'])).status, 0); ++ const before = readFileSync(target, 'utf8'); ++ for (const state of ['active', 'activating', 'deactivating', 'reloading']) { ++ const home = mkdtempSync(join(dir, 'si-')); ++ const bin = standIn(home, `mosaic-webui.service loaded ${state} running Mosaic Console\n`); ++ for (const reload of [[], ['--no-reload']]) { ++ const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, ...reload, '--port', '7444'], bin); ++ assert.equal(r.status, 1, `${state} ${reload}`); ++ assert.equal(r.stderr, `webui-service: refusing: mosaic-webui.service is running (${state}); Jason or Mos stops it first (systemctl --user stop mosaic-webui), then install, then start it\n`); ++ assert.equal(r.stdout, ''); ++ } ++ assert.deepEqual(otherCalls(join(home, 'calls.txt')), [], 'no daemon-reload'); ++ assert.equal(readFileSync(target, 'utf8'), before, `unit left alone while ${state}`); ++ assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service'], 'no temp file left'); ++ } ++ // inactive, failed, another unit's line, or nothing listed: install goes ahead. ++ for (const lines of ['mosaic-webui.service loaded inactive dead Mosaic Console\n', 'mosaic-webui.service loaded failed failed Mosaic Console\n', 'mosaic-bus@x.service loaded active running Bus\n', '']) { ++ const bin = standIn(mkdtempSync(join(dir, 'si-')), lines); ++ const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', '7444'], bin); ++ assert.equal(r.status, 0, `${JSON.stringify(lines)}: ${r.stderr}`); ++ } ++}); ++ ++test('when the state read fails, install and uninstall refuse with one line and change nothing', async (t) => { ++ const dir = tmp(t); ++ const unitDir = join(dir, 'units'); ++ const target = join(unitDir, 'mosaic-webui.service'); ++ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload'])).status, 0); ++ const before = readFileSync(target, 'utf8'); ++ const home = mkdtempSync(join(dir, 'si-')); ++ const bin = standIn(home, '', { listFails: true }); ++ const refusal = 'webui-service: refusing: systemctl --user list-units --no-legend --plain mosaic-webui.service failed, so the unit\'s state is unknown; nothing was changed\n'; ++ for (const args of [['install', ...INSTALL, '--dir', unitDir, '--port', '7444'], ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', '7444'], ['uninstall', '--dir', unitDir], ['uninstall', '--dir', unitDir, '--no-reload']]) { ++ const r = await run(dir, args, bin); ++ assert.equal(r.status, 1, args.join(' ')); ++ assert.equal(r.stderr, refusal, args.join(' ')); ++ assert.equal(r.stdout, ''); ++ } ++ assert.deepEqual(otherCalls(join(home, 'calls.txt')), [], 'no daemon-reload'); ++ assert.equal(readFileSync(target, 'utf8'), before); ++ assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']); ++}); ++ ++test('install needs --copy naming a copy that exists, and refuses outside the canonical checkout without --allow-worktree', async (t) => { ++ const dir = tmp(t); ++ const unitDir = join(dir, 'units'); ++ const cases = [ ++ [[], /^webui-service: install needs --copy ID, a copy made with: scripts\/webui-service\.sh copy COMMIT$/m], ++ [['--copy', COPY_ID.slice(0, 12)], /^webui-service: --copy must be a full 40-character commit id: 0123456789ab$/m], ++ [['--copy', COPY_ID.toUpperCase()], /^webui-service: --copy must be a full 40-character commit id: /m], ++ [['--copy', `${COPY_ID}0`], /^webui-service: --copy must be a full 40-character commit id: /m], ++ [['--copy', 'f'.repeat(40)], new RegExp(`^webui-service: no copy ${'f'.repeat(40)} in ${esc(join(dir, '.local', 'share', 'mosaic-console'))}; make it with: scripts/webui-service\\.sh copy ${'f'.repeat(40)}$`, 'm')], ++ [['--copy', COPY_ID, '--copies', 'relative'], /^webui-service: copies directory must be an absolute path: relative$/m], ++ [['--copy', COPY_ID, '--copies', join(dir, 'no copies')], /^webui-service: copies directory contains a character the unit cannot carry: /m], ++ ]; ++ for (const [copy, refusal] of cases) { ++ const r = await run(dir, ['install', '--allow-worktree', ...copy, '--dir', unitDir, '--no-reload']); ++ assert.equal(r.status, 1, copy.join(' ')); ++ assert.match(r.stderr, refusal, copy.join(' ')); ++ assert.ok(!existsSync(unitDir), copy.join(' ')); ++ } ++ // This suite runs in a git worktree, whose .git is a file: without ++ // --allow-worktree, install refuses before it reads anything else. ++ if (!statSync(join(REPO, '.git')).isDirectory()) { ++ const r = await run(dir, ['install', '--copy', COPY_ID, '--dir', unitDir, '--no-reload']); ++ assert.equal(r.status, 1); ++ assert.equal(r.stderr, `webui-service: refusing: ${REPO} is not the canonical checkout (its .git is not a directory); run copy and install from the canonical checkout\n`); ++ assert.ok(!existsSync(unitDir)); ++ } ++ // --copies moves where the copy is looked for. ++ const other = join(dir, 'other'); ++ mkdirSync(join(other, COPY_ID, 'packages', 'webui', 'src'), { recursive: true }); ++ writeFileSync(join(other, COPY_ID, 'packages', 'webui', 'src', 'cli.mjs'), ''); ++ const r = await run(dir, ['install', ...INSTALL, '--copies', other, '--dir', unitDir, '--no-reload']); ++ assert.equal(r.status, 0, r.stderr); ++ assert.equal(readFileSync(join(unitDir, 'mosaic-webui.service'), 'utf8'), filled(`--checkout ${REPO} --port 7332`, join(other, COPY_ID))); ++}); ++ ++test('render --copy runs the copy and reads the queue in the checkout; without --copy the unit is unchanged', async (t) => { ++ const dir = tmp(t); ++ const r = await run(dir, ['render', '--copy', COPY_ID, ...PREVIEW, '--send']); ++ assert.equal(r.status, 0, r.stderr); ++ assert.equal(r.stdout, filled(`--checkout ${REPO} --port 7332 ${PREVIEW_FLAGS} --send`, copyRoot(dir))); ++ assert.match(r.stdout, new RegExp(`^WorkingDirectory=${esc(copyRoot(dir))}$`, 'm')); ++ assert.match(r.stdout, new RegExp(`^ExecStart=\\S+ ${esc(copyRoot(dir))}/packages/webui/src/cli\\.mjs serve --checkout ${esc(REPO)} `, 'm')); ++ const plain = await run(dir, ['render', ...PREVIEW]); ++ assert.equal(plain.stdout, filled(`--port 7332 ${PREVIEW_FLAGS}`)); ++ assert.ok(!execStart(plain.stdout).includes('--checkout')); ++ // render only prints: it takes no --allow-worktree and needs no canonical checkout. ++ assert.equal((await run(dir, ['render', '--copy', 'f'.repeat(40)])).status, 1); ++}); ++ ++test('the uid header takes only an Authentik uid; the username header still takes a username', async (t) => { ++ const dir = tmp(t); ++ const unitDir = join(dir, 'units'); ++ const head = ['--public-host', 'console.example.com', '--auth-header']; ++ for (const header of ['X-authentik-uid', 'x-authentik-uid', 'X-AUTHENTIK-UID', 'x-AUTHENTIK-uid']) { ++ const ok = await run(dir, ['render', ...head, header, '--allow-user', UID]); ++ assert.equal(ok.status, 0, `${header}: ${ok.stderr}`); ++ for (const user of [UID.slice(1), `${UID}0`, UID.toUpperCase(), `${UID.slice(0, 63)}g`, 'jason', 'jason@example.com']) { ++ for (const verb of [['render'], ['install', ...INSTALL, '--dir', unitDir, '--no-reload']]) { ++ const r = await run(dir, [...verb, ...head, header, '--allow-user', user]); ++ assert.equal(r.status, 1, `${verb[0]} ${header} ${user}`); ++ assert.equal(r.stderr, `webui-service: with --auth-header ${header} the allowed user must be an Authentik uid, 64 lower-case hex characters (^[0-9a-f]{64}$, lead decision 101): ${user}\n`); ++ } ++ } ++ } ++ assert.ok(!existsSync(unitDir), 'no refused install wrote a unit'); ++ for (const user of ['jason', 'jason@example.com', UID]) { ++ const r = await run(dir, ['render', ...head, 'X-authentik-username', '--allow-user', user]); ++ assert.equal(r.status, 0, `${user}: ${r.stderr}`); ++ assert.ok(execStart(r.stdout).endsWith(` --allow-user ${user}`)); ++ } ++}); ++ ++test('a preview value cannot start with - or .', async (t) => { ++ const dir = tmp(t); ++ const cases = [ ++ [['--public-host', '-x.example.com', '--auth-header', 'X-authentik-uid', '--allow-user', UID], 'public host must be a hostname, no port or scheme: -x.example.com'], ++ [['--public-host', '.example.com', '--auth-header', 'X-authentik-uid', '--allow-user', UID], 'public host must be a hostname, no port or scheme: .example.com'], ++ [['--public-host', 'console.example.com', '--auth-header', '-X-authentik-uid', '--allow-user', UID], 'auth header must be a header name: -X-authentik-uid'], ++ [['--public-host', 'console.example.com', '--auth-header', 'X-authentik-username', '--allow-user', '-jason'], 'allowed user must start with a letter or digit: -jason'], ++ [['--public-host', 'console.example.com', '--auth-header', 'X-authentik-username', '--allow-user', '.jason'], 'allowed user must start with a letter or digit: .jason'], ++ [['--public-host', 'console.example.com', '--auth-header', 'X-authentik-username', '--allow-user', '--port'], 'allowed user must start with a letter or digit: --port'], ++ ]; ++ for (const [args, refusal] of cases) { ++ const r = await run(dir, ['render', ...args]); ++ assert.equal(r.status, 1, args.join(' ')); ++ assert.equal(r.stderr, `webui-service: ${refusal}\n`); ++ } ++}); ++ ++test('status names the copy and whether refactor has moved past it, and says when the unit runs from no copy', async (t) => { ++ const dir = tmp(t); ++ const unitDir = join(dir, 'units'); ++ const target = join(unitDir, 'mosaic-webui.service'); ++ const bin = standIn(mkdtempSync(join(dir, 'si-'))); ++ const port = await closedPort(); ++ const head = await new Promise((resolve, reject) => { ++ const git = spawn('git', ['-C', REPO, 'rev-parse', '--verify', 'refs/heads/refactor']); ++ let out = ''; ++ git.stdout.on('data', (d) => { out += d; }); ++ git.on('close', (code) => (code === 0 ? resolve(out.trim()) : reject(new Error('no refs/heads/refactor')))); ++ }); ++ // status only reads the unit file, so a hand-written one stands in. ++ mkdirSync(unitDir); ++ const unit = (wd) => writeFileSync(target, `[Service]\nWorkingDirectory=${wd}\nExecStart=/usr/bin/node ${wd}/packages/webui/src/cli.mjs serve --port ${port}\n`); ++ unit(join(dir, 'copies', head)); ++ let r = await run(dir, ['status', '--dir', unitDir], bin); ++ assert.equal(r.status, 0, r.stderr); ++ assert.match(r.stdout, new RegExp(`^copy: ${head}\nrefactor: ${head}, the copy's commit$`, 'm')); ++ unit(join(dir, 'copies', COPY_ID)); ++ r = await run(dir, ['status', '--dir', unitDir], bin); ++ assert.match(r.stdout, new RegExp(`^copy: ${COPY_ID}\nrefactor: ${head}, another commit; the copy is ${COPY_ID}$`, 'm')); ++ unit(REPO); ++ r = await run(dir, ['status', '--dir', unitDir], bin); ++ assert.match(r.stdout, new RegExp(`^copy: none; the unit runs from ${esc(REPO)}$`, 'm')); ++ assert.ok(!/^refactor:/m.test(r.stdout)); ++ // status reads; it calls nothing that changes the unit. ++ assert.deepEqual(readFileSync(join(dir, readdirSync(dir).find((d) => d.startsWith('si-')), 'calls.txt'), 'utf8').split('\n').filter(Boolean).filter((c) => !/^--user (is-enabled|is-active|show) mosaic-webui\.service( |$)/.test(c)), []); ++}); +diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh +index 944bb97a..e643cc6a 100755 +--- a/scripts/webui-service.sh ++++ b/scripts/webui-service.sh +@@ -1,18 +1,41 @@ + #!/usr/bin/env bash +-# `scripts/webui-service.sh render|install|uninstall|status`: the systemd +-# user unit that runs the Console, `node packages/webui/src/cli.mjs serve` +-# (lead decision 94). The template is ++# `scripts/webui-service.sh copy|render|install|uninstall|status`: the ++# systemd user unit that runs the Console, `node packages/webui/src/cli.mjs ++# serve` (lead decision 94), from a read-only copy of one landed commit ++# (row 77, #1571, lead decision 112). The template is + # packages/webui/systemd/mosaic-webui.service.in; the rendered unit is + # mosaic-webui.service in the systemd user directory, one instance. + # +-# render [FLAGS] print the rendered unit on stdout +-# install [--dir DIR] [--no-reload] [FLAGS] ++# copy COMMIT [--copies DIR] [--allow-worktree] ++# write `git archive` of the paths the Console ++# loads at COMMIT into DIR//, read-only; ++# DIR defaults to ++# ${XDG_DATA_HOME:-~/.local/share}/mosaic-console ++# render [--copy ID [--copies DIR]] [FLAGS] ++# print the rendered unit on stdout; without ++# --copy it runs from this checkout ++# install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] + # write the unit (write to a temp file, then +-# rename) and run `systemctl --user daemon-reload` ++# rename) and run `systemctl --user daemon-reload`; ++# refuses while the unit is running + # uninstall [--dir DIR] [--no-reload] +-# remove the unit; refuses while it is active +-# status [--dir DIR] unit state, whether it sends, then GET +-# /healthz on its port ++# remove the unit; refuses while it is running ++# status [--dir DIR] unit state, the copy it runs and whether ++# refactor has moved past it, whether it sends, ++# then GET /healthz on its port ++# ++# With --copy the unit's WorkingDirectory and ExecStart point at the copy, ++# and ExecStart passes this checkout as serve's --checkout: the queue read ++# runs the checkout's queue-read.mjs, since the queue store refuses code ++# outside the canonical checkout; everything else comes from the copy. ++# ++# copy and install run only from the canonical checkout, whose root holds a ++# .git directory (a linked worktree has a .git file). --allow-worktree lifts ++# that refusal and exists for the tests only, which run in worktrees. ++# ++# The update step: `copy COMMIT`; Jason or Mos stops the unit; `install ++# --copy ID` with the same flags; Jason or Mos starts it. Rollback is the ++# same with the earlier id. Copies stay until someone removes them. + # + # FLAGS are serve's: --port N (default 7332; 7330 is the operator's own + # Console), --business ID, --board URL, and the preview set --public-host +@@ -29,19 +52,27 @@ + # decision 102, which accepts that sends from a Console Mos started enter + # the trail as Jason (human-cli). Any other agent starting it, even detached + # through systemd, works around the human proof (lead decisions 62 and 94). ++# install and uninstall refuse while the unit is running, with or without ++# --no-reload: a unit rewritten under a running Console would take the new ++# ExecStart at its next Restart=on-failure, with nobody starting it. + # + # Exit codes: 0 ok, 1 operation failed, 4 usage. + set -euo pipefail + REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in" + UNIT_NAME="mosaic-webui.service" +-USAGE="usage: scripts/webui-service.sh render [FLAGS] | install [--dir DIR] [--no-reload] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR] +- FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]]" ++USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR] ++ FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]] ++ --allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout." + + die() { echo "webui-service: $*" >&2; exit 1; } + usage() { echo "$USAGE" >&2; exit 4; } + + UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" ++COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-console" ++COPY_ID="" ++COPY_FLAGS=0 ++ALLOW_WORKTREE=0 + RELOAD=1 + DIR_FLAGS=0 + PORT=7332 +@@ -57,6 +88,9 @@ parse_flags() { + while [ $# -gt 0 ]; do + case "$1" in + --dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; DIR_FLAGS=1; shift 2 ;; ++ --copy) [ $# -ge 2 ] || usage; COPY_ID="$2"; COPY_FLAGS=1; shift 2 ;; ++ --copies) [ $# -ge 2 ] || usage; COPIES="$2"; COPY_FLAGS=1; shift 2 ;; ++ --allow-worktree) ALLOW_WORKTREE=1; shift ;; + --no-reload) RELOAD=0; DIR_FLAGS=1; shift ;; + --port) [ $# -ge 2 ] || usage; PORT="$2"; SERVE_SET=1; shift 2 ;; + --business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;; +@@ -102,13 +136,63 @@ serve_flags() { + case "$PUBLIC_HOST" in *[!A-Za-z0-9.-]*) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac + case "$AUTH_HEADER" in [A-Za-z0-9]*) ;; *) die "auth header must be a header name: $AUTH_HEADER" ;; esac + case "$AUTH_HEADER" in *[!A-Za-z0-9-]*) die "auth header must be a header name: $AUTH_HEADER" ;; esac ++ case "$ALLOW_USER" in [A-Za-z0-9]*) ;; *) die "allowed user must start with a letter or digit: $ALLOW_USER" ;; esac + case "$ALLOW_USER" in *[!A-Za-z0-9._@+-]*) die "allowed user contains a character the unit cannot carry: $ALLOW_USER" ;; esac ++ # Lead decision 101: the uid header carries the Authentik uid, 64 ++ # lower-case hex characters, and nothing else is let through on it. ++ if [ "${AUTH_HEADER,,}" = x-authentik-uid ] && ! [[ "$ALLOW_USER" =~ ^[0-9a-f]{64}$ ]]; then ++ die "with --auth-header $AUTH_HEADER the allowed user must be an Authentik uid, 64 lower-case hex characters (^[0-9a-f]{64}\$, lead decision 101): $ALLOW_USER" ++ fi + flags="$flags --public-host $PUBLIC_HOST --auth-header $AUTH_HEADER --allow-user $ALLOW_USER" + fi + [ "$SEND" = 0 ] || flags="$flags --send" + echo "$flags" + } + ++# The paths the Console loads, the only ones a copy holds: serve's import ++# closure (packages/webui/src/cli.mjs and the human transport child, ++# packages/bus/src/human-cli.mjs) reaches these eight src directories; ++# serve runs scripts/mosaic-config.mjs as its config check and reads roles/ ++# and RELEASE; the unit's Documentation= names the README. The queue read ++# runs from the checkout (serve --checkout), so packages/queue is not here. ++# The service test serves from a copy, so a path missing here fails it. ++COPY_PATHS=( ++ packages/bus/src ++ packages/business/src ++ packages/cli/src ++ packages/conversation/src ++ packages/discord/src ++ packages/harness/src ++ packages/seat/src ++ packages/webui/src ++ packages/webui/README.md ++ scripts/mosaic-config.mjs ++ roles ++ RELEASE ++) ++ ++# N2 (row 75 review): copy and install read and point at this checkout, so ++# they run from the canonical one, never a worktree someone may remove. ++canonical() { ++ [ -d "$REPO/.git" ] || [ "$ALLOW_WORKTREE" = 1 ] || die "refusing: $REPO is not the canonical checkout (its .git is not a directory); run copy and install from the canonical checkout" ++} ++ ++# The copies directory goes into the unit as part of the copy's path. ++copies_ok() { ++ case "$COPIES" in /*) ;; *) die "copies directory must be an absolute path: $COPIES" ;; esac ++ case "$COPIES" in *[!A-Za-z0-9/._+-]*) die "copies directory contains a character the unit cannot carry: $COPIES" ;; esac ++} ++ ++# The root the unit runs from: the copy named by --copy, else this checkout. ++unit_root() { ++ if [ -z "$COPY_ID" ]; then echo "$REPO"; return 0; fi ++ case "$COPY_ID" in *[!0-9a-f]*) die "--copy must be a full 40-character commit id: $COPY_ID" ;; esac ++ [ "${#COPY_ID}" -eq 40 ] || die "--copy must be a full 40-character commit id: $COPY_ID" ++ copies_ok ++ [ -f "$COPIES/$COPY_ID/packages/webui/src/cli.mjs" ] || die "no copy $COPY_ID in $COPIES; make it with: scripts/webui-service.sh copy $COPY_ID" ++ echo "$COPIES/$COPY_ID" ++} ++ + render() { + [ -f "$TEMPLATE" ] || die "template missing: $TEMPLATE" + local node +@@ -119,18 +203,38 @@ render() { + case "$node" in /*) ;; *) die "node path must be absolute: $node" ;; esac + case "$node" in *[!A-Za-z0-9/._+-]*) die "node path contains a character the unit cannot carry: $node" ;; esac + case "$REPO" in *[!A-Za-z0-9/._+-]*) die "repository path contains a character the unit cannot carry: $REPO" ;; esac ++ local root ++ root="$(unit_root)" || exit 1 + local node_dir path="/usr/local/bin:/usr/bin:/bin" + node_dir="$(dirname "$node")" + case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac + local flags + flags="$(serve_flags)" || exit 1 +- sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "$TEMPLATE" ++ [ -z "$COPY_ID" ] || flags="--checkout $REPO $flags" ++ sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "$TEMPLATE" ++} ++ ++# The unit's active state from list-units, printed when it is anything but ++# inactive or failed (active, activating, deactivating, reloading...). A ++# unit systemd has not loaded is not listed and prints nothing. N4: a ++# failed call refuses with one line naming it; the state is unknown, so ++# nothing is written or removed. ++running() { ++ local out ++ out="$(systemctl --user list-units --no-legend --plain "$UNIT_NAME" 2>/dev/null)" || die "refusing: systemctl --user list-units --no-legend --plain $UNIT_NAME failed, so the unit's state is unknown; nothing was changed" ++ printf '%s\n' "$out" | awk -v unit="$UNIT_NAME" '$1 == unit && $3 != "inactive" && $3 != "failed" {print $3}' + } + + install_unit() { + parse_flags "$@" ++ canonical ++ [ -n "$COPY_ID" ] || die "install needs --copy ID, a copy made with: scripts/webui-service.sh copy COMMIT" + local unit + unit="$(render)" || exit 1 ++ # N1: a running unit would take the new ExecStart at its next restart. ++ local state ++ state="$(running)" || exit 1 ++ [ -z "$state" ] || die "refusing: $UNIT_NAME is running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui), then install, then start it" + mkdir -p "$UNIT_DIR" + local target="$UNIT_DIR/$UNIT_NAME" tmp + tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")" +@@ -163,13 +267,12 @@ MSG + + uninstall_unit() { + parse_flags "$@" +- [ "$SERVE_SET" = 0 ] || usage ++ [ "$SERVE_SET" = 0 ] && [ "$COPY_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage + local target="$UNIT_DIR/$UNIT_NAME" +- if [ "$RELOAD" = 1 ]; then +- local active +- active="$(systemctl --user list-units --no-legend --plain "$UNIT_NAME" 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')" +- [ -z "$active" ] || die "refusing: $UNIT_NAME is still running; the human stops it first (systemctl --user stop mosaic-webui)" +- fi ++ # N3: checked with --no-reload too, which skips only daemon-reload. ++ local state ++ state="$(running)" || exit 1 ++ [ -z "$state" ] || die "refusing: $UNIT_NAME is still running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui)" + if [ -f "$target" ]; then + rm -f "$target" + echo "removed: $target" +@@ -184,7 +287,7 @@ uninstall_unit() { + + status_unit() { + parse_flags "$@" +- [ "$SERVE_SET" = 0 ] && [ "$RELOAD" = 1 ] || usage ++ [ "$SERVE_SET" = 0 ] && [ "$RELOAD" = 1 ] && [ "$COPY_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage + local target="$UNIT_DIR/$UNIT_NAME" + echo "unit: $UNIT_NAME" + echo " enabled: $(systemctl --user is-enabled "$UNIT_NAME" 2>&1 || true)" +@@ -194,8 +297,26 @@ status_unit() { + echo "healthz: not checked; no unit at $target" + return 0 + fi +- # render puts --send right after the allowed user, which may itself be +- # the value --send; a unit that sends ends with both. ++ # The copy is the last part of WorkingDirectory; status only reads. ++ local wd copy head ++ wd="$(sed -n 's/^WorkingDirectory=//p' "$target")" ++ copy="${wd##*/}" ++ if [[ "$copy" =~ ^[0-9a-f]{40}$ ]]; then ++ echo "copy: $copy" ++ if head="$(git -C "$REPO" rev-parse --verify --quiet refs/heads/refactor 2>/dev/null)"; then ++ if [ "$head" = "$copy" ]; then ++ echo "refactor: $head, the copy's commit" ++ else ++ echo "refactor: $head, another commit; the copy is $copy" ++ fi ++ else ++ echo "refactor: unknown; git rev-parse refs/heads/refactor failed in $REPO" ++ fi ++ else ++ echo "copy: none; the unit runs from $wd" ++ fi ++ # render puts --send right after the allowed user. The allowed user ++ # starts with a letter or digit (N5), so it is never the value --send. + if grep -q '^ExecStart=.* --allow-user [^ ][^ ]* --send$' "$target"; then + echo "send: on (--send; human messages to roles)" + else +@@ -212,12 +333,49 @@ fetch(process.argv[1], { signal: AbortSignal.timeout(5000) }) + ' "$url")" + } + ++# Writes the copy into a temp directory beside the target, takes write ++# permission off every file, then renames it into place, so a copy at its ++# final path is always whole and read-only. An existing copy is left alone. ++copy_commit() { ++ [ $# -ge 1 ] || usage ++ local commit="$1"; shift ++ case "$commit" in -*) usage ;; esac ++ parse_flags "$@" ++ [ "$SERVE_SET" = 0 ] && [ "$DIR_FLAGS" = 0 ] && [ -z "$COPY_ID" ] || usage ++ canonical ++ copies_ok ++ case "$commit" in [A-Za-z0-9]*) ;; *) die "commit must be a commit id or branch name: $commit" ;; esac ++ case "$commit" in *[!A-Za-z0-9._/-]*) die "commit must be a commit id or branch name: $commit" ;; esac ++ git -C "$REPO" rev-parse --verify --quiet refs/heads/refactor >/dev/null || die "no refs/heads/refactor in $REPO" ++ local id ++ id="$(git -C "$REPO" rev-parse --verify --quiet "$commit^{commit}")" || die "not a commit: $commit" ++ git -C "$REPO" merge-base --is-ancestor "$id" refs/heads/refactor || die "refusing: $id is not reachable from refs/heads/refactor" ++ local target="$COPIES/$id" ++ if [ -e "$target" ]; then echo "exists: $target"; return 0; fi ++ mkdir -p "$COPIES" ++ COPY_TMP="$(mktemp -d "$COPIES/.$id.XXXXXX")" ++ trap 'rm -rf "$COPY_TMP"' EXIT ++ git -C "$REPO" archive --format=tar "$id" -- "${COPY_PATHS[@]}" | tar -x -f - -C "$COPY_TMP" || die "git archive of $id failed; no copy written" ++ find "$COPY_TMP" -type f -exec chmod a-w {} + ++ chmod 0755 "$COPY_TMP" ++ if ! mv -T "$COPY_TMP" "$target" 2>/dev/null; then ++ [ -e "$target" ] || die "could not rename the copy into $target" ++ echo "exists: $target" ++ return 0 ++ fi ++ trap - EXIT ++ echo "copied: $target" ++ echo "install it with: scripts/webui-service.sh install --copy $id, with the unit's flags" ++} ++ + [ $# -ge 1 ] || usage + cmd="$1"; shift + case "$cmd" in ++ copy) copy_commit "$@" ;; + render) + parse_flags "$@" +- [ "$DIR_FLAGS" = 0 ] || usage ++ [ "$DIR_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage ++ { [ -n "$COPY_ID" ] || [ "$COPY_FLAGS" = 0 ]; } || usage + render ;; + install) install_unit "$@" ;; + uninstall) uninstall_unit "$@" ;; diff --git a/agents/rocko/work/queue-77/byte-check.sh b/agents/rocko/work/queue-77/byte-check.sh new file mode 100755 index 000000000..41b5bdb82 --- /dev/null +++ b/agents/rocko/work/queue-77/byte-check.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Row 77 byte check: render without --copy, base script against candidate, +# same tree, same flags. Usage: byte-check.sh +# The base script is placed beside the candidate in /scripts for the +# run (so both resolve the same REPO and template) and removed afterwards. +# Only render runs; nothing installs, reads systemd or starts anything. +set -u +tree=$1 base=$2 out=$3 +mkdir -p "$out" +b="$tree/scripts/.webui-service.base.sh" +git -C "$tree" show "$base:scripts/webui-service.sh" > "$b" && chmod +x "$b" || exit 1 +trap 'rm -f "$b"' EXIT +# The base renders its own template; check the template's body is unchanged +# apart from the header comment, so the comparison is fair. +diff <(git -C "$tree" show "$base:packages/webui/systemd/mosaic-webui.service.in" | grep -v '^#') <(grep -v '^#' "$tree/packages/webui/systemd/mosaic-webui.service.in") > "$out/template-body.diff" && echo "template body: same" || echo "template body: DIFFERS" +uid=6f1c0e2a9b8d7c4e5f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e +n=0 same=0 +while IFS= read -r flags; do + n=$((n+1)) + # shellcheck disable=SC2086 + "$b" render $flags 2>&1 | grep -v '^#' > "$out/base-$n.txt"; bs=${PIPESTATUS[0]} + # shellcheck disable=SC2086 + "$tree/scripts/webui-service.sh" render $flags 2>&1 | grep -v '^#' > "$out/cand-$n.txt"; cs=${PIPESTATUS[0]} + if [ "$bs" = "$cs" ] && cmp -s "$out/base-$n.txt" "$out/cand-$n.txt"; then same=$((same+1)); r=same; else r=DIFFERS; fi + echo "$n exit $bs/$cs $r: render $flags" +done <, the service and pinned-copy +# tests run, the file restored. Usage: python3 mutants.py +import subprocess, shutil, sys +W=sys.argv[1] # the tree with the candidate applied +S='scripts/webui-service.sh' +M=[ + ('failed counts as running', S, '&& $3 != "failed" {', '{'), + ('reloading not running', S, '$3 != "failed" {', '$3 != "failed" && $3 != "reloading" {'), + ('list-units failure ignored', S, '2>/dev/null)" || die "refusing: systemctl --user list-units', '2>/dev/null)" || true; : "refusing: systemctl --user list-units'), + ('install skips state read', S, ' state="$(running)" || exit 1\n [ -z "$state" ] || die "refusing: $UNIT_NAME is running', ' state=""\n [ -z "$state" ] || die "refusing: $UNIT_NAME is running'), + ('uninstall skips state read with --no-reload', S, '[ -z "$state" ] || die "refusing: $UNIT_NAME is still running', '[ -z "$state" ] || [ "$RELOAD" = 0 ] || die "refusing: $UNIT_NAME is still running'), + ('uid header case-sensitive', S, '"${AUTH_HEADER,,}" = x-authentik-uid', '"$AUTH_HEADER" = X-authentik-uid'), + ('uid allows upper hex', S, '^[0-9a-f]{64}$ ]]; then', '^[0-9a-fA-F]{64}$ ]]; then'), + ('allowed user may start with -', S, 'case "$ALLOW_USER" in [A-Za-z0-9]*) ;;', 'case "$ALLOW_USER" in *) ;;'), + ('no --checkout in unit', S, 'flags="--checkout $REPO $flags"', 'flags="$flags"'), + ('canonical always passes', S, '[ -d "$REPO/.git" ] || [ "$ALLOW_WORKTREE" = 1 ] ||', 'true ||'), + ('install without --copy', S, '[ -n "$COPY_ID" ] || die "install needs --copy ID', 'true || die "install needs --copy ID'), + ('missing copy accepted', S, '[ -f "$COPIES/$COPY_ID/packages/webui/src/cli.mjs" ] ||', 'true ||'), + ('status drift wrong', S, 'if [ "$head" = "$copy" ]; then', 'if true; then'), + ('copy misses seat', S, ' packages/seat/src\n', ''), + ('copy stays writable', S, '-exec chmod a-w', '-exec true'), + ('copy off-branch accepted', S, 'merge-base --is-ancestor "$id" refs/heads/refactor ||', 'true ||'), + ('queue reads root', 'packages/webui/src/reads.mjs', 'root: queueRoot', 'root'), + ('--checkout relative ok', 'packages/webui/src/cli.mjs', 'isAbsolute(', '!!('), +] +res=[] +for name,f,a,b in M: + p=f'{W}/{f}'; s=open(p).read() + if s.count(a)<1: res.append(f'NOMATCH {name}'); continue + shutil.copy(p,p+'.bak'); open(p,'w').write(s.replace(a,b)) + try: + r=subprocess.run(['node','--test','tests/service.test.mjs','tests/pinned-copy.test.mjs'],cwd=f'{W}/packages/webui',capture_output=True,text=True,timeout=300) + fails=[l for l in r.stdout.splitlines() if l.startswith('✖') and 'failing tests' not in l] + res.append(f"{'KILLED' if r.returncode else 'SURVIVED'} {name}: {len(set(fails))} failing") + finally: + shutil.move(p+'.bak',p) +print('\n'.join(res)) diff --git a/agents/rocko/work/queue-77/out/byte-check.txt b/agents/rocko/work/queue-77/out/byte-check.txt new file mode 100644 index 000000000..6df0f3aed --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check.txt @@ -0,0 +1,10 @@ +template body: same +1 exit 0/0 same: render +2 exit 0/0 same: render --port 7444 +3 exit 0/0 same: render --business mosaic --board http://127.0.0.1:7331 +4 exit 0/0 same: render --public-host console.example.com --auth-header X-authentik-uid --allow-user 6f1c0e2a9b8d7c4e5f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e +5 exit 0/0 same: render --public-host console.example.com --auth-header X-authentik-uid --allow-user 6f1c0e2a9b8d7c4e5f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e --send +6 exit 0/0 same: render --public-host console.example.com --auth-header X-authentik-username --allow-user jason +7 exit 1/1 same: render --port 0 +8 exit 0/0 same: render --business a.b +8 of 8 same diff --git a/agents/rocko/work/queue-77/out/byte-check/base-1.txt b/agents/rocko/work/queue-77/out/byte-check/base-1.txt new file mode 100644 index 000000000..2035c1ff6 --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/base-1.txt @@ -0,0 +1,20 @@ +[Unit] +Description=Mosaic Console +Documentation=file:///mnt/storage/scratch/rocko-r77/packages/webui/README.md +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=/mnt/storage/scratch/rocko-r77 +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/node /mnt/storage/scratch/rocko-r77/packages/webui/src/cli.mjs serve --port 7332 +Restart=on-failure +RestartSec=15 +RestartPreventExitStatus=2 +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target diff --git a/agents/rocko/work/queue-77/out/byte-check/base-2.txt b/agents/rocko/work/queue-77/out/byte-check/base-2.txt new file mode 100644 index 000000000..dd66470fd --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/base-2.txt @@ -0,0 +1,20 @@ +[Unit] +Description=Mosaic Console +Documentation=file:///mnt/storage/scratch/rocko-r77/packages/webui/README.md +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=/mnt/storage/scratch/rocko-r77 +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/node /mnt/storage/scratch/rocko-r77/packages/webui/src/cli.mjs serve --port 7444 +Restart=on-failure +RestartSec=15 +RestartPreventExitStatus=2 +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target diff --git a/agents/rocko/work/queue-77/out/byte-check/base-3.txt b/agents/rocko/work/queue-77/out/byte-check/base-3.txt new file mode 100644 index 000000000..758862cfe --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/base-3.txt @@ -0,0 +1,20 @@ +[Unit] +Description=Mosaic Console +Documentation=file:///mnt/storage/scratch/rocko-r77/packages/webui/README.md +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=/mnt/storage/scratch/rocko-r77 +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/node /mnt/storage/scratch/rocko-r77/packages/webui/src/cli.mjs serve --port 7332 --business mosaic --board http://127.0.0.1:7331 +Restart=on-failure +RestartSec=15 +RestartPreventExitStatus=2 +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target diff --git a/agents/rocko/work/queue-77/out/byte-check/base-4.txt b/agents/rocko/work/queue-77/out/byte-check/base-4.txt new file mode 100644 index 000000000..0f9a7aa46 --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/base-4.txt @@ -0,0 +1,20 @@ +[Unit] +Description=Mosaic Console +Documentation=file:///mnt/storage/scratch/rocko-r77/packages/webui/README.md +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=/mnt/storage/scratch/rocko-r77 +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/node /mnt/storage/scratch/rocko-r77/packages/webui/src/cli.mjs serve --port 7332 --public-host console.example.com --auth-header X-authentik-uid --allow-user 6f1c0e2a9b8d7c4e5f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e +Restart=on-failure +RestartSec=15 +RestartPreventExitStatus=2 +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target diff --git a/agents/rocko/work/queue-77/out/byte-check/base-5.txt b/agents/rocko/work/queue-77/out/byte-check/base-5.txt new file mode 100644 index 000000000..bec656528 --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/base-5.txt @@ -0,0 +1,20 @@ +[Unit] +Description=Mosaic Console +Documentation=file:///mnt/storage/scratch/rocko-r77/packages/webui/README.md +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=/mnt/storage/scratch/rocko-r77 +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/node /mnt/storage/scratch/rocko-r77/packages/webui/src/cli.mjs serve --port 7332 --public-host console.example.com --auth-header X-authentik-uid --allow-user 6f1c0e2a9b8d7c4e5f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e --send +Restart=on-failure +RestartSec=15 +RestartPreventExitStatus=2 +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target diff --git a/agents/rocko/work/queue-77/out/byte-check/base-6.txt b/agents/rocko/work/queue-77/out/byte-check/base-6.txt new file mode 100644 index 000000000..6d5b84cbd --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/base-6.txt @@ -0,0 +1,20 @@ +[Unit] +Description=Mosaic Console +Documentation=file:///mnt/storage/scratch/rocko-r77/packages/webui/README.md +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=/mnt/storage/scratch/rocko-r77 +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/node /mnt/storage/scratch/rocko-r77/packages/webui/src/cli.mjs serve --port 7332 --public-host console.example.com --auth-header X-authentik-username --allow-user jason +Restart=on-failure +RestartSec=15 +RestartPreventExitStatus=2 +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target diff --git a/agents/rocko/work/queue-77/out/byte-check/base-7.txt b/agents/rocko/work/queue-77/out/byte-check/base-7.txt new file mode 100644 index 000000000..6a4dc77d8 --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/base-7.txt @@ -0,0 +1 @@ +webui-service: port must be 1..65535: 0 diff --git a/agents/rocko/work/queue-77/out/byte-check/base-8.txt b/agents/rocko/work/queue-77/out/byte-check/base-8.txt new file mode 100644 index 000000000..4b5518c80 --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/base-8.txt @@ -0,0 +1,20 @@ +[Unit] +Description=Mosaic Console +Documentation=file:///mnt/storage/scratch/rocko-r77/packages/webui/README.md +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=/mnt/storage/scratch/rocko-r77 +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/node /mnt/storage/scratch/rocko-r77/packages/webui/src/cli.mjs serve --port 7332 --business a.b +Restart=on-failure +RestartSec=15 +RestartPreventExitStatus=2 +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target diff --git a/agents/rocko/work/queue-77/out/byte-check/cand-1.txt b/agents/rocko/work/queue-77/out/byte-check/cand-1.txt new file mode 100644 index 000000000..2035c1ff6 --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/cand-1.txt @@ -0,0 +1,20 @@ +[Unit] +Description=Mosaic Console +Documentation=file:///mnt/storage/scratch/rocko-r77/packages/webui/README.md +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=/mnt/storage/scratch/rocko-r77 +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/node /mnt/storage/scratch/rocko-r77/packages/webui/src/cli.mjs serve --port 7332 +Restart=on-failure +RestartSec=15 +RestartPreventExitStatus=2 +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target diff --git a/agents/rocko/work/queue-77/out/byte-check/cand-2.txt b/agents/rocko/work/queue-77/out/byte-check/cand-2.txt new file mode 100644 index 000000000..dd66470fd --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/cand-2.txt @@ -0,0 +1,20 @@ +[Unit] +Description=Mosaic Console +Documentation=file:///mnt/storage/scratch/rocko-r77/packages/webui/README.md +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=/mnt/storage/scratch/rocko-r77 +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/node /mnt/storage/scratch/rocko-r77/packages/webui/src/cli.mjs serve --port 7444 +Restart=on-failure +RestartSec=15 +RestartPreventExitStatus=2 +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target diff --git a/agents/rocko/work/queue-77/out/byte-check/cand-3.txt b/agents/rocko/work/queue-77/out/byte-check/cand-3.txt new file mode 100644 index 000000000..758862cfe --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/cand-3.txt @@ -0,0 +1,20 @@ +[Unit] +Description=Mosaic Console +Documentation=file:///mnt/storage/scratch/rocko-r77/packages/webui/README.md +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=/mnt/storage/scratch/rocko-r77 +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/node /mnt/storage/scratch/rocko-r77/packages/webui/src/cli.mjs serve --port 7332 --business mosaic --board http://127.0.0.1:7331 +Restart=on-failure +RestartSec=15 +RestartPreventExitStatus=2 +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target diff --git a/agents/rocko/work/queue-77/out/byte-check/cand-4.txt b/agents/rocko/work/queue-77/out/byte-check/cand-4.txt new file mode 100644 index 000000000..0f9a7aa46 --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/cand-4.txt @@ -0,0 +1,20 @@ +[Unit] +Description=Mosaic Console +Documentation=file:///mnt/storage/scratch/rocko-r77/packages/webui/README.md +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=/mnt/storage/scratch/rocko-r77 +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/node /mnt/storage/scratch/rocko-r77/packages/webui/src/cli.mjs serve --port 7332 --public-host console.example.com --auth-header X-authentik-uid --allow-user 6f1c0e2a9b8d7c4e5f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e +Restart=on-failure +RestartSec=15 +RestartPreventExitStatus=2 +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target diff --git a/agents/rocko/work/queue-77/out/byte-check/cand-5.txt b/agents/rocko/work/queue-77/out/byte-check/cand-5.txt new file mode 100644 index 000000000..bec656528 --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/cand-5.txt @@ -0,0 +1,20 @@ +[Unit] +Description=Mosaic Console +Documentation=file:///mnt/storage/scratch/rocko-r77/packages/webui/README.md +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=/mnt/storage/scratch/rocko-r77 +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/node /mnt/storage/scratch/rocko-r77/packages/webui/src/cli.mjs serve --port 7332 --public-host console.example.com --auth-header X-authentik-uid --allow-user 6f1c0e2a9b8d7c4e5f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e --send +Restart=on-failure +RestartSec=15 +RestartPreventExitStatus=2 +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target diff --git a/agents/rocko/work/queue-77/out/byte-check/cand-6.txt b/agents/rocko/work/queue-77/out/byte-check/cand-6.txt new file mode 100644 index 000000000..6d5b84cbd --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/cand-6.txt @@ -0,0 +1,20 @@ +[Unit] +Description=Mosaic Console +Documentation=file:///mnt/storage/scratch/rocko-r77/packages/webui/README.md +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=/mnt/storage/scratch/rocko-r77 +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/node /mnt/storage/scratch/rocko-r77/packages/webui/src/cli.mjs serve --port 7332 --public-host console.example.com --auth-header X-authentik-username --allow-user jason +Restart=on-failure +RestartSec=15 +RestartPreventExitStatus=2 +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target diff --git a/agents/rocko/work/queue-77/out/byte-check/cand-7.txt b/agents/rocko/work/queue-77/out/byte-check/cand-7.txt new file mode 100644 index 000000000..6a4dc77d8 --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/cand-7.txt @@ -0,0 +1 @@ +webui-service: port must be 1..65535: 0 diff --git a/agents/rocko/work/queue-77/out/byte-check/cand-8.txt b/agents/rocko/work/queue-77/out/byte-check/cand-8.txt new file mode 100644 index 000000000..4b5518c80 --- /dev/null +++ b/agents/rocko/work/queue-77/out/byte-check/cand-8.txt @@ -0,0 +1,20 @@ +[Unit] +Description=Mosaic Console +Documentation=file:///mnt/storage/scratch/rocko-r77/packages/webui/README.md +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=/mnt/storage/scratch/rocko-r77 +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/node /mnt/storage/scratch/rocko-r77/packages/webui/src/cli.mjs serve --port 7332 --business a.b +Restart=on-failure +RestartSec=15 +RestartPreventExitStatus=2 +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target diff --git a/agents/rocko/work/queue-77/out/byte-check/template-body.diff b/agents/rocko/work/queue-77/out/byte-check/template-body.diff new file mode 100644 index 000000000..e69de29bb diff --git a/agents/rocko/work/queue-77/out/gate.sh b/agents/rocko/work/queue-77/out/gate.sh new file mode 100755 index 000000000..a4ccfd38c --- /dev/null +++ b/agents/rocko/work/queue-77/out/gate.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Row 77 gate: webui node suite, then every scripts/test-*.sh, sequentially, full output kept. +W=/mnt/storage/scratch/rocko-r77 +O=/mnt/storage/scratch/rocko-r77-out/gate +mkdir -p "$O" +cd "$W" || exit 1 +node --test 'packages/webui/tests/*.test.mjs' > "$O/node-webui.txt" 2>&1; echo "node-webui exit $?" | tee -a "$O/summary.txt" +grep -E '^ℹ (pass|fail)' "$O/node-webui.txt" | tee -a "$O/summary.txt" +for s in scripts/test-*.sh; do + n=$(basename "$s" .sh) + bash "$s" > "$O/$n.txt" 2>&1; echo "$n exit $?" | tee -a "$O/summary.txt" + tail -3 "$O/$n.txt" | sed "s/^/ $n: /" | tee -a "$O/summary.txt" +done +grep -l -E '✖|^not ok' "$O"/*.txt | sed 's/^/failure markers in: /' | tee -a "$O/summary.txt" diff --git a/agents/rocko/work/queue-77/out/gate/node-webui.txt b/agents/rocko/work/queue-77/out/gate/node-webui.txt new file mode 100644 index 000000000..3b86dc580 --- /dev/null +++ b/agents/rocko/work/queue-77/out/gate/node-webui.txt @@ -0,0 +1,103 @@ +✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (15601.557828ms) +Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286} +✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (18032.513274ms) +✔ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes (22758.305545ms) +✔ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape (700.409249ms) +✔ decisions route serves the roles and recent lists from the reader view (180.462266ms) +✔ credentials route serves the broker credential status from the reader view (35.303262ms) +✔ bus routes are GET-only, have no write verb, and keep the same-origin checks (525.954337ms) +✔ bus refusals map to statuses and never carry a path or a stack (350.69229ms) +✔ humanCall speaks the human transport protocol without blocking the server (6101.197852ms) +✔ busReader takes --business, else the live bus host, else refuses (2578.038579ms) +✔ humanCall kills a transport that runs past its timeout (588.320553ms) +✔ serve refuses a --business value that is not a business id (1939.668383ms) +✔ conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers (14055.749617ms) +✔ conversation view: a fork keeps the open branch, says so, and opens the new one on request (7310.541095ms) +✔ conversation view: a newer session with no readable history keeps the marker (3470.035276ms) +✔ conversation view: seats without history say so and offer no reply (2457.175278ms) +✔ Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content (10599.42132ms) +✔ return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch (62887.54945ms) +✔ copy writes the Console paths at the full id, read-only, and leaves an existing copy alone (2576.537587ms) +✔ copy refuses a commit off refactor, an unknown one, a worktree and a bad copies directory (8654.610227ms) +✔ copy writes nothing when an archived path is missing at the commit (3140.32104ms) +✔ serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout (5845.492103ms) +✔ serve refuses a --checkout that is not an absolute directory path (1879.635451ms) +✔ consoleReads runs the queue read under queueRoot and reads RELEASE under root (586.476585ms) +✔ preview options are all three or none, and each is checked (61.258631ms) +✔ the CLI refuses a partial or invalid preview flag set at start (10230.408835ms) +✔ the CLI with all three flags serves the preview mode (2990.333677ms) +✔ a request for the public name needs the header equal to the user id, else 401 and nothing read (124.488485ms) +✔ the header name matches without regard to case, and the value compare is exact (15.238063ms) +✔ the public name with the header reads the bus views, and the Host and Origin are checked first (169.82948ms) +✔ in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing (26.821294ms) +✔ loopback requests are unchanged in preview mode, and the bus views work with the board down (11.269594ms) +✔ a proxy that rewrites Host to the loopback port gets 403 on its first request, whatever the user (857.530788ms) +✔ a repeated Host is 400 before any other check, in preview mode and without it (18.09997ms) +✔ without the preview flags the public name is refused and writes still reach the board (12.972511ms) +✔ redactor: config, dataRoot, any absolute path, Discord ids and the JSON parser quote (4.93245ms) +✔ credential state from the date alone, as the bus states it (0.443466ms) +✔ queue: rows, one row, ids and methods, notes; no path or id from a row (11509.772275ms) +✔ queue: a refused read fails closed with the store's text, redacted (2381.40084ms) +✔ queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack (7251.422ms) +✔ business: names, vars on the allowlist, authority, credential metadata only (128.297462ms) +✔ business: missing or invalid file refuses with the module's text, redacted (66.712351ms) +✔ business without --business: the live bus host, else no-bus-host; no system config: not-configured (26.151662ms) +✔ settings: release, board, loopback address, notifier binding name only (887.802038ms) +✔ both presentations replace old activity with relaunch notice, label retained history, then resume after new activity (12778.075479ms) +✔ reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh (31777.273449ms) +✔ the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx (17479.194295ms) +✔ startServer takes send only as a function and only with the preview mode (154.231038ms) +✔ the CLI refuses --send without the preview flags, and without a configured bus (6282.066599ms) +✔ the CLI with --send and a configured bus sends through the human transport and logs one line (2812.495457ms) +✔ without --send the route is read-only like every other write, and config says no send (237.550692ms) +✔ with --send every other write is still 403 read-only, and nothing reaches the board or bus (317.206484ms) +✔ config says send only on the public name with --send on (36.559736ms) +✔ a send without the signed-in user is 401 and is neither sent nor logged, loopback included (329.90074ms) +✔ each request check refuses before anything is sent, and logs the refusal without the body (1039.266242ms) +✔ a send passes exactly to, body and in_reply_to, answers the id, and logs it (61.31387ms) +✔ a crafted role name cannot forge a log line (114.133966ms) +✔ a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried (326.701638ms) +✔ through the human transport the request is exactly the business, message.send and the keys (894.105727ms) +✔ against a real broker: the message is written once as a human send, and its refusals map (302.716121ms) +✔ loopback host and board origin fail closed (52.903382ms) +✔ real board fixture passes through WebUI; assets and isolated seen/reply work (1626.612392ms) +✔ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect (777.733655ms) +✔ unreachable board reports URL; CLI rejects unsupported options (8431.757782ms) +✔ render prints the unit without the preview flags (365.87324ms) +✔ render carries every serve flag, the preview set included (467.408924ms) +✔ a partial preview set is refused (369.42351ms) +✔ a value the unit cannot carry is refused, for every flag (6755.264513ms) +✔ the rendered unit names none of the human proof agent markers and sets no agent variable (272.774702ms) +✔ install into --dir with --no-reload writes the file whole, then reports unchanged (480.924142ms) +✔ install without --no-reload runs daemon-reload and nothing that starts the unit (137.136655ms) +✔ uninstall refuses while the unit is running, with or without --no-reload, and removes it once it is not (2078.794324ms) +✔ status prints the unit state and GET /healthz on the installed port (1481.358448ms) +✔ usage: an unknown verb or flag, and start or enable, exit 4 (395.422729ms) +✔ without --send, render is the template with its placeholders filled, byte for byte (106.775215ms) +✔ render --send with the preview set ends ExecStart with --send and changes nothing else (207.887044ms) +✔ --send without the whole preview set is refused and writes nothing (1005.576838ms) +✔ install --send into --dir with --no-reload writes the sending unit, and status says whether it sends (1821.277564ms) +✔ uninstall and status take no --send (283.979343ms) +✔ install refuses while the unit is running, in any state but inactive or failed, and changes nothing (2226.248786ms) +✔ when the state read fails, install and uninstall refuse with one line and change nothing (335.030766ms) +✔ install needs --copy naming a copy that exists, and refuses outside the canonical checkout without --allow-worktree (345.194863ms) +✔ render --copy runs the copy and reads the queue in the checkout; without --copy the unit is unchanged (228.923116ms) +✔ the uid header takes only an Authentik uid; the username header still takes a username (1816.904262ms) +✔ a preview value cannot start with - or . (144.651532ms) +✔ status names the copy and whether refactor has moved past it, and says when the unit runs from no copy (589.967005ms) +✔ tokens.css and icons.svg are byte copies of docs/design (83.129679ms) +✔ JetBrains Mono files match the digests in their sources file, with the OFL text (5.366978ms) +✔ shipped strings are unchanged, and the web keeps its write boundary (5.380723ms) +✔ shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette (15371.351516ms) +✔ five states on Board, Inbox, Tasks, Agents and Trail (5964.003504ms) +✔ Queue, Business and Settings in their five states, with no move button (13122.544578ms) +✔ seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module's words (4711.966993ms) +✔ the server log names no config path when the system config refuses (773.380807ms) +ℹ tests 94 +ℹ suites 0 +ℹ pass 94 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 65140.492167 diff --git a/agents/rocko/work/queue-77/out/gate/summary.txt b/agents/rocko/work/queue-77/out/gate/summary.txt new file mode 100644 index 000000000..378fcd375 --- /dev/null +++ b/agents/rocko/work/queue-77/out/gate/summary.txt @@ -0,0 +1,39 @@ +node-webui exit 0 +ℹ pass 94 +ℹ fail 0 +test-auth exit 0 + test-auth: OK auth.sh without valid config refuses + test-auth: + test-auth: selftest: 15 passed, 0 failed +test-conductor exit 0 + test-conductor: OK invalid policy exits 2 (exit 2) + test-conductor: + test-conductor: selftest: 17 passed, 0 failed +test-config exit 0 + test-config: OK failed validation modified nothing + test-config: + test-config: selftest: 24 passed, 0 failed +test-discord exit 0 + test-discord: OK service install with USER unset finishes and names the account for lingering + test-discord: + test-discord: discord suite: 66 passed, 0 failed +test-extension-package exit 0 + test-extension-package: OK nested second entrypoint fails closed + test-extension-package: + test-extension-package: extension package selftest: 18 passed, 0 failed +test-foundation exit 0 + test-foundation: OK no field of the non-bundle file is echoed + test-foundation: + test-foundation: selftest: 44 passed, 0 failed +test-queue exit 0 + test-queue: skip queue verify and render --check: this checkout (/mnt/storage/scratch/rocko-r77) is not the queue's canonical root (/mnt/storage/src/mosaic-stack) + test-queue: + test-queue: queue suite: 27 passed, 0 failed +test-release exit 0 + test-release: OK rollback without previous refuses (exit 1) + test-release: + test-release: selftest: 14 passed, 0 failed +test-task exit 0 + test-task: OK no agent identity on headless run + test-task: + test-task: selftest: 98 passed, 0 failed diff --git a/agents/rocko/work/queue-77/out/gate/test-auth.txt b/agents/rocko/work/queue-77/out/gate/test-auth.txt new file mode 100644 index 000000000..ac2287606 --- /dev/null +++ b/agents/rocko/work/queue-77/out/gate/test-auth.txt @@ -0,0 +1,17 @@ +OK status with missing harness credential exits 3 and still lists accounts +OK status reports harness credential (read-only) + mosaic accounts +OK api key material never reaches output +OK oauth token material never reaches output +OK unparseable credential file exits 2 +OK symlinked credential file exits 4 +OK env-side credential names reported +OK env var values never reach output +OK accounts without an accounts dir reports none and creates nothing +OK accounts lists files and marks the active one +OK loose account perms flagged in listing +OK agent --auth with missing account file refuses (exit 4) +OK agent --auth with non-0600 account file refuses +OK agent --auth with invalid account name refuses +OK auth.sh without valid config refuses + +selftest: 15 passed, 0 failed diff --git a/agents/rocko/work/queue-77/out/gate/test-conductor.txt b/agents/rocko/work/queue-77/out/gate/test-conductor.txt new file mode 100644 index 000000000..b65c9c406 --- /dev/null +++ b/agents/rocko/work/queue-77/out/gate/test-conductor.txt @@ -0,0 +1,55 @@ +Note: switching to '27a1a2bb162a96116e85c881b0061433741353fe'. + +You are in 'detached HEAD' state. You can look around, make experimental +changes and commit them, and you can discard any commits you make in this +state without impacting any branches by switching back to a branch. + +If you want to create a new branch to retain commits you create, you may +do so (now or later) by using -c with the switch command. Example: + + git switch -c + +Or undo this operation with: + + git switch - + +Turn off this advice by setting config variable advice.detachedHead to false + +Not currently on any branch. +nothing to commit, working tree clean +Note: switching to '27a1a2bb162a96116e85c881b0061433741353fe'. + +You are in 'detached HEAD' state. You can look around, make experimental +changes and commit them, and you can discard any commits you make in this +state without impacting any branches by switching back to a branch. + +If you want to create a new branch to retain commits you create, you may +do so (now or later) by using -c with the switch command. Example: + + git switch -c + +Or undo this operation with: + + git switch - + +Turn off this advice by setting config variable advice.detachedHead to false + +OK dry-run: allowed change, exit 0, nothing committed (exit 0) +OK dry-run committed nothing +OK apply: allowed change exits 0 (exit 0) +OK apply: attribution in commit subject +OK apply: target tree clean after commit +OK disallowed path refused (exit 1) +OK disallowed path: target untouched +OK syntax gate refused broken .mjs (exit 1) +OK syntax gate: target untouched +OK suite failure refused (exit 1) +OK suite failure: target reverted to clean +OK disabled policy refused (exit 2) +OK disabled policy: target untouched +OK failed run refused (exit 1) +OK failed run: target untouched +OK missing run exits 4 (exit 4) +OK invalid policy exits 2 (exit 2) + +selftest: 17 passed, 0 failed diff --git a/agents/rocko/work/queue-77/out/gate/test-config.txt b/agents/rocko/work/queue-77/out/gate/test-config.txt new file mode 100644 index 000000000..76c05ef74 --- /dev/null +++ b/agents/rocko/work/queue-77/out/gate/test-config.txt @@ -0,0 +1,26 @@ +OK absent adapter defaults to pi +OK adapter mock validates (exit 0) +OK unsupported adapter exits 2 (exit 2) +OK env exports adapter +OK bootstrap creates default when absent (exit 0) +OK bootstrap wrote config file +OK bootstrap is idempotent on existing config (exit 0) +OK bootstrap did not rewrite existing config +OK validate missing config exits 3 (exit 3) +OK malformed JSON exits 2 (exit 2) +OK unsupported configVersion exits 2 (exit 2) +OK unknown top-level key exits 2 (exit 2) +OK unknown execution key exits 2 (exit 2) +OK unsupported backend exits 2 (exit 2) +OK unsupported environment exits 2 (exit 2) +OK relative dataRoot exits 2 (exit 2) +OK non-canonical dataRoot exits 2 (exit 2) +OK filesystem root dataRoot exits 2 (exit 2) +OK home directory dataRoot exits 2 (exit 2) +OK dataRoot containing config dir exits 2 (exit 2) +OK control character in provider exits 2 (exit 2) +OK symlinked config file exits 2 (exit 2) +OK env exports resolve correctly +OK failed validation modified nothing + +selftest: 24 passed, 0 failed diff --git a/agents/rocko/work/queue-77/out/gate/test-discord.txt b/agents/rocko/work/queue-77/out/gate/test-discord.txt new file mode 100644 index 000000000..b013f9ffa --- /dev/null +++ b/agents/rocko/work/queue-77/out/gate/test-discord.txt @@ -0,0 +1,70 @@ +toolchain: node v26.8.1 + +OK syntax: packages/discord/src/approvals.mjs +OK syntax: packages/discord/src/authorize.mjs +OK syntax: packages/discord/src/binding.mjs +OK syntax: packages/discord/src/cli.mjs +OK syntax: packages/discord/src/connector.mjs +OK syntax: packages/discord/src/context.mjs +OK syntax: packages/discord/src/engine-pi.mjs +OK syntax: packages/discord/src/errors.mjs +OK syntax: packages/discord/src/gateway.mjs +OK syntax: packages/discord/src/git.mjs +OK syntax: packages/discord/src/journal.mjs +OK syntax: packages/discord/src/notify.mjs +OK syntax: packages/discord/src/rest.mjs +OK syntax: packages/discord/src/setspark.mjs +OK syntax: packages/discord/src/tools.mjs +OK syntax: packages/discord/src/web.mjs +OK syntax: packages/discord/bin/git-credential.mjs +OK syntax: packages/discord/extension/tools.mjs +OK syntax: packages/discord/tests/approvals.test.mjs +OK syntax: packages/discord/tests/authorize.test.mjs +OK syntax: packages/discord/tests/binding.test.mjs +OK syntax: packages/discord/tests/connector.test.mjs +OK syntax: packages/discord/tests/context.test.mjs +OK syntax: packages/discord/tests/engine.test.mjs +OK syntax: packages/discord/tests/fake-pi.mjs +OK syntax: packages/discord/tests/gateway.test.mjs +OK syntax: packages/discord/tests/git.test.mjs +OK syntax: packages/discord/tests/helpers.mjs +OK syntax: packages/discord/tests/journal.test.mjs +OK syntax: packages/discord/tests/notify.test.mjs +OK syntax: packages/discord/tests/recover.test.mjs +OK syntax: packages/discord/tests/rest.test.mjs +OK syntax: packages/discord/tests/setspark.test.mjs +OK syntax: packages/discord/tests/tools.test.mjs +OK syntax: packages/discord/tests/web.test.mjs +OK syntax: packages/discord/fixtures/claim-worker.mjs +OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs +OK syntax: scripts/discord.sh +OK syntax: scripts/discord-service.sh +OK packages/discord declares no dependencies +OK no bot-token-shaped string in packages/discord +OK fixture binding uses placeholder ids only +OK fixture binding validates +OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool +OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start +OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root +OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token +OK real pi with protocol vault adds reserve_id to the git verbs +OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key +OK real pi refuses a git key on a read-only root (fail closed) +OK real pi with the pilot flags (--no-tools) exposes no tool at all +OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed) +OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT +OK node --test packages/discord/tests/ (ℹ pass 178) +OK scripts/discord.sh --help exits 0 +OK scripts/discord.sh check without a binding exits 4 +OK scripts/discord.sh recover without a binding exits 4 +OK scripts/discord.sh reload without a binding exits 4 +OK scripts/discord-service.sh without a command exits 4 +OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP +OK service install writes the rendered unit (0644) and leaves no temp file +OK service install a second time reports unchanged +OK systemd-analyze verify accepts the rendered unit +OK service uninstall removes the unit file +OK service install with an unknown flag exits 4 +OK service install with USER unset finishes and names the account for lingering + +discord suite: 66 passed, 0 failed diff --git a/agents/rocko/work/queue-77/out/gate/test-extension-package.txt b/agents/rocko/work/queue-77/out/gate/test-extension-package.txt new file mode 100644 index 000000000..45fa4c290 --- /dev/null +++ b/agents/rocko/work/queue-77/out/gate/test-extension-package.txt @@ -0,0 +1,21 @@ +OK initial ordinary-file install +OK installed tree matches canonical source +OK installed tree has no symlinks +OK check detects installation drift +OK sync refuses to overwrite installation drift +OK check detects an extra destination file +OK check detects an extra destination directory +OK check rejects a destination symlink +OK sync accepts a canonical source update +OK updated installation matches canonical source +scripts/test-extension-package.sh: line 14: 983424 Killed "$@" > /dev/null 2>&1 +OK forced interruption kills the replacing process +OK next invocation recovers old consistent installation +OK interrupted replacement rolled back +OK sync succeeds after interruption recovery +OK unlocked stale lock file does not block +OK active lock refuses a concurrent sync +OK source symlink fails closed +OK nested second entrypoint fails closed + +extension package selftest: 18 passed, 0 failed diff --git a/agents/rocko/work/queue-77/out/gate/test-foundation.txt b/agents/rocko/work/queue-77/out/gate/test-foundation.txt new file mode 100644 index 000000000..6338e1e6d --- /dev/null +++ b/agents/rocko/work/queue-77/out/gate/test-foundation.txt @@ -0,0 +1,53 @@ +toolchain: node v26.8.1, python 3.12.8, jsonschema 4.26.0 + +OK syntax: scripts/foundation-inspect.mjs +OK syntax: scripts/foundation/strict-json.mjs +OK syntax: scripts/foundation/canonical.mjs +OK syntax: scripts/foundation/resolve.mjs +OK syntax: scripts/foundation/validate-record.mjs +OK syntax: scripts/foundation/fixtures/build-fixtures.mjs +OK syntax: scripts/foundation/canonical.test.mjs +OK syntax: scripts/foundation/cli.test.mjs +OK syntax: scripts/foundation/fixtures.test.mjs +OK syntax: scripts/foundation/resolve.test.mjs +OK syntax: scripts/foundation/strict-json.test.mjs +OK syntax: scripts/foundation/verify-schema.py (ast only; no bytecode written) +OK fixture generator runs +OK checked-in fixtures/bundles equal a fresh generation +OK checked-in fixtures/raw equal a fresh generation +OK checked-in fixtures/index.json equal a fresh generation +OK checked-in demo bundles equal a fresh generation +OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT +OK node --test scripts/foundation/ (ℹ pass 80) +OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof) + platform witness: strftime('%Y') for year 999 -> '999' (pinned checker refuses years 0001..0999) + node v26.8.1; corpus 1568 records (38 pinned fixtures, 478 unique bundle records, 1052 typeCase/mutation/lexical cases) + schema column: agree-valid 540, agree-invalid 991, DISAGREEMENTS 0; strict-only (parser-bound) cases: 27; unsupported-kind records not schema-assessed by the inspector: 10 + profile column (schema-valid records only): profile-valid 510, profile-invalid 30 + profile refusals asserted: 30 schema-agreed-valid records refused only by the strict typed-string profile (rule profile-pattern-mismatch), 12 declared by name; 73 named probes verified against declared schema/profile columns +OK oracle: zero schema-column disagreements with the pinned checker +OK oracle: strict-only profile refusals are counted and asserted +OK demo: permitted read preview exits 0 (exit 0) +OK demo: permitted file.change preview exits 0 (exit 0) +OK demo: assignment.change proposal is unresolved (exit 3) (exit 3) +OK demo: revoked registration is refused (exit 3) (exit 3) +OK demo: message is not authority (exit 3) (exit 3) +OK usage: no arguments exits 2 (exit 2) +OK io: missing file exits 4 (exit 4) +OK io: directory exits 4 (exit 4) +OK io: symlink exits 4 (O_NOFOLLOW) (exit 4) +OK bound: oversize fixture exits 2 (exit 2) +OK profile: one final LF in a typed selection id is refused before admission (exit 2) (exit 2) +OK profile: two final LFs fail the schema pattern itself (exit 2) (exit 2) +OK profile: escaped newlines in free-form text stay allowed (exit 0) (exit 0) +OK profile refusal is invalid-request/profile-pattern-mismatch with selection and operation withheld, value not echoed +OK text output starts with the disclaimer +OK json output is valid JSON with result allowed and exactly the charter §7 fields +OK json golden matches byte-for-byte +OK sandboxed bundle run (env -i, PATH=/nonexistent) produced the unresolved proposal +OK sandbox inventory (path/type/size/mode/uid/gid/inode/mtime/sha256) unchanged by runs +OK canary never printed (bundle run and credential-file run) +OK a non-bundle JSON file is refused at the shape gate, not read into output +OK no field of the non-bundle file is echoed + +selftest: 44 passed, 0 failed diff --git a/agents/rocko/work/queue-77/out/gate/test-queue.txt b/agents/rocko/work/queue-77/out/gate/test-queue.txt new file mode 100644 index 000000000..70701bb10 --- /dev/null +++ b/agents/rocko/work/queue-77/out/gate/test-queue.txt @@ -0,0 +1,35 @@ +toolchain: node v26.8.1, git version 2.55.0 + +OK syntax: packages/queue/src/cli.mjs +OK syntax: packages/queue/src/errors.mjs +OK syntax: packages/queue/src/io.mjs +OK syntax: packages/queue/src/lock.mjs +OK syntax: packages/queue/src/queue.mjs +OK syntax: packages/queue/src/review.mjs +OK syntax: packages/queue/src/store.mjs +OK syntax: packages/queue/tests/commit.test.mjs +OK syntax: packages/queue/tests/data.test.mjs +OK syntax: packages/queue/tests/dispatch.test.mjs +OK syntax: packages/queue/tests/helpers.mjs +OK syntax: packages/queue/tests/lock.test.mjs +OK syntax: packages/queue/tests/migration.test.mjs +OK syntax: packages/queue/tests/review.test.mjs +OK syntax: packages/queue/tests/store.test.mjs +OK syntax: packages/queue/tests/write.test.mjs +OK syntax: packages/queue/tests/fixtures/fake-gitea.mjs +OK syntax: packages/queue/tests/fixtures/kill-at.mjs +OK syntax: packages/queue/tests/fixtures/lock-child.mjs +OK syntax: packages/queue/tests/fixtures/mosaic-pre-a2.sh +OK syntax: scripts/queue-commit.sh +OK syntax: scripts/git-hooks/pre-commit +OK syntax: scripts/mosaic +OK queue-commit.sh, the guard and scripts/mosaic are executable +OK packages/queue declares no dependencies +ℹ tests 150 +ℹ pass 150 +ℹ fail 0 +OK node --test packages/queue/tests/ +OK scripts/mosaic queue help +skip queue verify and render --check: this checkout (/mnt/storage/scratch/rocko-r77) is not the queue's canonical root (/mnt/storage/src/mosaic-stack) + +queue suite: 27 passed, 0 failed diff --git a/agents/rocko/work/queue-77/out/gate/test-release.txt b/agents/rocko/work/queue-77/out/gate/test-release.txt new file mode 100644 index 000000000..7202b85d4 --- /dev/null +++ b/agents/rocko/work/queue-77/out/gate/test-release.txt @@ -0,0 +1,16 @@ +OK valid RELEASE resolves (exit 0) +OK invalid RELEASE exits 1 (exit 1) +OK missing RELEASE exits 1 (exit 1) +OK valid RELEASE leaves image tag consistent with version +OK status safe on empty state (exit 0) +OK status created no pointer +OK fault-injected activation refuses (exit 1) +OK refused activation wrote no pointer +OK refusal logged exactly once with valid fields +OK healthy activation succeeds (exit 0) +OK pointer written with valid fields +OK repeat activation succeeds (log grows) (exit 0) +OK log is append-only across activations +OK rollback without previous refuses (exit 1) + +selftest: 14 passed, 0 failed diff --git a/agents/rocko/work/queue-77/out/gate/test-task.txt b/agents/rocko/work/queue-77/out/gate/test-task.txt new file mode 100644 index 000000000..df6724a77 --- /dev/null +++ b/agents/rocko/work/queue-77/out/gate/test-task.txt @@ -0,0 +1,101 @@ +OK valid task validates (exit 0) +OK unknown task key exits 2 (exit 2) +OK unsupported taskVersion exits 2 (exit 2) +OK invalid task id exits 2 (exit 2) +OK empty prompt exits 2 (exit 2) +OK NUL in expectExact exits 2 (exit 2) +OK out-of-range timeout exits 2 (exit 2) +OK missing mission file exits 4 (exit 4) +OK task with valid mission validates (exit 0) +OK invalid mission exits 2 (exit 2) +OK validate missing task exits 4 (exit 4) +OK validation does not modify the task file +OK prune dry-run exits 0 (exit 0) +OK dry-run deleted nothing +OK prune --keep=2 --yes removes oldest (exit 0) +OK kept exactly 2 newest runs +OK newest run kept, oldest pruned +OK append-only receipt written (3 entries) +OK sessions/workspaces untouched by prune +OK prune with invalid keep exits 4 (exit 4) +OK mock adapter: gate passes on matching mock response (exit 0) +OK mock adapter: expect-mismatch recorded (exit 1) +OK mismatch reason recorded +OK unknown adapter fails closed (exit 1) +OK mission task runs via mock adapter (exit 0) +OK mission section injected into generated prompt +OK retry of mission run succeeds (exit 0) +OK retriedFrom lineage recorded +OK mission section present after retry (relative path resolved) +OK retry of missing run exits 4 (exit 4) +OK skill install bundled ms-tools (exit 0) +OK installed to skills-available +OK double install refuses (exit 1) +OK activate enables skill (exit 0) +OK enabled dir populated +OK uninstall while enabled refuses (exit 1) +OK deactivate moves back to available (exit 0) +OK uninstall removes from available (exit 0) +OK seat with enabled skill launches +OK skill path delivered to adapter +OK shipped researcher contract resolves (ceiling + network) +OK resolve-role refuses a non-role document (conductor policy) (exit 2) +OK resolve-role refuses name/filename mismatch (exit 2) +OK resolve-role refuses unknown network declaration (exit 2) +OK resolve-role refuses duplicate role tool (exit 2) +OK resolve-role refuses unsupported tool (exit 2) +OK resolve-role refuses missing contract file (exit 4) +OK version 1 role prints no contract line +OK shipped version 2 roles resolve with their contracts (pm, cto, coder, reviewer) +OK resolve-role accepts a minimal version 2 role (exit 0) +OK resolve-role refuses an action outside the vocabulary (exit 2) +OK resolve-role refuses a gated-only action in a role file (exit 2) +OK resolve-role refuses a Vikunja verb no role may hold (exit 2) +OK resolve-role refuses a version 2 role whose contract is missing (exit 2) +OK resolve-role refuses a contract that is a symbolic link (exit 2) +OK seat launches under role ceiling +OK role ceiling narrows seat tools (read,write,bash -> read,bash) +OK narrowing recorded loudly +OK missing role contract refuses launch (exit 2) +OK missing-contract refusal names the role +OK empty ceiling intersection -> tool-free seat +OK tool-free outcome recorded loudly +OK overridden agents dir without seat definition refuses (exit 4) +OK seat-resolution refusal names the seat +OK policy: mission only -> mission tools +OK policy: task only -> task tools +OK policy: both -> intersection (task narrowed) +OK policy: empty intersection -> tool-free +OK invalid mission capabilities rejected (exit 2) +mosaic-task: unsupported mission tool: "sudo" (supported: read, write, edit, bash, grep, find, ls) +OK task run with user layer present (exit 0) +OK user context dispatched into generated prompt +OK workspace+tools task runs via mock (exit 0) +OK workspace path + tools delivered to adapter +OK persistent workspace created on host +OK plain task still runs (no workspace/tools) (exit 0) +OK workspace var present but empty when absent +OK tools empty when absent +OK unknown tool exits 2 (exit 2) +OK workspace traversal exits 2 (exit 2) +OK live hello task succeeds with exact marker +OK result.json written in run dir +OK result.json contents are correct +OK wrong expectExact fails with exit 1 (reason: expect-mismatch) +OK each run gets a distinct run dir (no clobber) +OK list shows both runs +OK fork base: teach succeeds (exit 0) +OK fork child recalls ancestor context (exit 0) +OK forked child recalled ancestor code word +OK ancestor session untouched by fork +OK child session dir has its own branch file +OK onboard without name exits 4 (non-interactive) (exit 4) +OK onboard --name renders profile (exit 0) +OK profile written +OK canon structure: required filled, optional placeholdered +OK canon sections present +OK user recall run succeeds +OK recalled user name (response: Jason) +OK no agent identity on headless run + +selftest: 98 passed, 0 failed diff --git a/agents/rocko/work/queue-77/out/mutants.txt b/agents/rocko/work/queue-77/out/mutants.txt new file mode 100644 index 000000000..5d969f8d7 --- /dev/null +++ b/agents/rocko/work/queue-77/out/mutants.txt @@ -0,0 +1,18 @@ +KILLED failed counts as running: 2 failing +KILLED reloading not running: 2 failing +KILLED list-units failure ignored: 1 failing +KILLED install skips state read: 5 failing +KILLED uninstall skips state read with --no-reload: 1 failing +KILLED uid header case-sensitive: 1 failing +KILLED uid allows upper hex: 1 failing +KILLED allowed user may start with -: 2 failing +KILLED no --checkout in unit: 4 failing +KILLED canonical always passes: 2 failing +KILLED install without --copy: 1 failing +KILLED missing copy accepted: 2 failing +KILLED status drift wrong: 1 failing +KILLED copy misses seat: 1 failing +KILLED copy stays writable: 1 failing +KILLED copy off-branch accepted: 4 failing +KILLED queue reads root: 2 failing +KILLED --checkout relative ok: 1 failing diff --git a/agents/rocko/work/queue-77/packet-manifest.sha256 b/agents/rocko/work/queue-77/packet-manifest.sha256 new file mode 100644 index 000000000..35389fb2f --- /dev/null +++ b/agents/rocko/work/queue-77/packet-manifest.sha256 @@ -0,0 +1,38 @@ +0902ba62a53866f6bc35fba858f39bc50e23843b650f049cd613862625006a4c ./base.txt +669eabbd80559ab1e1bdf649a75b1ebcdf94bc0fdcc0c5312717b24a80005d20 ./BUILD.md +e852a913b48444a9043b48104939ebcde1bd4bae5bd6a30332d0d3e46326fbf3 ./build.patch +28c4708c062b752832576eb42aa1f1c9cede16f940d6d8929a4f47957c2f2b4d ./byte-check.sh +4927de6625a0159eb9633d679a348a77a98978cc403bd9eee78fff8c1d6482a0 ./candidate-manifest.sha256 +0fbda19895fc2cb9cff06a0d766e4bc5a509030405f3ae851c8cb1fe59437c5b ./files.txt +b917b2607ae5f27a95ac09d29b8a2fdedf80069eda0beab9943e22a167250188 ./mutants.py +7236b1386f47d3e88f44c1baeb3cdc3ba4e0d72df6cf57b3ecd7adf24f56dfb4 ./out/byte-check/base-1.txt +619a486a2828f6eab280f8df4dd82735464c02a5aded055137374f6f3c17f9b0 ./out/byte-check/base-2.txt +64d6be0adfe28df72a3b79ede380561ddf18a93afccacc27ed8314d05ce18c7d ./out/byte-check/base-3.txt +3d5a4aac2243cc40acb90f8917372cedc222e354ea09bce939856568735da229 ./out/byte-check/base-4.txt +ffbfc5ee5865a1fd4705f1ae32b1e802c7b68789955a5e3b381fa242781ea63d ./out/byte-check/base-5.txt +ce08c820abdee674b08f09768e6fc56b8a17cfe082829d8c26daa1e456ec1a6f ./out/byte-check/base-6.txt +d38e7cfe67f8045602923579a9087779cc05cb433490ea606132be49ac53a897 ./out/byte-check/base-7.txt +af9d8b17ad1c42d0c881ff9a72673044b0ebc52eb78cf453ee76614b2cc0be72 ./out/byte-check/base-8.txt +7236b1386f47d3e88f44c1baeb3cdc3ba4e0d72df6cf57b3ecd7adf24f56dfb4 ./out/byte-check/cand-1.txt +619a486a2828f6eab280f8df4dd82735464c02a5aded055137374f6f3c17f9b0 ./out/byte-check/cand-2.txt +64d6be0adfe28df72a3b79ede380561ddf18a93afccacc27ed8314d05ce18c7d ./out/byte-check/cand-3.txt +3d5a4aac2243cc40acb90f8917372cedc222e354ea09bce939856568735da229 ./out/byte-check/cand-4.txt +ffbfc5ee5865a1fd4705f1ae32b1e802c7b68789955a5e3b381fa242781ea63d ./out/byte-check/cand-5.txt +ce08c820abdee674b08f09768e6fc56b8a17cfe082829d8c26daa1e456ec1a6f ./out/byte-check/cand-6.txt +d38e7cfe67f8045602923579a9087779cc05cb433490ea606132be49ac53a897 ./out/byte-check/cand-7.txt +af9d8b17ad1c42d0c881ff9a72673044b0ebc52eb78cf453ee76614b2cc0be72 ./out/byte-check/cand-8.txt +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./out/byte-check/template-body.diff +c8865434415cf50faef608ff185e4390ef068b1239c1bdfa8195f7b7ce98f872 ./out/byte-check.txt +b511d20155fab3e2f60f323ceda693e04dba9e1c5cfb336ddf3fda21b5987469 ./out/gate/node-webui.txt +5bcf23a19a8970a3378478c2180f5cc203f5544c7a78cbc31b3c7ad6d827acbb ./out/gate.sh +5bb4a55294a8a7e9a872e383f5326d41066ef6b7629f5ce8897bb15995362859 ./out/gate/summary.txt +f91fc95a173339137f981e24fbf630acfde957e32c3cd71e28304532083c6a77 ./out/gate/test-auth.txt +111546d97a763f32573dc2f0c5c44e83f4a136ef9d301fb1a6b7b17227d3aa03 ./out/gate/test-conductor.txt +52d211444489ea729dafc3c9745d4474d9dfd50f41055cbad792424197f04073 ./out/gate/test-config.txt +7d5016ded1994048642f8b9ae845e52e8af4a069ba05c056c8f580a4589bacbe ./out/gate/test-discord.txt +814f454e3476f9f81245b8eba3011e5af1a108e9b84ad7530f24ff7e27a0ea4f ./out/gate/test-extension-package.txt +83dfaa21c4e4ad941088efab3f69e87ebd2f6bd3cc72ede938e21dc06da8a34f ./out/gate/test-foundation.txt +0cd57fe625b02fdc477f1aea2c54a9cabcc8af10eb2b53fd6dbbb1eab3b5c3fd ./out/gate/test-queue.txt +fa832794ee35025fb4559d58d01a270f6bff2eb65e863e9075b797f47fa68545 ./out/gate/test-release.txt +ccea37f4ffde1f160dcd1a1864988083a645d974e892065c956b338d0f8c853a ./out/gate/test-task.txt +0edfc91fb9d610eda6914e6e4517cce5588e0e25db5a1cd63cce77a4f0fda191 ./out/mutants.txt diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index f224fc131..903de94ad 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -762,3 +762,4 @@ are never rewritten or removed; corrections are new entries. 2026-10-12T18:18Z | sage | M1 brief (Vikunja #154), rows 80/81 briefed, decision 113 | M1 brief ff61135e; row 84 (#1578, rocko after 77) briefed rev 671 (95cb51c3); #1577 misfiled as jason.woltje, closed for #1578; rows 80, 81 queued→briefed revs 667-668 (Dewey's bbb9cc40); decision 113 rules row 61 field table follows the code, edited in the row (30adbd3e, rev 672 73ef8ba7); Vikunja #154 comment 31, #218 filed 2026-10-12T18:33Z | sage | #162 round 1 (decisions 114, 115), S7 restart | decisions 114-115 and P19 #219 Q115, #220 Q114, #221 Q116 ops (ops-01 via Mos), #222 Q116 stack in 681de778; row 42 briefed→in-progress rev 673 (c5885469) on the Q118 channel; S7 run log agents/sage/work/slice1-acceptance/run-log.md 2026-10-12T18:38Z | dewey | row 81 (#1575) in review, round 1 | packet 5afa8b0f, candidate manifest 2263ff5f (12/12 on a fresh worktree of ff11aa4b), my gate green 18:29-18:35Z, 17/17 mutants killed; in-progress→in-review revs 674-675, request comment 27763 (ed5d7d35); Darkwing reviews, Sage notifies Mos before landing (decision 109) +2026-10-12T18:38:55Z | rocko | row 77 (#1571) candidate round 1 | packet agents/rocko/work/queue-77/ (candidate manifest 4927de66, base 27a1a2bb); in-progress→in-review rev 676, request rocko-77-review-1 failed rev 677 (no credential; Sage sends as jarvis); gate webui 94/0 + 9 scripts green; 18/18 mutants killed; live unit untouched