feat(webui): F3 self-service restarts and owner settings writes on an org (#1873) #1884

Merged
jason.woltje merged 5 commits from org-f3-merge into refactor 2026-11-09 22:45:01 +00:00
25 changed files with 1296 additions and 100 deletions

No files matched your search

+7 -1
View File
@@ -583,7 +583,13 @@ async function consoleCommand(rest, io, deps) {
const receipt = await consoleStart(organizationContext(io, deps), { devLocalOwner: devOwner, detach: parsed.flags.has("--detach"), port: parsed.values["--port"] ?? "auto", ...(lan ? { bind: lan.bind, allowHosts: lan.allowHosts } : {}), warn: s => io.stderr.write(s) });
io.stdout.write(`${JSON.stringify(receipt)}\n`);
if (!parsed.flags.has("--detach")) {
const code = await receipt.done;
// F3: this process supervises the Console. A stop signal (the unit's SIGTERM, Ctrl-C) goes to
// the Console and ends supervision cleanly; a restart the Console asks for is relaunched here.
const stop = () => receipt.stop();
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, stop);
let code;
try { code = await receipt.done; }
finally { for (const signal of ["SIGINT", "SIGTERM"]) process.off(signal, stop); }
if (code !== 0) throw new CliError("console stopped unexpectedly", code);
}
}
+101 -31
View File
@@ -64,29 +64,22 @@ function takeLock(ctx) {
let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); }
return () => { closeSync(fd); rmSync(path, { force: true }); };
}
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
const value = port === 'auto' ? 0 : Number(port);
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
let lan;
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
const release = takeLock(ctx);
let child = null, done = null;
// The exit code a supervised Console uses to ask for a relaunch (EX_TEMPFAIL). Only the Console's
// own restart route (POST /api/console/restart, a human action) exits with it.
export const RESTART_EXIT = 75;
const sleep = ms => new Promise(r => setTimeout(r, ms));
let lastStamp = 0;
// One Console process: spawn it, wait for the URL it prints, check the address and port.
async function launch(ctx, args, lan, detach) {
lastStamp = Math.max(Date.now(), lastStamp + 1);
const log = join(directory(ctx), `console-${process.pid}-${lastStamp}.log`);
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
let child;
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
finally { closeSync(fd); }
const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
let exited = false; done.then(() => { exited = true; });
try {
const prior = state(ctx);
if (prior && !removeStale(ctx, prior, warn)) fail('console state exists; stop or inspect the console first');
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
if (org) await resolveOrg(ctx, org.id);
// The pre-S5 Console still accepts a business preselection.
const args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []),
...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : [])];
const log = join(directory(ctx), `console-${process.pid}-${Date.now()}.log`);
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
finally { closeSync(fd); }
done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
let exited = false; done.then(() => { exited = true; });
const deadline = Date.now() + 30000;
while (Date.now() < deadline && !exited) {
const text = readFileSync(log, 'utf8');
@@ -97,22 +90,99 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false,
if (forbidden.has(Number(match[3]))) fail('console selected a forbidden port');
const startTime = startTimeOf(child.pid);
if (!startTime) fail('console exited during startup');
writeFileSync(stateFile(ctx), JSON.stringify({ pid: child.pid, startTime, url: match[1], ...(lan ?? {}) }) + '\n', { mode: 0o600, flag: 'wx' });
if (detach) child.unref();
// The child's stderr goes to the log; the person who started it sees the warning here.
if (lan) warn(lanWarning(lan.bind, match[3]));
const result = { url: match[1], pid: child.pid, ...(lan ?? {}) };
Object.defineProperty(result, 'done', { value: done });
return result;
return { child, done, url: match[1], port: match[3], startTime };
}
await new Promise(r => setTimeout(r, 50));
await sleep(50);
}
fail(`console failed to start; see ${log}`);
} catch (e) {
if (child && child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
throw e;
}
}
const record = (run, lan) => JSON.stringify({ pid: run.child.pid, startTime: run.startTime, url: run.url, ...(lan ?? {}) }) + '\n';
// F3: a relaunch keeps the arguments and the address. The state file must still name the process
// that asked for the restart; a stop that ran in between wins, and nothing is relaunched.
async function relaunch(ctx, old, args, lan) {
const next = args.map((a, i) => (args[i - 1] === '--port' ? old.port : a));
let release;
for (let n = 0; !release; n++) {
try { release = takeLock(ctx); } catch (e) { if (n >= 50) throw e; await sleep(100); }
}
try {
const s = state(ctx);
if (!s || s.pid !== old.child.pid || s.startTime !== old.startTime || s.url !== old.url) fail('console state changed during restart');
let fresh;
// The old listener has closed; a refused bind is retried briefly.
for (let n = 0; !fresh; n++) {
try { fresh = await launch(ctx, next, lan, false); } catch (e) { if (n >= 4) throw e; await sleep(500); }
}
try {
if (fresh.url !== old.url) fail('console restarted on another address');
const tmp = `${stateFile(ctx)}.tmp-${randomBytes(4).toString('hex')}`;
writeFileSync(tmp, record(fresh, lan), { mode: 0o600, flag: 'wx' });
renameSync(tmp, stateFile(ctx));
} catch (e) { fresh.child.kill('SIGTERM'); await fresh.done; throw e; }
return fresh;
} finally { release(); }
}
// Foreground supervision: relaunch on RESTART_EXIT, end on any other exit. `stop` forwards a
// stop signal (the unit's SIGTERM, a terminal's Ctrl-C) to the Console; a stop is a clean end.
function supervise(ctx, first, args, lan, warn) {
let current = first, stopping = false;
const stop = () => {
if (stopping) return;
stopping = true;
const child = current.child;
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill('SIGTERM');
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }, 15000).unref();
};
const done = (async () => {
for (;;) {
const code = await current.done;
if (stopping) return 0;
if (code !== RESTART_EXIT) return code;
warn('mosaic: console asked for a restart; relaunching on the same arguments\n');
try { current = await relaunch(ctx, current, args, lan); }
catch (e) { warn(`mosaic: console restart failed: ${e.message}\n`); return 1; }
if (stopping) { stopping = false; stop(); await current.done; return 0; }
}
})();
return { done, stop };
}
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
// In the foreground the Console runs with --supervised: its restart route exits RESTART_EXIT and
// this process relaunches it in place (F3). A detached Console has no supervisor.
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
const value = port === 'auto' ? 0 : Number(port);
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
let lan;
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
const release = takeLock(ctx);
let run, args;
try {
const prior = state(ctx);
if (prior && !removeStale(ctx, prior, warn)) fail('console state exists; stop or inspect the console first');
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
if (org) await resolveOrg(ctx, org.id);
// The pre-S5 Console still accepts a business preselection.
args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []),
...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : []), ...(detach ? [] : ['--supervised'])];
run = await launch(ctx, args, lan, detach);
try { writeFileSync(stateFile(ctx), record(run, lan), { mode: 0o600, flag: 'wx' }); }
catch (e) { run.child.kill('SIGTERM'); await run.done; throw e; }
} finally { release(); }
if (detach) run.child.unref();
// The child's stderr goes to the log; the person who started it sees the warning here.
if (lan) warn(lanWarning(lan.bind, run.port));
const result = { url: run.url, pid: run.child.pid, ...(lan ?? {}) };
const supervision = detach ? { done: run.done, stop: () => run.child.kill('SIGTERM') } : supervise(ctx, run, args, lan, warn);
Object.defineProperty(result, 'done', { value: supervision.done });
Object.defineProperty(result, 'stop', { value: supervision.stop });
return result;
}
export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } = {}) {
const release = takeLock(ctx);
try {
+78 -1
View File
@@ -1,7 +1,7 @@
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 } from 'node:fs';
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';
@@ -133,3 +133,80 @@ test('mosaic console start refuses the LAN flags without the dev owner and allow
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);
});
+25 -6
View File
@@ -20,6 +20,8 @@ baseline CLI.
| Recover a stopped bus with stale host state | `mosaic bus recover <id>` | CLI only |
| Inspect or clear a dead lifecycle lock | `mosaic org lock show <id>` / `mosaic org lock clear <id>` | CLI only |
| Start, stop or restart a bus | `mosaic bus start <id>` / `mosaic bus stop <id>` / `mosaic bus restart <id>` | S6 integration hook |
| Restart (or first start) an organization's bus | `mosaic bus restart <id>`, or `mosaic bus start <id>` for a bus never started | Enabled (F3): the owner's **Restart bus** control, offered where a page says the bus is down or a change needs a restart; trailed |
| Restart the Console | Stop and run `mosaic console start` again | Enabled (F3) when `mosaic console start` runs it in the foreground: Settings > Console; the owner only; trailed. Other Consoles answer `not-supervised` |
| Create chat project linked to a configured repository | `mosaic project create <name> --repo <project> --org <id>` | Enabled |
| Create a thread | `mosaic thread create <project> <name> --org <id>` | Planned enabled |
| Open a thread | `mosaic thread open <thread> --org <id>` | Planned enabled |
@@ -34,19 +36,36 @@ baseline CLI.
| Mark a board session seen/unseen; reply to a board seat | No CLI equivalent identified | Unavailable |
| Approve or deny a decision | `mosaic decide <id> <choice> --org <id>` | Owner-only confirmation and resolution on the selected organization bus |
| Answer, attach, change mode, cancel or revoke a request | No equivalent CLI identified | Unavailable |
| Edit or reset launch/assistant limits | `mosaic limits set` / `mosaic limits reset` | Unavailable pending parity |
| Rename an instance | `mosaic name` | Unavailable pending parity |
| Stop an assistant | No equivalent CLI identified | Unavailable |
| Add, change or delete an instance; launch, regenerate or terminate an agent; change a veto | `mosaic org instance add`, `set`, `delete`, `launch`, `regenerate`, `terminate`; `mosaic org veto` (old spelling `mosaic business instance`, `mosaic business veto`) | Unavailable pending parity |
| Change routes or enabled harnesses/providers | `mosaic org instance set`; `mosaic plugins set` | Unavailable pending parity |
| Edit or reset launch/assistant limits | `mosaic limits set` / `mosaic limits reset` | Enabled (F3) for the owner on a selected organization's bus; unavailable on other Consoles pending parity |
| Rename an instance | `mosaic name` | Enabled (F3) for the owner on a selected organization's bus; unavailable elsewhere pending parity |
| Stop an assistant | No equivalent CLI identified | Enabled (F3) for the owner on a selected organization's bus; unavailable elsewhere |
| Add, change or delete an instance; launch, regenerate or terminate an agent; change a veto | `mosaic org instance add`, `set`, `delete`, `launch`, `regenerate`, `terminate`; `mosaic org veto` (old spelling `mosaic business instance`, `mosaic business veto`) | Enabled (F3) for the owner on a selected organization's bus, with **Restart NAME now** (terminate, then launch) after a route change; unavailable elsewhere pending parity |
| Change routes or enabled harnesses/providers | `mosaic org instance set`; `mosaic plugins set` | Enabled (F3) for the owner on a selected organization's bus; the page lists how the change reaches each saved agent; unavailable elsewhere pending parity |
| Halt or resume processing | `mosaic halt` / `mosaic resume` | Unavailable pending parity |
| Save a project file or revert library content | No equivalent CLI identified | Unavailable |
| Change a project remote | No equivalent CLI identified | Unavailable |
| Start, submit or cancel proxy sign-in; add an API key; set account priority or models | No equivalent CLI identified | Unavailable |
| Start, submit or cancel proxy sign-in; add an API key; set account priority or models | No equivalent CLI identified | Enabled (F3) for the owner on a selected organization's bus once its proxy is set up; setup itself stays `mosaic proxy init` in a terminal |
Browser navigation, local appearance preferences and unsaved drafts are local
state, not server writes. Signing out is a session action.
F3 writes (Console restart, bus restart, and the owner's routing, agent and
instance changes on an organization) append one line each to
`<dataRoot>/console/actions.jsonl` (0600): who (provider, issuer, subject,
name), when, what and the outcome. A restart that cannot write its line is
refused. On a Console with sign-in, no tester, granted user or API token
reaches these controls: each needs the signed-in owner, the page's exact Origin
and `Sec-Fetch-Site: same-origin`. Those headers stop another site's page, not
a local program, which can send any header it likes.
A dev local owner Console (`--dev-local-owner`) has no sign-in. Every request
that passes the Host and Origin checks acts as the owner, and this includes the
F3 controls. An agent launched on the same machine runs as the same OS user, so
it can restart the Console or a bus and change routing, agents and settings just
as the owner's page can. The startup warning says so. Use a dev instance for UI
testing only. The real boundary is sign-in (`--sso`); a refusal for local agent
peers in dev mode is a recorded follow-up.
### Route registration
A module may export `registerRoutes(table)` and be included with one entry in
+24
View File
@@ -0,0 +1,24 @@
// F3: the Console's trail of human operations (Console restart, org bus restart, route and
// instance changes made from the page). One JSON line per use: who, when, what, outcome.
// The file is <dataRoot>/console/actions.jsonl, owner-only. A write that cannot be made safely
// throws, and the caller refuses the operation (fail closed).
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, writeSync } from 'node:fs';
import { join } from 'node:path';
// The person, never a token: provider, issuer, subject and display name of the session.
export const actor = session => ({ provider: session.provider, iss: session.iss, sub: session.sub, ...(typeof session.name === 'string' ? { name: session.name } : {}) });
export function actionTrail(dir) {
const file = join(dir, 'actions.jsonl');
return entry => {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const st = lstatSync(dir);
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077) !== 0) throw new Error('unsafe trail directory');
const fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
try {
const f = fstatSync(fd);
if (!f.isFile() || f.uid !== process.getuid() || (f.mode & 0o777) !== 0o600 || f.nlink !== 1) throw new Error('unsafe trail file');
writeSync(fd, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`);
} finally { closeSync(fd); }
};
}
+18 -6
View File
@@ -22,7 +22,9 @@ import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.m
import { createSso, readSlot } from './sso.mjs';
import { readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { isAbsolute } from 'node:path';
import { isAbsolute, join } from 'node:path';
import { actionTrail } from './action-trail.mjs';
import { RESTART_EXIT } from '../../cli/src/org-console.mjs';
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
import { selectorScope } from '../../organization/src/selector.mjs';
// The bus verifies the Console's exact same-tree script in /proc, whose argv preserves a
@@ -40,12 +42,12 @@ if (process.platform === 'linux') {
}
}
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--supervised] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null;
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null, supervised = false;
const devNames = [];
// --org and --business are one selector in this parser's scope (rename design r2). A flag-shaped
// or invalid value keeps the old wording.
@@ -62,6 +64,8 @@ try {
if (flag === '--dev-local-owner') { devOwner = true; continue; }
if (flag === '--sso') { ssoOn = true; continue; }
if (flag === '--tester-socket') { testerSocket = true; continue; }
// F3: `mosaic console start` supervises this process and relaunches it on exit 75.
if (flag === '--supervised') { supervised = true; continue; }
if (flag === '--tester-port') throw new Error('--tester-port retired; activate explicitly with --tester-socket');
const value = args.shift();
if (!value) throw new Error(`missing value for ${flag}`);
@@ -165,11 +169,19 @@ try {
};
},
}) : null;
let server;
let server, testerServer = null;
// F3: the restart route closes every listener and connection, then exits RESTART_EXIT for the
// supervisor to relaunch. Without --supervised the route answers not-supervised.
const restart = supervised ? () => {
sso?.close(); testerServer?.close();
setTimeout(() => process.exit(RESTART_EXIT), 2000).unref();
server.close(() => process.exit(RESTART_EXIT));
server.closeAllConnections();
} : null;
const trail = system ? actionTrail(join(system.dataRoot, 'console')) : null;
try {
server = await startServer({ ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, preselect: options.business ?? null });
server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, preselect: options.business ?? null });
} catch (err) { sso?.close(); throw err; }
let testerServer = null;
if (testerSocket) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
@@ -0,0 +1,39 @@
// F3: POST /api/console/restart. The owner restarts the Console from Settings.
//
// The route table's hook fence has already required a signed-in session on the page's own name,
// the exact Origin and Sec-Fetch-Site: same-origin. This handler adds the owner, --send, a JSON
// `{}` body and the trail. The Console then closes and exits RESTART_EXIT, and the supervisor in
// `mosaic console start` relaunches it on the same arguments and address. Org buses are separate
// processes and keep running. A Console with no supervisor answers not-supervised and stays up.
import { actor } from './action-trail.mjs';
const json = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
};
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
async function emptyBody(req) {
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(one(req, 'content-type') ?? '')) return 'JSON body required';
let text = '';
for await (const chunk of req) { text += chunk; if (text.length > 64) return 'body too large'; }
let value;
try { value = JSON.parse(text); } catch { return 'invalid JSON'; }
return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0 ? null : 'the body is {}';
}
export async function consoleRestartHTTP(req, res, { session = null, owner = false, send = false, restart = null, trail = null, boot, log = () => {} }) {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'restarting the Console is the owner\'s' });
if (!send) return json(res, 403, { error: 'read-only', message: 'start the Console with --send to make changes' });
const invalid = await emptyBody(req);
if (invalid) return json(res, invalid === 'JSON body required' ? 415 : 400, { error: 'invalid-request', message: invalid });
if (!restart) return json(res, 409, { error: 'not-supervised', message: 'This Console was not started in the foreground by mosaic console start, so nothing would start it again. Restart it where it was started.' });
if (!trail) return json(res, 503, { error: 'trail-unavailable', message: 'this Console has no action trail, so it does not restart itself' });
try { trail({ by: actor(session), action: 'console.restart', outcome: 'restarting', boot }); }
catch { return json(res, 500, { error: 'trail-unavailable', message: 'the action trail could not be written, so the Console did not restart' }); }
log('console: restart requested by the owner');
// The answer reaches the page first; then every listener and connection closes.
res.once('finish', () => restart());
return json(res, 202, { outcome: 'restarting', boot });
}
+50 -4
View File
@@ -16,6 +16,7 @@
// POST /api/orgs/ID/setup/queue C2 queueInit mosaic org queue init
// POST /api/orgs/ID/setup/project/P C2 projectRepoInit mosaic org project repo init
// POST /api/orgs/ID/setup/bus C3 busStart mosaic bus start --detach
// POST /api/orgs/ID/restart/bus C3 busRestart mosaic bus restart (F3; busStart when stopped)
//
// GET /api/orgs (the list) and other /api/orgs/ID/... routes are S5's; this
// handler returns false for them.
@@ -25,12 +26,13 @@ import { lstatSync } from 'node:fs';
import { checkProject, initProject, projectRepo, recordContext } from '../../project/src/index.mjs';
import { refuseInsideAgent } from '../../cli/src/transport.mjs';
import { join } from 'node:path';
import { actor } from './action-trail.mjs';
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
const MAX_BODY = 64 * 1024;
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
// The org-template placeholder (packages/organization/src/org-service.mjs templateRender).
export const placeholderRoot = (id, project) => `/__mosaic_org__/${id}/projects/${project}`;
// Project fields the server owns. The browser never sees or sends them.
@@ -47,6 +49,7 @@ export function verbFor(step, id, opts = {}) {
if (step === 'delete') return `mosaic org delete ${id} --confirm ${id} --if-revision ${opts.revision}`;
if (step === 'records') return `mosaic project init ${id} ${opts.project}`;
if (step === 'bus') return `mosaic bus start ${id} --dev-local-owner --detach`;
if (step === 'bus-restart') return `mosaic bus restart ${id}`;
throw new Error(`unknown step ${step}`);
}
@@ -295,7 +298,8 @@ export function createOrgAdmin({ service, registry = null, bus = null, context }
// The handler. Returns true when it answered. `owner` is the signed-in owner
// session (provider, iss, sub) or null; `send` says --send is on; `origin` is
// the page's own origin for the write fence.
export async function orgAdminHTTP(req, res, { path, search = '', admin, session = null, owner = false, send = false, origin = null, log = () => {} }) {
// `trail` (F3) records each bus restart: who, when, which organization and the outcome.
export async function orgAdminHTTP(req, res, { path, search = '', admin, session = null, owner = false, send = false, origin = null, log = () => {}, trail = null }) {
const org = path.match(ROUTE);
const top = ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin'].includes(path) || (path === '/api/orgs' && req.method === 'POST');
if (!org && !top) return false;
@@ -311,7 +315,7 @@ export async function orgAdminHTTP(req, res, { path, search = '', admin, session
const principal = { provider: session.provider, iss: session.iss, sub: session.sub };
if (![principal.provider, principal.iss, principal.sub].every(v => typeof v === 'string' && v)) { json(res, 403, { error: 'owner-only' }); return true; }
try {
await route(req, res, { path, search, org, admin, principal, write, log });
await route(req, res, { path, search, org, admin, principal, write, log, trail, by: actor(session) });
} catch (error) {
if (error?.status) json(res, error.status, { error: error.message, ...(error.detail ? { message: error.detail } : {}), ...(error.errors ? { errors: error.errors } : {}) });
else { log(`org-admin: ${path}: ${error?.code ?? error?.name ?? 'error'}`); json(res, 500, { error: 'internal' }); }
@@ -385,7 +389,7 @@ const gitOptions = (body, res, step) => {
return null;
};
async function route(req, res, { path, search, org, admin, principal, write, log }) {
async function route(req, res, { path, search, org, admin, principal, write, log, trail = null, by = null }) {
if (path === '/api/org-presets' && !write) {
if (new URLSearchParams(search).size) return json(res, 422, { error: 'invalid', message: 'unexpected query parameter' });
const service = await need(admin, 'service', res, 'presets'); if (!service) return;
@@ -508,6 +512,7 @@ async function route(req, res, { path, search, org, admin, principal, write, log
if (kind === 'setup' && !sub && !write) {
try { return json(res, 200, await readSetup(admin, ctx, id)); } catch (e) { return refusal(res, e, { step: 'setup', id }); }
}
if (kind === 'restart') return restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by });
if (kind !== 'setup' || !sub || !write) return json(res, 404, { error: 'not found' });
// Every setup write reads the step back first and never overwrites.
@@ -553,3 +558,44 @@ async function route(req, res, { path, search, org, admin, principal, write, log
}
return json(res, 404, { error: 'not found' });
}
// F3 (REQ-WEB-3): one control restarts an organization's bus and answers with a done receipt. A
// running host restarts on the options it was started with (C3 busRestart keeps --dev-local-owner);
// a stopped one starts as setup starts it. The trail line is written before anything changes, and
// a trail that cannot be written refuses the restart. Stale host state is never cleared here.
async function restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by }) {
const step = 'bus-restart';
if (sub !== 'bus' || !write) return json(res, 404, { error: 'not found' });
const body = await readBody(req); strictKeys(body, []);
try { await service.showOrg(ctx, id); } catch (e) { return refusal(res, e, { step, id }); }
const bus = await need(admin, 'bus', res, step); if (!bus) return;
if (!trail) return json(res, 503, { error: 'trail-unavailable', step, message: 'this Console has no action trail, so it does not restart buses' });
let status;
try { status = await bus.busStatus(ctx, id); }
catch (e) {
if (['org-unknown', 'org-mismatch', 'org-unavailable', 'org-archived'].includes(e?.code)) return refusal(res, e, { step, id });
if (isCliError(e)) {
// Refused before anything changed, but the owner asked: the trail records the attempt.
try { trail({ by, action: 'bus.restart', org: id, outcome: 'refused', running: null }); } catch { log(`org-admin: ${id} bus restart: trail write failed after refused`); }
return json(res, 409, { error: 'bus-refused', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.` });
}
return refusal(res, e, { step, id });
}
const running = Number.isSafeInteger(status?.pid);
try { trail({ by, action: 'bus.restart', org: id, outcome: 'requested', running }); }
catch { return json(res, 500, { error: 'trail-unavailable', step, message: 'the action trail could not be written, so nothing was restarted' }); }
const note = outcome => { try { trail({ by, action: 'bus.restart', org: id, outcome }); } catch { log(`org-admin: ${id} bus restart: trail write failed after ${outcome}`); } };
let receipt;
try {
receipt = running ? await bus.busRestart(ctx, id) : await bus.busStart(ctx, id, { detach: true, devLocalOwner: by?.provider === 'local-dev', pm: false });
} catch (e) {
note('refused');
if (!isCliError(e)) return refusal(res, e, { step, id, unknownOk: true });
return json(res, 409, { error: /^bus failed to start\b/.test(e.message) ? 'bus-failed' : 'bus-refused', step, message: plainMessage(ctx, e.message) });
}
note('done');
log(`org-admin: ${id} bus ${running ? 'restarted' : 'started'}`);
let healthy = null;
try { healthy = (await bus.busStatus(ctx, id)).healthy; } catch { /* the receipt still stands */ }
return json(res, 200, { outcome: 'done', step, restarted: running, healthy, receipt: forReceipt(ctx, receipt), verb: running ? verbFor('bus-restart', id) : verbFor('bus', id) });
}
+84 -7
View File
@@ -10,8 +10,12 @@
const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const POLL_MS = 2000, POLL_LIMIT = 90;
const UNKNOWN = 'Outcome unknown. Read the agents again before trying; nothing was retried.';
// The same text bus.js shows for an instances.set answer (bus.js RESTART_TEXT, NO_RESTART_TEXT).
const RESTART_TEXT = 'Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
// The same text bus.js shows for an instances.set answer (bus.js restartText(), NO_RESTART_TEXT).
// F3: on an organization Console its owner restarts the bus from the receipt (console-restart.js).
const restartText = () => window.MosaicConsole?.busRestart
? 'Restart needed: the running bus keeps the roles it loaded at start. Restart the bus to load the change.'
: 'Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
const busControl = text => (String(text).includes('Restart needed:') ? window.MosaicConsole?.busButton('Restart bus') ?? '' : '');
const NO_RESTART_TEXT = 'No restart: the launcher reads the change at its next launch check.';
const DONE = { launch: 'Launched', regenerate: 'Regenerated', end: 'Terminated', delete: 'Deleted' };
let ctx = null, view = null, busy = false, receipt = '', confirming = null, pending = null, generation = 0;
@@ -30,7 +34,7 @@
const { txt, esc } = c, title = '<h2 id="agents-title" class="sr-only">Agents</h2>';
if (!v) return `<section class="panel" id="agents-section">${title}<p class="small warn">The agents could not be read, so there is nothing to act on. Read again to retry.</p></section>`;
const open=usable(), off=!open||busy||pending?' disabled':'', addOff=!open||busy||pending||c.instanceBusyOps.includes('add')?' disabled':'', changeOff=!open||busy||pending||c.instanceBusyOps.includes('change')?' disabled':'', defs=Object.keys(v.definitions||{}), accepted=defs.filter(d=>v.definitions[d]?.accepted);
const instanceStatus=op=>{const text=c.instanceReceipts[op]||'', current=c.instanceReceiptOp===op||(!c.instanceReceiptOp&&op==='add');return `<p role="status" data-instance-receipt="${op}"${current?' id="instances-receipt"':''} class="${text.startsWith('Outcome unknown')?'banner ref':text.includes('Draft kept')?'banner err':text.startsWith('Added')||text.startsWith('Changed')?'settings-success':''}">${txt(text)}</p>`;};
const instanceStatus=op=>{const text=c.instanceReceipts[op]||'', current=c.instanceReceiptOp===op||(!c.instanceReceiptOp&&op==='add');return `<p role="status" data-instance-receipt="${op}"${current?' id="instances-receipt"':''} class="${text.startsWith('Outcome unknown')?'banner ref':text.includes('Draft kept')?'banner err':text.startsWith('Added')||text.startsWith('Changed')?'settings-success':''}">${txt(text)}</p>${busControl(text)}`;};
const option=(name,selected,first)=>`<option value="">${first}</option>${accepted.map(d=>`<option value="${esc(d)}"${d===selected?' selected':''}>${txt(d)}</option>`).join('')}`;
const button=(action,name,label)=>`<button type="button"${['keep','unkeep'].includes(action)?' role="menuitem"':''} data-agent-action="${esc(action)}" data-agent-name="${esc(name)}"${off}>${txt(label)}</button>`;
const rows=Object.entries(v.instances).map(([name,e])=>{
@@ -45,7 +49,7 @@
const noHire=Array.isArray(v.noHire)?v.noHire:[], retired=Array.isArray(v.retired)?v.retired:[];
const hiring=!c.isTester?`<section class="panel"><h3>Hiring</h3><p>Turn hiring off to stop new agents of a definition.</p><div class="hiring-grid">${defs.map(n=>`<div>${open?`<button type="button" role="switch" aria-label="Hiring allowed for ${esc(n)}" aria-checked="${!noHire.includes(n)}" data-agent-action="${noHire.includes(n)?'hire':'nohire'}" data-agent-name="${esc(n)}"${off}><span class="switch-track"></span></button>`:`<span>${noHire.includes(n)?'Off':'On'}</span>`}<span class="hiring-name">${txt(n)}</span></div>`).join('')}</div>${retired.length?`<p>Retired names, never reused: ${retired.map(txt).join(', ')}.</p>`:''}</section>`:'';
const state=pending?`<p id="agents-pending">Request <code>${txt(pending.request)}</code> to ${txt(pending.action)} ${txt(pending.instance)}: ${txt(pending.state)}.</p>`:'';
return `<div id="agents-section">${title}${state}<section class="panel"><div class="table-wrap" role="region" aria-label="Agents"><table id="agents-table"><thead><tr><th id="agent-col-name" scope="col">Agent</th><th id="agent-col-definition" scope="col">Definition</th><th id="agent-col-launch" scope="col">Launch</th><th id="agent-col-kept" scope="col">Kept</th><th id="agent-col-running" scope="col">Running</th><th scope="col">Actions</th></tr></thead><tbody>${rows}</tbody></table></div><p role="status" id="agents-receipt">${txt(receipt)}</p>${!open||c.isTester?instanceStatus('add'):''}</section>${add}${hiring}<dialog id="agent-dialog" aria-labelledby="agent-dialog-title"><h2 id="agent-dialog-title"></h2><p id="agent-dialog-copy"></p><label id="agent-reason-label" hidden>Reason <input id="agent-dialog-reason" maxlength="500"></label><div><button type="button" id="agent-dialog-confirm">Confirm</button><button type="button" id="agent-dialog-cancel">Cancel</button></div></dialog></div>`;
return `<div id="agents-section">${title}${state}<section class="panel"><div class="table-wrap" role="region" aria-label="Agents"><table id="agents-table"><thead><tr><th id="agent-col-name" scope="col">Agent</th><th id="agent-col-definition" scope="col">Definition</th><th id="agent-col-launch" scope="col">Launch</th><th id="agent-col-kept" scope="col">Kept</th><th id="agent-col-running" scope="col">Running</th><th scope="col">Actions</th></tr></thead><tbody>${rows}</tbody></table></div><p role="status" id="agents-receipt">${txt(receipt)}</p>${busControl(receipt)}${!open||c.isTester?instanceStatus('add'):''}</section>${add}${hiring}<dialog id="agent-dialog" aria-labelledby="agent-dialog-title"><h2 id="agent-dialog-title"></h2><p id="agent-dialog-copy"></p><label id="agent-reason-label" hidden>Reason <input id="agent-dialog-reason" maxlength="500"></label><div><button type="button" id="agent-dialog-confirm">Confirm</button><button type="button" id="agent-dialog-cancel">Cancel</button></div></dialog></div>`;
}
// One POST. `null` means the page signed out; otherwise the status and the parsed answer.
@@ -79,7 +83,7 @@
const { ok, result } = answer;
if (!ok) { receipt = refused(result); redraw(); return; }
// Row 194 b6 (webby-0094 B1): a veto says whether the reload took, as a create does.
if (url === '/api/veto') { receipt = `${{ keep: 'Kept', unkeep: 'Unkept', nohire: 'No hire for', hire: 'Hiring allowed for' }[action]} ${name}. ${result.restart ? RESTART_TEXT : NO_RESTART_TEXT}`; reread(); return; }
if (url === '/api/veto') { receipt = `${{ keep: 'Kept', unkeep: 'Unkept', nohire: 'No hire for', hire: 'Hiring allowed for' }[action]} ${name}. ${result.restart ? restartText() : NO_RESTART_TEXT}`; reread(); return; }
if (!REQUEST_ID.test(result?.request ?? '')) { receipt = UNKNOWN; redraw(); return; }
if (action === 'end') delete reasons[name];
pending = { request: result.request, action, instance: name, state: 'open', tries: 0 };
@@ -122,7 +126,7 @@
try { answer = await post('/api/instances', { revision: view.revision, instance, op: 'add', definition }); } catch { answer = { ok: false, result: { sent: 'unknown' } }; }
busy = false;
if (answer === null) return;
if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}. ${answer.result.restart ? RESTART_TEXT : NO_RESTART_TEXT}`; reread(); }
if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}. ${answer.result.restart ? restartText() : NO_RESTART_TEXT}`; reread(); }
else { receipt = answer.ok ? UNKNOWN : `${refused(answer.result)} Draft kept.`; redraw(); }
}
@@ -151,5 +155,78 @@
// A sign-out stops any poll; nothing that was asked is retried.
document.addEventListener('mosaic-signed-out', () => { generation++; pending = null; confirming = null; });
window.MosaicAgents = Object.freeze({ section });
// F3: restart an agent so a route change reaches it now, offered by the Routing receipt. One
// terminate; then reads of the instances until the run has left (up to the 60 s regenerate
// allows); then one launch. A thread definition is not launched: its next message starts it.
// A refusal, a sign-out or an unknown outcome stops the chain and says so; nothing is retried.
const restarts = Object.create(null);
const RESTART_WAIT = 30;
function restartMarkup(name, thread = false) {
const r = restarts[name];
return `<span class="agent-restart"><button type="button" class="btn" data-agent-restart="${esc(name)}" data-agent-thread="${thread ? 'yes' : 'no'}"${r?.busy ? ' disabled' : ''}>${r?.busy ? 'Restarting…' : `Restart ${esc(name)} now`}</button> <span role="status" data-agent-restart-status="${esc(name)}">${esc(r?.text ?? '')}</span></span>`;
}
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const paintRestart = name => document.querySelectorAll('.agent-restart').forEach(el => {
const b = el.querySelector('[data-agent-restart]');
if (b?.dataset.agentRestart === name) el.outerHTML = restartMarkup(name, b.dataset.agentThread === 'yes');
});
async function send(url, payload) {
let res, result = null;
try { res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), cache: 'no-store', redirect: 'manual' }); }
catch { return { unknown: true }; }
if (res.type === 'opaqueredirect') return { signedOut: true };
try { result = await res.json(); } catch { return { unknown: true }; }
return { ok: res.ok, result };
}
// The request's closing read, or a reason the chain stops.
async function closing(request) {
for (let tries = 0; tries < POLL_LIMIT; tries++) {
await new Promise(r => setTimeout(r, POLL_MS));
let res, r = null;
try { res = await fetch(`/api/agent/request?id=${encodeURIComponent(request)}`, { cache: 'no-store', redirect: 'manual' }); } catch { continue; }
if (res.type === 'opaqueredirect') return { stop: 'Sign in again, then read the agents.' };
try { r = await res.json(); } catch { continue; }
if (res.ok && r?.request === request && r.state === 'closed') return { done: r };
}
return { stop: `Request ${request} is still open. Read the agents again later.` };
}
async function runs(name) {
try {
const res = await fetch('/api/bus/instances', { cache: 'no-store', redirect: 'manual' });
if (!res.ok || res.type === 'opaqueredirect') return null;
const e = (await res.json())?.rows?.[0]?.instances?.[name];
return e ? (Array.isArray(e.running) ? e.running.length : 0) : 0;
} catch { return null; }
}
async function restartAgent(name, thread) {
const r = restarts[name] = { busy: true, text: `Terminating ${name}…` };
const stop = text => { r.busy = false; r.text = text; paintRestart(name); };
paintRestart(name);
const asked = await send('/api/agent/terminate', { instance: name, reason: 'route change' });
if (asked.signedOut) return stop('Sign in again, then retry.');
if (asked.unknown || !asked.ok || !REQUEST_ID.test(asked.result?.request ?? '')) return stop(asked.ok || asked.unknown ? UNKNOWN : refused(asked.result));
const ended = await closing(asked.result.request);
if (ended.stop) return stop(ended.stop);
if (ended.done.reason) return stop(`${name}: ${ended.done.reason}. It was not restarted.`);
r.text = `Terminated ${name}. Waiting for its session to end…`; paintRestart(name);
let left = false;
for (let i = 0; i < RESTART_WAIT && !left; i++) { await new Promise(res => setTimeout(res, POLL_MS)); left = (await runs(name)) === 0; }
if (!left) return stop(`${name} was asked to end but its session is still running. Launch it from Agents once it has ended.`);
if (thread) return stop(`Terminated ${name} at ${new Date().toLocaleTimeString()}. Its next message starts it on the new route.`);
r.text = `Launching ${name}…`; paintRestart(name);
const launch = await send('/api/agent/launch', { instance: name });
if (launch.signedOut) return stop('Sign in again. It was terminated and not launched.');
if (launch.unknown || !launch.ok || !REQUEST_ID.test(launch.result?.request ?? '')) return stop(launch.ok || launch.unknown ? `${UNKNOWN} It was terminated.` : `${refused(launch.result)} It was terminated and not launched.`);
const started = await closing(launch.result.request);
if (started.stop) return stop(started.stop);
if (started.done.reason) return stop(`${name}: ${started.done.reason}. It was terminated and not launched.`);
return stop(`Restarted ${name} on the new route${started.done.done?.run ? ` (run ${started.done.done.run})` : ''}.`);
}
document.addEventListener('click', e => {
const b = e.target.closest?.('[data-agent-restart]');
if (!b || b.disabled || restarts[b.dataset.agentRestart]?.busy) return;
restartAgent(b.dataset.agentRestart, b.dataset.agentThread === 'yes');
});
window.MosaicAgents = Object.freeze({ section, restartMarkup, isThread: definition => threadDefinitions.has(definition) });
})();
+2 -1
View File
@@ -490,7 +490,7 @@
// (likely when the bus is down) still refreshes the list, its banner and the Settings Start control.
if (window.MosaicOrg) {
const org = (await window.MosaicOrg.readList()).find(row => row.id === window.MosaicOrg.id);
if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; return; }
if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; window.MosaicConsole?.attachBus(box, 'Restart the bus'); return; }
}
try {
const s = await api('/api/bus-status');
@@ -498,6 +498,7 @@
const b = s.boot;
box.replaceChildren(node('b', '', 'Bus down: organization file invalid. '), node('span', '', `${b.reason} `), node('span', 'small muted', `File ${b.file}. Refused at ${b.at}. Last good load: ${b.lastGood ?? 'none'}. Fix the file, then restart the bus.`));
box.hidden = false;
window.MosaicConsole?.attachBus(box, 'Restart the bus');
} catch { /* no status to show: leave the banner as it was */ }
}
async function refresh() {
+40 -16
View File
@@ -319,7 +319,7 @@
});
let configRead = Promise.resolve();
let canSend = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
let canSend = false, canMessage = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
const REPLIES = 50; // the broker's cap
function repliesHtml() {
if (replies === null) return '<p class="small warn">The replies to it could not be read. Read again to retry; <code>mosaic inbox</code> in a terminal lists them.</p>';
@@ -344,7 +344,7 @@
<button class="btn primary" type="submit" data-send aria-disabled="${sending}">${sending ? 'Sending…' : 'Send'}</button></form>${said}</section>`;
}
async function composer(get) {
if (!canSend) return '<p class="small muted">Messaging a role needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.</p>';
if (!canMessage) return '<p class="small muted">Messaging a role needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.</p>';
try { sendRoles = (await get('agents')).map(c => c.role).filter(r => typeof r === 'string' && SAFE.test(r)); } catch { sendRoles = null; }
if (receipt?.kind === 'sent') {
const r = receipt;
@@ -612,8 +612,8 @@
let heldOrganizations = { empty: false, invalid: [], error: null };
function heldTable(claims) {
const cols = ['Role', 'State', 'Attention', 'Address', 'Holder', 'Held since'];
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${c.address ? txt(c.address) : '<span class="muted">no address</span>'}</td><td>${txt(c.role || ('a session '+short(c.holder_run)))}${c.harness ? `<span class="source">${txt(c.harness)}</span>` : ''}</td><td>${when(c.at)}</td>${canSend ? `<td>${typeof c.role === 'string' && SAFE.test(c.role) ? `<button type="button" class="btn" data-message-role="${esc(c.role)}" aria-label="Message ${esc(c.role)}">Message</button>` : ''}</td>` : ''}</tr>`).join('');
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles on the board, scroll for all columns"><table class="s1-table s1-agents s1-held"><thead><tr>${cols.map(h => `<th scope="col">${h}</th>`).join('')}${canSend ? '<th scope="col"><span class="sr-only">Message</span></th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${c.address ? txt(c.address) : '<span class="muted">no address</span>'}</td><td>${txt(c.role || ('a session '+short(c.holder_run)))}${c.harness ? `<span class="source">${txt(c.harness)}</span>` : ''}</td><td>${when(c.at)}</td>${canMessage ? `<td>${typeof c.role === 'string' && SAFE.test(c.role) ? `<button type="button" class="btn" data-message-role="${esc(c.role)}" aria-label="Message ${esc(c.role)}">Message</button>` : ''}</td>` : ''}</tr>`).join('');
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles on the board, scroll for all columns"><table class="s1-table s1-agents s1-held"><thead><tr>${cols.map(h => `<th scope="col">${h}</th>`).join('')}${canMessage ? '<th scope="col"><span class="sr-only">Message</span></th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
}
function paintHeld(err, noOrganizations, invalidOrganizations, organizationError) {
heldErr = err;
@@ -982,7 +982,8 @@
// offered: binding credentials stays a hand edit. When a change needs a bus restart the receipt
// says so; the Console never restarts the bus. Drafts survive polling and refusals; nothing retries.
let instanceView=null, instanceDrafts={add:null,change:null}, instanceBusyOps=new Set(), instanceReceipt='', instanceReceiptOp=null, instanceReceipts=Object.create(null);
const RESTART_TEXT='Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
// F3: an organization Console's owner restarts the bus from the receipt (console-restart.js).
const restartText=()=>window.MosaicConsole?.busRestart?'Restart needed: the running bus keeps the roles it loaded at start. Restart the bus to load the change.':'Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
const NO_RESTART_TEXT='No restart: the launcher reads the change at its next launch check.';
function instancesSection(view){
const title='<h2 id="instances-title">Role instances</h2>';
@@ -1024,7 +1025,7 @@
try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;instanceReceipts[op]=instanceReceipt;render(false);return;}
instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false);
try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?RESTART_TEXT:NO_RESTART_TEXT}`;}
if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?restartText():NO_RESTART_TEXT}`;}
else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}}
catch{instanceReceipt=unknownInstance;}finally{instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);}
});
@@ -1035,6 +1036,21 @@
// choosing none for all three. Save sends the changed rows through routes.set. Drafts survive
// polling and refusals; nothing retries.
let routesView=null, routesDraft=null, routesBusy=false, routesReceipt='', routesEditVersion=0;
// F3: after a save, how the change reaches each saved agent: a session keeps the route it started
// with, so a running one moves when it ends (a thread when it goes idle) or when the owner
// restarts it here (agents.js restartMarkup). Read from the instances after the save.
let routesApply=null;
async function routesApplyRead(names){
let view=null;
try{const res=await fetch('/api/bus/instances',{cache:'no-store',redirect:'manual'});if(res.ok&&res.type!=='opaqueredirect')view=(await res.json())?.rows?.[0]??null;}catch{/* said below */}
return names.map(name=>{const e=view?.instances?.[name];
if(!view||!e)return {name,text:'Could not read whether it is running. A running session keeps its old route until it ends.'};
const running=Array.isArray(e.running)?e.running.length:0, thread=!!window.MosaicAgents?.isThread(e.definition);
if(e.launcher)return {name,text:'The launcher. It takes the new route at its next start.'};
if(!running)return {name,text:'Not running. Its next start uses the new route.'};
return {name,thread,restart:true,text:thread?'Running. Its session keeps the old route until the thread goes idle; the next message after that starts it on the new route. Restart it to move it now.':'Running. Its session keeps the old route until it ends. Restart it to move it now.'};});
}
const routesApplyMarkup=()=>routesApply?.length?`<ul class="routes-apply" aria-label="How the change reaches each agent">${routesApply.map(a=>`<li><b>${txt(a.name)}</b>: ${txt(a.text)}${a.restart&&canSend&&!isTester?` ${window.MosaicAgents?.restartMarkup(a.name,a.thread)??''}`:''}</li>`).join('')}</ul>`:'';
let harnessSettings=null, harnessDraft=null, harnessLoading=false, harnessReceipt='', harnessBusy=false;
function readHarnesses(){
if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true;
@@ -1070,7 +1086,7 @@
const providerControl=canSend&&!isTester?`<button type="button" id="provider-switch" data-enabled="${v.catalogue?.status!=='disabled'}">${v.catalogue?.status!=='disabled'?'Disable':'Enable'} model proxy catalogue</button><p role="status" id="provider-switch-status"></p>`:'';
const harnessControl=canSend&&!isTester?`<fieldset class="harness-control"><legend>Harnesses</legend>${harnessSettings ? [...new Set([...harnessSettings.available.harness,...(harnessSettings.plugins.harness??[])])].map(name=>`<label><input type="checkbox" data-harness="${esc(name)}"${(harnessDraft??harnessSettings.plugins.harness??[]).includes(name)?' checked':''}${harnessBusy?' disabled':''}> ${txt(name)}</label>`).join('')+` <button type="button" id="harness-save"${harnessBusy?' disabled':''}>Save harnesses</button>` : '<p>Harnesses unavailable.</p><button type="button" id="harness-read">Read harnesses again</button>'}<p role="status" id="harness-status">${txt(harnessReceipt)}</p></fieldset>`:'';
const off=v.harnesses.length?'':'<p class="small warn">No harness is switched on in the business file\'s plugins.harness, so every launch refuses harness-off and a save that changes a harness refuses too. Switch one on under Harnesses.</p>';
return `<section class="panel routing-panel" aria-labelledby="routes-title"><span class="sr-only">${title}</span>${catalogueInfo}${providerControl}${harnessControl}${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col" rowspan="2">Agent</th><th scope="colgroup" colspan="3">Primary</th><th scope="colgroup" colspan="3">Fallback</th></tr><tr>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l.replace('Fallback ','')}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><div class="settings-actions route-actions${routesDraft?' has-draft':''}"><button type="submit"${disabled}>${routesBusy?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save routes'}</button> <button type="button" id="routes-reload"${routesDraft?'':' hidden'}${routesBusy||!routesDraft?' disabled':''}>Discard draft</button><p role="status" id="routes-receipt" class="${routesReceipt.startsWith('Outcome unknown')?'banner ref':routesReceipt.includes('Draft kept')?'banner err':routesReceipt.includes('saved')?'settings-success':''}">${txt(routesReceipt)}</p></div></form></section>`;
return `<section class="panel routing-panel" aria-labelledby="routes-title"><span class="sr-only">${title}</span>${catalogueInfo}${providerControl}${harnessControl}${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col" rowspan="2">Agent</th><th scope="colgroup" colspan="3">Primary</th><th scope="colgroup" colspan="3">Fallback</th></tr><tr>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l.replace('Fallback ','')}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><div class="settings-actions route-actions${routesDraft?' has-draft':''}"><button type="submit"${disabled}>${routesBusy?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save routes'}</button> <button type="button" id="routes-reload"${routesDraft?'':' hidden'}${routesBusy||!routesDraft?' disabled':''}>Discard draft</button><p role="status" id="routes-receipt" class="${routesReceipt.startsWith('Outcome unknown')?'banner ref':routesReceipt.includes('Draft kept')?'banner err':routesReceipt.includes('saved')?'settings-success':''}">${txt(routesReceipt)}</p></div>${routesApplyMarkup()}</form></section>`;
}
function routesFields(form){const draft=Object.fromEntries(Object.keys(routesView.instances).map(name=>[name,routeCells(routesView.instances[name])]));for(const el of form.querySelectorAll('[data-route]')){const [name,key]=JSON.parse(el.dataset.route);if(draft[name])draft[name][key]=el.value;}return draft;}
// The changed rows only. A fallback with all three cells empty is cleared; one with some set goes
@@ -1112,13 +1128,13 @@
}catch(error){status.textContent=error.message;button.disabled=false;}
});
document.addEventListener('click',e=>{if(e.target.matches?.('.fallback-toggle'))e.target.closest('tr')?.classList.add('fallback-open');});
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';last.delete('routes');render(true);});
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';routesApply=null;last.delete('routes');render(true);});
document.addEventListener('submit',async e=>{
if(e.target.id!=='routes-form')return;e.preventDefault();if(!(canSend || isTester)||out()||routesBusy||!routesView)return;
routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;}
routesBusy=true;routesReceipt='';render(false);
routesBusy=true;routesReceipt='';routesApply=null;render(false);
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;}
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;routesApply=await routesApplyRead(Object.keys(payload.routes));}
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'} Draft kept.`;}}
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
});
@@ -1148,8 +1164,8 @@
});
});
// Organizations is writable when the server allows org setup, which it does in organization mode where send is off.
const readonlyPage = page => !['appearance','business'].includes(page) && !(page === 'organizations' && orgSetup) && !canSend && !isTester;
const settingsPages = [['appearance','Appearance'],['business',window.MosaicOrg ? 'Organization' : 'Business'],['limits','Launch limits'],['organizations','Organizations'],['routing','Routing'],['agents','Agents'],['assistant','Assistant'],['tokens','Token use'],['model-proxy','Model proxy'],['about','About']];
const readonlyPage = page => !['appearance','business','console'].includes(page) && !(page === 'organizations' && orgSetup) && !canSend && !isTester;
const settingsPages = [['appearance','Appearance'],['business',window.MosaicOrg ? 'Organization' : 'Business'],['limits','Launch limits'],['organizations','Organizations'],['routing','Routing'],['agents','Agents'],['assistant','Assistant'],['tokens','Token use'],['model-proxy','Model proxy'],['console','Console'],['about','About']];
function markSettingsDraft(page,dirty){const link=document.querySelector(`#settings-nav a[href="#/settings/${page}"]`), dot='<span class="settings-dot"><span class="sr-only">unsaved changes</span></span>';if(link && dirty && !link.querySelector('.settings-dot'))link.insertAdjacentHTML('beforeend',dot);const button=$('settings-disclosure');if(dirty && link?.getAttribute('aria-current')==='page' && button && !button.querySelector('.settings-dot'))button.querySelector('strong').insertAdjacentHTML('beforeend',dot);}
function settingsShell(page, body, helper='') {
const pages=isTester ? [['appearance','Appearance'],['business','Business'],['routing','Routing'],['agents','Agents'],['names','Seat names']] : settingsPages;
@@ -1164,6 +1180,8 @@
if (page==='appearance') return settingsShell(page,appearance(),'These choices apply in this browser only.');
if (page==='organizations') { await configRead; return settingsShell(page,window.MosaicOrgCreate.markup,'Create an organization from the template, edit it, and finish its setup. Each step names the command it equals.'); }
if (page==='model-proxy') { await configRead; return settingsShell(page,window.MosaicProxy.markup,'Accounts used by this instance, their priority, and the client keys assigned to agents.'); }
// F3: Settings > Console (console-restart.js). The server checks the owner, Origin and trail.
if (page==='console') { await configRead; await window.MosaicConsole?.ready; return settingsShell(page,window.MosaicConsole?.settingsMarkup()??'<section class="panel"><p class="small warn">The Console controls did not load. Reload the page to try again.</p></section>','Restart this Console on the same address and configuration.'); }
const s = await get.api('settings'), n = s.notifier || {};
let usage, names = null, instances = null, routes = null, limitRows = [], limitsRefused = false;
if (page==='tokens') { try { [usage] = await get('usage'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; } }
@@ -1715,10 +1733,14 @@
});
// Whether this server takes a send here. The form is drawn on the next Inbox render; the Board's
// Message buttons are drawn from the roles already read.
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => {
// F3: `settingsSend` lets the owner of an organization Console save Settings on the selected
// organization's bus; messaging a role still follows `send` (canMessage) and `chatSend`.
// A bus restart reads the config again and sends it here as a mosaic-config event.
const applyConfig = c => {
isTester = c?.tester === true;
canEdit = !isTester && c?.edit === true;
canSend = !isTester && c?.send === true;
canSend = !isTester && (c?.send === true || c?.settingsSend === true);
canMessage = !isTester && c?.send === true;
chatSend = !isTester && c?.chatSend === true;
orgSetup = !isTester && c?.orgSetup === true;
window.MosaicOrgBusStopped = !!window.MosaicOrg && c?.devOwner === true && orgSetup && c?.chatSend === false;
@@ -1726,7 +1748,7 @@
if (orgSetup && !$('org-new-link')) { const a = document.createElement('a'); a.id = 'org-new-link'; a.href = NEW_ORG; a.setAttribute('role', 'menuitem'); a.textContent = 'New organization'; document.querySelector('#avatar-menu > a[href="#/settings"]')?.after(a); }
ssoOn = c?.sso === true;
canDecide = !isTester && c?.decide === true;
if (isTester) {
if (isTester && !$('tester-banner')) {
const banner = document.createElement('div'); banner.id = 'tester-banner'; banner.className = 'banner ref';
banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.';
$('main').prepend(banner);
@@ -1734,7 +1756,9 @@
if (!canSend && !isTester && !ssoOn) return;
const path = routeOf().split('?')[0];
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
}).catch(() => {});
};
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {});
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') applyConfig(e.detail); });
// This context comes from the conversations renderer after its authorized read.
let projectContext = null;
document.addEventListener('mosaic-project-context', event => {
@@ -0,0 +1,172 @@
/* F3: restart the Console from Settings, restart an organization's bus, and bring an open page
back after any Console restart. The controls are the owner's: the server checks the human, the
exact Origin and Fetch Metadata, and writes the action trail. This file only asks and reports.
Reconnect: /api/config carries `boot`, an id the Console picks at each start. When a same-origin
request fails at the network level, or the owner asks for a restart, the page polls /api/config.
The same boot means the Console never went away, and the banner goes. A new boot means a new
Console process: the page reloads itself, so its scripts, icons and session match the new one.
A page with an unsaved draft does not reload; it says the Console is back and keeps the draft. */
(() => {
'use strict';
const nativeFetch = window.fetch.bind(window);
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const sleep = ms => new Promise(r => setTimeout(r, ms));
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
let config = null, boot = null, reconnecting = false, planned = false;
let restartState = 'idle', restartReceipt = '', busReceipt = '', busBusy = false;
const readConfig = () => nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })
.then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null));
const ready = readConfig().then(c => { config = c; boot = typeof c?.boot === 'string' ? c.boot : null; return c; }).catch(() => null);
const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.()
|| document.querySelector('.settings-dot')
|| [...document.querySelectorAll('#reply, #conv-reply, #doc-source, #send-body')].some(el => !!el.value?.trim()));
function banner(text, action = '') {
let box = $('console-reconnect');
if (!box) {
box = document.createElement('div'); box.id = 'console-reconnect'; box.className = 'banner ref console-reconnect';
box.setAttribute('role', 'status'); box.setAttribute('aria-live', 'polite');
($('main') ?? document.body).prepend(box);
}
box.innerHTML = `<span>${esc(text)}</span>${action}`;
box.hidden = false;
}
const hideBanner = () => { const box = $('console-reconnect'); if (box) box.hidden = true; };
// One poll loop at a time. It ends when the Console answers; it never sends anything but a read.
async function reconnect(reason) {
if (reconnecting) return;
reconnecting = true;
const started = Date.now();
banner(reason === 'restart' ? 'Console restarting. This page reconnects when it is back.' : 'The Console is not answering. Reconnecting…');
for (;;) {
const waited = Date.now() - started;
await sleep(waited < 120000 ? 1000 : 5000);
if (waited >= 120000) banner('The Console has not come back after two minutes. Check the terminal or service that started it. This page keeps trying.');
let c;
try {
const r = await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' });
// A sign-in redirect: the new Console wants a sign-in, which the reload shows.
if (r.type === 'opaqueredirect') { location.reload(); return; }
if (!r.ok) continue;
c = await r.json();
} catch { continue; }
// The process that took the restart can still answer before it closes.
if (boot !== null && c?.boot === boot) { if (planned) continue; reconnecting = false; hideBanner(); return; }
reconnecting = false; planned = false;
if (dirty()) {
boot = typeof c?.boot === 'string' ? c.boot : boot; config = c;
banner('The Console is back. Your unsaved draft is kept on this page.', ' <button type="button" class="btn" id="console-reconnect-reload">Reload now</button>');
return;
}
location.reload();
return;
}
}
document.addEventListener('click', e => { if (e.target.id === 'console-reconnect-reload') location.reload(); });
// A same-origin request that fails at the network level (the Console went away) starts the poll.
// An aborted request is an AbortError, not a TypeError, and starts nothing.
const sameOrigin = input => { try { return new URL(typeof input === 'string' ? input : input.url, location.href).origin === location.origin; } catch { return false; } };
window.fetch = (input, init) => nativeFetch(input, init).catch(err => {
if (err?.name === 'TypeError' && sameOrigin(input) && boot !== null) reconnect('lost');
throw err;
});
// Settings > Console.
function settingsMarkup() {
if (!config) return '<section class="panel"><p class="small warn">The Console settings could not be read. Reload the page to try again.</p></section>';
if (config.tester === true || config.user === true) return '<section class="panel"><p>Restarting the Console is the owner\'s.</p></section>';
const signOut = config.sso === true && config.devOwner !== true ? ' Sign-in sessions are held in memory, so everyone signs in again after it.' : '';
let body;
if (config.consoleRestart === true) {
const busy = restartState !== 'idle' ? ' disabled' : '';
const confirm = restartState === 'confirm'
? '<div class="settings-actions" id="console-restart-confirm"><p>Restart the Console now?</p><button type="button" id="console-restart-go">Restart now</button> <button type="button" id="console-restart-cancel">Cancel</button></div>' : '';
body = `<p>The Console stops, and <code>mosaic console start</code> starts it again on the same address, arguments and configuration. Organization buses are separate processes and keep running. Open pages show the restart and reconnect when it is back.${signOut}</p><p class="small muted">Each restart is recorded in the Console's action trail with your identity.</p><div class="settings-actions"><button type="button" id="console-restart"${busy}${restartState === 'confirm' ? ' hidden' : ''}>Restart Console</button></div>${confirm}`;
} else if (config.orgSetup !== true && config.send !== true) {
body = '<p>This Console is read-only, so it does not restart itself. Restart it where it was started.</p>';
} else {
body = '<p>This Console was not started in the foreground by <code>mosaic console start</code>, so nothing would start it again. Restart it where it was started: in its terminal, or with <code>systemctl --user restart</code> and its unit.</p>';
}
return `<section class="panel" aria-labelledby="console-restart-title"><h3 id="console-restart-title">Restart the Console</h3>${body}<p role="status" id="console-restart-receipt">${esc(restartReceipt)}</p></section>`;
}
const repaint = () => { const panel = $('console-restart-title')?.closest('section'); if (panel) panel.outerHTML = settingsMarkup(); };
async function restartConsole() {
restartState = 'sending'; restartReceipt = 'Asking the Console to restart…'; repaint();
let response, answer = null;
try { response = await nativeFetch('/api/console/restart', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' }); }
catch { restartState = 'idle'; restartReceipt = 'Outcome unknown. If the Console restarted, this page reconnects by itself.'; repaint(); reconnect('lost'); return; }
try { answer = await response.json(); } catch { /* an unreadable answer is refused below */ }
if (response.status === 202 && answer?.outcome === 'restarting') {
planned = true; if (typeof answer.boot === 'string') boot = answer.boot;
restartState = 'idle'; restartReceipt = `Restart requested at ${clock()}.`; repaint();
reconnect('restart');
return;
}
restartState = 'idle';
restartReceipt = response.type === 'opaqueredirect' ? 'Sign in again, then retry.' : (answer?.message || 'The restart was refused. Nothing was retried.');
repaint();
}
document.addEventListener('click', e => {
if (e.target.id === 'console-restart' && config?.consoleRestart === true) { restartState = 'confirm'; restartReceipt = ''; repaint(); $('console-restart-go')?.focus(); }
else if (e.target.id === 'console-restart-cancel') { restartState = 'idle'; repaint(); $('console-restart')?.focus(); }
else if (e.target.id === 'console-restart-go' && restartState === 'confirm') restartConsole();
});
// The org bus restart control, offered on the organization's own pages to its owner.
const busAllowed = () => !!window.MosaicOrg && config?.orgSetup === true;
function busButton(label = 'Restart bus') {
if (!busAllowed()) return '';
return `<span class="bus-restart"><button type="button" class="btn" data-bus-restart data-label="${esc(label)}"${busBusy ? ' disabled' : ''}>${esc(busBusy ? 'Restarting…' : label)}</button> <span role="status" data-bus-restart-receipt>${esc(busReceipt)}</span></span>`;
}
async function attachBus(box, label) {
await ready;
// The bus is unavailable again: an earlier receipt no longer describes it.
if (box && !box.hidden) $('bus-restart-done')?.remove();
if (!box || box.querySelector('[data-bus-restart]') || !busAllowed()) return;
box.insertAdjacentHTML('beforeend', ` ${busButton(label)}`);
}
const paintBus = () => document.querySelectorAll('.bus-restart').forEach(el => {
const label = el.querySelector('[data-bus-restart]')?.dataset.label;
el.outerHTML = busButton(label);
});
document.addEventListener('click', async e => {
const button = e.target.closest?.('[data-bus-restart]');
if (!button || button.disabled || busBusy || !busAllowed()) return;
const id = window.MosaicOrg.id;
busBusy = true; busReceipt = ''; paintBus();
try {
const response = await nativeFetch(`/api/orgs/${encodeURIComponent(id)}/restart/bus`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' });
if (response.type === 'opaqueredirect') throw new Error('Sign in again, then retry.');
const body = await response.json();
if (!response.ok || body.outcome !== 'done' || body.step !== 'bus-restart') throw new Error(body.message || 'The restart was refused. Nothing was retried.');
busReceipt = `${body.restarted ? 'Bus restarted' : 'Bus started'} at ${clock()}. ${body.healthy ? 'It answers.' : `It has not answered yet. Check it with ${body.verb.replace('restart', 'status')}.`}`;
// The page's send flags were read while the bus was in its old state; read them again. This
// read goes through the page's fetch, which display.js scopes to the selected organization.
// The organization list goes first, so the view the config event redraws shows the bus as it is now.
await Promise.resolve(window.MosaicOrg.refreshList?.()).catch(() => {});
const fresh = await window.fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => (r.ok ? r.json() : null)).catch(() => null);
if (fresh) { config = fresh; document.dispatchEvent(new CustomEvent('mosaic-config', { detail: fresh })); }
// A healthy bus ends the "Bus unavailable" banner. Its receipt stays on the page in its place.
const down = $('bus-down');
if (down && body.healthy) {
if (!down.hidden) {
$('bus-restart-done')?.remove();
const done = document.createElement('p'); done.id = 'bus-restart-done'; done.className = 'banner'; done.setAttribute('role', 'status');
done.textContent = busReceipt; down.after(done);
}
down.hidden = true;
}
} catch (error) {
busReceipt = error instanceof SyntaxError || error?.name === 'TypeError' ? 'Outcome unknown. Check the bus with mosaic bus status before retrying.' : error.message;
} finally { busBusy = false; paintBus(); }
});
window.MosaicConsole = Object.freeze({ ready, settingsMarkup, busButton, attachBus, get busRestart() { return busAllowed(); }, get config() { return config; } });
})();
+13 -3
View File
@@ -65,7 +65,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
const org = rows.find(row => row.id === decodeURIComponent(id) && !row.invalid);
const mark = document.querySelector('.wordmark');
if (mark && org) { const name = document.createElement('span'); name.id = 'current-organization'; name.textContent = ` · ${org.label || org.id}`; mark.append(name); }
if (org?.busHealthy === false) { const banner = document.getElementById('bus-down'); if (banner) { banner.textContent = 'Bus unavailable'; banner.hidden = false; } }
if (org?.busHealthy === false) { const banner = document.getElementById('bus-down'); if (banner) { banner.textContent = 'Bus unavailable'; banner.hidden = false; window.MosaicConsole?.attachBus(banner, 'Restart the bus'); } }
return rows;
};
let list = readList().then(paintHeader);
@@ -240,13 +240,23 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
});
const cleanup = () => { clearTimeout(poll); login = null; const redirect=$('proxy-login-submit')?.elements.redirect_url, key=$('proxy-apikey')?.elements.key; if(redirect)redirect.value='';if(key)key.value=''; };
window.addEventListener('pagehide', cleanup, {once:true});
let devOwner = false;
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(config => {
canWrite = config.send === true && config.tester !== true && config.user !== true;
// F3: an organization's settings writes go with settingsSend (the org Console has no global send).
canWrite = (config.send === true || config.settingsSend === true) && config.tester !== true && config.user !== true;
devOwner = config.devOwner === true;
document.querySelectorAll('[data-write]').forEach(fieldset => { fieldset.disabled = !canWrite; });
$('proxy-readonly').hidden = canWrite;
if (!canWrite) document.querySelectorAll('[aria-labelledby=proxy-signin-title],[aria-labelledby=proxy-apikey-title]').forEach(el=>el.hidden=true);
return refresh();
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message); if(error.message==='The model proxy is not configured for this instance.') { $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel, #proxy-refresh').forEach(el=>el.hidden=true); } });
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message); if(error.message==='The model proxy is not configured for this instance.') { $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel, #proxy-refresh').forEach(el=>el.hidden=true);
// F3: on a dev instance, say plainly why this page cannot set the proxy up and what does.
// Setup takes a management key typed twice at a terminal and writes the proxy's credentials;
// this page never handles that key. Account sign-in (a provider's own OAuth) then runs here.
if (devOwner) { const org = window.MosaicOrg?.id, note = document.createElement('div'); note.id = 'proxy-dev-setup'; note.className = 'panel';
note.innerHTML = '<h2>Set up the model proxy</h2><p>Nothing else on this dev Console asks for a sign-in. The proxy is the exception: its setup asks for a management key, typed twice, and writes the proxy\'s credentials. This page never handles that key, so setup runs once in a terminal on this machine:</p><pre><code></code></pre><p>Then refresh this page. Signing in to a Claude or Codex account is the provider\'s own sign-in, which cannot be skipped. It runs here, under Sign in to an account, with one button.</p>';
note.querySelector('code').textContent = `mosaic proxy fetch\nmosaic proxy init${org ? ` --org ${org}` : ''}`;
$('proxy-message').after(note); } } });
return cleanup;
}
window.MosaicProxy={markup,mount};
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script>
<script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<body>
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
<a class="skip" href="#main">Skip to content</a>
+1 -1
View File
@@ -100,7 +100,7 @@
});
window.addEventListener('pagehide', () => { endLogin(); $('proxy-apikey').elements.key.value = ''; });
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(config => {
canWrite = config.send === true && config.tester !== true && config.user !== true;
canWrite = (config.send === true || config.settingsSend === true) && config.tester !== true && config.user !== true;
document.querySelectorAll('[data-write]').forEach(fieldset => { fieldset.disabled = !canWrite; });
$('proxy-readonly').hidden = canWrite;
return refresh();
+10
View File
@@ -93,6 +93,16 @@ table.s1-table td code{font-size:12.5px;white-space:nowrap}
.banner.ref{border-color:color-mix(in srgb,var(--warning) 50%,var(--line));background:color-mix(in srgb,var(--warning) 9%,var(--surface))}
.banner b,.banner strong{color:var(--strong)}
.banner code{overflow-wrap:anywhere}
/* F3: the Console restart banner stays in view while the page waits for the Console, and the bus restart control sits inline in the receipt or banner that asks for it. */
.console-reconnect{position:sticky;top:0;z-index:30;align-items:center}
.console-reconnect>span{flex:1 1 220px;min-width:0;overflow-wrap:anywhere}
.bus-restart{display:inline-flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0}
.bus-restart [role=status]:empty{display:none}
.routes-apply{margin:12px 0 0;padding-left:18px;display:grid;gap:8px}
.routes-apply li{overflow-wrap:anywhere}
.agent-restart{display:inline-flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0}
.agent-restart [role=status]:empty{display:none}
#proxy-dev-setup pre{overflow-x:auto;max-width:100%}
/* Stream S4: which organization this is, in words. Colour (the theme) is never the only cue; the strip appears whenever the theme is not the built-in one. */
.org-cue{margin:0 0 12px;padding:6px 10px;border:1px solid var(--line);border-left:4px solid var(--action);border-radius:var(--r-table);background:var(--raised);color:var(--text);font-size:13px;overflow-wrap:anywhere}.skel-rows{display:grid;gap:10px;padding:14px 12px;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface)}
.skel{display:block;height:12px;border-radius:4px;background:var(--raised);animation:skel 1.4s ease-in-out infinite}
+36 -18
View File
@@ -2,7 +2,7 @@ import { proxyHTTP } from './proxy-http.mjs';
import { createServer } from 'node:http';
import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs';
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
import { timingSafeEqual } from 'node:crypto';
import { timingSafeEqual, randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import { isIP } from 'node:net';
import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from './bus.mjs';
@@ -19,6 +19,8 @@ import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } fro
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
import { consoleRestartHTTP } from './console-restart-http.mjs';
import { actor } from './action-trail.mjs';
import { devHostName } from '../../cli/src/dev-local-owner.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
@@ -55,7 +57,7 @@ const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
@@ -443,10 +445,11 @@ const reasonFor = (code, done) => {
return `A skill this role needs isn't turned on: scripts/skill.sh activate ${done.skill}`;
return Object.hasOwn(INSTANCE_REASONS, code) ? INSTANCE_REASONS[code] : Object.hasOwn(AGENT_REASONS, code) ? AGENT_REASONS[code] : typeof code === 'string' && code.startsWith('proxy-') ? 'Check the model proxy configuration and account status' : String(code ?? 'unknown');
};
async function instanceSend(req, res, { instancesWrite, preview, log }) {
let instance = null;
async function instanceSend(req, res, { instancesWrite, preview, log, note = null }) {
let instance = null, op = null;
// The instance is quoted and cut short so a crafted value can't forge a log line. Vars are never logged.
const line = outcome => log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
// F3 review P3: the owner's instance changes also go to the action trail, like routes and agents.
const line = outcome => { log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`); note?.({ action: 'instances.set', op, instance: typeof instance === 'string' ? instance.slice(0, 200) : null, outcome }); };
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
@@ -462,7 +465,7 @@ async function instanceSend(req, res, { instancesWrite, preview, log }) {
(has('vars') && (!value.vars || typeof value.vars !== 'object' || Array.isArray(value.vars))) || (has('launch') && typeof value.launch !== 'boolean')) {
return refuse(400, 'invalid-request', 'this takes a revision, an instance, op add or change and a definition, vars or launch, or op delete alone, nothing else');
}
({ instance } = value);
({ instance, op } = value);
// D4.1: the tester never deletes. The bus refuses it too (broker tester-refused); this makes no call.
if (deleting && preview.tester) return refuse(403, 'tester-refused', 'the tester listener doesn\'t delete. Nothing changed.');
let result;
@@ -499,10 +502,13 @@ const AGENT_ROUTES = {
'/api/veto': { kind: 'veto', verb: 'veto.set', keys: ['revision', 'op', 'name'], tester: false,
done: r => /^[a-f0-9]{64}$/.test(r?.revision ?? '') && typeof r.restart === 'boolean' && r.instances && typeof r.instances === 'object' && !Array.isArray(r.instances) },
};
async function agentSend(req, res, { route, agentWrite, preview, log }) {
// F3: the writes the owner makes on a selected organization's bus from Settings (dev org Console).
const ORG_OWNER_WRITE = path => path === '/api/settings' || path === '/api/instances' || path === '/api/routes'
|| Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/');
async function agentSend(req, res, { route, agentWrite, preview, log, note = null }) {
let target = null;
// The instance or name is quoted and cut short so a crafted value can't forge a log line. A reason is never logged.
const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(target)} ${outcome}`);
const line = outcome => { log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(target)} ${outcome}`); note?.({ action: route.verb, instance: typeof target === 'string' ? target.slice(0, 200) : null, outcome }); };
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
@@ -562,10 +568,11 @@ const ROUTE_REASONS = {
};
const routeObject = o => o && typeof o === 'object' && !Array.isArray(o);
const routesAnswer = v => /^[a-f0-9]{64}$/.test(v?.revision ?? '') && Array.isArray(v.harnesses) && Array.isArray(v.models) && routeObject(v.levels) && routeObject(v.instances);
async function routesSend(req, res, { routesWrite, preview, log }) {
// `note` (F3) adds the owner's change to the Console's action trail; best effort, after the log line.
async function routesSend(req, res, { routesWrite, preview, log, note = null }) {
// Instance names only; routes are never logged.
let names = [];
const line = outcome => log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`);
const line = outcome => { log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`); note?.({ action: 'routes.set', instances: names, outcome }); };
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
@@ -763,7 +770,7 @@ async function libraryStatusRead(res, read, search) {
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined } = {}) {
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID() } = {}) {
// org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host
// names (without a port) a remote browser may use; only the dev owner takes them, and only with
// them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback.
@@ -790,11 +797,14 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const registered = routeTable();
for (const registerRoutes of routes) registerRoutes(registered);
registered.add('GET', '/api/themes', (req, res) => themesHTTP(req, res, { log }), { publicRead: true });
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log });
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log, trail });
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore|restart)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
for (const method of ['GET', 'POST']) registered.add(method, path, adminRoute);
}
registered.add('POST', '/api/orgs', adminRoute);
// F3: the owner restarts the Console (console-restart-http.mjs). `restart` is set only under the
// supervisor; `boot` names this process so an open page can tell when the new one answers.
registered.add('POST', '/api/console/restart', (req, res, context) => consoleRestartHTTP(req, res, { ...context, restart, trail, boot, log }));
const upstream = boardURL(board);
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
@@ -942,7 +952,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null,
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, upstream = null } = selected);
}
if (!hook && !['GET', 'HEAD'].includes(req.method) && !decisionPath && path !== '/api/north-star/approve' && !(/^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f-]+\/(?:messages|open))$/i.test(path)))
// F3: the owner's settings writes reach the selected organization's bus too (limits, routing,
// agents, assistant, instances, model proxy). The routes below keep their own checks.
if (!hook && !['GET', 'HEAD'].includes(req.method) && !decisionPath && path !== '/api/north-star/approve' && !(/^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f-]+\/(?:messages|open))$/i.test(path))
&& !(orgId !== null && !tester && !granted && named && ORG_OWNER_WRITE(path)))
return json(res, 403, { error: 'unavailable', sent: false });
}
if (hook) {
@@ -953,6 +966,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return hook.handler(req, res, { path: originalPath, search, session: ssoSession, orgId, origin, owner: !tester && !granted && named && sso !== null, send: owner.send !== null });
}
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer;
// F3: the owner's routing and agent changes go to the action trail too (who, when, what).
const ownerNote = !tester && !granted && ssoSession && trail ? entry => {
try { trail({ by: actor(ssoSession), ...(orgId !== null ? { org: orgId } : {}), ...entry }); }
catch { log(`${new Date().toISOString()} trail write failed for ${entry.action}`); }
} : null;
// P4 page and verbs are owner-only, including reads and static page assets.
// This runs after owner sign-in and the granted-user fence, before generic
// tester writes or static-file serving. No localhost preview grants access.
@@ -1008,13 +1026,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (path === '/api/instances') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if ((!tester && (!send || !named)) || !instancesWrite) return json(res, 403, { error: 'read-only', sent: false });
return instanceSend(req, res, { instancesWrite, preview: writer, log });
return instanceSend(req, res, { instancesWrite, preview: writer, log, note: ownerNote });
}
// Row 194 (#1692, D7): the Agents section. No route gets a --send exception.
if (Object.hasOwn(AGENT_ROUTES, path)) {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if ((!tester && (!send || !named)) || (tester && !AGENT_ROUTES[path].tester) || !agentWrite) return json(res, 403, { error: 'read-only', sent: false });
return agentSend(req, res, { route: AGENT_ROUTES[path], agentWrite, preview: writer, log });
return agentSend(req, res, { route: AGENT_ROUTES[path], agentWrite, preview: writer, log, note: ownerNote });
}
if (path === '/api/agent/request') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
@@ -1024,7 +1042,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (path === '/api/routes') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if ((!tester && (!send || !named)) || !routesWrite) return json(res, 403, { error: 'read-only', sent: false });
return routesSend(req, res, { routesWrite, preview: writer, log });
return routesSend(req, res, { routesWrite, preview: writer, log, note: ownerNote });
}
if (path === '/api/library/revert') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
@@ -1138,7 +1156,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy } : {}), decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && !granted && named && sso !== null && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
+2 -1
View File
@@ -218,7 +218,8 @@ test('row 194 N5: agents.js is served as JavaScript and index.html loads it once
const s = await setup(t);
const js = await get(s, '/agents.js');
assert.equal(js.status, 200); assert.match(js.type, /^text\/javascript/);
assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section \}\)/);
// F3 adds the agent restart receipt and the thread test that Settings > Routing uses.
assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section, restartMarkup, isThread: definition => threadDefinitions\.has\(definition\) \}\)/);
assert.match(js.text, /Agent actions are unavailable here\. Use mosaic org instance from the instance CLI\./);
const html = await get(s, '/');
assert.equal(html.text.match(/<script src="\/agents\.js" defer><\/script>/g)?.length, 1);
+2 -2
View File
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false);
@@ -0,0 +1,101 @@
// F3: the owner restarts the Console from Settings > Console. The real supervisor in
// `mosaic console start` relaunches the Console on the same address, and the open page shows the
// restart and comes back by itself, with no manual reload. A page holding an unsaved draft keeps
// it and offers the reload instead of taking it.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
import { AGENT_MARKERS } from '../../cli/src/transport.mjs';
import { consoleStart, consoleStop } from '../../cli/src/org-console.mjs';
import { browser } from './browser.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
const sleep = ms => new Promise(r => setTimeout(r, ms));
// The page reloads on its own, so a check can land between two documents; it is retried.
async function until(b, expression, ms = 30000) {
const deadline = Date.now() + ms;
for (;;) {
try { if (await b.evaluate(`!!(${expression})`)) return; } catch { /* between documents */ }
if (Date.now() > deadline) throw new Error(`timed out: ${expression}`);
await sleep(50);
}
}
// Every banner the page shows is kept in sessionStorage, which outlives the page's own reload.
const WATCH = `(() => {
const seen = JSON.parse(sessionStorage.getItem('f3-seen') || '[]');
new MutationObserver(() => {
const box = document.getElementById('console-reconnect');
if (box && !box.hidden && seen.at(-1) !== box.textContent) { seen.push(box.textContent); sessionStorage.setItem('f3-seen', JSON.stringify(seen)); }
}).observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
return true;
})()`;
test('Settings > Console restarts the Console under its supervisor and the open page reconnects by itself', { timeout: 120000 }, async t => {
const home = mkdtempSync(join(tmpdir(), 'f3-'));
const env = { ...process.env, HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-f3test/config.json') };
for (const k of AGENT_MARKERS) delete env[k];
delete env.NODE_TEST_CONTEXT;
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'f3test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-f3test'), dataRoot: join(home, '.mosaic-f3test'), instance: 'f3test', env };
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
const warnings = [];
const run = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) });
t.after(async () => { run.stop(); await run.done; try { await consoleStop(ctx, { warn: () => {} }); } catch {} rmSync(home, { recursive: true, force: true }); });
const stateFile = join(ctx.dataRoot, 'console/console.json');
const pidNow = () => JSON.parse(readFileSync(stateFile, 'utf8')).pid;
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`${run.url}#/settings/console`);
await until(b, 'document.getElementById("console-restart")');
const boot1 = await b.evaluate('window.MosaicConsole.config.boot');
assert.equal(typeof boot1, 'string');
assert.match(await b.evaluate('document.getElementById("console-restart-title").closest("section").textContent'), /Organization buses are separate processes and keep running/);
await b.evaluate(`window.__f3 = 'first'`); await b.evaluate(WATCH);
// The button asks first; Cancel restarts nothing.
await b.evaluate('document.getElementById("console-restart").click()');
await until(b, 'document.getElementById("console-restart-cancel")');
await b.evaluate('document.getElementById("console-restart-cancel").click()');
await until(b, '!document.getElementById("console-restart-go") && !document.getElementById("console-restart").hidden');
assert.equal(pidNow(), run.pid);
await b.evaluate('document.getElementById("console-restart").click()');
await until(b, 'document.getElementById("console-restart-go")');
await b.evaluate('document.getElementById("console-restart-go").click()');
// The page reloads itself once the new Console answers: the marker is gone, the boot is new.
await until(b, `window.__f3 === undefined && window.MosaicConsole?.config?.boot && window.MosaicConsole.config.boot !== ${JSON.stringify(boot1)}`);
const seen1 = JSON.parse(await b.evaluate(`sessionStorage.getItem('f3-seen')`));
assert.equal(seen1[0], 'Console restarting. This page reconnects when it is back.');
assert.equal(await b.evaluate('location.hash'), '#/settings/console');
await until(b, 'document.getElementById("console-restart")');
const pid2 = pidNow();
assert.notEqual(pid2, run.pid);
assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).url, run.url);
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
const trail = readFileSync(join(ctx.dataRoot, 'console/actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
assert.deepEqual(trail.map(e => [e.action, e.outcome, e.by.provider, e.boot]), [['console.restart', 'restarting', 'local-dev', boot1]]);
// An unsaved draft (a reply being written) is not thrown away: the page says the Console is
// back and offers the reload. The textarea stands in for the chat reply box.
const boot2 = await b.evaluate('window.MosaicConsole.config.boot');
await b.evaluate(`(() => { window.__f3 = 'second'; sessionStorage.removeItem('f3-seen'); const t = document.createElement('textarea'); t.id = 'reply'; t.value = 'half a reply'; document.body.append(t); return true; })()`);
await b.evaluate(WATCH);
await b.evaluate('document.getElementById("console-restart").click()');
await until(b, 'document.getElementById("console-restart-go")');
await b.evaluate('document.getElementById("console-restart-go").click()');
await until(b, 'document.getElementById("console-reconnect-reload")');
assert.equal(await b.evaluate('window.__f3'), 'second');
assert.equal(await b.evaluate('document.getElementById("reply").value'), 'half a reply');
assert.deepEqual(JSON.parse(await b.evaluate(`sessionStorage.getItem('f3-seen')`)), ['Console restarting. This page reconnects when it is back.', 'The Console is back. Your unsaved draft is kept on this page. Reload now']);
assert.notEqual(await b.evaluate('window.MosaicConsole.config.boot'), boot2);
assert.notEqual(pidNow(), pid2);
await b.evaluate('document.getElementById("console-reconnect-reload").click()');
await until(b, `window.__f3 === undefined && document.getElementById("console-restart")`);
assert.equal(readFileSync(join(ctx.dataRoot, 'console/actions.jsonl'), 'utf8').split('\n').filter(Boolean).length, 2);
});
+284
View File
@@ -0,0 +1,284 @@
// F3: the owner's Console restart and the organization settings writes, through the real server.
// Each control is refused without the human, refused from another origin, works for the dev local
// owner and writes the action trail. No test reaches a live Console, a real config or a secret.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { actionTrail } from '../src/action-trail.mjs';
import { consoleAccess } from '../src/access.mjs';
import { close } from './fixture.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
const REVISION = 'a'.repeat(64);
const REQUEST = '10000000-0000-4000-8000-000000000001';
const ROUTES = { assistant: { harness: 'claude', model: 'opus', thinking: 'high' } };
const ROUTES_ANSWER = { revision: 'b'.repeat(64), harnesses: [], models: [], levels: {}, instances: { assistant: ROUTES.assistant } };
function call(target, { method = 'GET', path, headers = {}, body }) {
return new Promise((resolve, reject) => {
const all = { ...headers };
for (const k of Object.keys(all)) if (all[k] === null) delete all[k];
if (body !== undefined) all['content-length'] = Buffer.byteLength(body);
const req = request({ ...target, method, path, headers: all }, res => {
let raw = '';
res.on('data', c => { raw += c; });
res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch { /* not JSON */ } resolve({ status: res.statusCode, body: json, raw }); });
});
req.on('error', reject);
req.end(body);
});
}
const scratch = t => { const root = mkdtempSync(join(tmpdir(), 'f3-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
const lines = dir => (existsSync(join(dir, 'actions.jsonl')) ? readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []);
const spy = () => { const calls = []; let hit; const once = new Promise(r => { hit = r; }); const fn = () => { calls.push(Date.now()); hit(); }; return { fn, calls, once }; };
// A loopback Console under the dev local owner with --send; `opts` overrides any option.
async function devConsole(t, opts = {}) {
const root = scratch(t), dir = join(root, 'console'), restart = spy();
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), log: () => {}, restart: restart.fn, trail: actionTrail(dir), ...opts });
t.after(() => close(server));
const port = server.address().port, host = `127.0.0.1:${port}`, origin = `http://${host}`;
const write = (path, body = '{}', headers = {}) => call({ host: '127.0.0.1', port }, { method: 'POST', path, body, headers: { host, origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...headers } });
const read = async path => (await call({ host: '127.0.0.1', port }, { path, headers: { host } })).body;
return { port, host, origin, dir, root, restart, write, read };
}
const FENCE = port => [
[{ origin: null }, 'cross-origin request refused'],
[{ origin: 'http://evil.example' }, 'cross-origin request refused'],
[{ origin: `http://localhost:${port + 1}` }, 'cross-origin request refused'],
[{ 'sec-fetch-site': null }, 'cross-site request refused'],
[{ 'sec-fetch-site': 'cross-site' }, 'cross-site request refused'],
[{ 'sec-fetch-site': 'same-site' }, 'cross-site request refused'],
[{ host: `evil.example:${port}` }, 'non-local Host refused'],
[{ 'x-mosaic-tester': '1' }, 'tester listener required'],
];
test('the dev owner restarts a supervised Console once, after the answer, and the trail names them', async t => {
const c = await devConsole(t);
const config = await c.read('/api/config');
assert.equal(config.consoleRestart, true);
assert.match(config.boot, /^[0-9a-f-]{36}$/);
const r = await c.write('/api/console/restart');
assert.equal(r.status, 202, r.raw);
assert.deepEqual(r.body, { outcome: 'restarting', boot: config.boot });
await c.restart.once;
assert.equal(c.restart.calls.length, 1);
const trail = lines(c.dir);
assert.equal(trail.length, 1);
const [{ at, ...entry }] = trail;
assert.ok(Number.isFinite(Date.parse(at)));
assert.deepEqual(entry, { by: DEV, action: 'console.restart', outcome: 'restarting', boot: config.boot });
assert.equal(statSync(join(c.dir, 'actions.jsonl')).mode & 0o777, 0o600);
assert.equal(statSync(c.dir).mode & 0o777, 0o700);
});
test('a Console restart from another origin, site, Host or the tester header is refused and leaves no trail', async t => {
const c = await devConsole(t);
for (const [headers, error] of FENCE(c.port)) {
const r = await c.write('/api/console/restart', '{}', headers);
assert.equal(r.status, 403, JSON.stringify(headers));
assert.equal(r.body.error, error, JSON.stringify(headers));
}
assert.equal(c.restart.calls.length, 0);
assert.deepEqual(lines(c.dir), []);
});
test('without the signed-in owner there is no Console restart', async t => {
const root = scratch(t), dir = join(root, 'console'), restart = spy();
// A loopback Console with no sign-in has no owner at all.
const anon = await startServer({ port: 0, board: 'http://127.0.0.1:9', log: () => {}, restart: restart.fn, trail: actionTrail(dir) });
t.after(() => close(anon));
const ap = anon.address().port, ah = `127.0.0.1:${ap}`;
const a = await call({ host: '127.0.0.1', port: ap }, { method: 'POST', path: '/api/console/restart', body: '{}', headers: { host: ah, origin: `http://${ah}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
assert.equal(a.status, 403); assert.equal(a.body.error, 'owner-only');
assert.equal((await call({ host: '127.0.0.1', port: ap }, { path: '/api/config', headers: { host: ah } })).body.consoleRestart, false);
// Under --sso: signed out, a granted user, then the signed-in owner.
const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
const s = await createSsoSession(t, { users: consoleAccess({ load: () => access, business: () => 'demo' }) });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: s.sso, send: async () => ({}), log: () => {}, restart: restart.fn, trail: actionTrail(dir) });
t.after(() => close(web));
const port = web.address().port, target = { host: '127.0.0.1', port };
const post = cookie => call(target, { method: 'POST', path: '/api/console/restart', body: '{}', headers: s.writeHeaders(cookie) });
const out = await post(null);
assert.equal(out.status, 401); assert.equal(out.body.error, 'sign-in required');
const alice = await s.signIn(port, { sub: 'alice-sub' });
const granted = await post(alice);
assert.equal(granted.status, 403); assert.equal(granted.body.error, 'owner-only');
assert.equal(restart.calls.length, 0);
assert.deepEqual(lines(dir), []);
const owner = await s.signIn(port);
assert.equal((await call(target, { path: '/api/config', headers: s.readHeaders(owner) })).body.consoleRestart, true);
const ok = await post(owner);
assert.equal(ok.status, 202, ok.raw);
await restart.once;
const [entry] = lines(dir);
assert.equal(entry.by.provider, 'writer'); assert.equal(entry.by.sub, 'owner-sub'); assert.equal(entry.by.name, 'jason');
assert.equal(entry.action, 'console.restart');
});
test('the tester listener never restarts the Console', async t => {
const root = scratch(t), dir = join(root, 'console'), restart = spy(), socketPath = join(root, 'tester.sock');
const server = await startServer({ tester: true, testerSocket: socketPath, board: 'http://127.0.0.1:9', log: () => {}, restart: restart.fn, trail: actionTrail(dir) });
t.after(() => close(server));
const headers = { host: 'mosaic-tester.invalid', origin: 'http://mosaic-tester.invalid', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
const r = await call({ socketPath }, { method: 'POST', path: '/api/console/restart', body: '{}', headers });
assert.equal(r.status, 403); assert.equal(r.body.error, 'owner-only');
assert.equal((await call({ socketPath }, { path: '/api/config', headers: { host: 'mosaic-tester.invalid' } })).body.consoleRestart, false);
assert.equal(restart.calls.length, 0);
assert.deepEqual(lines(dir), []);
});
test('a Console without --send, its supervisor or a safe trail says so and stays up', async t => {
const readOnly = await devConsole(t, { send: null });
assert.equal((await readOnly.read('/api/config')).consoleRestart, false);
const ro = await readOnly.write('/api/console/restart');
assert.equal(ro.status, 403); assert.equal(ro.body.error, 'read-only');
const detached = await devConsole(t, { restart: null });
assert.equal((await detached.read('/api/config')).consoleRestart, false);
const d = await detached.write('/api/console/restart');
assert.equal(d.status, 409); assert.equal(d.body.error, 'not-supervised');
assert.deepEqual(lines(detached.dir), []);
const noTrail = await devConsole(t, { trail: null });
assert.equal((await noTrail.read('/api/config')).consoleRestart, false);
const n = await noTrail.write('/api/console/restart');
assert.equal(n.status, 503); assert.equal(n.body.error, 'trail-unavailable');
const broken = await devConsole(t, { trail: () => { throw new Error('disk full'); } });
const b = await broken.write('/api/console/restart');
assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable');
// A trail directory that is a symlink is unsafe; the restart is refused, nothing is written.
const root = scratch(t), real = join(root, 'real');
mkdirSync(real, { mode: 0o700 }); symlinkSync(real, join(root, 'console'));
const linked = await devConsole(t, { trail: actionTrail(join(root, 'console')) });
const l = await linked.write('/api/console/restart');
assert.equal(l.status, 500); assert.equal(l.body.error, 'trail-unavailable');
assert.equal(existsSync(join(real, 'actions.jsonl')), false);
for (const c of [readOnly, detached, noTrail, broken, linked]) assert.equal(c.restart.calls.length, 0);
});
test('the Console restart body is exactly {} as JSON', async t => {
const c = await devConsole(t);
for (const [body, headers, status] of [
['{}', { 'content-type': 'text/plain' }, 415],
['{}', { 'content-type': null }, 415],
['{"now":true}', {}, 400],
['[]', {}, 400],
['nope', {}, 400],
['', {}, 400],
]) {
const r = await c.write('/api/console/restart', body, headers);
assert.equal(r.status, status, `${body} ${JSON.stringify(headers)}`);
}
assert.equal(c.restart.calls.length, 0);
assert.deepEqual(lines(c.dir), []);
});
// The organization settings writes. `alpha` answers; `beta` is offline.
function orgs() {
const routed = [], launched = [], changed = [];
const organizations = {
list: async () => [{ id: 'alpha', label: 'Alpha', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: false }],
resolve: async id => (['alpha', 'beta'].includes(id) ? {
busHealthy: id === 'alpha', send: async () => ({}),
routesWrite: async args => { routed.push([id, args]); return ROUTES_ANSWER; },
agentWrite: async (verb, args) => { launched.push([id, verb, args]); return { request: REQUEST }; },
instancesWrite: async args => { changed.push([id, args]); return { revision: REVISION, restart: false, definitions: {}, instances: { coder: {} } }; },
} : null),
};
return { organizations, routed, launched, changed };
}
test('the dev owner changes an organization agent route and restarts it, and the trail names the organization', async t => {
const o = orgs();
const c = await devConsole(t, { organizations: o.organizations });
const alpha = await c.read('/api/orgs/alpha/config');
assert.equal(alpha.send, false); assert.equal(alpha.settingsSend, true); assert.equal(alpha.orgSetup, true);
assert.equal((await c.read('/api/orgs/beta/config')).settingsSend, false, 'an offline organization offers no settings writes');
const r = await c.write('/api/orgs/alpha/routes', JSON.stringify({ revision: REVISION, routes: ROUTES }));
assert.equal(r.status, 200, r.raw);
assert.deepEqual(o.routed, [['alpha', { revision: REVISION, routes: ROUTES }]]);
const launch = await c.write('/api/orgs/alpha/agent/launch', JSON.stringify({ instance: 'assistant' }));
assert.equal(launch.status, 200, launch.raw);
assert.deepEqual(o.launched, [['alpha', 'instance.launch', { instance: 'assistant' }]]);
const trail = lines(c.dir).map(({ at, ...e }) => e);
assert.deepEqual(trail, [
{ by: DEV, org: 'alpha', action: 'routes.set', instances: ['assistant'], outcome: 'done' },
{ by: DEV, org: 'alpha', action: 'instance.launch', instance: 'assistant', outcome: `done request=${REQUEST}` },
]);
// The limits route passes the gate and is refused by its own check: alpha has no settings transport.
const limits = await c.write('/api/orgs/alpha/settings', '{}');
assert.equal(limits.status, 403); assert.equal(limits.body.error, 'read-only');
assert.equal((await c.write('/api/orgs/missing/routes', JSON.stringify({ revision: REVISION, routes: ROUTES }))).status, 404);
});
test('F3 review P3: an organization instance change appends its trail line, as the README promises', async t => {
const o = orgs();
const c = await devConsole(t, { organizations: o.organizations });
const r = await c.write('/api/orgs/alpha/instances', JSON.stringify({ revision: REVISION, instance: 'coder', op: 'change', vars: {} }));
assert.equal(r.status, 200, r.raw);
assert.deepEqual(o.changed, [['alpha', { revision: REVISION, instance: 'coder', op: 'change', vars: {} }]]);
assert.deepEqual(lines(c.dir).map(({ at, ...e }) => e), [
{ by: DEV, org: 'alpha', action: 'instances.set', op: 'change', instance: 'coder', outcome: 'done restart=false' },
]);
});
test('organization settings writes from another origin or site are refused before the bus', async t => {
const o = orgs();
const c = await devConsole(t, { organizations: o.organizations });
const body = JSON.stringify({ revision: REVISION, routes: ROUTES });
for (const [headers] of FENCE(c.port)) {
for (const path of ['/api/orgs/alpha/routes', '/api/orgs/alpha/agent/terminate']) {
const r = await c.write(path, path.endsWith('routes') ? body : JSON.stringify({ instance: 'assistant' }), headers);
assert.equal(r.status, 403, `${path} ${JSON.stringify(headers)}`);
}
}
assert.deepEqual(o.routed, []); assert.deepEqual(o.launched, []);
});
test('organization settings writes need the signed-in owner: no sign-in, the tester listener and a granted user are refused', async t => {
const o = orgs(), root = scratch(t), dir = join(root, 'console');
const body = JSON.stringify({ revision: REVISION, routes: ROUTES });
const anon = await startServer({ port: 0, board: 'http://127.0.0.1:9', organizations: o.organizations, log: () => {}, trail: actionTrail(dir) });
t.after(() => close(anon));
const ap = anon.address().port, ah = `127.0.0.1:${ap}`;
const a = await call({ host: '127.0.0.1', port: ap }, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: { host: ah, origin: `http://${ah}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
assert.equal(a.status, 404); assert.equal(a.body.error, 'org-unavailable');
const socketPath = join(root, 'tester.sock');
const tester = await startServer({ tester: true, testerSocket: socketPath, board: 'http://127.0.0.1:9', organizations: o.organizations, log: () => {}, trail: actionTrail(dir) });
t.after(() => close(tester));
const tr = await call({ socketPath }, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: { host: 'mosaic-tester.invalid', origin: 'http://mosaic-tester.invalid', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
assert.equal(tr.status, 404); assert.equal(tr.body.error, 'org-unavailable');
const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
const s = await createSsoSession(t, { users: consoleAccess({ load: () => access, business: () => 'demo' }) });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: s.sso, send: async () => ({}), organizations: o.organizations, log: () => {}, trail: actionTrail(dir) });
t.after(() => close(web));
const port = web.address().port, target = { host: '127.0.0.1', port };
const alice = await s.signIn(port, { sub: 'alice-sub' });
const g = await call(target, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: s.writeHeaders(alice) });
assert.equal(g.status, 403); assert.equal(g.body.error, 'owner-only');
assert.deepEqual(o.routed, []);
assert.deepEqual(lines(dir), []);
// The signed-in owner on a deployed (non-dev) Console reaches the organization the same way.
const owner = await s.signIn(port);
const ok = await call(target, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: s.writeHeaders(owner) });
assert.equal(ok.status, 200, ok.raw);
assert.equal(o.routed.length, 1);
const [entry] = lines(dir);
assert.equal(entry.by.sub, 'owner-sub'); assert.equal(entry.org, 'alpha'); assert.equal(entry.action, 'routes.set');
});
+86
View File
@@ -9,6 +9,7 @@ import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { createOrgAdmin, orgAdminHTTP, browserProblems, pathFromMessage, toCanonical, forBrowser, plainMessage } from '../src/org-admin.mjs';
import { actionTrail } from '../src/action-trail.mjs';
import { close } from './fixture.mjs';
class OrgError extends Error { constructor(code, message = code) { super(message); this.code = code; } }
@@ -37,6 +38,7 @@ function fakes() {
busStatus: async (ctx, id) => { get(id); return { org: id, instance: 'test', root: `/data/org-runtime/${id}`, socket: '/run/s', healthy: running === id, pid: running === id ? 4242 : null }; },
busStart: async (ctx, id, opts) => { calls.push(['busStart', id, opts]); running = id; return { org: id, pid: 4242, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
busStopForDelete: async (ctx, id) => { calls.push(['busStopForDelete', id]); running = null; return { stopped: false, reason: 'no host runs' }; },
busRestart: async (ctx, id) => { calls.push(['busRestart', id]); running = id; return { org: id, pid: 4343, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
};
const admin = createOrgAdmin({ service, registry, bus, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
return { admin, orgs, calls, fail, service };
@@ -479,3 +481,87 @@ test('archive requires the injected bus stop and both lifecycle routes require o
for (const step of ['archive', 'restore']) assert.equal((await readonly.call('POST', `/api/orgs/acme/${step}`, { revision: 1 })).status, 403);
assert.equal(called, false);
});
// F3 (REQ-WEB-3): the owner restarts an organization's bus from the page, with a done receipt.
const trailIn = t => { const root = mkdtempSync(join(tmpdir(), 'f3-trail-')); t.after(() => rmSync(root, { recursive: true, force: true })); const dir = join(root, 'console'); return { dir, trail: actionTrail(dir), lines: () => { try { return readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.ok(Number.isFinite(Date.parse(at))); return e; }); } catch (e) { if (e.code === 'ENOENT') return []; throw e; } } }; };
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
test('the owner starts a stopped bus and restarts a running one, and the trail records each', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
assert.equal((await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') })).status, 201);
let r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(r.status, 200, r.raw);
assert.deepEqual(r.body, { outcome: 'done', step: 'bus-restart', restarted: false, healthy: true, receipt: { org: 'acme', pid: 4242, socket: '<host path>', root: '<data root>/org-runtime/acme' }, verb: 'mosaic bus start acme --dev-local-owner --detach' });
// A stopped bus starts as setup starts it, keeping the dev local owner.
assert.deepEqual(c.calls.filter(x => x[0] === 'busStart').map(x => x.slice(1)), [['acme', { detach: true, devLocalOwner: true, pm: false }]]);
r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(r.status, 200, r.raw);
assert.equal(r.body.restarted, true); assert.equal(r.body.healthy, true); assert.equal(r.body.verb, 'mosaic bus restart acme');
assert.equal(r.body.receipt.pid, 4343);
assert.deepEqual(c.calls.filter(x => x[0] === 'busRestart'), [['busRestart', 'acme']]);
assert.deepEqual(tr.lines(), [
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: false },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: true },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
]);
});
test('a bus restart from another origin or site, without the owner or --send, or for no organization is refused', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
for (const headers of [{ origin: null }, { origin: 'http://evil.example' }, { origin: `http://localhost:${c.port + 1}` }, { 'sec-fetch-site': null }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': 'same-site' }, { 'x-mosaic-tester': '1' }]) {
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {}, headers);
assert.equal(r.status, 403, JSON.stringify(headers));
}
assert.equal((await c.call('POST', '/api/orgs/acme/restart/bus', {}, { 'content-type': 'text/plain' })).status, 415);
assert.equal((await c.call('POST', '/api/orgs/acme/restart/bus', { now: true })).status, 400);
assert.equal((await c.call('GET', '/api/orgs/acme/restart/bus')).status, 404);
assert.equal((await c.call('POST', '/api/orgs/acme/restart/queue', {})).status, 404);
const unknown = await c.call('POST', '/api/orgs/ghost/restart/bus', {});
assert.equal(unknown.status, 404); assert.equal(unknown.body.error, 'org-unavailable');
const readOnly = await console_(t, { trail: tr.trail, send: null });
const ro = await readOnly.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(ro.status, 403); assert.equal(ro.body.error, 'read-only');
const anon = await console_(t, { trail: tr.trail, sso: null, send: null });
const a = await anon.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(a.status, 403); assert.equal(a.body.error, 'owner-only');
assert.equal(c.calls.filter(x => ['busStart', 'busRestart'].includes(x[0])).length, 0);
assert.deepEqual(tr.lines(), []);
});
test('a bus restart with no trail, or a trail that cannot be written, changes nothing', async t => {
const c = await console_(t);
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(r.status, 503); assert.equal(r.body.error, 'trail-unavailable');
const broken = await console_(t, { trail: () => { throw new Error('disk full'); } });
await broken.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
const b = await broken.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable');
for (const x of [c, broken]) assert.equal(x.calls.filter(y => ['busStart', 'busRestart'].includes(y[0])).length, 0);
});
test('a bus that refuses to restart is a visible failure and the trail says refused', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
await c.call('POST', '/api/orgs/acme/setup/bus', {});
const bus = await c.admin.bus();
bus.busRestart = async () => { throw Object.assign(new Error('bus failed to start; see /data/org-runtime/acme/host/host.log'), { name: 'CliError', exitCode: 3 }); };
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
assert.equal(r.body.message, 'bus failed to start; see <data root>/org-runtime/acme/host/host.log');
assert.deepEqual(tr.lines().map(e => e.outcome), ['requested', 'refused']);
// A host whose status cannot be read (a stale lock) is refused before anything changes; the
// attempt is still in the trail, and nothing restarts or starts.
bus.busStatus = async () => { throw Object.assign(new Error('stale bus host lock'), { name: 'CliError', exitCode: 3 }); };
const before = c.calls.length;
const s = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(s.status, 409); assert.equal(s.body.error, 'bus-refused');
assert.equal(s.body.message, 'stale bus host lock. Inspect it with mosaic bus status acme; mosaic bus recover acme clears a host that died.');
assert.deepEqual(tr.lines().slice(2), [{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'refused', running: null }]);
assert.equal(c.calls.slice(before).filter(y => ['busStart', 'busRestart'].includes(y[0])).length, 0);
});
+4 -1
View File
@@ -54,7 +54,10 @@ test('organization routes bind reads and writes to the authorized organization',
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);
assert.equal((await post('/api/orgs/alpha/settings', {}))[1].error, 'unavailable');
// 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');
});
@@ -0,0 +1,105 @@
// F3: after a route save, Settings > Routing says how the change reaches each saved agent, read
// from the instances: the launcher and a stopped agent take it at their next start, a running
// session keeps its old route until it ends (a thread when it goes idle), and a running agent
// offers "Restart now". Restart now terminates with the reason "route change", waits for the
// session to end and launches it again; a thread is only terminated. Real server under the dev
// local owner, a stub bus, every write gated by the browser's own Origin and recorded in the trail.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { actionTrail } from '../src/action-trail.mjs';
import { close } from './fixture.mjs';
const REV = 'a'.repeat(64), REV2 = 'b'.repeat(64);
const END = '10000000-0000-4000-8000-0000000000e1', LAUNCH = '10000000-0000-4000-8000-0000000000a1', END2 = '10000000-0000-4000-8000-0000000000e2';
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
const route = thinking => ({ harness: 'claude', model: 'opus', thinking, fallback: null });
test('F3 Settings > Routing: a save lists how the change reaches each agent, and Restart now moves a running one', { timeout: 120000 }, async t => {
const root = mkdtempSync(join(tmpdir(), 'f3r-')); t.after(() => rmSync(root, { recursive: true, force: true }));
const entry = (definition, extra) => ({ definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [], alive: [], ...extra });
const instances = {
chief: entry('chief', { launcher: true }),
coder: entry('coder', { running: ['run-1'], alive: ['run-1'] }),
assistant: entry('assistant', { running: ['run-a'], alive: ['run-a'] }),
reviewer: entry('reviewer', { launched: false }),
};
const view = () => structuredClone({ business: 'acme', revision: REV, launch: true, noHire: [], retired: [], instances,
definitions: Object.fromEntries(Object.values(instances).map(e => [e.definition, { services: [], accepted: true }])) });
let routes = { revision: REV, harnesses: ['claude'], models: ['opus'], levels: { claude: ['low', 'high'] },
instances: Object.fromEntries(Object.keys(instances).map(n => [n, route('high')])) };
const asked = [], requests = new Map();
const bus = { messages: async () => ({ messages: [], unread: 0 }), usage: async () => { throw Error('no usage'); }, instances: async () => view(), routes: async () => structuredClone(routes) };
const routesWrite = async args => {
asked.push(['routes.set', args]);
routes = { ...routes, revision: REV2, instances: { ...routes.instances, ...structuredClone(args.routes) } };
return structuredClone(routes);
};
const agentWrite = async (verb, args) => {
asked.push([verb, args]);
if (verb === 'instance.end') {
const id = args.instance === 'coder' ? END : END2;
requests.set(id, { instance: args.instance, action: 'end' });
// The session ends a moment after the request closes, so the page has to wait for it.
setTimeout(() => { instances[args.instance].running = []; instances[args.instance].alive = []; }, 400);
return { request: id, runs: [...instances[args.instance].running], cancelled: [], taken: [] };
}
if (verb === 'instance.launch') { requests.set(LAUNCH, { instance: args.instance, action: 'launch' }); instances[args.instance].running = ['run-2']; return { request: LAUNCH }; }
if (verb === 'instance.request') {
const r = requests.get(args.request);
return { request: args.request, instance: r.instance, action: r.action, state: 'closed', done: { run: r.action === 'launch' ? 'run-2' : null, transaction: null, refusal: null }, cancelled: null };
}
throw Error(`unexpected ${verb}`);
};
const dir = join(root, 'console');
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), bus, routesWrite, agentWrite,
reads: { settings: () => ({ business: 'acme', release: 'fixture', notifier: {} }) }, trail: actionTrail(dir), log: () => {} });
t.after(() => close(web));
const b = await browser(); t.after(() => b.close());
const wait = (expr, ms = 20000) => b.evaluate(`(async()=>{const end=Date.now()+${ms};for(;;){if(${expr})return true;if(Date.now()>end)throw Error('wait failed: '+${JSON.stringify(expr)});await new Promise(r=>setTimeout(r,30));}})()`);
const cell = (name, key) => `[...document.querySelectorAll('[data-route]')].find(e=>e.dataset.route===${JSON.stringify(JSON.stringify([name, key]))})`;
const items = () => b.evaluate(`[...document.querySelectorAll('.routes-apply li')].map(li=>{const c=li.cloneNode(true);c.querySelector('.agent-restart')?.remove();return c.textContent.trim();})`);
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`);
await wait(`${cell('coder', 'thinking')}&&!${cell('coder', 'thinking')}.disabled`);
assert.equal(await b.evaluate(`!!document.querySelector('.routes-apply')`), false, 'nothing is listed before a save');
for (const name of ['chief', 'coder', 'assistant', 'reviewer']) await b.evaluate(`(()=>{const s=${cell(name, 'thinking')};s.value='low';s.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`);
await b.evaluate(`document.getElementById('routes-form').requestSubmit();true`);
await wait(`document.querySelector('.routes-apply')`);
assert.deepEqual(asked, [['routes.set', { revision: REV, routes: { chief: route('low'), coder: route('low'), assistant: route('low'), reviewer: route('low') } }]]);
assert.equal(await b.evaluate(`document.getElementById('routes-receipt').textContent`), 'Routes saved for chief, coder, assistant, reviewer. The next launch of each uses the new route.');
assert.deepEqual(await items(), [
'chief: The launcher. It takes the new route at its next start.',
'coder: Running. Its session keeps the old route until it ends. Restart it to move it now.',
'assistant: Running. Its session keeps the old route until the thread goes idle; the next message after that starts it on the new route. Restart it to move it now.',
'reviewer: Not running. Its next start uses the new route.',
]);
// Only the running agents offer the restart, and the thread is marked as one.
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('.routes-apply [data-agent-restart]')].map(e=>[e.dataset.agentRestart,e.dataset.agentThread,e.textContent])`),
[['coder', 'no', 'Restart coder now'], ['assistant', 'yes', 'Restart assistant now']]);
// Restart now on a session: terminate with the reason, wait for it to end, then one launch.
await b.evaluate(`document.querySelector('[data-agent-restart="coder"]').click();true`);
await wait(`document.querySelector('[data-agent-restart-status="coder"]')?.textContent==='Restarted coder on the new route (run run-2).'`, 30000);
assert.deepEqual(asked.slice(1).filter(([v]) => v !== 'instance.request'), [['instance.end', { instance: 'coder', reason: 'route change' }], ['instance.launch', { instance: 'coder' }]]);
assert.equal(asked.filter(([v, a]) => v === 'instance.launch' && a.instance === 'coder').length, 1, 'one launch, after the session ended');
// A thread is terminated, not launched: its next message starts it on the new route.
await b.evaluate(`document.querySelector('[data-agent-restart="assistant"]').click();true`);
await wait(`/^Terminated assistant at .+\\. Its next message starts it on the new route\\.$/.test(document.querySelector('[data-agent-restart-status="assistant"]')?.textContent??'')`, 30000);
assert.equal(asked.filter(([v, a]) => v === 'instance.launch' && a.instance === 'assistant').length, 0);
// The trail names the dev owner for the save and each lifecycle write.
const trail = readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.match(at, /^\d{4}-\d\d-\d\dT/); return e; });
assert.deepEqual(trail.map(e => [e.action, e.by?.provider, e.outcome?.split(' ')[0]]), [
['routes.set', 'local-dev', 'done'], ['instance.end', 'local-dev', 'done'], ['instance.launch', 'local-dev', 'done'], ['instance.end', 'local-dev', 'done'],
]);
assert.deepEqual(trail[0].by, DEV);
await b.viewport(390, 844);
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=390`), true, 'no sideways scroll at phone width');
});
@@ -122,5 +122,16 @@ test('an unconfigured proxy is one empty state without forms or a false read-onl
await b.navigate(f.base+'/#/settings/model-proxy');await wait('document.querySelector("#proxy-message")?.textContent.includes("not configured")');
assert.equal(await b.evaluate('document.querySelector("#proxy-message").classList.contains("empty")'),true);
assert.equal(await b.evaluate('!!document.querySelector(".settings-readonly")'),false);
assert.equal(await b.evaluate('[...document.querySelectorAll(".settings-model-proxy .panel:not(#proxy-dev-setup)")].every(e=>e.hidden)'),true);
// F3: a dev Console adds one note naming the terminal setup. It holds no form and no input: the
// management key is never typed into this page.
assert.equal(await b.evaluate('document.querySelector("#proxy-dev-setup code")?.textContent'),'mosaic proxy fetch\nmosaic proxy init');
assert.equal(await b.evaluate('document.querySelectorAll("#proxy-dev-setup form,#proxy-dev-setup input,#proxy-dev-setup textarea").length'),0);
});
test('an unconfigured proxy without the dev owner shows no setup note', {timeout:90000}, async t => {
const {f,b,wait}=await w15Fixture(t,{scenario:{'/api/config':{send:true},'GET /api/proxy/accounts':{status:503,body:{error:'not-configured'}},'GET /api/proxy/keys':{status:503,body:{error:'not-configured'}}}});
await b.navigate(f.base+'/#/settings/model-proxy');await wait('document.querySelector("#proxy-message")?.textContent.includes("not configured")');
assert.equal(await b.evaluate('!!document.querySelector("#proxy-dev-setup")'),false);
assert.equal(await b.evaluate('[...document.querySelectorAll(".settings-model-proxy .panel")].every(e=>e.hidden)'),true);
});