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