Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebe2af86de | ||
|
|
1947a056fb | ||
|
|
7ffe932250 |
No files matched your search
+1
-1
@@ -333,7 +333,7 @@ For local development on a named instance only, `mosaic bus start BUSINESS --dev
|
||||
|
||||
`mosaic console start --dev-local-owner --dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens that dev owner Console to other machines, for UI testing from another device on a trusted LAN. It has no sign-in: anyone who can reach ADDR:PORT acts as the owner. Start, and `mosaic console status` while that Console runs, print `WARNING: dev owner Console listens on ADDR:PORT with no sign-in; anyone who can reach it acts as the owner.` on stderr. ADDR is `0.0.0.0` or an IPv4 address, and a specific address must also be one of the names. NAME is a host name or IPv4 address without a port; give several as one comma-separated value. The Console answers only `NAME:<bound port>` and the loopback names, and takes a write only when Origin is `http://` plus that same Host. A browser on such an http origin sends no Fetch Metadata, so for an allowed NAME a write with that exact Origin and no `Sec-Fetch-Site` counts as same-origin; a `Sec-Fetch-Site` the browser did send is still checked. Both flags are refused without `--dev-local-owner`, and `--dev-insecure-bind` is refused without a `--dev-allow-host`. SSO, preview and tester Consoles stay on loopback. The Console state file records `bind` and `allowHosts`. `mosaic console status` prints the state as JSON (`bind` is `127.0.0.1` for a loopback Console) and never moves a stale state file.
|
||||
|
||||
`mosaic console restart` and `mosaic org reload ORG` are the Console's Settings controls (F3) from a terminal (U1, #1873). Each sends one request to the running Console named in the instance's console state, as the page sends it (the Console's own Host and Origin, `Sec-Fetch-Site: same-origin`, a JSON `{}` body), so the Console applies the same owner, `--send` and origin checks and writes the same action trail, `<dataRoot>/console/actions.jsonl`. Both refuse inside an agent run (exit 3) before reading anything, and refuse with exit 3 when no Console runs. `console restart` works only for a Console started in the foreground by `mosaic console start` (otherwise the Console answers `not-supervised`); it waits up to 30 seconds for the relaunched Console to answer and prints `{"outcome":"restarted","from":OLD,"boot":NEW}`. `org reload ORG` restarts a running bus or starts a stopped one and prints the Console's done receipt. A request that left but got no answer is reported as outcome unknown (exit 1) and is never retried; check `mosaic console status`, `mosaic bus status ORG` and the trail. `mosaic bus restart ORG` remains the trail-less host verb.
|
||||
`mosaic console restart` and `mosaic org reload ORG` are the Console's Settings controls (F3) from a terminal (U1, #1873). Each sends one request to the running Console named in the instance's console state, as the page sends it (the Console's own Host and Origin, `Sec-Fetch-Site: same-origin`, a JSON `{}` body), so the Console applies the same owner, `--send` and origin checks and writes the same action trail, `<dataRoot>/console/actions.jsonl`. Both refuse inside an agent run (exit 3) before reading anything, and refuse with exit 3 when no Console runs. `console restart` works only for a Console started in the foreground by `mosaic console start`, or by `mosaic console start --dev-local-owner --detach`, which leaves a detached supervisor (`packages/cli/src/console-supervisor.mjs`, its own session) running the Console as the foreground start does; its receipt adds the supervisor's `supervisor` pid, and its later warnings go to `console/supervisor-*.log` under the data root. `mosaic console stop` stops that Console, and its supervisor then exits. `mosaic console start --dev-local-owner` refuses inside an agent run (exit 3), with or without `--detach`, checking the starting process's own environment as well as the one given for the Console; a start without `--dev-local-owner` is unchanged. A detached start whose supervisor does not reply within 60 seconds signals the supervisor's process group, then kills it, and clears `console/start.lock` only when the lock names that supervisor (pid and start time) and it is gone. Any other Console answers `not-supervised`. `console restart` waits up to 30 seconds for the relaunched Console to answer and prints `{"outcome":"restarted","from":OLD,"boot":NEW}`. `org reload ORG` restarts a running bus or starts a stopped one and prints the Console's done receipt. A request that left but got no answer is reported as outcome unknown (exit 1) and is never retried; check `mosaic console status`, `mosaic bus status ORG` and the trail. `mosaic bus restart ORG` remains the trail-less host verb.
|
||||
|
||||
The instance processing switch is separate from per-business launches off/on.
|
||||
`mosaic halt` durably stops managed processing across every business in the
|
||||
|
||||
@@ -595,6 +595,8 @@ async function consoleCommand(rest, io, deps) {
|
||||
// org-lan-bind: refused here, before any config is read, unless both flags come with
|
||||
// --dev-local-owner. ADDR is 0.0.0.0 or an IPv4 address; NAME[,NAME...] has no ports.
|
||||
const devOwner = parsed.flags.has("--dev-local-owner"), bind = parsed.values["--dev-insecure-bind"] ?? null;
|
||||
// D1: an agent run never starts a dev-local-owner Console (org-console.mjs consoleStart checks again).
|
||||
if (devOwner) { refuseInsideAgent(process.env); refuseInsideAgent(io.env); }
|
||||
const names = Object.hasOwn(parsed.values, "--dev-allow-host") ? [parsed.values["--dev-allow-host"]] : [];
|
||||
let lan;
|
||||
try { lan = devLanOptions({ devOwner, bind, names }); } catch (err) { throw usage(err.message); }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// D1 (#1873): the supervisor of a detached dev-local-owner Console. `mosaic console start
|
||||
// --dev-local-owner --detach` starts this process in its own session (org-console.mjs
|
||||
// detachedSupervisor). It starts the Console exactly as a foreground start does, with --supervised,
|
||||
// and relaunches it when the owner restarts it (F3). Its one stdout line is the start's reply:
|
||||
// { receipt, warnings } or { error, exitCode, warnings }. Later warnings go to stderr, the log.
|
||||
import { consoleStart } from './org-console.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
|
||||
const { configDir, dataRoot, instance, port, bind, allowHosts } = JSON.parse(process.argv[2]);
|
||||
const warnings = [];
|
||||
let started = false;
|
||||
const warn = s => { if (started) process.stderr.write(s); else warnings.push(s); };
|
||||
// The starting CLI can be gone before the reply (Ctrl-C, a wrapper's timeout). The Console is
|
||||
// started and its state names it, so a reply nobody reads is dropped and supervision goes on.
|
||||
process.stdout.on('error', () => {});
|
||||
const reply = value => new Promise(resolve => process.stdout.write(`${JSON.stringify(value)}\n`, () => resolve()));
|
||||
// A stop signal during the start (the CLI's start timeout signals this process group) is kept and
|
||||
// acted on once the start settles, so a failed start still releases the start lock.
|
||||
let stopAsked = false;
|
||||
const early = () => { stopAsked = true; };
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, early);
|
||||
let receipt;
|
||||
try {
|
||||
receipt = await consoleStart({ configDir, dataRoot, instance, env: process.env }, { devLocalOwner: true, port, bind, allowHosts, warn });
|
||||
} catch (e) {
|
||||
const exitCode = e instanceof CliError ? e.exitCode : 1;
|
||||
if (!(e instanceof CliError)) process.stderr.write(`${e?.stack ?? e}\n`);
|
||||
await reply({ ...(e instanceof CliError ? { error: e.message } : {}), exitCode, warnings });
|
||||
process.exit(exitCode);
|
||||
}
|
||||
started = true;
|
||||
// A stop signal ends supervision cleanly, as in the foreground (cli.mjs consoleCommand).
|
||||
const stop = () => receipt.stop();
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) { process.off(signal, early); process.on(signal, stop); }
|
||||
if (stopAsked) stop();
|
||||
await reply({ receipt, warnings });
|
||||
const code = await receipt.done;
|
||||
if (code !== 0) process.stderr.write(`mosaic: console stopped unexpectedly (${code})\n`);
|
||||
process.exit(code);
|
||||
@@ -3,13 +3,14 @@ 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, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { constants, closeSync, fstatSync, 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]);
|
||||
@@ -63,8 +64,25 @@ function takeLock(ctx) {
|
||||
checkDir(ctx);
|
||||
const path = join(directory(ctx), 'start.lock');
|
||||
let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); }
|
||||
// D1: the lock names its holder, so a detached start that timed out can clear the lock of the
|
||||
// supervisor it killed (clearDeadLock) and no other.
|
||||
try { writeSync(fd, JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid) })); } catch { /* an unnamed lock is only never cleared */ }
|
||||
return () => { closeSync(fd); rmSync(path, { force: true }); };
|
||||
}
|
||||
// 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;
|
||||
@@ -153,15 +171,76 @@ function supervise(ctx, first, args, lan, warn) {
|
||||
})();
|
||||
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 Console has no supervisor.
|
||||
// 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. Other starts are unchanged. This process's own
|
||||
// environment is checked as well as the one given for the Console (security re-check P1).
|
||||
if (devLocalOwner) { refuseInsideAgent(process.env); if (ctx.env) refuseInsideAgent(ctx.env); }
|
||||
if (detach && devLocalOwner) return detachedSupervisor(ctx, { port, bind, allowHosts, warn });
|
||||
const release = takeLock(ctx);
|
||||
let run, args;
|
||||
try {
|
||||
|
||||
@@ -115,21 +115,47 @@ test('mosaic console restart relaunches a supervised Console and the trail recor
|
||||
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
|
||||
});
|
||||
|
||||
test('a detached Console refuses mosaic console restart, and org reload of an unknown org, with no trail', { timeout: 90000 }, async t => {
|
||||
// D1 (#1873): a detached dev-owner Console runs under a detached supervisor, so the terminal restart
|
||||
// works on it as on a foreground one. Before D1 this Console refused with not-supervised.
|
||||
test('a detached dev-owner Console restarts through mosaic console restart, and org reload of an unknown org is refused with no trail', { timeout: 120000 }, async t => {
|
||||
const { ctx, env, trailFile } = 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(!FORBIDDEN.includes(port));
|
||||
const r = await mosaic(env, ['console', 'restart']);
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.match(r.stderr, /console restart refused \(not-supervised\)/);
|
||||
// The real organization service refuses an org that does not exist before any trail line.
|
||||
const unknown = await mosaic(env, ['org', 'reload', 'nosuch']);
|
||||
assert.equal(unknown.status, 3, unknown.stderr);
|
||||
assert.match(unknown.stderr, /org reload refused \(org-[a-z-]+\)/);
|
||||
assert.equal(existsSync(trailFile), false);
|
||||
const before = (await get(port, '/api/config')).body;
|
||||
assert.equal(before.consoleRestart, true);
|
||||
const r = await mosaic(env, ['console', 'restart']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const answer = JSON.parse(r.stdout);
|
||||
assert.equal(answer.outcome, 'restarted'); assert.equal(answer.from, before.boot);
|
||||
assert.equal((await get(port, '/api/config')).body.boot, answer.boot);
|
||||
assert.deepEqual(trailLines(trailFile), [{ by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot }]);
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
// The supervisor is unref'd, so a timer keeps this process awake while it exits.
|
||||
let code; result.done.then(c => { code = c; });
|
||||
for (let i = 0; i < 200 && code === undefined; i++) await new Promise(r => setTimeout(r, 50));
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
|
||||
// A dev-owner Console nothing supervises (here one in this process, started with no restart hook)
|
||||
// still refuses the terminal restart and stays up, with no trail line.
|
||||
test('an unsupervised Console refuses mosaic console restart with not-supervised and stays up', { timeout: 90000 }, async t => {
|
||||
const { ctx, env, trailFile } = await instance(t);
|
||||
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), log: () => {}, trail: actionTrail(join(ctx.dataRoot, 'console')) });
|
||||
t.after(() => close(server));
|
||||
const port = server.address().port;
|
||||
assert.ok(!FORBIDDEN.includes(port));
|
||||
recordConsole(ctx, `http://127.0.0.1:${port}/`);
|
||||
const r = await mosaic(env, ['console', 'restart']);
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.match(r.stderr, /console restart refused \(not-supervised\)/);
|
||||
assert.equal((await get(port, '/api/config')).status, 200);
|
||||
assert.equal(existsSync(trailFile), false);
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
});
|
||||
|
||||
// The F3 bus control through the Console's own route: a stopped bus starts and a running one restarts.
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync, statSync } from 'node:fs';
|
||||
import { get, request } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
|
||||
import { humanEnv } from './helpers.mjs';
|
||||
import { consoleStart, consoleStatus, consoleStop } from '../src/org-console.mjs';
|
||||
import { consoleStart, consoleStatus, consoleStop, consoleRestart } from '../src/org-console.mjs';
|
||||
|
||||
const REPO = new URL('../../../', import.meta.url).pathname;
|
||||
// A detached supervisor's handle is unref'd, so its exit is awaited with a timer that keeps the loop alive.
|
||||
async function exitOf(done, ms = 20000) {
|
||||
let code; done.then(c => { code = c; });
|
||||
for (const deadline = Date.now() + ms; code === undefined; await new Promise(r => setTimeout(r, 100))) if (Date.now() > deadline) assert.fail('the supervisor did not exit');
|
||||
return code;
|
||||
}
|
||||
test('detached console reports its live loopback URL and stops', { timeout: 60000 }, async t => {
|
||||
const home = mkdtempSync(join(tmpdir(), 's1-'));
|
||||
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') });
|
||||
@@ -31,11 +38,13 @@ test('detached console reports its live loopback URL and stops', { timeout: 6000
|
||||
assert.ok(Number.isSafeInteger(result.pid));
|
||||
// A selector producer (rename r2): the Console child gets the active organization as --business.
|
||||
const argv = readFileSync(`/proc/${result.pid}/cmdline`, 'utf8').split('\0').slice(0, -1);
|
||||
assert.deepEqual(argv, [process.execPath, join(REPO, 'packages/webui/src/cli.mjs'), 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner']);
|
||||
// D1 (#1873): a detached dev-owner Console runs under a detached supervisor, so it is --supervised.
|
||||
assert.deepEqual(argv, [process.execPath, join(REPO, 'packages/webui/src/cli.mjs'), 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner', '--supervised']);
|
||||
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url.port)));
|
||||
const status = await new Promise((resolve, reject) => get(url, res => { res.resume(); resolve(res.statusCode); }).on('error', reject));
|
||||
assert.equal(status, 200);
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
assert.equal(await exitOf(result.done), 0);
|
||||
});
|
||||
|
||||
test('mosaic console start and stop dispatch through the CLI', { timeout: 60000 }, async t => {
|
||||
@@ -198,15 +207,208 @@ test('a foreground console relaunches on the same address when the owner restart
|
||||
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 => {
|
||||
// D1 (#1873, readiness audit P1-1): Restart Console on a detached dev-owner Console. The start runs
|
||||
// the foreground supervision in a process of its own session; the Console is that process's child.
|
||||
const SUPERVISOR = join(REPO, 'packages/cli/src/console-supervisor.mjs');
|
||||
const statOf = pid => readFileSync(`/proc/${pid}/stat`, 'utf8').replace(/^.*\) /, '').split(' ');
|
||||
test('a detached dev-owner console runs under a detached supervisor, so the owner restarts it in place', { timeout: 120000 }, async t => {
|
||||
const { ctx, mosaic } = await instance(t);
|
||||
const warnings = [];
|
||||
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: s => warnings.push(s) });
|
||||
t.after(() => { try { process.kill(result.supervisor, 'SIGTERM'); } catch {} });
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.deepEqual(Object.keys(result), ['url', 'pid', 'supervisor']);
|
||||
const port = Number(new URL(result.url).port);
|
||||
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(port));
|
||||
const sup = argvOf(result.supervisor);
|
||||
assert.deepEqual(sup.slice(0, 2), [process.execPath, SUPERVISOR]);
|
||||
assert.deepEqual(JSON.parse(sup[2]), { configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: 's1test', port: 'auto', bind: null, allowHosts: [] });
|
||||
// The supervisor leads its own session; the Console is its child, started as in the foreground.
|
||||
assert.equal(Number(statOf(result.supervisor)[3]), result.supervisor);
|
||||
assert.equal(Number(statOf(result.pid)[1]), result.supervisor);
|
||||
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);
|
||||
const stateFile = join(ctx.dataRoot, 'console/console.json'), 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(existsSync(trailFile), false);
|
||||
// The page's request, as `mosaic console restart` sends it: 202, then a new boot on the same address.
|
||||
const restarted = await consoleRestart(ctx);
|
||||
assert.equal(restarted.outcome, 'restarted'); assert.equal(restarted.from, before.boot); assert.notEqual(restarted.boot, before.boot);
|
||||
// The relaunched Console answers before the supervisor writes its state (relaunch), so wait for it.
|
||||
const saved = await relaunchedState(ctx, result.pid);
|
||||
assert.notEqual(saved.pid, result.pid);
|
||||
assert.equal(saved.url, result.url);
|
||||
assert.equal(Number(statOf(saved.pid)[1]), result.supervisor);
|
||||
assert.deepEqual(argvOf(saved.pid), [process.execPath, WEBUI, 'serve', '--port', String(port), '--business', 'alpha', '--dev-local-owner', '--supervised']);
|
||||
const after = (await call(port, '/api/config')).body;
|
||||
assert.equal(after.boot, restarted.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.deepEqual({ ...line, at: undefined }, { at: undefined, by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot });
|
||||
// The supervisor's later warnings go to its log under the data root.
|
||||
const logs = readdirSync(join(ctx.dataRoot, 'console')).filter(x => /^supervisor-\d+-\d+\.log$/.test(x));
|
||||
assert.equal(logs.length, 1);
|
||||
assert.equal(statSync(join(ctx.dataRoot, 'console', logs[0])).mode & 0o777, 0o600);
|
||||
assert.equal(readFileSync(join(ctx.dataRoot, 'console', logs[0]), 'utf8'), 'mosaic: console asked for a restart; relaunching on the same arguments\n');
|
||||
// A second start is refused by the supervisor's start and says so here; nothing else starts.
|
||||
await assert.rejects(consoleStart(ctx, { devLocalOwner: true, detach: true }), e => e.exitCode === 3 && /console state exists/.test(e.message));
|
||||
const cli = mosaic(['console', 'start', '--dev-local-owner', '--detach', '--port', 'auto']);
|
||||
assert.equal(cli.status, 3); assert.match(cli.stderr, /console state exists/); assert.equal(cli.stdout, '');
|
||||
assert.deepEqual(consoleStatus(ctx), { running: true, pid: saved.pid, url: result.url, bind: '127.0.0.1' });
|
||||
// A stop ends the Console and then the supervisor, cleanly.
|
||||
assert.deepEqual(await consoleStop(ctx), { stopped: true, pid: saved.pid });
|
||||
assert.equal(await exitOf(result.done), 0);
|
||||
assert.deepEqual(consoleStatus(ctx), { running: false });
|
||||
});
|
||||
|
||||
test('a supervisor stopped by a signal stops its detached dev-owner console', { timeout: 60000 }, async t => {
|
||||
const { ctx } = await instance(t);
|
||||
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
|
||||
t.after(() => { try { process.kill(result.supervisor, 'SIGTERM'); } catch {} });
|
||||
result.stop();
|
||||
assert.equal(await exitOf(result.done), 0);
|
||||
assert.equal(consoleStatus(ctx).running, false);
|
||||
});
|
||||
|
||||
// relaunch() writes console.json after the new Console answers; read it once it names a new pid.
|
||||
async function relaunchedState(ctx, oldPid) {
|
||||
const file = join(ctx.dataRoot, 'console/console.json');
|
||||
for (const deadline = Date.now() + 15000; ; await new Promise(r => setTimeout(r, 50))) {
|
||||
const saved = JSON.parse(readFileSync(file, 'utf8'));
|
||||
if (saved.pid !== oldPid || Date.now() > deadline) return saved;
|
||||
}
|
||||
}
|
||||
|
||||
// D1 fix round (build review P2): the starting CLI can die before it reads the supervisor's reply
|
||||
// (Ctrl-C, a wrapper's timeout). The reply is dropped, and the supervisor keeps supervising.
|
||||
test('a supervisor whose starting CLI is gone keeps supervising, so Restart Console still relaunches', { timeout: 60000 }, async t => {
|
||||
const { ctx } = await instance(t);
|
||||
const child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: 's1test', port: 'auto', bind: null, allowHosts: [] })],
|
||||
{ env: ctx.env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => { try { process.kill(child.pid, 'SIGTERM'); } catch {} });
|
||||
const exit = new Promise(resolve => child.once('exit', code => resolve(code)));
|
||||
let stderr = ''; child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
|
||||
child.stdout.destroy();
|
||||
let status = consoleStatus(ctx);
|
||||
for (const deadline = Date.now() + 30000; !status.running; status = consoleStatus(ctx)) {
|
||||
if (Date.now() > deadline) assert.fail(`the console did not start: ${stderr}`);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
// The reply follows the state write; give it time to meet the closed pipe.
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
assert.equal(child.exitCode, null, stderr);
|
||||
assert.equal(Number(statOf(status.pid)[1]), child.pid);
|
||||
const restarted = await consoleRestart(ctx);
|
||||
assert.equal(restarted.outcome, 'restarted');
|
||||
const after = await relaunchedState(ctx, status.pid);
|
||||
assert.notEqual(after.pid, status.pid); assert.equal(after.url, status.url);
|
||||
assert.equal(Number(statOf(after.pid)[1]), child.pid);
|
||||
assert.deepEqual(await consoleStop(ctx), { stopped: true, pid: after.pid });
|
||||
assert.equal(await exit, 0);
|
||||
assert.doesNotMatch(stderr, /EPIPE|Unhandled/);
|
||||
});
|
||||
|
||||
// D1 fix round (security review P2): a stop signal while the supervisor holds the start lock is kept
|
||||
// until the start settles; then the Console stops, the lock is released and the supervisor exits.
|
||||
test('a supervisor signalled during its start stops the console it started and releases the start lock', { timeout: 60000 }, async t => {
|
||||
const { ctx } = await instance(t);
|
||||
const child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: 's1test', port: 'auto', bind: null, allowHosts: [] })],
|
||||
{ env: ctx.env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} });
|
||||
const exit = new Promise(resolve => child.once('exit', (code, signal) => resolve({ code, signal })));
|
||||
let stdout = '', stderr = '';
|
||||
child.stdout.setEncoding('utf8').on('data', c => { stdout += c; });
|
||||
child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
|
||||
const lock = join(ctx.dataRoot, 'console/start.lock');
|
||||
for (const deadline = Date.now() + 20000; !existsSync(lock); await new Promise(r => setTimeout(r, 2))) {
|
||||
if (Date.now() > deadline) assert.fail(`the supervisor never took the start lock: ${stderr}`);
|
||||
}
|
||||
process.kill(child.pid, 'SIGTERM');
|
||||
assert.deepEqual(await exit, { code: 0, signal: null }, stderr);
|
||||
assert.equal(existsSync(lock), false);
|
||||
// As after a foreground stop by signal, the state file stays and reads stale, not running.
|
||||
assert.equal(consoleStatus(ctx).running, false);
|
||||
// The started Console was this supervisor's child, and it is gone.
|
||||
const reply = JSON.parse(stdout.trim().split('\n')[0]);
|
||||
assert.equal(existsSync(`/proc/${reply.receipt.pid}`), false);
|
||||
});
|
||||
|
||||
// D1 fix round (security review P1): an agent run never starts a dev-local-owner Console.
|
||||
test('an agent run cannot start a dev-local-owner console; a start without it is unchanged', { timeout: 60000 }, async t => {
|
||||
const { ctx, mosaic } = await instance(t);
|
||||
const agent = { ...ctx, env: { ...ctx.env, MOSAIC_RUN_ID: 'run-1' } };
|
||||
for (const detach of [true, false]) {
|
||||
await assert.rejects(consoleStart(agent, { devLocalOwner: true, detach, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /only from a human shell/.test(e.message));
|
||||
}
|
||||
// A clean environment given for the Console does not hide this process's own marker (re-check P1).
|
||||
process.env.MOSAIC_RUN_ID = 'run-2';
|
||||
try {
|
||||
for (const detach of [true, false]) {
|
||||
await assert.rejects(consoleStart(ctx, { devLocalOwner: true, detach, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /found MOSAIC_RUN_ID/.test(e.message));
|
||||
}
|
||||
} finally { delete process.env.MOSAIC_RUN_ID; }
|
||||
const agentCli = args => spawnSync(join(REPO, 'scripts/mosaic'), args, { env: { ...ctx.env, MOSAIC_AGENT_RUN: '1' }, encoding: 'utf8', timeout: 30000 });
|
||||
const refused = agentCli(['console', 'start', '--dev-local-owner', '--detach', '--port', 'auto']);
|
||||
assert.equal(refused.status, 3); assert.equal(refused.stdout, ''); assert.match(refused.stderr, /only from a human shell/);
|
||||
const consoleDir = join(ctx.dataRoot, 'console');
|
||||
assert.deepEqual(existsSync(consoleDir) ? readdirSync(consoleDir) : [], []);
|
||||
assert.deepEqual(consoleStatus(ctx), { running: false });
|
||||
const plain = agentCli(['console', 'start', '--detach', '--port', 'auto']);
|
||||
assert.equal(plain.status, 0, plain.stderr);
|
||||
assert.deepEqual(Object.keys(JSON.parse(plain.stdout)), ['url', 'pid']);
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
});
|
||||
|
||||
// D1 security re-check P2: a supervisor that hangs holding the start lock is killed with its group
|
||||
// after the 60 s start timeout and 10 s grace. Its lock names it, so the start clears it and the
|
||||
// next start proceeds. The hook blocks the supervisor's event loop once it holds the lock (at its
|
||||
// console state read), so its stop handler never runs and only the group SIGKILL ends it.
|
||||
test('a detached start that times out clears the start lock of the supervisor it killed', { timeout: 150000 }, async t => {
|
||||
const { ctx } = await instance(t);
|
||||
const hook = join(dirname(ctx.dataRoot), 'hang-after-lock.mjs');
|
||||
writeFileSync(hook, `import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module';
|
||||
if (process.argv[1]?.endsWith('console-supervisor.mjs')) { const real = fs.openSync;
|
||||
fs.openSync = (...args) => { if (String(args[0]).endsWith('/console/console.json')) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); return real(...args); };
|
||||
syncBuiltinESMExports(); }\n`);
|
||||
const hung = { ...ctx, env: { ...ctx.env, NODE_OPTIONS: `--import=${pathToFileURL(hook).href}` } };
|
||||
const lock = join(ctx.dataRoot, 'console/start.lock');
|
||||
const began = Date.now();
|
||||
await assert.rejects(consoleStart(hung, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} }),
|
||||
e => e.exitCode === 3 && /^console failed to start; see console\/supervisor-\d+-\d+\.log under the data root$/.test(e.message));
|
||||
assert.ok(Date.now() - began >= 69000, 'the start waited out its timeout and grace');
|
||||
assert.equal(existsSync(lock), false);
|
||||
assert.deepEqual(consoleStatus(ctx), { running: false });
|
||||
// The next start is not blocked by the killed supervisor's lock.
|
||||
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
assert.equal(await exitOf(result.done), 0);
|
||||
});
|
||||
|
||||
// Without --dev-local-owner a detached start is what it was before D1: no supervisor, restart refused.
|
||||
test('a detached console without the dev owner has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => {
|
||||
const { ctx, mosaic } = await instance(t);
|
||||
const warnings = [];
|
||||
const result = await consoleStart(ctx, { detach: true, port: 'auto', warn: s => warnings.push(s) });
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.deepEqual(Object.keys(result), ['url', 'pid']);
|
||||
const port = Number(new URL(result.url).port);
|
||||
assert.ok(!argvOf(result.pid).includes('--supervised'));
|
||||
assert.deepEqual(argvOf(result.pid), [process.execPath, WEBUI, 'serve', '--port', '0', '--business', 'alpha']);
|
||||
assert.equal(readdirSync(join(ctx.dataRoot, 'console')).filter(x => x.startsWith('supervisor-')).length, 0);
|
||||
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');
|
||||
// Without a dev owner there is no owner session to restart as: refused before the supervisor check.
|
||||
assert.equal(r.status, 403, JSON.stringify(r.body)); assert.deepEqual(r.body, { error: 'owner-only' });
|
||||
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);
|
||||
// The CLI's receipt keeps its two fields.
|
||||
const start = mosaic(['console', 'start', '--detach', '--port', 'auto']);
|
||||
assert.equal(start.status, 0, start.stderr);
|
||||
assert.deepEqual(Object.keys(JSON.parse(start.stdout)), ['url', 'pid']);
|
||||
assert.equal(start.stderr, '');
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
});
|
||||
@@ -21,7 +21,8 @@ baseline CLI.
|
||||
| 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` |
|
||||
| Restart the Console | Stop and run `mosaic console start` again | Enabled (F3) when `mosaic console start` runs it in the foreground, or with `--dev-local-owner --detach` under a detached supervisor (D1): Settings > Console; the owner only; trailed. Other Consoles answer `not-supervised` |
|
||||
| Save, review, confirm or reverse an organization's autonomy | Save only: edit the autonomy block and run `mosaic org update ORG --from FILE --if-revision N`; no autonomy verb yet (readiness P1-3) | Enabled (D1) on a `--dev-local-owner` Console: the owner only, on the selected organization's bus, behind a fence that passes only the four console verbs `autonomy.set.console`, `autonomy.review.console`, `decision.confirm.console` and `decision.reverse.console` |
|
||||
| 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 |
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// AU: owner Console transport only. Identity and organization never come from a JSON body.
|
||||
export const AUTONOMY_ROUTE=/^\/api\/autonomy(?:\/(confirm|reverse|review))?$/;
|
||||
// D1 (#1873): the owner writes this route makes, and a fence that passes only those to a console transport.
|
||||
export const AUTONOMY_WRITE_VERBS=Object.freeze(['autonomy.set.console','autonomy.review.console','decision.confirm.console','decision.reverse.console']);
|
||||
export const autonomyCaller=call=>async(verb,args,options)=>{if(!AUTONOMY_WRITE_VERBS.includes(verb))throw new Error(`the autonomy writer does not call ${verb}`);return call(verb,args,options);};
|
||||
const one=(req,k)=>req.headersDistinct[k]?.length===1?req.headersDistinct[k][0]:null;
|
||||
const json=(res,status,value)=>{res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify(value));};
|
||||
export async function autonomyHTTP(req,res,{path,read,write,session,live,origin}) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
|
||||
import { organizationConsole } from './organizations.mjs';
|
||||
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
|
||||
import { SYSTEM_TYPE_VERBS } from './system-types-http.mjs';
|
||||
import { autonomyCaller } from './autonomy-http.mjs';
|
||||
import { consoleAccess } from './access.mjs';
|
||||
import { accessContextFor } from '../../cli/src/access.mjs';
|
||||
import { accessFilePath, loadAccess } from '../../organization/src/access.mjs';
|
||||
@@ -183,6 +184,10 @@ try {
|
||||
// Phase P2 (#1873): the owner's System types writes, the organization's enable switch (plugins.set
|
||||
// with its revision) and a project's selection, over the owner's human transport under --send only.
|
||||
systemTypesWrite: session.user === undefined && sendOn ? agentCaller(owner, SYSTEM_TYPE_VERBS) : null,
|
||||
// D1 (#1873): the owner's autonomy writes (save, review, confirm, reverse) on this organization's bus.
|
||||
// These options exist only on a --dev-local-owner Console (`organizations` above). The fence passes
|
||||
// those four verbs and nothing else the console transport carries.
|
||||
autonomyWrite: session.user === undefined && sendOn ? autonomyCaller(consoleCall({ dataRoot: root, socket: paths.socket, business: id })) : null,
|
||||
reads: consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: id, orgRoot: root }),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -859,7 +859,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
let boundAddress = null, boundPort = null;
|
||||
const server = createServer(async (req, res) => {
|
||||
// A granted user's request rebinds these below; every other request keeps the owner's.
|
||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [];
|
||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [], autonomyWrite = null;
|
||||
res.setHeader('x-content-type-options', 'nosniff');
|
||||
res.setHeader('referrer-policy', 'no-referrer');
|
||||
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
|
||||
@@ -954,7 +954,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
const instanceAction = originalPath === '/api/console/restart';
|
||||
if (session && !session.rootOwner && session.user === undefined && orgId === null && !orgList && !hook?.publicRead && !instanceAction && path !== '/api/config' && path !== '/healthz' && !files.has(path)) return json(res, 404, { error: 'not-found' });
|
||||
if (session && !session.rootOwner && session.user === undefined) {
|
||||
bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = upstream = null;
|
||||
bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = autonomyWrite = upstream = null;
|
||||
}
|
||||
if (session?.user !== undefined && !session.rootOwner && !instanceAction) {
|
||||
if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
|
||||
@@ -991,7 +991,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
({ bus = null, reads = 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, systemTypesWrite = null, upstream = null } = selected);
|
||||
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, autonomyWrite = null, upstream = null } = selected);
|
||||
}
|
||||
// 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.
|
||||
@@ -1052,7 +1052,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// Phase P2 (#1873): the owner's System types read and writes on the selected organization.
|
||||
if(autonomyPath) {
|
||||
if(tester || granted || !named || sso===null)return json(res,403,{error:'owner-only'});
|
||||
return autonomyHTTP(req,res,{path,read:requestRead,write:send?requestWrite:null,session:ssoSession,
|
||||
// D1 (#1873): an organization's own autonomy writer (`autonomyWrite`, the four autonomy verbs only);
|
||||
// without one the route keeps the request writer, as before.
|
||||
return autonomyHTTP(req,res,{path,read:requestRead,write:send?(autonomyWrite??requestWrite):null,session:ssoSession,
|
||||
live:()=>{const live=sso.session(req);return live?.rootOwner===true?live:null;},origin:localOrigin??sso.publicOrigin});
|
||||
}
|
||||
if (systemTypesPath) {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// D1 (#1873, readiness audit P0-2): on a Console started with --dev-local-owner, an organization's
|
||||
// autonomy page read `writable:false` and refused every owner save. The organization options built in
|
||||
// cli.mjs carried no writer the autonomy route could use. This runs the real cli.mjs serve
|
||||
// --dev-local-owner against a real organization record; only the bus transports cli.mjs builds are
|
||||
// replaced (loader hook), so no bus or human proof is involved.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync, renameSync, chmodSync, writeFileSync } from 'node:fs';
|
||||
import { request } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { tmp, fixture } from '../../cli/tests/helpers.mjs';
|
||||
import { fakeOidc } from './helpers/fake-oidc.mjs';
|
||||
import { ownerAccounts } from './helpers/owner-accounts.mjs';
|
||||
import { SESSION_COOKIE } from '../src/sso.mjs';
|
||||
import { createOrg } from '../../organization/src/org-service.mjs';
|
||||
import { orgPaths } from '../../cli/src/config.mjs';
|
||||
import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs';
|
||||
|
||||
const AUTONOMY = { revision: 3, autonomy: { default: 'near' } };
|
||||
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
|
||||
|
||||
// `sso`: a deployed Console instead (--sso --send, a fake OP on loopback, the owner signed in).
|
||||
async function consoleFor(t, { sso = false } = {}) {
|
||||
const root = tmp(t, 'o-'), f = fixture(root), home = join(root, 'home'), configDir = join(home, '.config', 'mosaic-test');
|
||||
mkdirSync(join(home, '.config'), { recursive: true }); renameSync(join(root, 'config'), configDir);
|
||||
chmodSync(configDir, 0o700); chmodSync(join(configDir, 'businesses'), 0o700);
|
||||
writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'test' }));
|
||||
const doc = structuredClone(f.doc); doc.id = 'alpha'; doc.human = DEV_OWNER.sub; doc.projects.stack.root = '/__mosaic_org__/alpha/projects/stack';
|
||||
const org = await createOrg({ configDir, dataRoot: f.dataRoot, instance: 'test', principal: DEV_OWNER }, 'alpha', doc);
|
||||
const log = join(root, 'calls.jsonl'); writeFileSync(log, '');
|
||||
const busURL = new URL('../src/bus.mjs', import.meta.url).href, orgBusURL = new URL('../../cli/src/org-bus.mjs', import.meta.url).href;
|
||||
// The console transport answers the autonomy verbs from fixed rows and logs (kind, business, socket, verb, args, subject).
|
||||
const busShim = join(root, 'bus-shim.mjs'), orgBusShim = join(root, 'org-bus-shim.mjs'), hook = join(root, 'hook.mjs');
|
||||
writeFileSync(busShim, `import { appendFileSync } from 'node:fs';
|
||||
import { BusReadError } from ${JSON.stringify(busURL)};
|
||||
export * from ${JSON.stringify(busURL)};
|
||||
const record = (kind, o, verb, args, extra = {}) => appendFileSync(${JSON.stringify(log)}, JSON.stringify({ kind, business: o.business ?? null, socket: o.socket ?? null, verb, args, ...extra }) + '\\n');
|
||||
export const busCaller = o => async (verb, args) => { record('human', o, verb, args); return verb === 'plugins' ? { plugins: { channel: ['cli', 'webui'] } } : []; };
|
||||
export const consoleCall = o => async (verb, args, { subject }) => {
|
||||
record('console', o, verb, args, { subject });
|
||||
if (verb === 'autonomy.get.console') return { revision: 3, autonomy: { default: 'in' } };
|
||||
if (verb === 'autonomy.set.console') return { revision: 4, autonomy: args.autonomy, receipt: { action: 'autonomy.set', revision: 4 } };
|
||||
if (['autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console'].includes(verb)) return { answered: verb };
|
||||
throw new BusReadError('unavailable', 'fixture: ' + verb);
|
||||
};
|
||||
`);
|
||||
writeFileSync(orgBusShim, `export * from ${JSON.stringify(orgBusURL)};\nexport const busStatus = async () => ({ healthy: true });\n`);
|
||||
writeFileSync(hook, `import { registerHooks } from 'node:module';
|
||||
registerHooks({ resolve(specifier, context, next) {
|
||||
if (context.parentURL?.endsWith('/webui/src/cli.mjs')) {
|
||||
if (specifier === './bus.mjs') return { url: ${JSON.stringify(pathToFileURL(busShim).href)}, shortCircuit: true };
|
||||
if (specifier === '../../cli/src/org-bus.mjs') return { url: ${JSON.stringify(pathToFileURL(orgBusShim).href)}, shortCircuit: true };
|
||||
}
|
||||
return next(specifier, context);
|
||||
} });
|
||||
`);
|
||||
const env = { ...f.env, HOME: home, MOSAIC_CONFIG: join(configDir, 'config.json'), NODE_OPTIONS: `--import=${pathToFileURL(hook).href}` };
|
||||
let op = null, a = null;
|
||||
if (sso) {
|
||||
op = await fakeOidc(); t.after(() => op.close());
|
||||
a = op.issuer('a');
|
||||
const provider = { id: a.name, issuer: a.issuer, clientId: a.clientId, clientSecret: { slot: a.name }, algs: [...new Set(a.keys.map(k => k.alg))], subjects: [{ sub: `user-${a.name}`, name: `person-${a.name}` }] };
|
||||
const config = JSON.parse(readFileSync(env.MOSAIC_CONFIG, 'utf8'));
|
||||
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify({ ...config, console: { sso: { publicOrigin: ORIGIN, providers: [provider] } } }));
|
||||
mkdirSync(join(configDir, 'secrets', 'mosaic-stack'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(configDir, 'secrets', 'mosaic-stack', a.name), `${a.clientSecret}\n`, { mode: 0o600 });
|
||||
writeFileSync(join(configDir, 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: ownerAccounts({ providers: [provider] }).load().users.map(u => ({ ...u, created: '2026-11-10T00:00:00Z' })), memberships: [], invites: [] }), { mode: 0o600 });
|
||||
writeFileSync(join(root, 'ca.pem'), op.cert);
|
||||
env.NODE_EXTRA_CA_CERTS = join(root, 'ca.pem');
|
||||
}
|
||||
const mode = sso ? ['--sso', '--send'] : ['--dev-local-owner'];
|
||||
const child = spawn(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...mode], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let out = ''; child.stdout.on('data', c => out += c); child.stderr.on('data', c => out += c);
|
||||
t.after(() => { if (child.exitCode === null) child.kill('SIGTERM'); });
|
||||
for (let i = 0; i < 300 && !/Mosaic Console: (http:\/\/127\.0\.0\.1:\d+\/)/.test(out); i++) {
|
||||
if (child.exitCode !== null) break;
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
}
|
||||
const base = /Mosaic Console: (http:\/\/127\.0\.0\.1:(\d+)\/)/.exec(out);
|
||||
assert.ok(base, `the Console did not start:\n${out}`);
|
||||
const calls = () => readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
|
||||
return { port: Number(base[2]), calls, org, op, a, rootSocket: join(f.dataRoot, 'bus', 'broker.sock') };
|
||||
}
|
||||
|
||||
function call(port, path, { method = 'GET', body = null, headers = {}, host = `127.0.0.1:${port}`, origin = `http://${host}` } = {}) {
|
||||
const h = { host, ...(method === 'GET' ? {} : { origin, '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, headers: res.headers, body: json }); });
|
||||
});
|
||||
r.on('error', reject); r.end(body ?? undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('the dev-local owner reads an organization autonomy page as writable and a save reaches that organization bus', { timeout: 60000 }, async t => {
|
||||
const { port, calls, org } = await consoleFor(t);
|
||||
const subject = { provider: DEV_OWNER.provider, iss: DEV_OWNER.iss, sub: DEV_OWNER.sub };
|
||||
const read = await call(port, '/api/orgs/alpha/autonomy');
|
||||
assert.equal(read.status, 200, JSON.stringify(read.body));
|
||||
assert.deepEqual(read.body, { revision: 3, autonomy: { default: 'in' }, writable: true });
|
||||
const saved = await call(port, '/api/orgs/alpha/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY) });
|
||||
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
||||
assert.deepEqual(saved.body, { revision: 4, autonomy: { default: 'near' }, receipt: { action: 'autonomy.set', revision: 4 }, writable: true });
|
||||
const socket = orgPaths(org.root).socket;
|
||||
assert.deepEqual(calls().filter(c => c.kind === 'console').map(({ business, socket: s, verb, args, subject: who }) => ({ business, socket: s, verb, args, subject: who })), [
|
||||
{ business: 'alpha', socket, verb: 'autonomy.get.console', args: {}, subject },
|
||||
{ business: 'alpha', socket, verb: 'autonomy.set.console', args: AUTONOMY, subject },
|
||||
]);
|
||||
// Review, confirm and reverse go to the same organization bus as the owner (D1 fix round).
|
||||
for (const [step, body, verb] of [['review', { decisionClass: 'merge' }, 'autonomy.review.console'], ['confirm', { id: 'd-1', confirm: true, reason: 'checked' }, 'decision.confirm.console'], ['reverse', { id: 'd-1' }, 'decision.reverse.console']]) {
|
||||
const r = await call(port, `/api/orgs/alpha/autonomy/${step}`, { method: 'POST', body: JSON.stringify(body) });
|
||||
assert.equal(r.status, 200, `${step}: ${JSON.stringify(r.body)}`);
|
||||
assert.deepEqual(r.body, { answered: verb, writable: true });
|
||||
assert.deepEqual(calls().filter(c => c.kind === 'console').at(-1), { kind: 'console', business: 'alpha', socket, verb, args: body, subject });
|
||||
}
|
||||
// The write keeps the route's own checks: another site saves nothing.
|
||||
const before = calls().length;
|
||||
for (const headers of [{ origin: 'http://evil.example' }, { 'sec-fetch-site': 'cross-site' }]) {
|
||||
const r = await call(port, '/api/orgs/alpha/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY), headers });
|
||||
assert.equal(r.status, 403, JSON.stringify(headers));
|
||||
}
|
||||
assert.equal(calls().length, before);
|
||||
// Request intake and project remote writes stay unavailable for the organizations release (README inventory).
|
||||
const config = (await call(port, '/api/orgs/alpha/config')).body;
|
||||
assert.equal(config.requestWrites, false); assert.equal(config.projectRemoteWrites, false);
|
||||
});
|
||||
|
||||
test('the organization autonomy writer passes the four autonomy verbs and nothing else', async () => {
|
||||
// Imported here so the other tests in this file also run against BASE, which has no such export.
|
||||
const { AUTONOMY_WRITE_VERBS, autonomyCaller } = await import('../src/autonomy-http.mjs');
|
||||
const seen = [];
|
||||
const write = autonomyCaller(async (verb, args, options) => { seen.push(verb); return { verb, args, options }; });
|
||||
for (const verb of AUTONOMY_WRITE_VERBS) assert.equal((await write(verb, {}, { subject: {} })).verb, verb);
|
||||
assert.deepEqual(AUTONOMY_WRITE_VERBS, ['autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console']);
|
||||
for (const verb of ['autonomy.get.console', 'request.draft.console', 'project.remote.set.console', 'decision.resolve.console', 'hire.start.console', 'prd.revoke.console']) {
|
||||
await assert.rejects(write(verb, {}, { subject: {} }), /does not call/);
|
||||
}
|
||||
assert.deepEqual(seen, AUTONOMY_WRITE_VERBS);
|
||||
});
|
||||
|
||||
// Without --dev-local-owner nothing changed (D1 acceptance). A deployed Console (--sso --send) has no
|
||||
// organization routes, and its own autonomy route still reads and writes through the instance's request
|
||||
// transport (the root bus, no business), for its signed-in owner, exactly as before D1.
|
||||
test('a deployed --sso --send Console is unchanged: no organization autonomy route, and the root route keeps the request transport', { timeout: 60000 }, async t => {
|
||||
const { port, calls, op, a, rootSocket } = await consoleFor(t, { sso: true });
|
||||
const login = await call(port, '/auth/login', { host: HOST });
|
||||
assert.equal(login.status, 303);
|
||||
const loginCookie = [login.headers['set-cookie'] ?? []].flat()[0].split(';')[0];
|
||||
const granted = op.grant(a, login.headers.location);
|
||||
const state = new URL(login.headers.location).searchParams.get('state');
|
||||
const signed = await call(port, `/auth/callback?${new URLSearchParams({ code: granted.code, state })}`, { host: HOST, headers: { cookie: loginCookie } });
|
||||
const cookie = [signed.headers['set-cookie'] ?? []].flat().find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0];
|
||||
assert.ok(cookie, `no session: ${signed.status}`);
|
||||
const orgRead = await call(port, '/api/orgs/alpha/autonomy', { host: HOST, headers: { cookie } });
|
||||
assert.equal(orgRead.status, 404); assert.deepEqual(orgRead.body, { error: 'not found' });
|
||||
const orgSave = await call(port, '/api/orgs/alpha/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY), host: HOST, origin: ORIGIN, headers: { cookie } });
|
||||
assert.equal(orgSave.status, 403); assert.deepEqual(orgSave.body, { error: 'read-only', message: 'the Console preview is read-only' });
|
||||
const read = await call(port, '/api/autonomy', { host: HOST, headers: { cookie } });
|
||||
assert.equal(read.status, 200, JSON.stringify(read.body));
|
||||
assert.deepEqual(read.body, { revision: 3, autonomy: { default: 'in' }, writable: true });
|
||||
const saved = await call(port, '/api/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY), host: HOST, origin: ORIGIN, headers: { cookie } });
|
||||
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
||||
assert.deepEqual(saved.body, { revision: 4, autonomy: { default: 'near' }, receipt: { action: 'autonomy.set', revision: 4 }, writable: true });
|
||||
const subject = { provider: a.name, iss: a.issuer, sub: `user-${a.name}` };
|
||||
assert.deepEqual(calls().filter(c => c.kind === 'console').map(({ business, socket, verb, subject: who }) => ({ business, socket, verb, subject: who })), [
|
||||
{ business: null, socket: rootSocket, verb: 'autonomy.get.console', subject },
|
||||
{ business: null, socket: rootSocket, verb: 'autonomy.set.console', subject },
|
||||
]);
|
||||
});
|
||||
Reference in new issue
Block a user