From 7ffe9322502d9d30c1dedc78099c9e113860a9d1 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 11 Nov 2026 12:50:51 -0600 Subject: [PATCH 1/3] feat(console): dev-local-owner Console writes autonomy and restarts when detached (#1873) A --dev-local-owner Console's organization pages read writable:false because the per-org options had no autonomy writer and the route fell back to the request transport, which org options never set. The org options now carry an autonomy writer on the organization's console transport, fenced to the four owner autonomy verbs. The route prefers it and keeps requestWrite otherwise. mosaic console start --dev-local-owner --detach now runs the Console under a detached supervisor, so Restart Console (exit 75) relaunches it on the same address. A non-dev detached Console is unchanged. A deployed --sso --send Console has no organization routes and keeps its root autonomy transport; a test pins both. --- docs/TOOLS.md | 2 +- packages/cli/src/console-supervisor.mjs | 30 ++++ packages/cli/src/org-console.mjs | 47 ++++- packages/cli/tests/console-actions.test.mjs | 36 +++- packages/cli/tests/org-console.test.mjs | 96 +++++++++- packages/webui/README.md | 3 +- packages/webui/src/autonomy-http.mjs | 3 + packages/webui/src/cli.mjs | 5 + packages/webui/src/serve.mjs | 10 +- .../webui/tests/org-autonomy-write.test.mjs | 165 ++++++++++++++++++ 10 files changed, 380 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/console-supervisor.mjs create mode 100644 packages/webui/tests/org-autonomy-write.test.mjs diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 131a13771..0da5cff29 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. Any other 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. 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/console-supervisor.mjs b/packages/cli/src/console-supervisor.mjs new file mode 100644 index 000000000..b21935fbb --- /dev/null +++ b/packages/cli/src/console-supervisor.mjs @@ -0,0 +1,30 @@ +// 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); }; +const reply = value => new Promise(resolve => process.stdout.write(`${JSON.stringify(value)}\n`, () => resolve())); +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.on(signal, 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..6c0c2b53c 100644 --- a/packages/cli/src/org-console.mjs +++ b/packages/cli/src/org-console.mjs @@ -153,15 +153,60 @@ 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 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(); + if (line === undefined) { child.kill('SIGTERM'); await done; 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); } + 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..f2d5af1e1 100644 --- a/packages/cli/tests/org-console.test.mjs +++ b/packages/cli/tests/org-console.test.mjs @@ -7,9 +7,15 @@ import { join } from 'node:path'; 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 +37,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 +206,93 @@ 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); + const saved = JSON.parse(readFileSync(stateFile, 'utf8')); + 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); +}); + +// 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..086d0fcf0 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 | None; console verbs `autonomy.set.console`, `autonomy.review.console`, `decision.confirm.console`, `decision.reverse.console` | Enabled (D1) on a `--dev-local-owner` Console: the owner only, on the selected organization's bus, behind a fence that passes those four verbs and nothing else | | 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..8bc74932a --- /dev/null +++ b/packages/webui/tests/org-autonomy-write.test.mjs @@ -0,0 +1,165 @@ +// 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 } }; + 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 }, + ]); + // 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 }, + ]); +}); -- 2.54.0 From 1947a056fbce10b451b191c0809ff8a0ffe16475 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 11 Nov 2026 13:05:27 -0600 Subject: [PATCH 2/3] fix(console): D1 review fixes for the detached dev-owner supervisor (#1873) Build review P2: the supervisor ignores a lost reply pipe and keeps supervising. Security P2: a stop signal during start is held until the start settles, and the CLI's start timeout signals the supervisor's process group. Security P1 (part): console start --dev-local-owner refuses inside an agent run. Tests cover review, confirm and reverse on the org autonomy route; TOOLS.md and the webui README are corrected. --- docs/TOOLS.md | 2 +- packages/cli/src/cli.mjs | 2 + packages/cli/src/console-supervisor.mjs | 11 ++- packages/cli/src/org-console.mjs | 15 +++- packages/cli/tests/org-console.test.mjs | 87 ++++++++++++++++++- packages/webui/README.md | 2 +- .../webui/tests/org-autonomy-write.test.mjs | 8 ++ 7 files changed, 121 insertions(+), 6 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 0da5cff29..930931dde 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`, 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. Any other 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`; a start without `--dev-local-owner` is unchanged. 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..b167888bb 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(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 index b21935fbb..c94323b08 100644 --- a/packages/cli/src/console-supervisor.mjs +++ b/packages/cli/src/console-supervisor.mjs @@ -10,7 +10,15 @@ const { configDir, dataRoot, instance, port, bind, allowHosts } = JSON.parse(pro 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 }); @@ -23,7 +31,8 @@ try { 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.on(signal, 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`); diff --git a/packages/cli/src/org-console.mjs b/packages/cli/src/org-console.mjs index 6c0c2b53c..a9d56afa5 100644 --- a/packages/cli/src/org-console.mjs +++ b/packages/cli/src/org-console.mjs @@ -10,6 +10,7 @@ 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]); @@ -182,7 +183,16 @@ async function detachedSupervisor(ctx, { port, bind, allowHosts, warn }) { ]); clearTimeout(timer); child.stdout.destroy(); - if (line === undefined) { child.kill('SIGTERM'); await done; fail(unclear); } + // 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. + 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; } + 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); @@ -206,6 +216,9 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false, 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. + if (devLocalOwner) refuseInsideAgent(ctx.env ?? process.env); if (detach && devLocalOwner) return detachedSupervisor(ctx, { port, bind, allowHosts, warn }); const release = takeLock(ctx); let run, args; diff --git a/packages/cli/tests/org-console.test.mjs b/packages/cli/tests/org-console.test.mjs index f2d5af1e1..089226c90 100644 --- a/packages/cli/tests/org-console.test.mjs +++ b/packages/cli/tests/org-console.test.mjs @@ -1,6 +1,6 @@ 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'; @@ -237,7 +237,8 @@ test('a detached dev-owner console runs under a detached supervisor, so the owne // 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); - const saved = JSON.parse(readFileSync(stateFile, 'utf8')); + // 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); @@ -272,6 +273,88 @@ test('a supervisor stopped by a signal stops its detached dev-owner console', { 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)); + } + 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); +}); + // 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); diff --git a/packages/webui/README.md b/packages/webui/README.md index 086d0fcf0..4517aa3a9 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -22,7 +22,7 @@ baseline CLI. | 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, 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 | None; console verbs `autonomy.set.console`, `autonomy.review.console`, `decision.confirm.console`, `decision.reverse.console` | Enabled (D1) on a `--dev-local-owner` Console: the owner only, on the selected organization's bus, behind a fence that passes those four verbs and nothing else | +| 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/tests/org-autonomy-write.test.mjs b/packages/webui/tests/org-autonomy-write.test.mjs index 8bc74932a..2374e0248 100644 --- a/packages/webui/tests/org-autonomy-write.test.mjs +++ b/packages/webui/tests/org-autonomy-write.test.mjs @@ -42,6 +42,7 @@ 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); }; `); @@ -109,6 +110,13 @@ test('the dev-local owner reads an organization autonomy page as writable and a { 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' }]) { -- 2.54.0 From ebe2af86de8dc977f8267a8095a6e68b4fd1ebf6 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 11 Nov 2026 13:24:27 -0600 Subject: [PATCH 3/3] fix(console): D1 security re-check fixes for the dev-owner start (#1873) The agent-run refusal on a dev-local-owner start now checks the starting process's own environment as well as the environment given for the Console, so a caller cannot pass a clean env to get past it. The start lock now names its holder (pid and start time). A detached start that times out and kills its supervisor's process group clears the lock only when it names that supervisor and the supervisor is gone, so a forced kill no longer blocks every later start. --- docs/TOOLS.md | 2 +- packages/cli/src/cli.mjs | 2 +- packages/cli/src/org-console.mjs | 29 +++++++++++++++++--- packages/cli/tests/org-console.test.mjs | 35 ++++++++++++++++++++++++- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 930931dde..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`, 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`; a start without `--dev-local-owner` is unchanged. 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. +`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 b167888bb..cf596b786 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -596,7 +596,7 @@ async function consoleCommand(rest, io, deps) { // --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(io.env); + 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/org-console.mjs b/packages/cli/src/org-console.mjs index a9d56afa5..f3bcd369f 100644 --- a/packages/cli/src/org-console.mjs +++ b/packages/cli/src/org-console.mjs @@ -3,7 +3,7 @@ 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'; @@ -64,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; @@ -169,6 +186,7 @@ async function detachedSupervisor(ctx, { port, bind, allowHosts, warn }) { 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`; @@ -184,13 +202,15 @@ async function detachedSupervisor(ctx, { port, bind, allowHosts, warn }) { 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. + // 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; @@ -217,8 +237,9 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false, 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. - if (devLocalOwner) refuseInsideAgent(ctx.env ?? process.env); + // 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; diff --git a/packages/cli/tests/org-console.test.mjs b/packages/cli/tests/org-console.test.mjs index 089226c90..a200635ae 100644 --- a/packages/cli/tests/org-console.test.mjs +++ b/packages/cli/tests/org-console.test.mjs @@ -3,7 +3,8 @@ import assert from 'node:assert/strict'; 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'; @@ -343,6 +344,13 @@ test('an agent run cannot start a dev-local-owner console; a start without it is 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/); @@ -355,6 +363,31 @@ test('an agent run cannot start a dev-local-owner console; a start without it is 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); -- 2.54.0