Files
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

120 lines
7.8 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { BusReadError } from '../src/bus.mjs';
import { close } from './fixture.mjs';
const thread = '20000000-0000-4000-8000-000000000002';
const message = '30000000-0000-4000-8000-000000000003';
test('scoped chat project creation reaches only the selected organization', async t => {
const calls = [];
const organizations = { list: async () => [], resolve: async id => ({ busHealthy: true, send: async () => ({}), chat: { createProject: async args => { calls.push([id, args]); return { id: thread }; } } }) };
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server));
const base = `http://127.0.0.1:${server.address().port}`;
const response = await fetch(`${base}/api/orgs/alpha/chat/projects`, { method: 'POST', headers: { origin: base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Work', repo: 'work' }) });
assert.equal(response.status, 200); assert.deepEqual(calls, [['alpha', { name: 'Work', repo: 'work' }]]);
});
test('organization routes bind reads and writes to the authorized organization', async t => {
const calls = [];
const organizations = {
list: async () => [{ id: 'alpha', label: 'Alpha', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: false }],
resolve: async (id, principal) => {
assert.equal(principal.sub, 'local-dev-owner');
if (!['alpha', 'beta'].includes(id)) throw Error('unavailable');
return {
busHealthy: id === 'alpha',
bus: { inbox: async () => { if (id === 'beta') throw Error('down'); return [{ id }]; } },
chat: { send: async args => { calls.push([id, args]); return { id: message }; } },
send: async () => ({}),
};
},
};
const server = await startServer({ port: 0, sso: createDevOwner(), organizations,
requestRead: async () => { throw Error('a sibling transport must never be used'); }, log: () => {} });
t.after(() => close(server));
const base = `http://127.0.0.1:${server.address().port}`;
const get = async path => { const r = await fetch(base + path); return [r.status, await r.json()]; };
const post = async (path, body, extra = {}) => { const r = await fetch(base + path, { method: 'POST', headers: { origin: base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...extra }, body: JSON.stringify(body) }); return [r.status, await r.json()]; };
assert.deepEqual((await get('/api/orgs'))[1].map(o => o.id), ['alpha', 'beta']);
assert.equal((await get('/api/orgs/alpha/bus/inbox'))[1].rows[0].id, 'alpha');
const config = (await get('/api/orgs/alpha/config'))[1];
assert.equal(config.send, false);
assert.equal(config.chatSend, true);
assert.equal((await get('/api/orgs/beta/config'))[1].chatSend, false, 'offline organization cannot offer live writes');
assert.notEqual((await get('/api/orgs/beta/bus/inbox'))[0], 200);
assert.equal((await get('/api/orgs/alpha/bus/inbox'))[0], 200);
assert.equal((await get('/api/orgs/alpha/board'))[1].error, 'not-configured');
assert.equal((await get('/api/orgs/alpha/requests/request1'))[0], 503);
assert.deepEqual(await get('/api/orgs/missing/bus/inbox'), [404, { error: 'org-unavailable' }]);
assert.deepEqual(await get('/api/orgs/unauthorized/bus/inbox'), [404, { error: 'org-unavailable' }]);
assert.equal((await post(`/api/orgs/alpha/chat/threads/${thread}/messages`, { body: 'hello' }))[0], 200);
assert.deepEqual(calls, [['alpha', { thread, body: 'hello' }]]);
assert.equal((await post(`/api/orgs/beta/chat/threads/${thread}/messages`, { body: 'hello' }, { origin: 'http://evil.example' }))[0], 403);
assert.equal(calls.length, 1);
// F3: the owner's settings writes now reach the selected organization; this one offers no
// settings writer, so the route itself answers read-only and nothing is written.
assert.equal((await post('/api/orgs/alpha/settings', {}))[1].error, 'read-only');
assert.equal((await post('/api/orgs/alpha/settings', {}, { origin: 'http://evil.example' }))[0], 403);
assert.equal((await post('/api/settings', {}))[1].error, 'org-unavailable');
});
test('registered hooks run behind the existing sign-in and write fences', async t => {
const calls = [];
const organizations = { list: async () => [], resolve: async (id) => id === 'alpha' ? {} : null };
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, routes: [table => {
table.add('GET', '/api/themes', (_req, res) => { calls.push('themes'); res.end('themes'); });
table.add('POST', '/api/orgs', (_req, res) => { calls.push('create'); res.end('created'); });
table.add('POST', /^\/api\/orgs\/([^/]+)\/setup\/start$/, (_req, res, context) => { calls.push(context.orgId); res.end('started'); });
}] });
t.after(() => close(server));
const base = `http://127.0.0.1:${server.address().port}`;
assert.equal(await (await fetch(base + '/api/themes')).text(), 'themes');
const post = (path, origin, site) => fetch(base + path, { method: 'POST', headers: { origin, 'sec-fetch-site': site } });
assert.equal((await post('/api/orgs', base, 'cross-site')).status, 403);
assert.equal((await post('/api/orgs', base, 'same-origin')).status, 200);
assert.equal((await post('/api/orgs/unknown/setup/start', base, 'same-origin')).status, 404);
assert.equal((await post('/api/orgs/alpha/setup/start', base, 'same-origin')).status, 200);
assert.deepEqual(calls, ['themes', 'create', 'alpha']);
});
test('decision routes use only the selected organization and reject a sibling decision', async t => {
const betaId = '20000000-0000-4000-8000-000000000003';
const digest = 'a'.repeat(64), calls = [], beta = { resolved: false };
const organizations = {
list: async () => [{ id: 'alpha' }, { id: 'beta' }],
resolve: async id => ({
busHealthy: true, send: async () => ({}),
decisionWrite: async (verb, args) => {
calls.push([id, verb]);
if (id !== 'beta' || args.id !== betaId) throw new BusReadError('decision-not-found');
if (verb === 'decision.preview.console') return { id: betaId, business: 'beta', digest, question: 'Choose?', kind: 'decision', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }] };
beta.resolved = true; return { id: betaId, op: 'resolved', choice: args.choice };
},
}),
};
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, send: async () => ({}), log: () => {} });
t.after(() => close(server));
const base = `http://127.0.0.1:${server.address().port}`;
const post = async (org, step, body) => {
const r = await fetch(`${base}/api/orgs/${org}/decisions/${betaId}/${step}`, { method: 'POST', headers: { origin: base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, body: JSON.stringify(body) });
return { status: r.status, body: await r.json() };
};
const wrong = await post('alpha', 'confirmation', { choice: 'yes' });
assert.equal(wrong.status, 404);
assert.equal(wrong.body.error, 'decision-not-found');
assert.equal(beta.resolved, false);
assert.deepEqual(calls, [['alpha', 'decision.preview.console']]);
const confirmation = await post('beta', 'confirmation', { choice: 'yes' });
assert.equal(confirmation.status, 200);
const crossResolve = await post('alpha', 'resolve', { choice: 'yes', confirmation: confirmation.body.confirmation });
assert.notEqual(crossResolve.status, 200);
assert.equal(beta.resolved, false);
const second = await post('beta', 'confirmation', { choice: 'yes' });
const resolve = await post('beta', 'resolve', { choice: 'yes', confirmation: second.body.confirmation });
assert.equal(resolve.status, 200);
assert.equal(beta.resolved, true);
});