// 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); } }