webui: Console pinned copy, update step and service hardening (#1571)

Row 77, round 3 (round 2 rebased onto row 95). Rocko built it. This
covers row 75's N1-N5 and Vikunja 146 and 147. `install --copy ID`
refuses unless every ExecStart path of the bus unit, read from
`systemctl --user show` so drop-ins count, lies inside that copy. It
also refuses when the unit isn't loaded, no unit is loaded, ExecStart
has no path, or systemctl fails. COPY_PATHS gains packages/tasks/src and
packages/tasks/fields.json. Every copy directory is 0555. TOOLS.md and
the webui README give the removal command and the rollback floor.

Candidate manifest d9880f4c92d1d976027562a03cc5dd07512954e86f49f1da0ebb34e642a955f5
(10 paths, 8 applied by the patch, 2 packet files already committed),
base 2e26fbe1, packet agents/rocko/work/queue-77/r2b/. Approvals on round
3: Darkwing 27979 and Filbert 27985 (Filbert round 2: 27898). Sage's
gate rerun 22:32:51Z-22:35:12Z on 459c4fc6 plus the patch (row 92
included), manifest 10/10: webui 101, control-board 124, and every
scripts/test-*.sh exited 0 (test-release 4, Docker cases skipped at a
dead socket).

Live at the next Console install and restart (decision 109). Mos makes
that call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 17:35:26 -05:00
co-authored by Claude Opus 5.5
parent cca5f23eaf
commit 0d10cf0798
8 changed files with 921 additions and 94 deletions
+221 -22
View File
@@ -1,18 +1,44 @@
#!/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/<full id>/, 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. install
# refuses unless the bus unit runs from the same copy (bus_on_copy below),
# and nothing puts the bus on a copy yet, so the step can't be used until a
# later row does.
#
# 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 +55,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 +91,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 +139,69 @@ 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 nine src directories
# (packages/tasks/src since row 61: reads.mjs imports its field table
# loader, which reads packages/tasks/fields.json);
# 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 pinned-copy test serves from a copy: a missing import fails it at
# load, and a missing file read at request time (fields.json, RELEASE,
# roles/) fails the route that reads it, which the test also GETs.
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
packages/tasks/src
packages/tasks/fields.json
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 +212,68 @@ 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}'
}
# The bus host proves a human by checking that the calling process runs its
# own packages/bus/src/human-cli.mjs (packages/bus/src/human.mjs), and the
# Console spawns the one beside its own files. So a Console on a copy works
# only with a bus on that same copy: install refuses unless every bus unit
# it would call has an ExecStart path inside the copy. With --business the
# unit is [email protected]; without it, every mosaic-bus@ unit systemd
# has loaded, and none loaded refuses. Like the state read, a failed call
# refuses with one line.
bus_on_copy() {
local copy="$1" units unit out load paths p
if [ -n "$BUSINESS" ]; then
units="mosaic-bus@$BUSINESS.service"
else
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl --user list-units --all --no-legend --plain mosaic-bus@*.service failed, so the bus unit is unknown; nothing was changed"
units="$(printf '%s\n' "$out" | awk '$1 ~ /^mosaic-bus@.+\.service$/ {print $1}')"
[ -n "$units" ] || die "refusing: no mosaic-bus@ unit is loaded, and the Console on the copy $copy needs its bus on that copy; name the business with --business ID"
fi
for unit in $units; do
out="$(systemctl --user show "$unit" -p LoadState -p ExecStart 2>/dev/null)" || die "refusing: systemctl --user show $unit failed, so the bus unit is unknown; nothing was changed"
load="$(printf '%s\n' "$out" | sed -n 's/^LoadState=//p')"
[ "$load" = loaded ] || die "refusing: no bus unit $unit is installed (LoadState ${load:-unknown}); the Console on the copy $copy needs its bus on that copy"
paths="$(printf '%s\n' "$out" | sed -n 's/^ExecStart=//p' | grep -o 'path=[^ ;]*' | sed 's/^path=//' || true)"
[ -n "$paths" ] || die "refusing: $unit has no ExecStart path, so where its bus runs from is unknown; nothing was changed"
for p in $paths; do
case "$p" in "$copy"/*) ;; *) die "refusing: $unit runs from $p, not the copy $copy; the bus accepts only its own human-cli.mjs, so every Console bus call would fail human-required until the bus runs from the same copy" ;; esac
done
done
}
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
bus_on_copy "$COPIES/$COPY_ID"
# 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 +306,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 +326,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 +336,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 +372,51 @@ 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 'chmod -R u+w "$COPY_TMP" 2>/dev/null; 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"
# Read-only files and directories: a writer that replaces by rename
# (git, editors, sed -i) can't change a copy either.
find "$COPY_TMP" -type f -exec chmod a-w {} +
find "$COPY_TMP" -type d -exec chmod 0555 {} +
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 "$@" ;;