diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 131a13771..90742bc9f 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -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:` 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, `/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, `/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 diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 56d454236..cf596b786 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -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); } diff --git a/packages/cli/src/console-supervisor.mjs b/packages/cli/src/console-supervisor.mjs new file mode 100644 index 000000000..c94323b08 --- /dev/null +++ b/packages/cli/src/console-supervisor.mjs @@ -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); diff --git a/packages/cli/src/org-console.mjs b/packages/cli/src/org-console.mjs index b7d3d9397..f3bcd369f 100644 --- a/packages/cli/src/org-console.mjs +++ b/packages/cli/src/org-console.mjs @@ -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 { diff --git a/packages/cli/tests/console-actions.test.mjs b/packages/cli/tests/console-actions.test.mjs index 0aee8ed27..14c8858dc 100644 --- a/packages/cli/tests/console-actions.test.mjs +++ b/packages/cli/tests/console-actions.test.mjs @@ -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. diff --git a/packages/cli/tests/org-console.test.mjs b/packages/cli/tests/org-console.test.mjs index 645f7d6b7..a200635ae 100644 --- a/packages/cli/tests/org-console.test.mjs +++ b/packages/cli/tests/org-console.test.mjs @@ -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); }); diff --git a/packages/webui/README.md b/packages/webui/README.md index 397d044c2..4517aa3a9 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -21,7 +21,8 @@ baseline CLI. | Inspect or clear a dead lifecycle lock | `mosaic org lock show ` / `mosaic org lock clear ` | CLI only | | Start, stop or restart a bus | `mosaic bus start ` / `mosaic bus stop ` / `mosaic bus restart ` | S6 integration hook | | Restart (or first start) an organization's bus | `mosaic bus restart `, or `mosaic bus start ` 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 --repo --org ` | Enabled | | Create a thread | `mosaic thread create --org ` | Planned enabled | | Open a thread | `mosaic thread open --org ` | Planned enabled | diff --git a/packages/webui/src/autonomy-http.mjs b/packages/webui/src/autonomy-http.mjs index ec6fe8730..cb6042bf3 100644 --- a/packages/webui/src/autonomy-http.mjs +++ b/packages/webui/src/autonomy-http.mjs @@ -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}) { diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 704f30586..b23bef8b1 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -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 }), }; }, diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 961affc3a..f5fe60d27 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -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) { diff --git a/packages/webui/tests/org-autonomy-write.test.mjs b/packages/webui/tests/org-autonomy-write.test.mjs new file mode 100644 index 000000000..2374e0248 --- /dev/null +++ b/packages/webui/tests/org-autonomy-write.test.mjs @@ -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 }, + ]); +});