diff --git a/agents/dewey/work/queue-83/build.md b/agents/dewey/work/queue-83/build.md new file mode 100644 index 000000000..2ea2594c0 --- /dev/null +++ b/agents/dewey/work/queue-83/build.md @@ -0,0 +1,212 @@ +# Row 83 (#1576): Console service, the unit script takes --send, round 1 + +Owner: Dewey. Reviewers: Darkwing and Filbert, then Sage's gate. Brief: +`docs/plans/2026-10-12_console-service-send.md` § "The unit script takes +--send", blob `f8d14d70` (re-pinned at rev 630 for lead decision 102; first +pinned as `739bea66` at rev 627, briefed at rev 628). Lead decision 101 +gives the reason. Base `d13b9300`. None of the candidate's paths, nor the +template, changed between `d13b9300` and `refactor` HEAD (`a1c5c8fc`) when +the packet was built. + +`build.patch` is `git diff --cached --binary d13b9300 --` the four paths +below. Apply it with `git apply --index` to a detached worktree of the base; +`candidate-manifest.sha256` then checks 4/4 OK (checked on a fresh +worktree). + +## What ships + +- `render` and `install` take `--send`, a flag with no value. It is + accepted only with all three preview flags. Otherwise `serve_flags` + refuses before anything is written: exit 1, empty stdout, and on stderr + `webui-service: --send needs the preview mode (--public-host, + --auth-header and --allow-user)`, serve's own wording (cli.mjs:37). + `install` renders before `mkdir`, so a refusal creates no directory and + leaves an installed unit alone (tested). +- With `--send`, ExecStart ends `... --allow-user ID --send`. Nothing else + in the unit changes (tested line by line against the same render without + `--send`). +- Without `--send`, every render is the template with its four + placeholders filled, byte for byte (new test), and the base and + candidate scripts give the same stdout, stderr, status and installed + files on every argument list the base tests use (`identity.txt`). +- `uninstall` and `status` take no `--send`: it sets SERVE_SET like the + other serve flags, so both exit 4 (tested with a stand-in systemctl). +- `status` prints one new line when a unit is installed: `send: on + (--send; human messages to roles)` or `send: off (read-only)`. The brief + allowed this "if small"; it is five lines plus a comment. With no unit + installed there is no send line. +- `USAGE` and the header comment name `[--send]` inside the preview set. + README's service paragraph and TOOLS.md's "Console service" section say + the script takes `--send` only with the preview set (exit 1, nothing + written), that ExecStart then ends with `--send`, and that the Console + needs the system config at start (`--send needs the bus configured`, + exit 2, which the unit doesn't restart: `RestartPreventExitStatus=2` in + the template). README's "Message a role" `--send` paragraph gets one + sentence pointing at `scripts/webui-service.sh install` with `--send`. +- The start wording (lead decision 102). The script's header comment, its + install message, TOOLS.md's "Console service" and README's service + paragraph now say Jason starts and restarts the Console, and so may Mos + under 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, still works around the human proof (decisions 62 + and 94). The script still has no start, stop or restart verb. A test + checks the install message's wording. + +The template (`packages/webui/systemd/mosaic-webui.service.in`) is +unchanged. `install` prints no new line; its `serve:` line already shows +the ExecStart, `--send` included. The install message's opening sentence +changed wording only (decision 102). + +## Decisions and tradeoffs + +- **Refusal order.** The `--send` check runs before the all-three check. + `--send` with one or two preview flags therefore gets the `--send` + message, not "all three or none". The brief asks for a message naming + the preview flags the way serve refuses, and serve checks the preview + set first. Both messages name the three flags. A partial preview set + without `--send` still gets "all three or none" (base test unchanged). +- **Status detection is anchored on the allowed user.** My first version + matched `^ExecStart=.* --send$`. The mutant `status-send-anywhere` + survived it, and that showed a real gap: `--allow-user` accepts the value + `--send` (its character set allows `-`, and serve's `USER` pattern + accepts it), so a read-only unit's ExecStart can end + `--allow-user --send`. The check is now + `^ExecStart=.* --allow-user [^ ][^ ]* --send$`. render always puts + `--send` right after the allowed user, so a sending unit ends with both + the allowed user and `--send`. A test installs both cases, read-only + `--allow-user --send` and sending `--allow-user --send --send`, and + checks status for each. +- **No config or bus check in the script** (out of scope per the brief). + A unit installed with `--send` on a host without the system config + installs fine and exits 2 at start; the docs say so. + +## Files + +- `scripts/webui-service.sh`: `SEND=0`, the `--send` case, the refusal + and the append in `serve_flags`, the status line, USAGE and header. +- `packages/webui/tests/service.test.mjs`: 5 new tests, 2 widened. +- `packages/webui/README.md`: the service paragraph, one sentence in + "Message a role". +- `docs/TOOLS.md`: the "Console service" FLAGS comment and prose. + +## Tests + +`packages/webui/tests/service.test.mjs`, 15 tests (10 base, 5 new). The +helper `filled(flags)` is the template with `@REPO@`, `@PATH@`, `@NODE@` +and `@FLAGS@` replaced, computed in the test the way the base script does +it. + +- *without --send, render is the template with its placeholders filled, + byte for byte*: five flag combinations, from none to every flag. +- *render --send with the preview set ends ExecStart with --send and + changes nothing else*: four argument orders (`--send` first, last, in the + middle of the preview set, with `--port` and `--business`). Each equals + `filled(flags + ' --send')`, and against the same render without + `--send` exactly one line differs, the ExecStart, by ` --send`. +- *--send without the whole preview set is refused and writes nothing*: + `--send` alone, with `--port`, with `--business`, with each one of the + three preview flags, and with each two of the three. Each `render` gives + exit 1, empty stdout and serve's message. Each `install` into a fresh + `--dir` gives the same, and the directory is never created. An install + of `--send` with two preview flags over an installed unit leaves it byte + for byte, with no temp file and no systemctl call. +- *install --send into --dir with --no-reload writes the sending unit, and + status says whether it sends*: `written:`, the `serve:` line ending + `--send`, the file equal to `filled(...)`, mode 0644, no temp file; + status `send: on`; reinstall `unchanged:`; install without `--send` + writes the read-only unit and status says `send: off (read-only)`; the + odd allowed user `--send` case above; no `send:` line without a unit. + The only systemctl calls are status's reads. Status's `/healthz` goes to + a loopback port that was just closed, so it reaches no real Console. +- *uninstall and status take no --send*: exit 4 for `uninstall --send` + (with and without `--no-reload`) and `status --send`, against a stand-in + systemctl that records no call; the unit stays installed. +- Widened: the agent-marker test also renders `[...PREVIEW, '--send']`; + the usage test adds `render --send yes ...PREVIEW` (`--send` takes no + value); the install test checks the decision 102 start wording in the + install message. + +Why the uninstall and status cases sit in their own test: in the usage +loop they run with the real `/usr/bin/systemctl` on PATH. A mutant where +`--send` doesn't set SERVE_SET would let `uninstall --send` reach a real +`systemctl --user daemon-reload`. With the stand-in, no mutant can. + +## Byte identity (`identity.mjs`, `identity.txt`) + +One scratch worktree of `d13b9300` holds the base script as +`scripts/webui-service-base.sh` and the candidate as +`scripts/webui-service.sh`, so REPO and the template are the same for +both. Each script runs the same 86 argument lists from a clean scratch: +every render the base tests make (the value and port refusals included), +the usage list, `--help`, and an install, reinstall, refused install, +install over, status, uninstall, uninstall and status sequence, all with +`--no-reload` and a stand-in systemctl. + +Result: 69 identical, 17 expected differences, 0 unexpected. The 17 are +the usage text on 12 exit-4 cases plus `--help` (the FLAGS line names +`[--send]`), three installs whose message differs only in the decision 102 +start wording (the base message's opening lines replaced by the +candidate's, then everything else identical, installed unit included), +and one status with a unit installed (the added `send: off (read-only)` +line, nothing else). A self-check: with the append made +unconditional, the same run reports 13 unexpected differences. + +## Mutants (`mutants.py`, `mutants.txt`) + +15 single-site mutants of `scripts/webui-service.sh`, each run against the +base tests (`d13b9300`'s `service.test.mjs`), the candidate tests and +`identity.mjs`. Baselines: all three pass on the unmutated candidate. +Rerun on the decision 102 candidate, 2026-10-12T03:53Z; same results as +the first run. + +| mutant | base tests | candidate tests | +|---|---|---| +| send-no-preview-check | survived | killed | +| send-needs-any-preview | survived | killed | +| send-message-reworded | survived | killed | +| send-no-append | survived | killed | +| send-append-always | killed | killed | +| send-append-before-preview | survived | killed | +| send-default-on | killed | killed | +| send-no-serve-set | survived | killed | +| send-takes-value | survived | killed | +| send-flag-unknown | survived | killed | +| status-send-always-off | survived | killed | +| status-send-always-on | survived | killed | +| status-no-send-line | survived | killed | +| status-send-anywhere | survived | killed | +| status-send-suffix-only | survived | killed | + +The candidate tests kill 15 of 15. The two the brief names, +`send-no-preview-check` and `send-no-append`, survive the base tests. +`send-append-always` and `send-default-on` are killed by the base tests +too, as a change to every render should be. + +## Gate (my run on the candidate) + +On the staged candidate in a worktree of `d13b9300`, run sequentially, +2026-10-12T03:54:20Z to 03:58:42Z (`gate-summary.txt`): + +- `packages/webui`, `node --test 'tests/*.test.mjs'`: 81 tests, 81 pass, + rc=0. +- `scripts/test-auth.sh`, `test-conductor.sh`, `test-config.sh`, + `test-discord.sh`, `test-extension-package.sh`, `test-foundation.sh`, + `test-queue.sh`, `test-release.sh`, `test-task.sh`: all rc=0. + +## For reviewers + +- **Allowed user starting with `-`.** The base script and serve both accept + an allowed user like `--send` or `-x`. That isn't new here, and changing + it would change no-`--send` renders, which the brief rules out. The only + effect on this row was status detection, now anchored. If it should be + refused, that's a follow-up for the script and serve together. +- **The template's comment still says Jason starts it.** + `mosaic-webui.service.in` lines 6-9 say Jason starts it from a personal + shell and that an agent starting it works around the human proof. The + template isn't in this row's files and is rendered into every unit, so + changing it would break the byte identity the brief asks for. Bringing + it in line with decision 102 is a follow-up (a template change plus the + render tests that pin it). +- Nothing here starts, enables or restarts a unit, and no test reaches the + real systemd: every install and uninstall uses `--no-reload` or a + stand-in systemctl. diff --git a/agents/dewey/work/queue-83/build.patch b/agents/dewey/work/queue-83/build.patch new file mode 100644 index 000000000..2446ffdb4 --- /dev/null +++ b/agents/dewey/work/queue-83/build.patch @@ -0,0 +1,366 @@ +diff --git a/docs/TOOLS.md b/docs/TOOLS.md +index 631e1814..89c85715 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 5ac12d95..4a7cf535 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 d50b6652..da440d6b 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 4ff06833..944bb97a 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 <, where scripts/webui-service-base.sh is the +// base script and scripts/webui-service.sh the candidate. Nothing here reaches +// the real systemd: install and uninstall run with --no-reload, status with a +// stand-in systemctl. +import { spawnSync } from 'node:child_process'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +const WT = process.argv[2]; +const BASE = join(WT, 'scripts', 'webui-service-base.sh'); +const CAND = join(WT, '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 scratch = mkdtempSync(join(tmpdir(), 'r83-identity-')); +const bin = join(scratch, 'bin'); +mkdirSync(bin); +writeFileSync(join(bin, 'systemctl'), '#!/bin/sh\ncase "$*" in *is-enabled*) echo enabled ;; *is-active*) echo inactive ;; esac\nexit 0\n'); +chmodSync(join(bin, 'systemctl'), 0o755); + +const set = (flag, value) => { + const preview = PREVIEW.slice(); + const i = preview.indexOf(flag); + if (i >= 0) { preview[i + 1] = value; return preview; } + return [flag, value]; +}; +const U = join(scratch, 'units'); +const cases = [ + [], ['--port', '7340', '--business', 'mosaic.dev_1', '--board', 'http://127.0.0.1:7331', ...PREVIEW], + PREVIEW, ['--business', 'mosaic'], [...PREVIEW, '--business', 'mosaic'], + PREVIEW.slice(0, 2), PREVIEW.slice(2, 6), [...PREVIEW.slice(0, 2), ...PREVIEW.slice(4, 6)], +].map((a) => ['render', ...a]); +for (const flag of ['--business', '--board', '--public-host', '--auth-header', '--allow-user']) { + for (const bad of [' x', '%n', '\n', '$HOME', '"', '\\', '|', '&']) { + cases.push(['render', ...set(flag, flag === '--board' ? `http://127.0.0.1:7331${bad}` : `a${bad}b`)]); + } +} +for (const port of ['0', '65536', '7a', '', '-1', '999999', '7332', '65535']) cases.push(['render', '--port', port]); +for (const args of [['--business', 'Mosaic'], ['--business', '-x'], ['--business', 'a'.repeat(65)], ['--board', 'https://127.0.0.1:7331'], ['--board', '127.0.0.1:7331'], set('--public-host', 'console.example.com:443'), set('--public-host', 'https://console.example.com'), set('--auth-header', 'X_uid')]) cases.push(['render', ...args]); +for (const args of [[], ['start'], ['stop'], ['enable'], ['restart'], ['render', '--bogus'], ['render', '--port'], ['render', '--dir', scratch], ['render', '--no-reload'], ['uninstall', '--port', '7332'], ['status', '--business', 'mosaic'], ['status', '--no-reload'], ['--help']]) cases.push(args); +// Install, then the same again (unchanged), a refusal over it, then uninstall. +cases.push(['install', '--dir', U, '--no-reload', '--business', 'mosaic'], ['install', '--dir', U, '--no-reload', '--business', 'mosaic'], ['install', '--dir', U, '--no-reload', '--business', 'a b'], ['install', '--dir', join(scratch, 'never'), '--no-reload', '--port', '0'], ['install', '--dir', U, '--no-reload', ...PREVIEW, '--port', '7340'], ['status', '--dir', U], ['uninstall', '--dir', U, '--no-reload'], ['uninstall', '--dir', U, '--no-reload'], ['status', '--dir', U]); + +const env = { PATH: `${bin}:${NODE_DIR}:/usr/bin:/bin`, HOME: scratch, XDG_CONFIG_HOME: join(scratch, 'config') }; +const files = (dir) => (existsSync(dir) ? readdirSync(dir).sort().map((f) => `${f}\n${readFileSync(join(dir, f), 'utf8')}`).join('\n') : ''); +// Each script runs the whole sequence from a clean scratch, so install state carries the same way. +function sequence(script) { + rmSync(U, { recursive: true, force: true }); + rmSync(join(scratch, 'never'), { recursive: true, force: true }); + return cases.map((args) => { + const r = spawnSync(script, args, { env, encoding: 'utf8' }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr, units: files(U), never: existsSync(join(scratch, 'never')) }; + }); +} +const base = sequence(BASE); +const cand = sequence(CAND); +const usageBase = readFileSync(BASE, 'utf8').match(/^ FLAGS: .*"$/m)[0].slice(0, -1); +const usageCand = readFileSync(CAND, 'utf8').match(/^ FLAGS: .*"$/m)[0].slice(0, -1); +// The install message's lines before its command list. +const message = (script) => { const s = readFileSync(script, 'utf8'); return s.slice(s.indexOf('cat < { + const b = base[i], c = cand[i]; + const label = JSON.stringify(args).replaceAll(scratch, '$S'); + if (JSON.stringify(b) === JSON.stringify(c)) { same++; return; } + const usageOnly = b.status === 4 && c.status === 4 && JSON.stringify({ ...b, stderr: b.stderr.replace(usageBase, usageCand) }) === JSON.stringify(c); + const helpOnly = args[0] === '--help' && JSON.stringify({ ...b, stdout: b.stdout.replace(usageBase, usageCand) }) === JSON.stringify(c); + const bl = b.stdout.split('\n'), cl = c.stdout.split('\n'); + const sendLineOnly = args[0] === 'status' && b.status === c.status && b.stderr === c.stderr && b.units === c.units + && JSON.stringify(cl.filter((l) => !/^send: off \(read-only\)$/.test(l))) === JSON.stringify(bl) && cl.length === bl.length + 1; + const messageOnly = args[0] === 'install' && b.status === 0 && msgBase !== msgCand && b.stdout.includes(msgBase) && JSON.stringify({ ...b, stdout: b.stdout.replace(msgBase, msgCand) }) === JSON.stringify(c); + if (messageOnly) { expected++; console.log(`start wording only ${label}`); return; } + if (usageOnly || helpOnly) { expected++; console.log(`usage text only ${label}`); return; } + if (sendLineOnly) { expected++; console.log(`send: line only ${label}: + ${cl.find((l) => l.startsWith('send:'))}`); return; } + unexpected++; + console.log(`DIFFERS ${label}\n base ${JSON.stringify(b)}\n cand ${JSON.stringify(c)}`); +}); +console.log(`${cases.length} cases: ${same} identical, ${expected} expected differences, ${unexpected} unexpected`); +rmSync(scratch, { recursive: true, force: true }); +process.exitCode = unexpected ? 1 : 0; diff --git a/agents/dewey/work/queue-83/identity.txt b/agents/dewey/work/queue-83/identity.txt new file mode 100644 index 000000000..bcffcc0d0 --- /dev/null +++ b/agents/dewey/work/queue-83/identity.txt @@ -0,0 +1,18 @@ +usage text only [] +usage text only ["start"] +usage text only ["stop"] +usage text only ["enable"] +usage text only ["restart"] +usage text only ["render","--bogus"] +usage text only ["render","--port"] +usage text only ["render","--dir","$S"] +usage text only ["render","--no-reload"] +usage text only ["uninstall","--port","7332"] +usage text only ["status","--business","mosaic"] +usage text only ["status","--no-reload"] +usage text only ["--help"] +start wording only ["install","--dir","$S/units","--no-reload","--business","mosaic"] +start wording only ["install","--dir","$S/units","--no-reload","--business","mosaic"] +start wording only ["install","--dir","$S/units","--no-reload","--public-host","console.example.com","--auth-header","X-authentik-uid","--allow-user","jason","--port","7340"] +send: line only ["status","--dir","$S/units"]: + send: off (read-only) +86 cases: 69 identical, 17 expected differences, 0 unexpected diff --git a/agents/dewey/work/queue-83/mutants.py b/agents/dewey/work/queue-83/mutants.py new file mode 100644 index 000000000..115e79789 --- /dev/null +++ b/agents/dewey/work/queue-83/mutants.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# Dewey row 83 mutants: single-site edits to scripts/webui-service.sh in a mutant worktree (never +# the build tree). Each mutant runs against the base tests (d13b9300's service.test.mjs, copied in +# as tests/base-service.test.mjs) and the candidate tests (tests/service.test.mjs), then +# identity.mjs. The candidate is uncommitted there, so the script is restored from the copy read +# before the edit, not from git. +# Usage: mutants.py [mutant ...]. A site that doesn't match exactly once is BAD-SITE. +import os, subprocess, sys + +R = os.path.expanduser('~/dewey-scratch/r83') +T = R + '/mut' +OUT = R + '/out/mut' +S = 'scripts/webui-service.sh' +BASE_TESTS = ['node', '--test', 'packages/webui/tests/base-service.test.mjs'] +CAND_TESTS = ['node', '--test', 'packages/webui/tests/service.test.mjs'] +IDENTITY = ['node', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'identity.mjs'), T] + +CHECK = ' [ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user)"\n' +APPEND = ' [ "$SEND" = 0 ] || flags="$flags --send"\n' +CASE = ' --send) SEND=1; SERVE_SET=1; shift ;;\n' +STATUS_IF = """ if grep -q '^ExecStart=.* --allow-user [^ ][^ ]* --send$' "$target"; then\n""" +STATUS_BLOCK = STATUS_IF + """ echo "send: on (--send; human messages to roles)" + else + echo "send: off (read-only)" + fi +""" +MUTANTS = [ + # name, edits [(old, new)] + ('send-no-preview-check', [(CHECK, '')]), + ('send-needs-any-preview', [(CHECK, CHECK.replace('-eq 3', '-ne 0'))]), + ('send-message-reworded', [(CHECK, CHECK.replace('needs the preview mode', 'needs the preview set'))]), + ('send-no-append', [(APPEND, '')]), + ('send-append-always', [(APPEND, ' flags="$flags --send"\n')]), + ('send-append-before-preview', [(APPEND, ''), (' local preview=0\n', APPEND + ' local preview=0\n')]), + ('send-default-on', [('SEND=0\n', 'SEND=1\n')]), + ('send-no-serve-set', [(CASE, CASE.replace(' SERVE_SET=1;', ''))]), + ('send-takes-value', [(CASE, ' --send) [ $# -ge 2 ] || usage; SEND=1; SERVE_SET=1; shift 2 ;;\n')]), + ('send-flag-unknown', [(CASE, '')]), + ('status-send-always-off', [(STATUS_IF, ' if false; then\n')]), + ('status-send-always-on', [(STATUS_IF, ' if true; then\n')]), + ('status-no-send-line', [(STATUS_BLOCK, '')]), + ('status-send-anywhere', [(STATUS_IF, """ if grep -q -- '--send' "$target"; then\n""")]), + ('status-send-suffix-only', [(STATUS_IF, """ if grep -q '^ExecStart=.* --send$' "$target"; then\n""")]), +] + +def run(cmd): + try: + p = subprocess.run(cmd, cwd=T, capture_output=True, text=True, env={**os.environ, 'TMPDIR': R + '/tmp'}, timeout=600) + except subprocess.TimeoutExpired: + return 'TIMEOUT', 'timed out after 600 s' + return p.returncode, p.stdout + p.stderr + +def verdict(rc): + return 'survived' if rc == 0 else ('TIMEOUT' if rc == 'TIMEOUT' else 'killed') + +os.makedirs(OUT, exist_ok=True) +os.makedirs(R + '/tmp', exist_ok=True) +path = os.path.join(T, S) +pristine = open(path).read() +for label, cmd in (('base tests', BASE_TESTS), ('candidate tests', CAND_TESTS), ('identity', IDENTITY)): + rc, out = run(cmd) + open(f'{OUT}/baseline-{label.replace(" ", "-")}.txt', 'w').write(out) + print(f'baseline {label}:', 'pass' if rc == 0 else f'FAIL rc={rc}', flush=True) +want = sys.argv[1:] +results = [] +for name, edits in MUTANTS: + if want and name not in want: + continue + bad = [old for old, _ in edits if pristine.count(old) != 1] + if bad: + results.append(f'{name} BAD-SITE ({[pristine.count(o) for o in bad]} matches)') + print(results[-1], flush=True) + continue + mutated = pristine + for old, new in edits: + mutated = mutated.replace(old, new) + try: + open(path, 'w').write(mutated) + b_rc, b_out = run(BASE_TESTS) + c_rc, c_out = run(CAND_TESTS) + i_rc, i_out = run(IDENTITY) + finally: + open(path, 'w').write(pristine) + open(f'{OUT}/{name}.txt', 'w').write(f'== base tests rc={b_rc}\n{b_out}\n== candidate tests rc={c_rc}\n{c_out}\n== identity rc={i_rc}\n{i_out}') + results.append(f'{name:28} base tests {verdict(b_rc):8} candidate tests {verdict(c_rc):8} identity {"same" if i_rc == 0 else "differs"}') + print(results[-1], flush=True) +assert open(path).read() == pristine, 'script not restored' +killed = sum(1 for r in results if 'candidate tests killed' in r) +print(f'{len(results)} mutants: candidate tests kill {killed}', flush=True) diff --git a/agents/dewey/work/queue-83/mutants.txt b/agents/dewey/work/queue-83/mutants.txt new file mode 100644 index 000000000..80fd44e7a --- /dev/null +++ b/agents/dewey/work/queue-83/mutants.txt @@ -0,0 +1,19 @@ +baseline base tests: pass +baseline candidate tests: pass +baseline identity: pass +send-no-preview-check base tests survived candidate tests killed identity same +send-needs-any-preview base tests survived candidate tests killed identity same +send-message-reworded base tests survived candidate tests killed identity same +send-no-append base tests survived candidate tests killed identity same +send-append-always base tests killed candidate tests killed identity differs +send-append-before-preview base tests survived candidate tests killed identity same +send-default-on base tests killed candidate tests killed identity differs +send-no-serve-set base tests survived candidate tests killed identity same +send-takes-value base tests survived candidate tests killed identity same +send-flag-unknown base tests survived candidate tests killed identity same +status-send-always-off base tests survived candidate tests killed identity same +status-send-always-on base tests survived candidate tests killed identity differs +status-no-send-line base tests survived candidate tests killed identity same +status-send-anywhere base tests survived candidate tests killed identity same +status-send-suffix-only base tests survived candidate tests killed identity same +15 mutants: candidate tests kill 15