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
+14 -3
View File
@@ -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}`);
+5 -3
View File
@@ -168,14 +168,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'); }
};