# 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").