diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 631e18146..89c857152 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -246,17 +246,22 @@ scripts/webui-service.sh install [--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] -# [--public-host NAME --auth-header HEADER --allow-user ID] +# [--public-host NAME --auth-header HEADER --allow-user ID [--send]] ``` The systemd user unit `mosaic-webui.service` runs the Console, `node packages/webui/src/cli.mjs serve`, with serve's flags as given: `--port` defaults to 7332 (7330 is the operator's own Console), and the -preview set goes all three or none. The template is +preview set goes all three or none. `--send` (row 83) goes only with the +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 and -`GET /healthz` on the installed port. Each value is held to a plain +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 @@ -264,9 +269,11 @@ already in use) is not restarted. 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; an agent starting it, even through systemd, works around the -human proof (lead decisions 62 and 94). Jason starts it from his own shell: -`systemctl --user enable --now mosaic-webui`. +capability. Jason starts 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 +systemd, works around the human proof (lead decisions 62 and 94). Exit codes: `0` ok · `1` failed or refused · `4` usage. ## Discord connector (`scripts/discord.sh`) diff --git a/packages/webui/README.md b/packages/webui/README.md index 5ac12d958..4a7cf535b 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -100,8 +100,15 @@ Under `--send` the first of those can also send (next section). To run the Console as a systemd user unit, `scripts/webui-service.sh` renders and installs one with these flags (row 75; `docs/TOOLS.md` -"Console service"). Jason starts it from his own shell (lead decision 94). -The script takes no `--send`, so a unit it installs is read-only. +"Console service"). Jason starts and restarts it, and so may Mos under lead +decision 102, which accepts that sends from a Console Mos started enter the +trail as Jason. Any other agent starting it works around the human proof +(lead decisions 62 and 94). Without `--send` the unit is read-only. The script takes `--send` (row 83) +only with the three preview flags; otherwise it exits 1 and writes nothing. +With it, the unit's ExecStart ends with `--send`, and the Console needs the +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. ## Message a role (row 76, #1570) @@ -113,9 +120,11 @@ node packages/webui/src/cli.mjs serve --port 7332 \ `--send` is off by default. It goes only with the three preview flags and refuses at start without them. It also needs the system config: without it the start refuses with `--send needs the bus configured`, where a read-only -Console would still serve the board. With `--send` the Inbox has a "Message -a role" box: a role picker filled from the agents read, a text box and -Send. The message reaches the role as a message from the human, class INFO, +Console would still serve the board. A unit that sends comes from +`scripts/webui-service.sh install` with `--send` (above). With `--send` the +Inbox has a "Message a role" box: a role picker filled from the agents read, +a text box and Send. The message reaches the role as a message from the +human, class INFO, through `message.send` on the human transport. It goes the same way as one sent from the CLI, with class INFO where `mosaic talk` uses REQUEST (lead decision 95). Decisions are still answered in a diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs index d50b6652b..da440d6b8 100644 --- a/packages/webui/tests/service.test.mjs +++ b/packages/webui/tests/service.test.mjs @@ -11,10 +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. 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 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) { + 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); +} + +// A loopback port with nothing listening, so status's /healthz request +// reaches no real Console. +async function closedPort() { + const server = createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address(); + server.close(); + await once(server, 'close'); + return port; +} function tmp(t) { const dir = mkdtempSync(join(tmpdir(), 'webui-service-')); @@ -114,7 +139,7 @@ test('the rendered unit names none of the human proof agent markers and sets no const source = readFileSync(join(REPO, 'packages', 'bus', 'src', 'human.mjs'), 'utf8'); const markers = [...source.match(/const MARKERS = \[([^\]]*)\]/)[1].matchAll(/'([A-Z_]+)'/g)].map((m) => m[1]); assert.ok(markers.length >= 7 && markers.includes('MOSAIC_BUS_CAP') && markers.includes('CLAUDECODE'), markers.join(' ')); - for (const args of [[], [...PREVIEW, '--business', 'mosaic']]) { + for (const args of [[], [...PREVIEW, '--business', 'mosaic'], [...PREVIEW, '--send']]) { const r = await run(dir, ['render', ...args]); assert.equal(r.status, 0, r.stderr); for (const marker of markers) assert.ok(!r.stdout.includes(marker), marker); @@ -212,9 +237,129 @@ 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']]) { + 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]]) { const r = await run(dir, args); assert.equal(r.status, 4, args.join(' ')); assert.match(r.stderr, /^usage: scripts\/webui-service\.sh/); } }); + +test('without --send, render is the template with its placeholders filled, byte for byte', async (t) => { + const dir = tmp(t); + for (const [args, flags] of [ + [[], '--port 7332'], + [['--port', '7340'], '--port 7340'], + [['--business', 'mosaic'], '--port 7332 --business mosaic'], + [PREVIEW, `--port 7332 ${PREVIEW_FLAGS}`], + [['--port', '7340', '--business', 'mosaic.dev_1', '--board', 'http://127.0.0.1:7331', ...PREVIEW], `--port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 ${PREVIEW_FLAGS}`], + ]) { + const r = await run(dir, ['render', ...args]); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, filled(flags), args.join(' ')); + } +}); + +test('render --send with the preview set ends ExecStart with --send and changes nothing else', async (t) => { + const dir = tmp(t); + const extra = ['--port', '7340', '--business', 'mosaic']; + for (const args of [[...PREVIEW, '--send'], ['--send', ...PREVIEW], [...PREVIEW.slice(0, 2), '--send', ...PREVIEW.slice(2)], ['--send', ...extra, ...PREVIEW]]) { + const plain = args.filter((a) => a !== '--send'); + const flags = `${plain.includes('--port') ? '--port 7340 --business mosaic' : '--port 7332'} ${PREVIEW_FLAGS}`; + 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)); + 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'); + assert.equal(a.length, b.length); + assert.deepEqual(a.map((l, i) => (l === b[i] ? null : [l, b[i]])).filter(Boolean), [[`${execStart(without.stdout)} --send`, execStart(without.stdout)]]); + } +}); + +test('--send without the whole preview set is refused and writes nothing', async (t) => { + const dir = tmp(t); + const pairs = [PREVIEW.slice(0, 2), PREVIEW.slice(2, 4), PREVIEW.slice(4, 6)]; + const partial = [[], ['--port', '7340'], ['--business', 'mosaic'], ...pairs, [...pairs[0], ...pairs[1]], [...pairs[0], ...pairs[2]], [...pairs[1], ...pairs[2]]]; + for (const args of partial) { + const r = await run(dir, ['render', '--send', ...args]); + assert.equal(r.status, 1, args.join(' ')); + assert.equal(r.stdout, '', args.join(' ')); + assert.match(r.stderr, SEND_REFUSAL, args.join(' ')); + } + 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); + 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); + const before = readFileSync(target, 'utf8'); + const r = await run(dir, ['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'); +}); + +test('install --send into --dir with --no-reload writes the sending unit, and status says whether it sends', async (t) => { + const dir = tmp(t); + const unitDir = join(dir, 'units'); + 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']); + 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`)); + assert.equal(statSync(target).mode & 0o777, 0o644); + assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']); + const bin = standIn(dir); + const sending = await run(dir, ['status', '--dir', unitDir], bin); + 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); + assert.match(again.stdout, new RegExp(`^unchanged: ${target}$`, 'm')); + const readOnly = await run(dir, ['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)); + 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)), []); + 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(' ')); + } +}); + +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); + 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(' ')); + assert.match(r.stderr, /^usage: scripts\/webui-service\.sh/); + assert.equal(r.stdout, '', args.join(' ')); + } + assert.ok(existsSync(join(unitDir, 'mosaic-webui.service')), 'still installed'); + assert.ok(!existsSync(join(dir, 'calls.txt')), 'no systemctl call'); +}); diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh index 4ff068338..944bb97a9 100755 --- a/scripts/webui-service.sh +++ b/scripts/webui-service.sh @@ -11,19 +11,24 @@ # rename) and run `systemctl --user daemon-reload` # uninstall [--dir DIR] [--no-reload] # remove the unit; refuses while it is active -# status [--dir DIR] unit state, then GET /healthz on its port +# status [--dir DIR] unit state, whether it sends, then GET +# /healthz on its port # # 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 -# NAME --auth-header HEADER --allow-user ID, all three or none. systemd +# NAME --auth-header HEADER --allow-user ID, all three or none, and --send +# (row 83), taken only with the preview set: the unit's Console then sends +# human messages to roles and needs the system config at start. systemd # expands % and $ and splits on spaces, so each value is held to a plain # character set and refused otherwise. None is a secret. # # 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. An agent starting it, even detached through systemd, works -# around the human proof (lead decisions 62 and 94). Jason starts it from -# his own shell: `systemctl --user enable --now mosaic-webui`. +# capability. Jason starts 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 detached +# through systemd, works around the human proof (lead decisions 62 and 94). # # Exit codes: 0 ok, 1 operation failed, 4 usage. set -euo pipefail @@ -31,7 +36,7 @@ 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]" + FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]]" die() { echo "webui-service: $*" >&2; exit 1; } usage() { echo "$USAGE" >&2; exit 4; } @@ -45,6 +50,7 @@ BOARD="" PUBLIC_HOST="" AUTH_HEADER="" ALLOW_USER="" +SEND=0 SERVE_SET=0 parse_flags() { @@ -58,6 +64,7 @@ parse_flags() { --public-host) [ $# -ge 2 ] || usage; PUBLIC_HOST="$2"; SERVE_SET=1; shift 2 ;; --auth-header) [ $# -ge 2 ] || usage; AUTH_HEADER="$2"; SERVE_SET=1; shift 2 ;; --allow-user) [ $# -ge 2 ] || usage; ALLOW_USER="$2"; SERVE_SET=1; shift 2 ;; + --send) SEND=1; SERVE_SET=1; shift ;; *) usage ;; esac done @@ -86,6 +93,9 @@ serve_flags() { [ -z "$PUBLIC_HOST" ] || preview=$((preview + 1)) [ -z "$AUTH_HEADER" ] || preview=$((preview + 1)) [ -z "$ALLOW_USER" ] || preview=$((preview + 1)) + # serve refuses --send without the preview set; refuse it here first, + # so no unit is written that would only exit 2 at start. + [ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user)" if [ "$preview" -ne 0 ]; then [ "$preview" -eq 3 ] || die "--public-host, --auth-header and --allow-user go together: all three or none" case "$PUBLIC_HOST" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac @@ -95,6 +105,7 @@ serve_flags() { case "$ALLOW_USER" in *[!A-Za-z0-9._@+-]*) die "allowed user contains a character the unit cannot carry: $ALLOW_USER" ;; esac flags="$flags --public-host $PUBLIC_HOST --auth-header $AUTH_HEADER --allow-user $ALLOW_USER" fi + [ "$SEND" = 0 ] || flags="$flags --send" echo "$flags" } @@ -138,8 +149,9 @@ install_unit() { echo "daemon-reload done" fi cat <