Files
stack/packages/cli/tests/org-console.test.mjs
jason.woltje 1b6cac2bdf feat(webui): restart the Console, the org bus and agents from the WebUI (F3)
Self-service operation from the WebUI, for a dev instance first.

- Settings > Console > Restart Console. A foreground `mosaic console
  start` now supervises the webui: the child exits 75 after answering a
  restart and the supervisor relaunches it with the same argv. Org buses
  keep running. /api/config carries a per-process boot id, so the open
  page polls until a new process answers and then reloads. A detached
  Console has no supervisor and answers 409.
- Restart bus on every "Restart needed" receipt and on the bus-down
  banner: POST /api/orgs/<id>/restart/bus restarts a running bus or
  starts a stopped one, then shows a done receipt with the time.
- Settings > Routing: after a save, the page lists how the change
  reaches each agent (next start, session end, thread idle) and offers
  "Restart <agent> now" for a running one. A session is terminated with
  the reason "route change" and launched again; a thread is terminated.
- Organization settings: the signed-in owner's settings, routing,
  agent, plug-in and Model proxy writes now reach the selected org's
  bus instead of answering 403. Org mode exists only under
  --dev-local-owner. Proxy setup stays a terminal step; a dev-only note
  names the two commands.

Every control needs the signed-in owner session, the page's exact
Origin and Sec-Fetch-Site: same-origin. The tester listener, granted
users, API tokens, agents and bus roles are refused before any work.
Each use is written to <dataRoot>/console/actions.jsonl (0600, no
symlinks) with who, when, what and the outcome. The Console and bus
restart trails fail closed. No change to credential handling, the
broker, the bus tenant boundary or v2 schemas.
2026-11-09 13:37:28 -06:00

