Files
stack/packages/cli/src/org-console.mjs
T

459 lines
28 KiB
JavaScript

// Instance-wide Console process; organization routing is owned by S5.
import { randomBytes } from 'node:crypto';
import { processState } from '../../seat/src/process-state.mjs';
import { spawn } from 'node:child_process';
import { request as httpRequest } from 'node:http';
import { constants, closeSync, fstatSync, ftruncateSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync, writeSync } from 'node:fs';
import { basename, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
import { cmdlineOf, startTimeOf } from './host.mjs';
import { CliError } from './errors.mjs';
import { devLanOptions } from './dev-local-owner.mjs';
import { refuseInsideAgent } from './transport.mjs';
const WEBUI = fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url));
const forbidden = new Set([7332, 7340, 7341, 33223, 17450, 17443, 44307]);
const fail = message => { throw new CliError(message, 3); };
const directory = ctx => join(ctx.dataRoot, 'console');
const stateFile = ctx => join(directory(ctx), 'console.json');
function state(ctx) {
let fd;
try { fd = openSync(stateFile(ctx), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (e) { if (e.code === 'ENOENT') return null; fail('console state is unreadable'); }
let data;
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.nlink !== 1) fail('unsafe console state');
try { data = JSON.parse(readFileSync(fd, 'utf8')); } catch { fail('console state is unreadable'); }
} finally { closeSync(fd); }
if (!Number.isSafeInteger(data?.pid) || data.pid <= 0 || typeof data.startTime !== 'string' || !data.startTime || typeof data.url !== 'string') fail('console state is invalid');
// org-lan-bind: a LAN-exposed Console records where it listens and the names it answers.
if (data.bind !== undefined || data.allowHosts !== undefined) {
try { if (!Array.isArray(data.allowHosts) || data.allowHosts.some(name => typeof name !== 'string' || name.includes(',')) || !devLanOptions({ devOwner: true, bind: data.bind, names: data.allowHosts })) throw new Error(); }
catch { fail('console state is invalid'); }
}
return data;
}
const lanWarning = (bind, port) => `WARNING: dev owner Console listens on ${bind}:${port} with no sign-in; anyone who can reach it acts as the owner.\n`;
function checkDir(ctx) {
mkdirSync(directory(ctx), { recursive: true, mode: 0o700 });
const st = lstatSync(directory(ctx));
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o700) fail('unsafe console directory');
}
function liveConsole(s) {
let p;
try { p = processState(s.pid); } catch { fail('console process identity is uncertain'); }
return p.alive && p.startTime === s.startTime;
}
function removeStale(ctx, s, warn) {
if (liveConsole(s)) return false;
const file = stateFile(ctx), st = lstatSync(file);
const current = state(ctx);
if (!current || current.pid !== s.pid || current.startTime !== s.startTime || current.url !== s.url) fail('console state changed during recovery');
if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('unsafe console state');
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const aside = `${file}.stale-${stamp}-${randomBytes(4).toString('hex')}`;
const currentStat = lstatSync(file);
if (currentStat.dev !== st.dev || currentStat.ino !== st.ino) fail('console state changed during recovery');
renameSync(file, aside);
warn(`mosaic: stale console state moved to ${aside}\n`);
return true;
}
// E2E6 fix 4: what a start.lock that already exists names. Null when it names no holder: unreadable,
// empty (a holder between its open and its write), not a regular file of this user, or a bad pid or
// start time. Such a lock is never cleared. A lock its holder marked released ({"released":true},
// written through the holder's own descriptor) names no live holder and is cleared (f6c review P3-1).
function lockHolder(path) {
let fd;
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch { return null; }
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || st.nlink !== 1 || st.size > 4096) return null;
const data = JSON.parse(readFileSync(fd, 'utf8'));
if (data?.released === true && Object.keys(data).length === 1) return { released: true, dev: st.dev, ino: st.ino };
if (!Number.isSafeInteger(data?.pid) || data.pid <= 0 || typeof data.startTime !== 'string' || !/^\d+$/.test(data.startTime)) return null;
return { pid: data.pid, startTime: data.startTime, dev: st.dev, ino: st.ino };
} catch { return null; } finally { closeSync(fd); }
}
// True when the process the lock names (pid and start time) is gone. A /proc read that cannot settle
// it counts as live, so an uncertain holder is never cleared.
function holderGone(holder) {
if (holder.released) return true;
let p;
try { p = processState(holder.pid); } catch { return false; }
return !p.alive || p.startTime !== holder.startTime;
}
// Moves aside and removes the lock the holder check read, and only that file: a lock another start
// took in between is put back and the clear refuses.
function clearLock(path, holder) {
const aside = `${path}.dead-${randomBytes(4).toString('hex')}`;
try { renameSync(path, aside); } catch { fail('console start lock changed while it was being cleared; run the command again'); }
const st = lstatSync(aside);
if (st.dev !== holder.dev || st.ino !== holder.ino) {
// Put back without replacing: if a third start took the path meanwhile, the aside stays for a human.
let restored = false;
try { linkSync(aside, path); restored = true; } catch { /* left aside */ }
if (restored) rmSync(aside, { force: true });
fail('console start lock changed while it was being cleared; run the command again');
}
rmSync(aside, { force: true });
}
function takeLock(ctx, warn = () => {}) {
checkDir(ctx);
const path = join(directory(ctx), 'start.lock');
let fd;
for (let cleared = false; fd === undefined;) {
try { fd = openSync(path, 'wx', 0o600); }
catch (e) {
if (e?.code !== 'EEXIST' || cleared) fail('console start lock exists');
// E2E6 fix 4: a lock whose named holder (pid and start time) is gone is cleared once, and the
// take tried again. A live holder, or a lock that names none, refuses and stays.
const holder = lockHolder(path);
if (!holder) fail('console start lock exists and names no holder; remove console/start.lock under the data root once no console start or stop runs');
if (!holderGone(holder)) fail(`console start lock is held by process ${holder.pid}, which still runs; wait for its start or stop to finish`);
clearLock(path, holder);
warn(holder.released ? 'mosaic: cleared a console start lock its holder had released\n' : `mosaic: cleared a console start lock left by process ${holder.pid}, which is gone\n`);
cleared = true;
}
}
// D1: the lock names its holder, so a detached start that timed out can clear the lock of the
// supervisor it killed (clearDeadLock), and a later start or stop can clear it once that holder is gone.
try { writeSync(fd, JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid) })); } catch { /* an unnamed lock is only never cleared */ }
// f6c review P3-1: the release marks its own file released, through its own descriptor, and removes
// the path only while the path is still that file. A clear that moved the lock aside and puts it back
// after this release puts back a released lock, which the next start or stop clears; and the release
// never removes a lock another start took meanwhile.
// A failed marker write (disk full) still removes the path: an empty lock would refuse forever.
return () => {
try {
try { ftruncateSync(fd, 0); writeSync(fd, JSON.stringify({ released: true }), 0); } catch { /* the matched unlink below still runs */ }
const own = fstatSync(fd), now = lstatSync(path);
if (own.dev === now.dev && own.ino === now.ino) rmSync(path, { force: true });
} catch { /* gone already, or moved aside by a clear */ } finally { closeSync(fd); }
};
}
// Removes start.lock only when it names this exact process (pid and start time) and that process
// is gone. A lock that is unreadable, unnamed or another holder's stays.
function clearDeadLock(ctx, pid, startTime) {
if (!startTime) return;
const path = join(directory(ctx), 'start.lock');
let owner;
try {
const st = lstatSync(path);
if (!st.isFile() || st.uid !== process.getuid()) return;
owner = JSON.parse(readFileSync(path, 'utf8'));
} catch { return; }
if (owner?.pid !== pid || owner.startTime !== startTime || startTimeOf(pid) === startTime) return;
rmSync(path, { force: true });
}
// 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 deadline = Date.now() + 30000;
while (Date.now() < deadline && !exited) {
const text = readFileSync(log, 'utf8');
// The URL is loopback, or the specific address a LAN Console binds.
const match = /Mosaic Console: (http:\/\/([0-9.]+):(\d+)\/)/.exec(text);
if (match) {
if (match[2] !== '127.0.0.1' && match[2] !== lan?.bind) fail('console printed an unexpected address');
if (forbidden.has(Number(match[3]))) fail('console selected a forbidden port');
const startTime = startTimeOf(child.pid);
if (!startTime) fail('console exited during startup');
return { child, done, url: match[1], port: match[3], startTime };
}
await sleep(50);
}
// The log is named under the data root, never by host path (PR #1916).
fail(`console failed to start; see console/${basename(log)} under the data root`);
} catch (e) {
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 };
}
// D1 (#1873): `--dev-local-owner --detach` runs the foreground supervision above in a process of
// its own session (console-supervisor.mjs), so Restart Console works on a detached dev Console. The
// supervisor takes the start lock and writes the console state as a foreground start does. This
// process waits for the supervisor's one-line reply, passes its warnings on and leaves it running.
const SUPERVISOR = fileURLToPath(new URL('./console-supervisor.mjs', import.meta.url));
async function detachedSupervisor(ctx, { port, bind, allowHosts, warn }) {
checkDir(ctx);
lastStamp = Math.max(Date.now(), lastStamp + 1);
const name = `supervisor-${process.pid}-${lastStamp}.log`;
const fd = openSync(join(directory(ctx), name), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
let child;
try {
child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: ctx.instance, port, bind, allowHosts })],
{ env: ctx.env ?? process.env, detached: true, stdio: ['ignore', 'pipe', fd] });
} finally { closeSync(fd); }
const supervisorStart = child.pid ? startTimeOf(child.pid) : null;
const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
// The log is named under the data root, never by host path (PR #1916).
const unclear = `console failed to start; see console/${name} under the data root`;
let text = '', timer;
child.stdout.setEncoding('utf8');
const line = await Promise.race([
new Promise(resolve => {
child.stdout.on('data', c => { text += c; const n = text.indexOf('\n'); if (n >= 0) resolve(text.slice(0, n)); else if (text.length > 65536) resolve(null); });
child.stdout.once('end', () => resolve(null));
}),
new Promise(resolve => { timer = setTimeout(() => resolve(undefined), 60000); }),
]);
clearTimeout(timer);
child.stdout.destroy();
// The supervisor leads its own process group, with the Console it may have started: a timed-out
// start signals that group, then kills it if it is still there after 10 seconds. A supervisor
// killed while it held the start lock never released it, so the lock it named is cleared.
if (line === undefined) {
const group = signal => { try { process.kill(-child.pid, signal); } catch { /* already gone */ } };
group('SIGTERM');
let grace; const exited = await Promise.race([done.then(() => true), new Promise(r => { grace = setTimeout(() => r(false), 10000); })]);
clearTimeout(grace);
if (!exited) { group('SIGKILL'); await done; }
clearDeadLock(ctx, child.pid, supervisorStart);
fail(unclear);
}
let reply = null;
try { reply = JSON.parse(line); } catch { /* no reply */ }
for (const w of Array.isArray(reply?.warnings) ? reply.warnings : []) if (typeof w === 'string') warn(w);
child.unref();
if (typeof reply?.receipt?.url !== 'string' || !Number.isSafeInteger(reply.receipt.pid)) {
const code = [1, 2, 3, 4].includes(reply?.exitCode) ? reply.exitCode : 1;
throw new CliError(typeof reply?.error === 'string' ? reply.error : unclear, code);
}
const result = { ...reply.receipt, supervisor: child.pid };
Object.defineProperty(result, 'done', { value: done });
Object.defineProperty(result, 'stop', { value: () => child.kill('SIGTERM') });
return result;
}
// `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 dev-local-owner Console runs under a
// detached supervisor (D1); any other detached Console has none.
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); }
// D1 fix round (security review P1): a dev-local-owner Console acts as the owner with no sign-in,
// so an agent run never starts one, detached or not. agent-guard (#1873): nor any other Console.
// This process's own environment is checked as well as the one given for the Console (security
// re-check P1). A Restart Console relaunch goes through relaunch(), not here.
refuseInsideAgent(process.env); if (ctx.env) refuseInsideAgent(ctx.env);
if (detach && devLocalOwner) return detachedSupervisor(ctx, { port, bind, allowHosts, warn });
const release = takeLock(ctx, warn);
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, warn);
try {
const s = state(ctx);
if (!s || removeStale(ctx, s, warn)) return { stopped: false };
if (!liveConsole(s)) fail('console process changed before stop');
const argv = cmdlineOf(s.pid);
if (!argv || !argv.length) fail('console process identity is uncertain');
const i = argv.indexOf(WEBUI);
// The other entry script is a host path, so the refusal never names it (PR #1916).
if (i < 0 || argv[i + 1] !== 'serve') fail('console was started from another checkout; stop it there');
process.kill(s.pid, 'SIGTERM');
const deadline = Date.now() + 20000;
while (Date.now() < deadline) {
let p; try { p = processState(s.pid); } catch { fail('console process identity is uncertain'); }
if (!p.alive || p.startTime !== s.startTime) { rmSync(stateFile(ctx)); return { stopped: true, pid: s.pid }; }
await new Promise(r => setTimeout(r, 100));
}
fail('console did not stop');
} finally { release(); }
}
// Read-only: what the state file says and whether that process still runs. It never moves a
// stale state file (start and stop do that under the start lock).
export function consoleStatus(ctx, { warn = s => process.stderr.write(s) } = {}) {
const s = state(ctx);
if (!s) return { running: false };
const running = liveConsole(s);
const bind = s.bind ?? '127.0.0.1';
const port = /:(\d+)\/$/.exec(s.url)?.[1] ?? 'unknown';
// The same warning start printed, for as long as a Console started with the LAN flags runs.
if (running && s.bind) warn(lanWarning(bind, port));
return { running, ...(running ? {} : { stale: true }), pid: s.pid, url: s.url, bind, ...(s.allowHosts ? { allowHosts: s.allowHosts } : {}) };
}
// U1 (#1873): `mosaic console restart` and `mosaic org reload ID` are the F3 page controls from a
// terminal. Each one is a request to the running Console named by console.json, sent as the page
// sends it: the Console's own Host and Origin, Sec-Fetch-Site: same-origin and a JSON `{}` body.
// The Console's route applies its owner, --send and origin checks and writes the action trail;
// nothing here writes the trail, signals a process or touches a bus. The CLI verbs refuse inside
// an agent run before they get here (transport.mjs refuseInsideAgent).
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
function consoleTarget(ctx) {
const s = state(ctx);
if (!s) fail('no Console runs for this instance; start one with mosaic console start');
if (!liveConsole(s)) fail('the Console in console state is not running; see mosaic console status');
const url = /^http:\/\/([0-9.]+):(\d+)\/$/.exec(s.url);
if (!url) fail('console state is invalid');
const port = Number(url[2]);
if (forbidden.has(port)) fail('console state names a forbidden port');
// A LAN Console on 0.0.0.0 answers on loopback too; one bound to an address answers only there.
const address = s.bind && s.bind !== '0.0.0.0' ? s.bind : '127.0.0.1';
return { address, port, host: `${address}:${port}` };
}
function consoleCall(target, { method = 'GET', path, timeoutMs, body = {} }) {
return new Promise((resolve, reject) => {
const payload = method === 'POST' ? JSON.stringify(body) : undefined;
const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } : {}) };
let sent = false;
const req = httpRequest({ host: target.address, port: target.port, method, path, headers, timeout: timeoutMs }, res => {
let text = '';
res.setEncoding('utf8');
res.on('data', chunk => { text += chunk; if (text.length > 65536) req.destroy(new Error('the Console answer is too large')); });
res.on('end', () => { let body = null; try { body = JSON.parse(text); } catch { /* not JSON */ } resolve({ status: res.statusCode, body }); });
res.on('error', e => reject(Object.assign(e, { sent: true })));
});
req.on('timeout', () => req.destroy(Object.assign(new Error('timed out'), { code: 'timeout' })));
req.on('finish', () => { sent = true; });
req.on('error', e => reject(Object.assign(e, { sent })));
req.end(payload);
});
}
// A refused connection never reached the Console. Anything else after the request left may have
// changed something, so it is outcome-unknown and nothing is retried.
async function consolePost(target, path, { timeoutMs, unknown, body }) {
let answer;
try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs, body }); }
catch (e) {
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
throw new CliError(`outcome unknown: ${unknown}`, 1);
}
return answer;
}
function refusal(answer, what) {
const code = typeof answer.body?.error === 'string' && /^[a-z-]{1,64}$/.test(answer.body.error) ? answer.body.error : `http ${answer.status}`;
const message = typeof answer.body?.message === 'string' ? `: ${answer.body.message.slice(0, 500)}` : '';
return new CliError(`${what} refused (${code})${message}`, answer.status >= 500 ? 1 : 3);
}
// POST /api/console/restart, then wait for the relaunched Console (a new boot id on /api/config).
export async function consoleRestart(ctx, { timeoutMs = 10000, waitMs = 30000 } = {}) {
const target = consoleTarget(ctx);
const answer = await consolePost(target, '/api/console/restart', { timeoutMs, unknown: 'the Console may be restarting; check mosaic console status and the action trail' });
if (answer.status !== 202 || answer.body?.outcome !== 'restarting' || typeof answer.body.boot !== 'string') throw refusal(answer, 'console restart');
const from = answer.body.boot, deadline = Date.now() + waitMs;
while (Date.now() < deadline) {
await sleep(250);
try {
const config = await consoleCall(target, { path: '/api/config', timeoutMs: 2000 });
if (config.status === 200 && typeof config.body?.boot === 'string' && config.body.boot !== from) return { outcome: 'restarted', from, boot: config.body.boot };
} catch { /* closed while it restarts */ }
}
throw new CliError('the Console accepted the restart but has not answered since; check mosaic console status', 1);
}
// POST /api/orgs/ID/restart/bus: a running bus restarts, a stopped one starts (org-admin.mjs restartBus).
export async function consoleBusRestart(ctx, id, { timeoutMs = 120000 } = {}) {
if (typeof id !== 'string' || !ORG_ID.test(id)) throw new CliError('invalid organization id', 4);
const target = consoleTarget(ctx);
const answer = await consolePost(target, `/api/orgs/${id}/restart/bus`, { timeoutMs, unknown: `the bus may be restarting; check mosaic bus status ${id} and the action trail` });
if (answer.status !== 200 || answer.body?.outcome !== 'done' || answer.body.step !== 'bus-restart') throw refusal(answer, 'org reload');
return answer.body;
}
// E2 (#1873, P1-3): the owner's org page actions from a terminal (org-owner.mjs): the hire form and
// the autonomy page, each through the running Console's own org route, sent as the page sends it.
// The route applies its owner, --send, origin, body and live-session checks and asks the org's own
// bus; nothing here decides or writes anything. A GET that fails changed nothing, so it is exit 1
// with no outcome-unknown wording; a POST that fails after it left is outcome-unknown.
export async function consoleOrgRequest(ctx, id, { method = 'GET', path, body, timeoutMs = 30000, unknown }) {
if (typeof id !== 'string' || !ORG_ID.test(id)) throw new CliError('invalid organization id', 4);
const target = consoleTarget(ctx);
const route = `/api/orgs/${id}/${path}`;
if (method === 'POST') return consolePost(target, route, { timeoutMs, unknown, body });
try { return await consoleCall(target, { path: route, timeoutMs }); }
catch (e) {
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
throw new CliError('the Console did not answer; see mosaic console status', 1);
}
}