review(rocko): row 75 round 1 packet, the Console as a user service (#1569)
Committed by sage for rocko (no credential). Candidate manifest 7dc571db, packet manifest 0bc9a870, base 43d2328f.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,115 @@
|
|||||||
|
# Row 75 (#1569): Console as a user service, candidate packet, round 1
|
||||||
|
|
||||||
|
Author: Rocko. Reviewer: Filbert. Brief: `docs/plans/2026-10-11_dogfood-cutover.md`,
|
||||||
|
section "Console as a user service" (blob 1b124173 at 57f36a96; the rev 501
|
||||||
|
re-pin to blob f5d56554 changes only row 76's section). Lead decisions 94 and
|
||||||
|
95. Base: `43d2328f` (`base.txt`). At 2026-10-11T22:32Z the checkout HEAD
|
||||||
|
`2d04426c` has no change to any candidate path since the base, and
|
||||||
|
`build.patch` applies to it cleanly. The packet is uncommitted. I made no
|
||||||
|
commits, pushes or Gitea calls, read no token or private binding, and
|
||||||
|
started no unit.
|
||||||
|
|
||||||
|
## Files (`files.txt`, `candidate-manifest.sha256`)
|
||||||
|
|
||||||
|
| File | What |
|
||||||
|
|---|---|
|
||||||
|
| `scripts/webui-service.sh` (new, 0755) | `render`, `install [--dir DIR] [--no-reload]`, `uninstall [--dir DIR] [--no-reload]`, `status [--dir DIR]` on the `bus-service.sh` pattern; exit 0 ok, 1 failed, 4 usage |
|
||||||
|
| `packages/webui/systemd/mosaic-webui.service.in` (new) | the unit template |
|
||||||
|
| `packages/webui/tests/service.test.mjs` (new) | 10 tests |
|
||||||
|
| `docs/TOOLS.md` | one new section, "Console service (`scripts/webui-service.sh`)", before the Discord connector section; nothing else touched |
|
||||||
|
|
||||||
|
No `src/` file and no README. Row 74 owns those.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- The serve flags are `--port N` (default 7332, 1..65535), `--business ID`,
|
||||||
|
`--board URL` and the preview set `--public-host NAME --auth-header
|
||||||
|
HEADER --allow-user ID`, all three or none.
|
||||||
|
- Each value has its own plain character set:
|
||||||
|
- business: serve's ID pattern, `[a-z0-9][a-z0-9._-]{0,63}`.
|
||||||
|
- board: `http://` followed by `[A-Za-z0-9.:/_-]`. Serve checks the rest itself: a loopback origin with no path.
|
||||||
|
- public host: `[A-Za-z0-9.-]` with a leading alphanumeric, so no port and no scheme.
|
||||||
|
- header: `[A-Za-z0-9-]`.
|
||||||
|
- user: `[A-Za-z0-9._@+-]`.
|
||||||
|
- None of the sets allows a space, newline, `%`, `$`, quote, backslash, `&` or `|`. That means neither systemd nor the `sed` render can read a value as anything other than the value.
|
||||||
|
- Every refusal exits 1 and writes nothing. A refused `install` doesn't create the unit directory.
|
||||||
|
- `ExecStart=@NODE@ @REPO@/packages/webui/src/cli.mjs serve @FLAGS@`. NODE is
|
||||||
|
`command -v node` at render time, kept as given (here `/usr/bin/node`).
|
||||||
|
systemd doesn't look up an ExecStart command in `Environment=PATH`, so a bare
|
||||||
|
`node` would not be found the way the brief's wording suggests. The REPO
|
||||||
|
and node paths are held to `[A-Za-z0-9/._+-]`.
|
||||||
|
- `WorkingDirectory` and `PATH` are filled in as the bus unit fills them:
|
||||||
|
`/usr/local/bin:/usr/bin:/bin`, with node's directory prepended if it isn't
|
||||||
|
already there. The other settings are `Restart=on-failure`,
|
||||||
|
`RestartPreventExitStatus=2`, `KillSignal=SIGTERM`, `TimeoutStopSec=30`,
|
||||||
|
`NoNewPrivileges=yes` and `WantedBy=default.target`. There is no other
|
||||||
|
`Environment=` line and no `MOSAIC_*`, `CLAUDE*`, `CODEX*` or `PI_*` variable.
|
||||||
|
- `install` prints the `systemctl --user enable --now mosaic-webui` line for
|
||||||
|
the human to run. The script has no start, stop, enable or restart verb; the
|
||||||
|
header and TOOLS.md say why (lead decisions 62 and 94).
|
||||||
|
- `uninstall` refuses while `list-units` shows the unit active, activating or
|
||||||
|
deactivating, unless `--no-reload` is given, as in bus-service.
|
||||||
|
- `status` prints is-enabled, is-active and `show`, then reads `--port` from the
|
||||||
|
installed unit's ExecStart and prints `GET http://127.0.0.1:<port>/healthz`:
|
||||||
|
the status code and body, or `unreachable (<code>)`. It exits 0 either way,
|
||||||
|
as bus-service status does.
|
||||||
|
|
||||||
|
## Points for the reviewer
|
||||||
|
|
||||||
|
1. **Exit 2 isn't retried.** `serve` exits 2 for usage, config and listen errors,
|
||||||
|
EADDRINUSE included (`serve.mjs` lines 149–150 reject, `cli.mjs` sets
|
||||||
|
exitCode 2). With `RestartPreventExitStatus=2`, a port conflict stops the
|
||||||
|
unit rather than looping. I chose that on purpose, and the template comment
|
||||||
|
says so. Serve has no separate config exit code: a missing system config
|
||||||
|
still serves, and the bus routes answer not-configured.
|
||||||
|
2. **The preview flags need row 74.** Until row 74 lands, a unit rendered with
|
||||||
|
the preview set makes `serve` refuse with exit 2 (`unknown option`) and not
|
||||||
|
restart. Rendering and installing are unaffected.
|
||||||
|
3. **Serve runs under systemd.** It runs as written. The user manager's
|
||||||
|
environment (`systemctl --user show-environment`) carries none of the
|
||||||
|
human.mjs markers, and the ancestry walk accepts at ppid 1. That is the gap
|
||||||
|
lead decisions 62 and 94 name, and it's why only Jason starts the unit. I did
|
||||||
|
not start it to prove this; `systemd-analyze --user verify` on the rendered
|
||||||
|
unit exits 0.
|
||||||
|
4. **An empty value counts as unset.** `--board ""` and `--business ""` are
|
||||||
|
treated as unset, the way bus-service treats an empty value. An empty member
|
||||||
|
of the preview set counts as missing, so a set with one empty member is
|
||||||
|
refused as partial.
|
||||||
|
|
||||||
|
## Tests (`packages/webui/tests/service.test.mjs`)
|
||||||
|
|
||||||
|
The brief asks for six things:
|
||||||
|
|
||||||
|
- render with and without the preview flags;
|
||||||
|
- a partial set refused, in all three two-of-three shapes;
|
||||||
|
- space, `%`, newline, `$`, quote, backslash, `|` and `&` refused for each value flag, plus bad ports, business ids, boards, hosts and headers;
|
||||||
|
- the rendered unit names none of the `MARKERS` that the test parses from `packages/bus/src/human.mjs`, and its only Environment key is PATH;
|
||||||
|
- install into `--dir` with `--no-reload` writes the file whole and identical to `render`, at mode 0644, with no temp file left and no systemctl call; a second install reports unchanged; a refused install leaves the installed unit alone;
|
||||||
|
- uninstall refuses for active, activating and deactivating through a stand-in `systemctl` on PATH, then removes the unit once it is inactive.
|
||||||
|
|
||||||
|
I added three tests beyond the brief:
|
||||||
|
|
||||||
|
- install without `--no-reload` calls only `daemon-reload`;
|
||||||
|
- status against an in-process `/healthz` server on port 0, then with the server down, then with no unit installed;
|
||||||
|
- usage exits 4, including the `start`, `stop`, `enable` and `restart` verbs.
|
||||||
|
|
||||||
|
No test reaches the real systemd or spawns `human-cli.mjs`. `XDG_CONFIG_HOME` and `HOME` point into a temp directory.
|
||||||
|
|
||||||
|
## Gate (`out/gate/`, sequential, scratch worktree at 43d2328f with the patch, `node_modules` linked)
|
||||||
|
|
||||||
|
| Suite | Result |
|
||||||
|
|---|---|
|
||||||
|
| webui `node --test 'packages/webui/tests/*.test.mjs'` | 50/50 (service: 10/10) |
|
||||||
|
| test-auth | 15 passed, 0 failed |
|
||||||
|
| test-conductor | 17 passed, 0 failed |
|
||||||
|
| test-config | 24 passed, 0 failed |
|
||||||
|
| test-discord | 66 passed, 0 failed (node 178 pass) |
|
||||||
|
| test-extension-package | 18 passed, 0 failed |
|
||||||
|
| test-foundation | 44 passed, 0 failed |
|
||||||
|
| test-queue | 27 passed, 0 failed |
|
||||||
|
| test-release | 14 passed, 0 failed |
|
||||||
|
| test-task | 98 passed, 0 failed (Docker) |
|
||||||
|
|
||||||
|
After the gate I removed the `rocko-r75_default` Docker network.
|
||||||
|
`out/gate/start.txt` and `out/gate/end.txt` hold the times. shellcheck isn't
|
||||||
|
installed on dragon-lin; `bash -n` passes.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
43d2328f3a413b066f435c13cacd4df462670c57
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
|
||||||
|
index 9a28a090..631e1814 100644
|
||||||
|
--- a/docs/TOOLS.md
|
||||||
|
+++ b/docs/TOOLS.md
|
||||||
|
@@ -238,6 +238,37 @@ stdin and stdout it exits 4 with the usage.
|
||||||
|
Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
|
||||||
|
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
|
||||||
|
|
||||||
|
+## Console service (`scripts/webui-service.sh`)
|
||||||
|
+
|
||||||
|
+```bash
|
||||||
|
+scripts/webui-service.sh render [FLAGS] # print the unit
|
||||||
|
+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]
|
||||||
|
+```
|
||||||
|
+
|
||||||
|
+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
|
||||||
|
+`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
|
||||||
|
+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
|
||||||
|
+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`.
|
||||||
|
+Exit codes: `0` ok · `1` failed or refused · `4` usage.
|
||||||
|
+
|
||||||
|
## Discord connector (`scripts/discord.sh`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
diff --git a/packages/webui/systemd/mosaic-webui.service.in b/packages/webui/systemd/mosaic-webui.service.in
|
||||||
|
new file mode 100644
|
||||||
|
index 00000000..081bd963
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/packages/webui/systemd/mosaic-webui.service.in
|
||||||
|
@@ -0,0 +1,35 @@
|
||||||
|
+# Mosaic Console: `node packages/webui/src/cli.mjs serve`, one instance.
|
||||||
|
+# Rendered by scripts/webui-service.sh from packages/webui/systemd/; @REPO@,
|
||||||
|
+# @PATH@, @NODE@ and the serve flags below are filled in at install time.
|
||||||
|
+# Edit the template and reinstall; do not edit the installed copy.
|
||||||
|
+#
|
||||||
|
+# The Console reads the bus through the human transport, so whoever starts
|
||||||
|
+# this unit holds the human's capability. Jason starts it from his own
|
||||||
|
+# shell (`systemctl --user enable --now mosaic-webui`); an agent starting
|
||||||
|
+# it works around the human proof (lead decisions 62 and 94). The unit sets
|
||||||
|
+# no MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.
|
||||||
|
+[Unit]
|
||||||
|
+Description=Mosaic Console
|
||||||
|
+Documentation=file://@REPO@/packages/webui/README.md
|
||||||
|
+StartLimitIntervalSec=600
|
||||||
|
+StartLimitBurst=5
|
||||||
|
+
|
||||||
|
+[Service]
|
||||||
|
+Type=simple
|
||||||
|
+WorkingDirectory=@REPO@
|
||||||
|
+Environment=PATH=@PATH@
|
||||||
|
+# The server binds 127.0.0.1 only. Usage, config and listen refusals
|
||||||
|
+# (a port already in use included) exit 2 and are not retried; fix the
|
||||||
|
+# flags or free the port, then start it again.
|
||||||
|
+ExecStart=@NODE@ @REPO@/packages/webui/src/cli.mjs serve @FLAGS@
|
||||||
|
+Restart=on-failure
|
||||||
|
+RestartSec=15
|
||||||
|
+RestartPreventExitStatus=2
|
||||||
|
+# `systemctl --user stop` sends SIGTERM; the server stops accepting and
|
||||||
|
+# closes. Open connections that outlast TimeoutStopSec are killed.
|
||||||
|
+KillSignal=SIGTERM
|
||||||
|
+TimeoutStopSec=30
|
||||||
|
+NoNewPrivileges=yes
|
||||||
|
+
|
||||||
|
+[Install]
|
||||||
|
+WantedBy=default.target
|
||||||
|
diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs
|
||||||
|
new file mode 100644
|
||||||
|
index 00000000..d50b6652
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/packages/webui/tests/service.test.mjs
|
||||||
|
@@ -0,0 +1,220 @@
|
||||||
|
+import { test } from 'node:test';
|
||||||
|
+import assert from 'node:assert/strict';
|
||||||
|
+import { spawn } from 'node:child_process';
|
||||||
|
+import { createServer } from 'node:http';
|
||||||
|
+import { once } from 'node:events';
|
||||||
|
+import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||||
|
+import { tmpdir } from 'node:os';
|
||||||
|
+import { dirname, join } from 'node:path';
|
||||||
|
+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.
|
||||||
|
+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'];
|
||||||
|
+
|
||||||
|
+function tmp(t) {
|
||||||
|
+ const dir = mkdtempSync(join(tmpdir(), 'webui-service-'));
|
||||||
|
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
+ return dir;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// A stand-in systemctl that prints `lines` for list-units and logs every call.
|
||||||
|
+function standIn(dir, lines = '') {
|
||||||
|
+ const bin = join(dir, 'bin');
|
||||||
|
+ mkdirSync(bin);
|
||||||
|
+ writeFileSync(join(dir, 'list-units.txt'), lines);
|
||||||
|
+ writeFileSync(join(bin, 'systemctl'), `#!/bin/sh
|
||||||
|
+echo "$*" >> "${dir}/calls.txt"
|
||||||
|
+case "$*" in
|
||||||
|
+ *list-units*) cat "${dir}/list-units.txt" ;;
|
||||||
|
+ *is-enabled*) echo enabled ;;
|
||||||
|
+ *is-active*) echo active ;;
|
||||||
|
+esac
|
||||||
|
+exit 0
|
||||||
|
+`);
|
||||||
|
+ chmodSync(join(bin, 'systemctl'), 0o755);
|
||||||
|
+ return bin;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// Async, so an in-process server can answer the script's /healthz request.
|
||||||
|
+async function run(dir, args, bin = null) {
|
||||||
|
+ const env = { PATH: `${bin ? `${bin}:` : ''}${NODE_DIR}:/usr/bin:/bin`, HOME: dir, XDG_CONFIG_HOME: join(dir, 'config') };
|
||||||
|
+ const child = spawn(SCRIPT, args, { env });
|
||||||
|
+ let stdout = '', stderr = '';
|
||||||
|
+ child.stdout.on('data', (d) => { stdout += d; });
|
||||||
|
+ child.stderr.on('data', (d) => { stderr += d; });
|
||||||
|
+ const [status] = await once(child, 'close');
|
||||||
|
+ return { status, stdout, stderr };
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+const execStart = (unit) => unit.split('\n').find((l) => l.startsWith('ExecStart='));
|
||||||
|
+
|
||||||
|
+test('render prints the unit without the preview flags', async (t) => {
|
||||||
|
+ const dir = tmp(t);
|
||||||
|
+ const r = await run(dir, ['render']);
|
||||||
|
+ assert.equal(r.status, 0, r.stderr);
|
||||||
|
+ assert.match(r.stdout, new RegExp(`^WorkingDirectory=${REPO.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'));
|
||||||
|
+ assert.match(r.stdout, /^Environment=PATH=\S*\/usr\/local\/bin:\/usr\/bin:\/bin$/m);
|
||||||
|
+ assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7332`);
|
||||||
|
+ for (const line of ['Restart=on-failure', 'RestartPreventExitStatus=2', 'NoNewPrivileges=yes', 'WantedBy=default.target']) {
|
||||||
|
+ assert.ok(r.stdout.split('\n').includes(line), line);
|
||||||
|
+ }
|
||||||
|
+ assert.doesNotMatch(r.stdout, /@[A-Z]+@/, 'every placeholder is filled');
|
||||||
|
+});
|
||||||
|
+
|
||||||
|
+test('render carries every serve flag, the preview set included', async (t) => {
|
||||||
|
+ const dir = tmp(t);
|
||||||
|
+ const r = await run(dir, ['render', '--port', '7340', '--business', 'mosaic.dev_1', '--board', 'http://127.0.0.1:7331', ...PREVIEW]);
|
||||||
|
+ assert.equal(r.status, 0, r.stderr);
|
||||||
|
+ assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 --public-host console.example.com --auth-header X-authentik-uid --allow-user jason`);
|
||||||
|
+});
|
||||||
|
+
|
||||||
|
+test('a partial preview set is refused', async (t) => {
|
||||||
|
+ const dir = tmp(t);
|
||||||
|
+ for (const args of [PREVIEW.slice(0, 2), PREVIEW.slice(2, 6), [...PREVIEW.slice(0, 2), ...PREVIEW.slice(4, 6)]]) {
|
||||||
|
+ const r = await run(dir, ['render', ...args]);
|
||||||
|
+ assert.equal(r.status, 1, args.join(' '));
|
||||||
|
+ assert.equal(r.stdout, '');
|
||||||
|
+ assert.match(r.stderr, /all three or none/);
|
||||||
|
+ }
|
||||||
|
+});
|
||||||
|
+
|
||||||
|
+test('a value the unit cannot carry is refused, for every flag', async (t) => {
|
||||||
|
+ const dir = tmp(t);
|
||||||
|
+ 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];
|
||||||
|
+ };
|
||||||
|
+ for (const flag of ['--business', '--board', '--public-host', '--auth-header', '--allow-user']) {
|
||||||
|
+ for (const bad of [' x', '%n', '\n', '$HOME', '"', '\\', '|', '&']) {
|
||||||
|
+ const value = flag === '--board' ? `http://127.0.0.1:7331${bad}` : `a${bad}b`;
|
||||||
|
+ const r = await run(dir, ['render', ...set(flag, value)]);
|
||||||
|
+ assert.equal(r.status, 1, `${flag} ${JSON.stringify(value)}`);
|
||||||
|
+ assert.equal(r.stdout, '', `${flag} ${JSON.stringify(value)}`);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ for (const port of ['0', '65536', '7a', '', '-1', '999999']) {
|
||||||
|
+ const r = await run(dir, ['render', '--port', port]);
|
||||||
|
+ assert.equal(r.status, 1, `port ${JSON.stringify(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')]) {
|
||||||
|
+ const r = await run(dir, ['render', ...args]);
|
||||||
|
+ assert.equal(r.status, 1, args.join(' '));
|
||||||
|
+ }
|
||||||
|
+});
|
||||||
|
+
|
||||||
|
+test('the rendered unit names none of the human proof agent markers and sets no agent variable', async (t) => {
|
||||||
|
+ const dir = tmp(t);
|
||||||
|
+ 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']]) {
|
||||||
|
+ 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);
|
||||||
|
+ const envLines = r.stdout.split('\n').filter((l) => /^\s*(Environment|EnvironmentFile|PassEnvironment|UnsetEnvironment)\s*=/.test(l));
|
||||||
|
+ assert.deepEqual(envLines.map((l) => l.split('=')[1]), ['PATH']);
|
||||||
|
+ assert.doesNotMatch(r.stdout, /^[^#]*\b(MOSAIC_|CLAUDE|CODEX|PI_)/m);
|
||||||
|
+ }
|
||||||
|
+});
|
||||||
|
+
|
||||||
|
+test('install into --dir with --no-reload writes the file whole, then reports unchanged', async (t) => {
|
||||||
|
+ const dir = tmp(t);
|
||||||
|
+ const unitDir = join(dir, 'units');
|
||||||
|
+ const bin = standIn(dir);
|
||||||
|
+ const rendered = await run(dir, ['render', '--business', 'mosaic']);
|
||||||
|
+ const r = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--business', 'mosaic'], bin);
|
||||||
|
+ assert.equal(r.status, 0, r.stderr);
|
||||||
|
+ const target = join(unitDir, 'mosaic-webui.service');
|
||||||
|
+ assert.match(r.stdout, new RegExp(`^written: ${target}$`, 'm'));
|
||||||
|
+ assert.match(r.stdout, /systemctl --user enable --now mosaic-webui/);
|
||||||
|
+ assert.equal(readFileSync(target, 'utf8'), rendered.stdout);
|
||||||
|
+ assert.equal(statSync(target).mode & 0o777, 0o644);
|
||||||
|
+ assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service'], 'no temp file left behind');
|
||||||
|
+ const again = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--business', 'mosaic'], bin);
|
||||||
|
+ assert.equal(again.status, 0, again.stderr);
|
||||||
|
+ assert.match(again.stdout, new RegExp(`^unchanged: ${target}$`, 'm'));
|
||||||
|
+ assert.ok(!existsSync(join(dir, 'calls.txt')), 'no systemctl call with --no-reload');
|
||||||
|
+ // A refused value writes nothing and leaves the installed unit alone.
|
||||||
|
+ const bad = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--business', 'a b'], bin);
|
||||||
|
+ assert.equal(bad.status, 1);
|
||||||
|
+ assert.equal(readFileSync(target, 'utf8'), rendered.stdout);
|
||||||
|
+ assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']);
|
||||||
|
+ const fresh = join(dir, 'never');
|
||||||
|
+ assert.equal((await run(dir, ['install', '--dir', fresh, '--no-reload', '--port', '0'], bin)).status, 1);
|
||||||
|
+ assert.ok(!existsSync(fresh), 'a refusal creates no directory');
|
||||||
|
+});
|
||||||
|
+
|
||||||
|
+test('install without --no-reload runs daemon-reload and nothing that starts the unit', async (t) => {
|
||||||
|
+ const dir = tmp(t);
|
||||||
|
+ const bin = standIn(dir);
|
||||||
|
+ const r = await run(dir, ['install', '--dir', join(dir, 'units')], bin);
|
||||||
|
+ assert.equal(r.status, 0, r.stderr);
|
||||||
|
+ assert.match(r.stdout, /^daemon-reload done$/m);
|
||||||
|
+ assert.equal(readFileSync(join(dir, 'calls.txt'), 'utf8'), '--user daemon-reload\n');
|
||||||
|
+});
|
||||||
|
+
|
||||||
|
+test('uninstall refuses while the unit is active, and removes it once it is not', async (t) => {
|
||||||
|
+ const dir = tmp(t);
|
||||||
|
+ const unitDir = join(dir, 'units');
|
||||||
|
+ const target = join(unitDir, 'mosaic-webui.service');
|
||||||
|
+ for (const state of ['active', 'activating', 'deactivating']) {
|
||||||
|
+ const bin = standIn(mkdtempSync(join(dir, 'si-')), `mosaic-webui.service loaded ${state} running Mosaic Console\n`);
|
||||||
|
+ assert.equal((await run(dir, ['install', '--dir', unitDir, '--no-reload'])).status, 0);
|
||||||
|
+ const r = await run(dir, ['uninstall', '--dir', unitDir], bin);
|
||||||
|
+ assert.equal(r.status, 1, state);
|
||||||
|
+ assert.match(r.stderr, /refusing: mosaic-webui\.service is still running/);
|
||||||
|
+ assert.ok(existsSync(target), `still installed while ${state}`);
|
||||||
|
+ }
|
||||||
|
+ const bin = standIn(mkdtempSync(join(dir, 'si-')), 'mosaic-webui.service loaded inactive dead Mosaic Console\n');
|
||||||
|
+ const r = await run(dir, ['uninstall', '--dir', unitDir], bin);
|
||||||
|
+ assert.equal(r.status, 0, r.stderr);
|
||||||
|
+ assert.match(r.stdout, new RegExp(`^removed: ${target}$`, 'm'));
|
||||||
|
+ assert.match(r.stdout, /^daemon-reload done$/m);
|
||||||
|
+ assert.ok(!existsSync(target));
|
||||||
|
+ const absent = await run(dir, ['uninstall', '--dir', unitDir, '--no-reload'], bin);
|
||||||
|
+ assert.equal(absent.status, 0);
|
||||||
|
+ assert.match(absent.stdout, /^absent: /m);
|
||||||
|
+});
|
||||||
|
+
|
||||||
|
+test('status prints the unit state and GET /healthz on the installed port', async (t) => {
|
||||||
|
+ const dir = tmp(t);
|
||||||
|
+ const unitDir = join(dir, 'units');
|
||||||
|
+ const bin = standIn(dir);
|
||||||
|
+ const server = createServer((req, res) => {
|
||||||
|
+ if (req.url === '/healthz') { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}\n'); return; }
|
||||||
|
+ res.writeHead(404); res.end();
|
||||||
|
+ });
|
||||||
|
+ server.listen(0, '127.0.0.1');
|
||||||
|
+ await once(server, 'listening');
|
||||||
|
+ t.after(() => server.close());
|
||||||
|
+ const { port } = server.address();
|
||||||
|
+ assert.equal((await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port)])).status, 0);
|
||||||
|
+ const r = await run(dir, ['status', '--dir', unitDir], bin);
|
||||||
|
+ assert.equal(r.status, 0, r.stderr);
|
||||||
|
+ assert.match(r.stdout, /^ {2}enabled: enabled$/m);
|
||||||
|
+ assert.match(r.stdout, /^ {2}active: {2}active$/m);
|
||||||
|
+ assert.match(r.stdout, new RegExp(`^healthz: http://127\\.0\\.0\\.1:${port}/healthz 200 \\{"ok":true\\}$`, 'm'));
|
||||||
|
+ server.close();
|
||||||
|
+ await once(server, 'close');
|
||||||
|
+ const down = await run(dir, ['status', '--dir', unitDir], bin);
|
||||||
|
+ assert.equal(down.status, 0, down.stderr);
|
||||||
|
+ assert.match(down.stdout, /^healthz: \S+ unreachable \(ECONNREFUSED\)$/m);
|
||||||
|
+ const none = await run(dir, ['status', '--dir', join(dir, 'empty')], bin);
|
||||||
|
+ assert.match(none.stdout, /^healthz: not checked; no unit at /m);
|
||||||
|
+});
|
||||||
|
+
|
||||||
|
+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']]) {
|
||||||
|
+ const r = await run(dir, args);
|
||||||
|
+ assert.equal(r.status, 4, args.join(' '));
|
||||||
|
+ assert.match(r.stderr, /^usage: scripts\/webui-service\.sh/);
|
||||||
|
+ }
|
||||||
|
+});
|
||||||
|
diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh
|
||||||
|
new file mode 100755
|
||||||
|
index 00000000..4ff06833
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/scripts/webui-service.sh
|
||||||
|
@@ -0,0 +1,208 @@
|
||||||
|
+#!/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
|
||||||
|
+# 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]
|
||||||
|
+# write the unit (write to a temp file, then
|
||||||
|
+# 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
|
||||||
|
+#
|
||||||
|
+# 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
|
||||||
|
+# 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`.
|
||||||
|
+#
|
||||||
|
+# 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]"
|
||||||
|
+
|
||||||
|
+die() { echo "webui-service: $*" >&2; exit 1; }
|
||||||
|
+usage() { echo "$USAGE" >&2; exit 4; }
|
||||||
|
+
|
||||||
|
+UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||||
|
+RELOAD=1
|
||||||
|
+DIR_FLAGS=0
|
||||||
|
+PORT=7332
|
||||||
|
+BUSINESS=""
|
||||||
|
+BOARD=""
|
||||||
|
+PUBLIC_HOST=""
|
||||||
|
+AUTH_HEADER=""
|
||||||
|
+ALLOW_USER=""
|
||||||
|
+SERVE_SET=0
|
||||||
|
+
|
||||||
|
+parse_flags() {
|
||||||
|
+ while [ $# -gt 0 ]; do
|
||||||
|
+ case "$1" in
|
||||||
|
+ --dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; DIR_FLAGS=1; shift 2 ;;
|
||||||
|
+ --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 ;;
|
||||||
|
+ --board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;;
|
||||||
|
+ --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 ;;
|
||||||
|
+ *) usage ;;
|
||||||
|
+ esac
|
||||||
|
+ done
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+# The serve flags as one ExecStart fragment. Each character set excludes
|
||||||
|
+# space, %, $, quotes, backslash, & and |, so neither systemd nor the sed
|
||||||
|
+# below can read a value as anything but itself.
|
||||||
|
+serve_flags() {
|
||||||
|
+ case "$PORT" in ''|*[!0-9]*) die "port must be 1..65535: $PORT" ;; esac
|
||||||
|
+ [ "${#PORT}" -le 5 ] && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || die "port must be 1..65535: $PORT"
|
||||||
|
+ local flags="--port $PORT"
|
||||||
|
+ if [ -n "$BUSINESS" ]; then
|
||||||
|
+ case "$BUSINESS" in [a-z0-9]*) ;; *) die "business must be a business id: $BUSINESS" ;; esac
|
||||||
|
+ case "$BUSINESS" in *[!a-z0-9._-]*) die "business must be a business id: $BUSINESS" ;; esac
|
||||||
|
+ [ "${#BUSINESS}" -le 64 ] || die "business must be a business id: $BUSINESS"
|
||||||
|
+ flags="$flags --business $BUSINESS"
|
||||||
|
+ fi
|
||||||
|
+ if [ -n "$BOARD" ]; then
|
||||||
|
+ # serve checks the rest (a loopback origin, no path or credentials).
|
||||||
|
+ case "$BOARD" in http://?*) ;; *) die "board must be an http:// URL: $BOARD" ;; esac
|
||||||
|
+ case "$BOARD" in *[!A-Za-z0-9.:/_-]*) die "board contains a character the unit cannot carry: $BOARD" ;; esac
|
||||||
|
+ flags="$flags --board $BOARD"
|
||||||
|
+ fi
|
||||||
|
+ local preview=0
|
||||||
|
+ [ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
|
||||||
|
+ [ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
|
||||||
|
+ [ -z "$ALLOW_USER" ] || preview=$((preview + 1))
|
||||||
|
+ 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
|
||||||
|
+ 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 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
|
||||||
|
+ echo "$flags"
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+render() {
|
||||||
|
+ [ -f "$TEMPLATE" ] || die "template missing: $TEMPLATE"
|
||||||
|
+ local node
|
||||||
|
+ node="$(command -v node || true)"
|
||||||
|
+ [ -n "$node" ] || die "node not found on PATH"
|
||||||
|
+ # systemd does not look an ExecStart command up in Environment=PATH,
|
||||||
|
+ # so the unit runs node by the path found here, kept as given.
|
||||||
|
+ 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 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"
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+install_unit() {
|
||||||
|
+ parse_flags "$@"
|
||||||
|
+ local unit
|
||||||
|
+ unit="$(render)" || exit 1
|
||||||
|
+ mkdir -p "$UNIT_DIR"
|
||||||
|
+ local target="$UNIT_DIR/$UNIT_NAME" tmp
|
||||||
|
+ tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")"
|
||||||
|
+ printf '%s\n' "$unit" > "$tmp"
|
||||||
|
+ chmod 0644 "$tmp"
|
||||||
|
+ if [ -f "$target" ] && cmp -s "$tmp" "$target"; then
|
||||||
|
+ rm -f "$tmp"
|
||||||
|
+ echo "unchanged: $target"
|
||||||
|
+ else
|
||||||
|
+ mv -f "$tmp" "$target"
|
||||||
|
+ echo "written: $target"
|
||||||
|
+ fi
|
||||||
|
+ echo "serve: $(sed -n 's/^ExecStart=//p' "$target")"
|
||||||
|
+ if [ "$RELOAD" = 1 ]; then
|
||||||
|
+ systemctl --user daemon-reload || die "daemon-reload failed"
|
||||||
|
+ echo "daemon-reload done"
|
||||||
|
+ fi
|
||||||
|
+ cat <<MSG
|
||||||
|
+this script starts nothing: the Console holds the human's capability, so the
|
||||||
|
+human starts it from their own shell:
|
||||||
|
+ systemctl --user enable --now mosaic-webui start now and at login
|
||||||
|
+ systemctl --user status mosaic-webui
|
||||||
|
+ journalctl --user -u mosaic-webui -f the Console's log
|
||||||
|
+ systemctl --user stop mosaic-webui SIGTERM; restartable
|
||||||
|
+ scripts/webui-service.sh status unit state and /healthz
|
||||||
|
+survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
|
||||||
|
+MSG
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+uninstall_unit() {
|
||||||
|
+ parse_flags "$@"
|
||||||
|
+ [ "$SERVE_SET" = 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
|
||||||
|
+ if [ -f "$target" ]; then
|
||||||
|
+ rm -f "$target"
|
||||||
|
+ echo "removed: $target"
|
||||||
|
+ else
|
||||||
|
+ echo "absent: $target"
|
||||||
|
+ fi
|
||||||
|
+ if [ "$RELOAD" = 1 ]; then
|
||||||
|
+ systemctl --user daemon-reload || die "daemon-reload failed"
|
||||||
|
+ echo "daemon-reload done"
|
||||||
|
+ fi
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+status_unit() {
|
||||||
|
+ parse_flags "$@"
|
||||||
|
+ [ "$SERVE_SET" = 0 ] && [ "$RELOAD" = 1 ] || usage
|
||||||
|
+ local target="$UNIT_DIR/$UNIT_NAME"
|
||||||
|
+ echo "unit: $UNIT_NAME"
|
||||||
|
+ echo " enabled: $(systemctl --user is-enabled "$UNIT_NAME" 2>&1 || true)"
|
||||||
|
+ echo " active: $(systemctl --user is-active "$UNIT_NAME" 2>&1 || true)"
|
||||||
|
+ systemctl --user show "$UNIT_NAME" -p MainPID -p NRestarts -p ExecMainStartTimestamp -p Result 2>/dev/null | sed 's/^/ /' || true
|
||||||
|
+ if [ ! -f "$target" ]; then
|
||||||
|
+ echo "healthz: not checked; no unit at $target"
|
||||||
|
+ return 0
|
||||||
|
+ fi
|
||||||
|
+ local port
|
||||||
|
+ port="$(sed -n 's/^ExecStart=.* --port \([0-9][0-9]*\).*$/\1/p' "$target")"
|
||||||
|
+ [ -n "$port" ] || { echo "healthz: not checked; no --port in $target"; return 0; }
|
||||||
|
+ local url="http://127.0.0.1:$port/healthz"
|
||||||
|
+ echo "healthz: $url $(node -e '
|
||||||
|
+fetch(process.argv[1], { signal: AbortSignal.timeout(5000) })
|
||||||
|
+ .then(async (res) => console.log(`${res.status} ${(await res.text()).trim()}`))
|
||||||
|
+ .catch((err) => console.log(`unreachable (${err.cause?.code ?? err.name})`));
|
||||||
|
+' "$url")"
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+[ $# -ge 1 ] || usage
|
||||||
|
+cmd="$1"; shift
|
||||||
|
+case "$cmd" in
|
||||||
|
+ render)
|
||||||
|
+ parse_flags "$@"
|
||||||
|
+ [ "$DIR_FLAGS" = 0 ] || usage
|
||||||
|
+ render ;;
|
||||||
|
+ install) install_unit "$@" ;;
|
||||||
|
+ uninstall) uninstall_unit "$@" ;;
|
||||||
|
+ status) status_unit "$@" ;;
|
||||||
|
+ -h|--help) echo "$USAGE" ;;
|
||||||
|
+ *) usage ;;
|
||||||
|
+esac
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
12ab94c9541afa26c4d823fbae53712834b1cdcefbd8f1970fd29145eecef9ad docs/TOOLS.md
|
||||||
|
9c2eec77207baad825bb4ab6a7d2dac17d92cf62f1cf4ab173a4902b7c07002c packages/webui/systemd/mosaic-webui.service.in
|
||||||
|
9c1a6c8b2eeace79f6d061e67d97cfb22195d5fb1e1ea6040049de95c833cb20 packages/webui/tests/service.test.mjs
|
||||||
|
69fad4eb85c3e9297f6ebba2ec84d17d49a5ef028d1787e981f5af61fc9a8912 scripts/webui-service.sh
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
docs/TOOLS.md
|
||||||
|
packages/webui/systemd/mosaic-webui.service.in
|
||||||
|
packages/webui/tests/service.test.mjs
|
||||||
|
scripts/webui-service.sh
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
2026-10-11T22:32:03Z
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
2026-10-11T22:25:46Z
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
test-auth rc=0
|
||||||
|
test-conductor rc=0
|
||||||
|
test-config rc=0
|
||||||
|
test-discord rc=0
|
||||||
|
test-extension-package rc=0
|
||||||
|
test-foundation rc=0
|
||||||
|
test-queue rc=0
|
||||||
|
test-release rc=0
|
||||||
|
test-task rc=0
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
OK status with missing harness credential exits 3 and still lists accounts
|
||||||
|
OK status reports harness credential (read-only) + mosaic accounts
|
||||||
|
OK api key material never reaches output
|
||||||
|
OK oauth token material never reaches output
|
||||||
|
OK unparseable credential file exits 2
|
||||||
|
OK symlinked credential file exits 4
|
||||||
|
OK env-side credential names reported
|
||||||
|
OK env var values never reach output
|
||||||
|
OK accounts without an accounts dir reports none and creates nothing
|
||||||
|
OK accounts lists files and marks the active one
|
||||||
|
OK loose account perms flagged in listing
|
||||||
|
OK agent --auth with missing account file refuses (exit 4)
|
||||||
|
OK agent --auth with non-0600 account file refuses
|
||||||
|
OK agent --auth with invalid account name refuses
|
||||||
|
OK auth.sh without valid config refuses
|
||||||
|
|
||||||
|
selftest: 15 passed, 0 failed
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
Note: switching to '43d2328f3a413b066f435c13cacd4df462670c57'.
|
||||||
|
|
||||||
|
You are in 'detached HEAD' state. You can look around, make experimental
|
||||||
|
changes and commit them, and you can discard any commits you make in this
|
||||||
|
state without impacting any branches by switching back to a branch.
|
||||||
|
|
||||||
|
If you want to create a new branch to retain commits you create, you may
|
||||||
|
do so (now or later) by using -c with the switch command. Example:
|
||||||
|
|
||||||
|
git switch -c <new-branch-name>
|
||||||
|
|
||||||
|
Or undo this operation with:
|
||||||
|
|
||||||
|
git switch -
|
||||||
|
|
||||||
|
Turn off this advice by setting config variable advice.detachedHead to false
|
||||||
|
|
||||||
|
Not currently on any branch.
|
||||||
|
nothing to commit, working tree clean
|
||||||
|
Note: switching to '43d2328f3a413b066f435c13cacd4df462670c57'.
|
||||||
|
|
||||||
|
You are in 'detached HEAD' state. You can look around, make experimental
|
||||||
|
changes and commit them, and you can discard any commits you make in this
|
||||||
|
state without impacting any branches by switching back to a branch.
|
||||||
|
|
||||||
|
If you want to create a new branch to retain commits you create, you may
|
||||||
|
do so (now or later) by using -c with the switch command. Example:
|
||||||
|
|
||||||
|
git switch -c <new-branch-name>
|
||||||
|
|
||||||
|
Or undo this operation with:
|
||||||
|
|
||||||
|
git switch -
|
||||||
|
|
||||||
|
Turn off this advice by setting config variable advice.detachedHead to false
|
||||||
|
|
||||||
|
OK dry-run: allowed change, exit 0, nothing committed (exit 0)
|
||||||
|
OK dry-run committed nothing
|
||||||
|
OK apply: allowed change exits 0 (exit 0)
|
||||||
|
OK apply: attribution in commit subject
|
||||||
|
OK apply: target tree clean after commit
|
||||||
|
OK disallowed path refused (exit 1)
|
||||||
|
OK disallowed path: target untouched
|
||||||
|
OK syntax gate refused broken .mjs (exit 1)
|
||||||
|
OK syntax gate: target untouched
|
||||||
|
OK suite failure refused (exit 1)
|
||||||
|
OK suite failure: target reverted to clean
|
||||||
|
OK disabled policy refused (exit 2)
|
||||||
|
OK disabled policy: target untouched
|
||||||
|
OK failed run refused (exit 1)
|
||||||
|
OK failed run: target untouched
|
||||||
|
OK missing run exits 4 (exit 4)
|
||||||
|
OK invalid policy exits 2 (exit 2)
|
||||||
|
|
||||||
|
selftest: 17 passed, 0 failed
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
OK absent adapter defaults to pi
|
||||||
|
OK adapter mock validates (exit 0)
|
||||||
|
OK unsupported adapter exits 2 (exit 2)
|
||||||
|
OK env exports adapter
|
||||||
|
OK bootstrap creates default when absent (exit 0)
|
||||||
|
OK bootstrap wrote config file
|
||||||
|
OK bootstrap is idempotent on existing config (exit 0)
|
||||||
|
OK bootstrap did not rewrite existing config
|
||||||
|
OK validate missing config exits 3 (exit 3)
|
||||||
|
OK malformed JSON exits 2 (exit 2)
|
||||||
|
OK unsupported configVersion exits 2 (exit 2)
|
||||||
|
OK unknown top-level key exits 2 (exit 2)
|
||||||
|
OK unknown execution key exits 2 (exit 2)
|
||||||
|
OK unsupported backend exits 2 (exit 2)
|
||||||
|
OK unsupported environment exits 2 (exit 2)
|
||||||
|
OK relative dataRoot exits 2 (exit 2)
|
||||||
|
OK non-canonical dataRoot exits 2 (exit 2)
|
||||||
|
OK filesystem root dataRoot exits 2 (exit 2)
|
||||||
|
OK home directory dataRoot exits 2 (exit 2)
|
||||||
|
OK dataRoot containing config dir exits 2 (exit 2)
|
||||||
|
OK control character in provider exits 2 (exit 2)
|
||||||
|
OK symlinked config file exits 2 (exit 2)
|
||||||
|
OK env exports resolve correctly
|
||||||
|
OK failed validation modified nothing
|
||||||
|
|
||||||
|
selftest: 24 passed, 0 failed
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
toolchain: node v26.8.1
|
||||||
|
|
||||||
|
OK syntax: packages/discord/src/approvals.mjs
|
||||||
|
OK syntax: packages/discord/src/authorize.mjs
|
||||||
|
OK syntax: packages/discord/src/binding.mjs
|
||||||
|
OK syntax: packages/discord/src/cli.mjs
|
||||||
|
OK syntax: packages/discord/src/connector.mjs
|
||||||
|
OK syntax: packages/discord/src/context.mjs
|
||||||
|
OK syntax: packages/discord/src/engine-pi.mjs
|
||||||
|
OK syntax: packages/discord/src/errors.mjs
|
||||||
|
OK syntax: packages/discord/src/gateway.mjs
|
||||||
|
OK syntax: packages/discord/src/git.mjs
|
||||||
|
OK syntax: packages/discord/src/journal.mjs
|
||||||
|
OK syntax: packages/discord/src/notify.mjs
|
||||||
|
OK syntax: packages/discord/src/rest.mjs
|
||||||
|
OK syntax: packages/discord/src/setspark.mjs
|
||||||
|
OK syntax: packages/discord/src/tools.mjs
|
||||||
|
OK syntax: packages/discord/src/web.mjs
|
||||||
|
OK syntax: packages/discord/bin/git-credential.mjs
|
||||||
|
OK syntax: packages/discord/extension/tools.mjs
|
||||||
|
OK syntax: packages/discord/tests/approvals.test.mjs
|
||||||
|
OK syntax: packages/discord/tests/authorize.test.mjs
|
||||||
|
OK syntax: packages/discord/tests/binding.test.mjs
|
||||||
|
OK syntax: packages/discord/tests/connector.test.mjs
|
||||||
|
OK syntax: packages/discord/tests/context.test.mjs
|
||||||
|
OK syntax: packages/discord/tests/engine.test.mjs
|
||||||
|
OK syntax: packages/discord/tests/fake-pi.mjs
|
||||||
|
OK syntax: packages/discord/tests/gateway.test.mjs
|
||||||
|
OK syntax: packages/discord/tests/git.test.mjs
|
||||||
|
OK syntax: packages/discord/tests/helpers.mjs
|
||||||
|
OK syntax: packages/discord/tests/journal.test.mjs
|
||||||
|
OK syntax: packages/discord/tests/notify.test.mjs
|
||||||
|
OK syntax: packages/discord/tests/recover.test.mjs
|
||||||
|
OK syntax: packages/discord/tests/rest.test.mjs
|
||||||
|
OK syntax: packages/discord/tests/setspark.test.mjs
|
||||||
|
OK syntax: packages/discord/tests/tools.test.mjs
|
||||||
|
OK syntax: packages/discord/tests/web.test.mjs
|
||||||
|
OK syntax: packages/discord/fixtures/claim-worker.mjs
|
||||||
|
OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs
|
||||||
|
OK syntax: scripts/discord.sh
|
||||||
|
OK syntax: scripts/discord-service.sh
|
||||||
|
OK packages/discord declares no dependencies
|
||||||
|
OK no bot-token-shaped string in packages/discord
|
||||||
|
OK fixture binding uses placeholder ids only
|
||||||
|
OK fixture binding validates
|
||||||
|
OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool
|
||||||
|
OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start
|
||||||
|
OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root
|
||||||
|
OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token
|
||||||
|
OK real pi with protocol vault adds reserve_id to the git verbs
|
||||||
|
OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key
|
||||||
|
OK real pi refuses a git key on a read-only root (fail closed)
|
||||||
|
OK real pi with the pilot flags (--no-tools) exposes no tool at all
|
||||||
|
OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed)
|
||||||
|
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
|
||||||
|
OK node --test packages/discord/tests/ (ℹ pass 178)
|
||||||
|
OK scripts/discord.sh --help exits 0
|
||||||
|
OK scripts/discord.sh check without a binding exits 4
|
||||||
|
OK scripts/discord.sh recover without a binding exits 4
|
||||||
|
OK scripts/discord.sh reload without a binding exits 4
|
||||||
|
OK scripts/discord-service.sh without a command exits 4
|
||||||
|
OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP
|
||||||
|
OK service install writes the rendered unit (0644) and leaves no temp file
|
||||||
|
OK service install a second time reports unchanged
|
||||||
|
OK systemd-analyze verify accepts the rendered unit
|
||||||
|
OK service uninstall removes the unit file
|
||||||
|
OK service install with an unknown flag exits 4
|
||||||
|
OK service install with USER unset finishes and names the account for lingering
|
||||||
|
|
||||||
|
discord suite: 66 passed, 0 failed
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
OK initial ordinary-file install
|
||||||
|
OK installed tree matches canonical source
|
||||||
|
OK installed tree has no symlinks
|
||||||
|
OK check detects installation drift
|
||||||
|
OK sync refuses to overwrite installation drift
|
||||||
|
OK check detects an extra destination file
|
||||||
|
OK check detects an extra destination directory
|
||||||
|
OK check rejects a destination symlink
|
||||||
|
OK sync accepts a canonical source update
|
||||||
|
OK updated installation matches canonical source
|
||||||
|
scripts/test-extension-package.sh: line 14: 3009858 Killed "$@" > /dev/null 2>&1
|
||||||
|
OK forced interruption kills the replacing process
|
||||||
|
OK next invocation recovers old consistent installation
|
||||||
|
OK interrupted replacement rolled back
|
||||||
|
OK sync succeeds after interruption recovery
|
||||||
|
OK unlocked stale lock file does not block
|
||||||
|
OK active lock refuses a concurrent sync
|
||||||
|
OK source symlink fails closed
|
||||||
|
OK nested second entrypoint fails closed
|
||||||
|
|
||||||
|
extension package selftest: 18 passed, 0 failed
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
toolchain: node v26.8.1, python 3.12.8, jsonschema 4.26.0
|
||||||
|
|
||||||
|
OK syntax: scripts/foundation-inspect.mjs
|
||||||
|
OK syntax: scripts/foundation/strict-json.mjs
|
||||||
|
OK syntax: scripts/foundation/canonical.mjs
|
||||||
|
OK syntax: scripts/foundation/resolve.mjs
|
||||||
|
OK syntax: scripts/foundation/validate-record.mjs
|
||||||
|
OK syntax: scripts/foundation/fixtures/build-fixtures.mjs
|
||||||
|
OK syntax: scripts/foundation/canonical.test.mjs
|
||||||
|
OK syntax: scripts/foundation/cli.test.mjs
|
||||||
|
OK syntax: scripts/foundation/fixtures.test.mjs
|
||||||
|
OK syntax: scripts/foundation/resolve.test.mjs
|
||||||
|
OK syntax: scripts/foundation/strict-json.test.mjs
|
||||||
|
OK syntax: scripts/foundation/verify-schema.py (ast only; no bytecode written)
|
||||||
|
OK fixture generator runs
|
||||||
|
OK checked-in fixtures/bundles equal a fresh generation
|
||||||
|
OK checked-in fixtures/raw equal a fresh generation
|
||||||
|
OK checked-in fixtures/index.json equal a fresh generation
|
||||||
|
OK checked-in demo bundles equal a fresh generation
|
||||||
|
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
|
||||||
|
OK node --test scripts/foundation/ (ℹ pass 80)
|
||||||
|
OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof)
|
||||||
|
platform witness: strftime('%Y') for year 999 -> '999' (pinned checker refuses years 0001..0999)
|
||||||
|
node v26.8.1; corpus 1568 records (38 pinned fixtures, 478 unique bundle records, 1052 typeCase/mutation/lexical cases)
|
||||||
|
schema column: agree-valid 540, agree-invalid 991, DISAGREEMENTS 0; strict-only (parser-bound) cases: 27; unsupported-kind records not schema-assessed by the inspector: 10
|
||||||
|
profile column (schema-valid records only): profile-valid 510, profile-invalid 30
|
||||||
|
profile refusals asserted: 30 schema-agreed-valid records refused only by the strict typed-string profile (rule profile-pattern-mismatch), 12 declared by name; 73 named probes verified against declared schema/profile columns
|
||||||
|
OK oracle: zero schema-column disagreements with the pinned checker
|
||||||
|
OK oracle: strict-only profile refusals are counted and asserted
|
||||||
|
OK demo: permitted read preview exits 0 (exit 0)
|
||||||
|
OK demo: permitted file.change preview exits 0 (exit 0)
|
||||||
|
OK demo: assignment.change proposal is unresolved (exit 3) (exit 3)
|
||||||
|
OK demo: revoked registration is refused (exit 3) (exit 3)
|
||||||
|
OK demo: message is not authority (exit 3) (exit 3)
|
||||||
|
OK usage: no arguments exits 2 (exit 2)
|
||||||
|
OK io: missing file exits 4 (exit 4)
|
||||||
|
OK io: directory exits 4 (exit 4)
|
||||||
|
OK io: symlink exits 4 (O_NOFOLLOW) (exit 4)
|
||||||
|
OK bound: oversize fixture exits 2 (exit 2)
|
||||||
|
OK profile: one final LF in a typed selection id is refused before admission (exit 2) (exit 2)
|
||||||
|
OK profile: two final LFs fail the schema pattern itself (exit 2) (exit 2)
|
||||||
|
OK profile: escaped newlines in free-form text stay allowed (exit 0) (exit 0)
|
||||||
|
OK profile refusal is invalid-request/profile-pattern-mismatch with selection and operation withheld, value not echoed
|
||||||
|
OK text output starts with the disclaimer
|
||||||
|
OK json output is valid JSON with result allowed and exactly the charter §7 fields
|
||||||
|
OK json golden matches byte-for-byte
|
||||||
|
OK sandboxed bundle run (env -i, PATH=/nonexistent) produced the unresolved proposal
|
||||||
|
OK sandbox inventory (path/type/size/mode/uid/gid/inode/mtime/sha256) unchanged by runs
|
||||||
|
OK canary never printed (bundle run and credential-file run)
|
||||||
|
OK a non-bundle JSON file is refused at the shape gate, not read into output
|
||||||
|
OK no field of the non-bundle file is echoed
|
||||||
|
|
||||||
|
selftest: 44 passed, 0 failed
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
toolchain: node v26.8.1, git version 2.55.0
|
||||||
|
|
||||||
|
OK syntax: packages/queue/src/cli.mjs
|
||||||
|
OK syntax: packages/queue/src/errors.mjs
|
||||||
|
OK syntax: packages/queue/src/io.mjs
|
||||||
|
OK syntax: packages/queue/src/lock.mjs
|
||||||
|
OK syntax: packages/queue/src/queue.mjs
|
||||||
|
OK syntax: packages/queue/src/review.mjs
|
||||||
|
OK syntax: packages/queue/src/store.mjs
|
||||||
|
OK syntax: packages/queue/tests/commit.test.mjs
|
||||||
|
OK syntax: packages/queue/tests/data.test.mjs
|
||||||
|
OK syntax: packages/queue/tests/dispatch.test.mjs
|
||||||
|
OK syntax: packages/queue/tests/helpers.mjs
|
||||||
|
OK syntax: packages/queue/tests/lock.test.mjs
|
||||||
|
OK syntax: packages/queue/tests/migration.test.mjs
|
||||||
|
OK syntax: packages/queue/tests/review.test.mjs
|
||||||
|
OK syntax: packages/queue/tests/store.test.mjs
|
||||||
|
OK syntax: packages/queue/tests/write.test.mjs
|
||||||
|
OK syntax: packages/queue/tests/fixtures/fake-gitea.mjs
|
||||||
|
OK syntax: packages/queue/tests/fixtures/kill-at.mjs
|
||||||
|
OK syntax: packages/queue/tests/fixtures/lock-child.mjs
|
||||||
|
OK syntax: packages/queue/tests/fixtures/mosaic-pre-a2.sh
|
||||||
|
OK syntax: scripts/queue-commit.sh
|
||||||
|
OK syntax: scripts/git-hooks/pre-commit
|
||||||
|
OK syntax: scripts/mosaic
|
||||||
|
OK queue-commit.sh, the guard and scripts/mosaic are executable
|
||||||
|
OK packages/queue declares no dependencies
|
||||||
|
ℹ tests 150
|
||||||
|
ℹ pass 150
|
||||||
|
ℹ fail 0
|
||||||
|
OK node --test packages/queue/tests/
|
||||||
|
OK scripts/mosaic queue help
|
||||||
|
skip queue verify and render --check: this checkout (/mnt/storage/scratch/rocko-r75) is not the queue's canonical root (/mnt/storage/src/mosaic-stack)
|
||||||
|
|
||||||
|
queue suite: 27 passed, 0 failed
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
OK valid RELEASE resolves (exit 0)
|
||||||
|
OK invalid RELEASE exits 1 (exit 1)
|
||||||
|
OK missing RELEASE exits 1 (exit 1)
|
||||||
|
OK valid RELEASE leaves image tag consistent with version
|
||||||
|
OK status safe on empty state (exit 0)
|
||||||
|
OK status created no pointer
|
||||||
|
OK fault-injected activation refuses (exit 1)
|
||||||
|
OK refused activation wrote no pointer
|
||||||
|
OK refusal logged exactly once with valid fields
|
||||||
|
OK healthy activation succeeds (exit 0)
|
||||||
|
OK pointer written with valid fields
|
||||||
|
OK repeat activation succeeds (log grows) (exit 0)
|
||||||
|
OK log is append-only across activations
|
||||||
|
OK rollback without previous refuses (exit 1)
|
||||||
|
|
||||||
|
selftest: 14 passed, 0 failed
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
OK valid task validates (exit 0)
|
||||||
|
OK unknown task key exits 2 (exit 2)
|
||||||
|
OK unsupported taskVersion exits 2 (exit 2)
|
||||||
|
OK invalid task id exits 2 (exit 2)
|
||||||
|
OK empty prompt exits 2 (exit 2)
|
||||||
|
OK NUL in expectExact exits 2 (exit 2)
|
||||||
|
OK out-of-range timeout exits 2 (exit 2)
|
||||||
|
OK missing mission file exits 4 (exit 4)
|
||||||
|
OK task with valid mission validates (exit 0)
|
||||||
|
OK invalid mission exits 2 (exit 2)
|
||||||
|
OK validate missing task exits 4 (exit 4)
|
||||||
|
OK validation does not modify the task file
|
||||||
|
OK prune dry-run exits 0 (exit 0)
|
||||||
|
OK dry-run deleted nothing
|
||||||
|
OK prune --keep=2 --yes removes oldest (exit 0)
|
||||||
|
OK kept exactly 2 newest runs
|
||||||
|
OK newest run kept, oldest pruned
|
||||||
|
OK append-only receipt written (3 entries)
|
||||||
|
OK sessions/workspaces untouched by prune
|
||||||
|
OK prune with invalid keep exits 4 (exit 4)
|
||||||
|
OK mock adapter: gate passes on matching mock response (exit 0)
|
||||||
|
OK mock adapter: expect-mismatch recorded (exit 1)
|
||||||
|
OK mismatch reason recorded
|
||||||
|
OK unknown adapter fails closed (exit 1)
|
||||||
|
OK mission task runs via mock adapter (exit 0)
|
||||||
|
OK mission section injected into generated prompt
|
||||||
|
OK retry of mission run succeeds (exit 0)
|
||||||
|
OK retriedFrom lineage recorded
|
||||||
|
OK mission section present after retry (relative path resolved)
|
||||||
|
OK retry of missing run exits 4 (exit 4)
|
||||||
|
OK skill install bundled ms-tools (exit 0)
|
||||||
|
OK installed to skills-available
|
||||||
|
OK double install refuses (exit 1)
|
||||||
|
OK activate enables skill (exit 0)
|
||||||
|
OK enabled dir populated
|
||||||
|
OK uninstall while enabled refuses (exit 1)
|
||||||
|
OK deactivate moves back to available (exit 0)
|
||||||
|
OK uninstall removes from available (exit 0)
|
||||||
|
OK seat with enabled skill launches
|
||||||
|
OK skill path delivered to adapter
|
||||||
|
OK shipped researcher contract resolves (ceiling + network)
|
||||||
|
OK resolve-role refuses a non-role document (conductor policy) (exit 2)
|
||||||
|
OK resolve-role refuses name/filename mismatch (exit 2)
|
||||||
|
OK resolve-role refuses unknown network declaration (exit 2)
|
||||||
|
OK resolve-role refuses duplicate role tool (exit 2)
|
||||||
|
OK resolve-role refuses unsupported tool (exit 2)
|
||||||
|
OK resolve-role refuses missing contract file (exit 4)
|
||||||
|
OK version 1 role prints no contract line
|
||||||
|
OK shipped version 2 roles resolve with their contracts (pm, cto, coder, reviewer)
|
||||||
|
OK resolve-role accepts a minimal version 2 role (exit 0)
|
||||||
|
OK resolve-role refuses an action outside the vocabulary (exit 2)
|
||||||
|
OK resolve-role refuses a gated-only action in a role file (exit 2)
|
||||||
|
OK resolve-role refuses a Vikunja verb no role may hold (exit 2)
|
||||||
|
OK resolve-role refuses a version 2 role whose contract is missing (exit 2)
|
||||||
|
OK resolve-role refuses a contract that is a symbolic link (exit 2)
|
||||||
|
OK seat launches under role ceiling
|
||||||
|
OK role ceiling narrows seat tools (read,write,bash -> read,bash)
|
||||||
|
OK narrowing recorded loudly
|
||||||
|
OK missing role contract refuses launch (exit 2)
|
||||||
|
OK missing-contract refusal names the role
|
||||||
|
OK empty ceiling intersection -> tool-free seat
|
||||||
|
OK tool-free outcome recorded loudly
|
||||||
|
OK overridden agents dir without seat definition refuses (exit 4)
|
||||||
|
OK seat-resolution refusal names the seat
|
||||||
|
OK policy: mission only -> mission tools
|
||||||
|
OK policy: task only -> task tools
|
||||||
|
OK policy: both -> intersection (task narrowed)
|
||||||
|
OK policy: empty intersection -> tool-free
|
||||||
|
OK invalid mission capabilities rejected (exit 2)
|
||||||
|
mosaic-task: unsupported mission tool: "sudo" (supported: read, write, edit, bash, grep, find, ls)
|
||||||
|
OK task run with user layer present (exit 0)
|
||||||
|
OK user context dispatched into generated prompt
|
||||||
|
OK workspace+tools task runs via mock (exit 0)
|
||||||
|
OK workspace path + tools delivered to adapter
|
||||||
|
OK persistent workspace created on host
|
||||||
|
OK plain task still runs (no workspace/tools) (exit 0)
|
||||||
|
OK workspace var present but empty when absent
|
||||||
|
OK tools empty when absent
|
||||||
|
OK unknown tool exits 2 (exit 2)
|
||||||
|
OK workspace traversal exits 2 (exit 2)
|
||||||
|
OK live hello task succeeds with exact marker
|
||||||
|
OK result.json written in run dir
|
||||||
|
OK result.json contents are correct
|
||||||
|
OK wrong expectExact fails with exit 1 (reason: expect-mismatch)
|
||||||
|
OK each run gets a distinct run dir (no clobber)
|
||||||
|
OK list shows both runs
|
||||||
|
OK fork base: teach succeeds (exit 0)
|
||||||
|
OK fork child recalls ancestor context (exit 0)
|
||||||
|
OK forked child recalled ancestor code word
|
||||||
|
OK ancestor session untouched by fork
|
||||||
|
OK child session dir has its own branch file
|
||||||
|
OK onboard without name exits 4 (non-interactive) (exit 4)
|
||||||
|
OK onboard --name renders profile (exit 0)
|
||||||
|
OK profile written
|
||||||
|
OK canon structure: required filled, optional placeholdered
|
||||||
|
OK canon sections present
|
||||||
|
OK user recall run succeeds
|
||||||
|
OK recalled user name (response: Jason)
|
||||||
|
OK no agent identity on headless run
|
||||||
|
|
||||||
|
selftest: 98 passed, 0 failed
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (11674.789973ms)
|
||||||
|
Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286}
|
||||||
|
✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (13706.759161ms)
|
||||||
|
✔ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes (14648.970608ms)
|
||||||
|
✔ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape (394.51471ms)
|
||||||
|
✔ decisions route serves the roles and recent lists from the reader view (222.487933ms)
|
||||||
|
✔ bus routes are GET-only, have no write verb, and keep the same-origin checks (387.603619ms)
|
||||||
|
✔ bus refusals map to statuses and never carry a path or a stack (78.352185ms)
|
||||||
|
✔ humanCall speaks the human transport protocol without blocking the server (3140.208613ms)
|
||||||
|
✔ busReader takes --business, else the live bus host, else refuses (1268.741589ms)
|
||||||
|
✔ humanCall kills a transport that runs past its timeout (689.727705ms)
|
||||||
|
✔ serve refuses a --business value that is not a business id (1230.463868ms)
|
||||||
|
✔ conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers (10519.34976ms)
|
||||||
|
✔ conversation view: a fork keeps the open branch, says so, and opens the new one on request (4307.797103ms)
|
||||||
|
✔ conversation view: a newer session with no readable history keeps the marker (2145.820226ms)
|
||||||
|
✔ conversation view: seats without history say so and offer no reply (1639.322949ms)
|
||||||
|
✔ Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content (10745.517796ms)
|
||||||
|
✔ return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch (62200.059017ms)
|
||||||
|
✔ redactor: config, dataRoot, any absolute path, Discord ids and the JSON parser quote (3.112119ms)
|
||||||
|
✔ credential state from the date alone, as the bus states it (0.367852ms)
|
||||||
|
✔ queue: rows, one row, ids and methods, notes; no path or id from a row (3294.622674ms)
|
||||||
|
✔ queue: a refused read fails closed with the store's text, redacted (1038.09559ms)
|
||||||
|
✔ queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack (5104.478556ms)
|
||||||
|
✔ business: names, vars on the allowlist, authority, credential metadata only (60.140853ms)
|
||||||
|
✔ business: missing or invalid file refuses with the module's text, redacted (52.456468ms)
|
||||||
|
✔ business without --business: the live bus host, else no-bus-host; no system config: not-configured (58.34462ms)
|
||||||
|
✔ settings: release, board, loopback address, notifier binding name only (1081.077431ms)
|
||||||
|
✔ both presentations replace old activity with relaunch notice, label retained history, then resume after new activity (11148.260802ms)
|
||||||
|
✔ reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh (28830.543544ms)
|
||||||
|
✔ loopback host and board origin fail closed (24.245814ms)
|
||||||
|
✔ real board fixture passes through WebUI; assets and isolated seen/reply work (561.19726ms)
|
||||||
|
✔ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect (321.722171ms)
|
||||||
|
✔ unreachable board reports URL; CLI rejects unsupported options (2796.028317ms)
|
||||||
|
✔ render prints the unit without the preview flags (75.26698ms)
|
||||||
|
✔ render carries every serve flag, the preview set included (112.407931ms)
|
||||||
|
✔ a partial preview set is refused (197.407027ms)
|
||||||
|
✔ a value the unit cannot carry is refused, for every flag (3876.079297ms)
|
||||||
|
✔ the rendered unit names none of the human proof agent markers and sets no agent variable (315.404716ms)
|
||||||
|
✔ install into --dir with --no-reload writes the file whole, then reports unchanged (682.03444ms)
|
||||||
|
✔ install without --no-reload runs daemon-reload and nothing that starts the unit (588.619294ms)
|
||||||
|
✔ uninstall refuses while the unit is active, and removes it once it is not (987.496602ms)
|
||||||
|
✔ status prints the unit state and GET /healthz on the installed port (1510.367271ms)
|
||||||
|
✔ usage: an unknown verb or flag, and start or enable, exit 4 (285.484472ms)
|
||||||
|
✔ tokens.css and icons.svg are byte copies of docs/design (4.747715ms)
|
||||||
|
✔ JetBrains Mono files match the digests in their sources file, with the OFL text (8.967146ms)
|
||||||
|
✔ shipped strings are unchanged, and the web keeps its write boundary (8.310162ms)
|
||||||
|
✔ shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette (17866.768235ms)
|
||||||
|
✔ five states on Board, Inbox, Tasks, Agents and Trail (6193.108566ms)
|
||||||
|
✔ Queue, Business and Settings in their five states, with no move button (14895.192654ms)
|
||||||
|
✔ seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module's words (3021.179379ms)
|
||||||
|
✔ the server log names no config path when the system config refuses (691.292426ms)
|
||||||
|
ℹ tests 50
|
||||||
|
ℹ suites 0
|
||||||
|
ℹ pass 50
|
||||||
|
ℹ fail 0
|
||||||
|
ℹ cancelled 0
|
||||||
|
ℹ skipped 0
|
||||||
|
ℹ todo 0
|
||||||
|
ℹ duration_ms 63231.404253
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
6dcdc74e02a3f49c7fed27c21860508cb655980ac71d569c96951ac013145884 ./base.txt
|
||||||
|
0f35cd1953b1038b488347e9df1b4309064fa869e1c14684370753a519b09607 ./BUILD.md
|
||||||
|
27cc48485f92764cf9a4f92a746c209d1e30dd511064cdf8071858d00c62b5a9 ./build.patch
|
||||||
|
7dc571dbe87a484f465c82e1965e499c57d9af047bfe702faa3fe041138dd154 ./candidate-manifest.sha256
|
||||||
|
896d274d49f93a194d36a2cf081574569140f8ab78ff5dead6737ab9619d0a8f ./files.txt
|
||||||
|
94e11500657683c821e50aaf9d5e746a26fc09b1321020fd4bf9b4e2c20dd533 ./out/gate/end.txt
|
||||||
|
a2171dad35389dbd0260fd8fcc70611288eca6fdff4292632e3de50ab14800ef ./out/gate/start.txt
|
||||||
|
573c60b32cb048dbe07739738eaeaa300b7a72d08764f23d63454d8154d582ae ./out/gate/summary.txt
|
||||||
|
f91fc95a173339137f981e24fbf630acfde957e32c3cd71e28304532083c6a77 ./out/gate/test-auth.log
|
||||||
|
1c464d617a1e10ef15a275cc9beeaf975d27de84d05071231970ca76af5d81fd ./out/gate/test-conductor.log
|
||||||
|
52d211444489ea729dafc3c9745d4474d9dfd50f41055cbad792424197f04073 ./out/gate/test-config.log
|
||||||
|
7d5016ded1994048642f8b9ae845e52e8af4a069ba05c056c8f580a4589bacbe ./out/gate/test-discord.log
|
||||||
|
62d2769554912857c4020cd38af23d8711134805dba60983433bd394afc14289 ./out/gate/test-extension-package.log
|
||||||
|
83dfaa21c4e4ad941088efab3f69e87ebd2f6bd3cc72ede938e21dc06da8a34f ./out/gate/test-foundation.log
|
||||||
|
4d3f40bf6a83fca6aed9cab44c2c130ae90c0d2d4943a3a2b51e9141ada71f0c ./out/gate/test-queue.log
|
||||||
|
fa832794ee35025fb4559d58d01a270f6bff2eb65e863e9075b797f47fa68545 ./out/gate/test-release.log
|
||||||
|
ccea37f4ffde1f160dcd1a1864988083a645d974e892065c956b338d0f8c853a ./out/gate/test-task.log
|
||||||
|
c0728d5190cd13bb0bee5d39bddc12776f2f8c724a28faee358ae81a034e1469 ./out/gate/webui.log
|
||||||
Reference in New Issue
Block a user