213 lines
15 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync, statSync } from 'node:fs';
import { get, request } from 'node:http';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
import { humanEnv } from './helpers.mjs';
import { consoleStart, consoleStatus, consoleStop } from '../src/org-console.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
test('detached console reports its live loopback URL and stops', { timeout: 60000 }, async t => {
const home = mkdtempSync(join(tmpdir(), 's1-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 's1test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-s1test'), dataRoot: join(home, '.mosaic-s1test'), instance: 's1test', env };
await reserveOrg(ctx, 'alpha');
await activateOrg(ctx, 'alpha');
t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); });
mkdirSync(join(ctx.dataRoot, 'console'), { mode: 0o700 });
writeFileSync(join(ctx.dataRoot, 'console/console.json'), JSON.stringify({ pid: 2147483647, startTime: '1', url: 'http://127.0.0.1:1/' }), { mode: 0o600 });
const warnings = [];
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: s => warnings.push(s) });
assert.equal(warnings.length, 1);
assert.equal(readdirSync(join(ctx.dataRoot, 'console')).filter(x => x.startsWith('console.json.stale-')).length, 1);
await assert.rejects(consoleStart(ctx, { devLocalOwner: true, detach: true }), /state exists/);
const url = new URL(result.url);
assert.equal(url.hostname, '127.0.0.1');
assert.ok(Number.isSafeInteger(result.pid));
// A selector producer (rename r2): the Console child gets the active organization as --business.
const argv = readFileSync(`/proc/${result.pid}/cmdline`, 'utf8').split('\0').slice(0, -1);
assert.deepEqual(argv, [process.execPath, join(REPO, 'packages/webui/src/cli.mjs'), 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner']);
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url.port)));
const status = await new Promise((resolve, reject) => get(url, res => { res.resume(); resolve(res.statusCode); }).on('error', reject));
assert.equal(status, 200);
assert.equal((await consoleStop(ctx)).stopped, true);
});
test('mosaic console start and stop dispatch through the CLI', { timeout: 60000 }, async t => {
const home = mkdtempSync(join(tmpdir(), 's1-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') });
assert.equal(spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 's1test'], { env }).status, 0);
const ctx = { configDir: join(home, '.config/mosaic-s1test'), dataRoot: join(home, '.mosaic-s1test'), instance: 's1test', env };
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); });
const start = spawnSync(join(REPO, 'scripts/mosaic'), ['console', 'start', '--dev-local-owner', '--detach', '--port', 'auto'], { env, encoding: 'utf8', timeout: 30000 });
assert.equal(start.status, 0, start.stderr);
assert.match(JSON.parse(start.stdout).url, /^http:\/\/127\.0\.0\.1:\d+\/$/);
const stop = spawnSync(join(REPO, 'scripts/mosaic'), ['console', 'stop'], { env, encoding: 'utf8', timeout: 30000 });
assert.equal(stop.status, 0, stop.stderr);
assert.equal(JSON.parse(stop.stdout).stopped, true);
});
// org-lan-bind: --dev-insecure-bind and --dev-allow-host. The Console binds 0.0.0.0 and the test
// reaches it on 127.0.0.1 with an allowed Host name, as a LAN browser would.
const hostGet = (port, host) => new Promise((resolve, reject) => {
request({ host: '127.0.0.1', port, path: '/', headers: { host } }, res => { res.resume(); resolve(res.statusCode); }).on('error', reject).end();
});
async function instance(t) {
const home = mkdtempSync(join(tmpdir(), 's1-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 's1test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-s1test'), dataRoot: join(home, '.mosaic-s1test'), instance: 's1test', env };
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); });
const mosaic = args => spawnSync(join(REPO, 'scripts/mosaic'), args, { env, encoding: 'utf8', timeout: 30000 });
return { ctx, mosaic };
}
test('a LAN console warns, records its bind and answers only its allowed names', { timeout: 60000 }, async t => {
const { ctx, mosaic } = await instance(t);
const warnings = [];
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'], warn: s => warnings.push(s) });
const url = new URL(result.url), port = url.port;
assert.equal(url.hostname, '127.0.0.1');
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(port)));
const warning = `WARNING: dev owner Console listens on 0.0.0.0:${port} with no sign-in; anyone who can reach it acts as the owner.\n`;
assert.deepEqual(warnings, [warning]);
assert.equal(result.bind, '0.0.0.0'); assert.deepEqual(result.allowHosts, ['lan.test', '10.1.10.37']);
const saved = JSON.parse(readFileSync(join(ctx.dataRoot, 'console/console.json'), 'utf8'));
assert.deepEqual({ url: saved.url, bind: saved.bind, allowHosts: saved.allowHosts }, { url: result.url, bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'] });
// The serve process prints the same warning on its own stderr, which the Console log keeps.
const logs = readdirSync(join(ctx.dataRoot, 'console')).filter(x => /^console-\d+-\d+\.log$/.test(x));
assert.equal(logs.length, 1);
assert.ok(readFileSync(join(ctx.dataRoot, 'console', logs[0]), 'utf8').includes(warning), 'serve log has the warning');
assert.equal(await hostGet(port, `lan.test:${port}`), 200);
assert.equal(await hostGet(port, `10.1.10.37:${port}`), 200);
assert.equal(await hostGet(port, `127.0.0.1:${port}`), 200);
assert.equal(await hostGet(port, `other.test:${port}`), 403);
assert.equal(await hostGet(port, `lan.test:${Number(port) + 1}`), 403);
const statusWarnings = [];
const status = consoleStatus(ctx, { warn: s => statusWarnings.push(s) });
assert.deepEqual(status, { running: true, pid: result.pid, url: result.url, bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'] });
assert.deepEqual(statusWarnings, [warning]);
const cli = mosaic(['console', 'status']);
assert.equal(cli.status, 0, cli.stderr);
assert.deepEqual(JSON.parse(cli.stdout), status);
assert.equal(cli.stderr, warning);
assert.equal((await consoleStop(ctx)).stopped, true);
assert.deepEqual(consoleStatus(ctx), { running: false });
// A state file whose LAN fields do not validate is refused, not trusted.
for (const bad of [{ bind: '0.0.0.0' }, { bind: '0.0.0.0', allowHosts: [] }, { bind: '0.0.0.0', allowHosts: ['a.test,b.test'] }, { bind: 'lan.test', allowHosts: ['lan.test'] }, { allowHosts: ['lan.test'] }]) {
writeFileSync(join(ctx.dataRoot, 'console/console.json'), JSON.stringify({ pid: 2147483647, startTime: '1', url: 'http://127.0.0.1:1/', ...bad }), { mode: 0o600 });
assert.throws(() => consoleStatus(ctx), /console state is invalid/, JSON.stringify(bad));
}
rmSync(join(ctx.dataRoot, 'console/console.json'));
});
test('mosaic console start refuses the LAN flags without the dev owner and allowed names', { timeout: 60000 }, async t => {
const { ctx, mosaic } = await instance(t);
for (const [args, reason] of [
[['--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /--dev-insecure-bind needs --dev-local-owner/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0'], /needs at least one --dev-allow-host/],
[['--dev-local-owner', '--dev-allow-host', 'lan.test'], /--dev-allow-host needs --dev-insecure-bind/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test:7350'], /without a port/],
[['--dev-local-owner', '--dev-insecure-bind', '10.1.10.37', '--dev-allow-host', 'lan.test'], /must also be a --dev-allow-host name/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'a.test', '--dev-allow-host', 'b.test'], /--dev-allow-host needs one value/],
]) {
const r = mosaic(['console', 'start', '--detach', '--port', 'auto', ...args]);
assert.equal(r.status, 4, `${args.join(' ')}: ${r.stderr}`);
assert.match(r.stderr, reason, args.join(' '));
assert.doesNotMatch(r.stderr, /WARNING: dev owner/);
assert.equal(r.stdout, '');
assert.equal(existsSync(join(ctx.dataRoot, 'console')), false, args.join(' '));
}
await assert.rejects(consoleStart(ctx, { detach: true, bind: '0.0.0.0', allowHosts: ['lan.test'] }), /--dev-insecure-bind needs --dev-local-owner/);
assert.equal(existsSync(join(ctx.dataRoot, 'console')), false);
const none = mosaic(['console', 'status']);
assert.equal(none.status, 0, none.stderr);
assert.deepEqual(JSON.parse(none.stdout), { running: false });
assert.equal(mosaic(['console', 'status', '--json']).status, 4);
});
// F3: the owner restarts the Console from Settings. A foreground `mosaic console start` runs the
// Console with --supervised and relaunches it on the same arguments and address when it exits 75.
const WEBUI = join(REPO, 'packages/webui/src/cli.mjs');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const argvOf = pid => readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').slice(0, -1);
function call(port, path, { method = 'GET', body = null, headers = {} } = {}) {
const h = { host: `127.0.0.1:${port}`, ...(method === 'GET' ? {} : { origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }), ...headers };
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
return new Promise((resolve, reject) => {
const r = request({ host: '127.0.0.1', port, path, method, headers: h }, res => {
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch {} resolve({ status: res.statusCode, body: json }); });
});
r.on('error', reject); r.end(body ?? undefined);
});
}
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
test('a foreground console relaunches on the same address when the owner restarts it, and a stop ends it', { timeout: 90000 }, async t => {
const { ctx } = await instance(t);
const warnings = [];
const result = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) });
t.after(async () => { result.stop(); await result.done; });
const port = Number(new URL(result.url).port);
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(port));
assert.deepEqual(argvOf(result.pid), [process.execPath, WEBUI, 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner', '--supervised']);
const before = (await call(port, '/api/config')).body;
assert.equal(before.consoleRestart, true); assert.equal(typeof before.boot, 'string');
const stateFile = join(ctx.dataRoot, 'console/console.json');
const trailFile = join(ctx.dataRoot, 'console/actions.jsonl');
// Another origin or site restarts nothing.
for (const headers of [{ origin: 'http://evil.example' }, { origin: null }, { 'sec-fetch-site': 'cross-site' }, { host: `evil.example:${port}` }]) {
assert.equal((await call(port, '/api/console/restart', { method: 'POST', body: '{}', headers })).status, 403, JSON.stringify(headers));
}
assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).pid, result.pid);
assert.equal(existsSync(trailFile), false);
const answer = await call(port, '/api/console/restart', { method: 'POST', body: '{}' });
assert.equal(answer.status, 202);
assert.deepEqual(answer.body, { outcome: 'restarting', boot: before.boot });
let saved;
for (const deadline = Date.now() + 30000; ; await sleep(100)) {
saved = JSON.parse(readFileSync(stateFile, 'utf8'));
if (saved.pid !== result.pid) break;
if (Date.now() > deadline) assert.fail('the Console was not relaunched');
}
// Same address, same arguments; the auto port is now the port it had.
assert.equal(saved.url, result.url);
assert.deepEqual(argvOf(saved.pid), [process.execPath, WEBUI, 'serve', '--port', String(port), '--business', 'alpha', '--dev-local-owner', '--supervised']);
assert.equal(statSync(stateFile).mode & 0o777, 0o600);
const after = (await call(port, '/api/config')).body;
assert.notEqual(after.boot, before.boot);
assert.equal(after.consoleRestart, true);
const [line, ...more] = readFileSync(trailFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
assert.deepEqual(more, []);
assert.ok(Number.isFinite(Date.parse(line.at)));
assert.deepEqual({ ...line, at: undefined }, { at: undefined, by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot });
assert.equal(statSync(trailFile).mode & 0o777, 0o600);
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
assert.deepEqual(consoleStatus(ctx), { running: true, pid: saved.pid, url: result.url, bind: '127.0.0.1' });
result.stop();
assert.equal(await result.done, 0);
assert.equal(consoleStatus(ctx).running, false);
});
test('a detached console has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => {
const { ctx } = await instance(t);
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
const port = Number(new URL(result.url).port);
assert.ok(!argvOf(result.pid).includes('--supervised'));
assert.equal((await call(port, '/api/config')).body.consoleRestart, false);
const r = await call(port, '/api/console/restart', { method: 'POST', body: '{}' });
assert.equal(r.status, 409); assert.equal(r.body.error, 'not-supervised');
assert.equal((await call(port, '/api/config')).status, 200);
assert.equal(existsSync(join(ctx.dataRoot, 'console/actions.jsonl')), false);
assert.equal((await consoleStop(ctx)).stopped, true);
});