import { test } from 'node:test'; 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 { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { tmpdir } from 'node:os'; import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs'; import { humanEnv } from './helpers.mjs'; import { consoleStart, consoleStatus, consoleStop, 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') }); const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 's1test'], { env, encoding: 'utf8' }); assert.equal(setup.status, 0, setup.stderr); const ctx = { configDir: join(home, '.config/mosaic-s1test'), dataRoot: join(home, '.mosaic-s1test'), instance: 's1test', env }; await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha'); t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); }); mkdirSync(join(ctx.dataRoot, 'console'), { mode: 0o700 }); writeFileSync(join(ctx.dataRoot, 'console/console.json'), JSON.stringify({ pid: 2147483647, startTime: '1', url: 'http://127.0.0.1:1/' }), { mode: 0o600 }); const warnings = []; const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: s => warnings.push(s) }); assert.equal(warnings.length, 1); assert.equal(readdirSync(join(ctx.dataRoot, 'console')).filter(x => x.startsWith('console.json.stale-')).length, 1); await assert.rejects(consoleStart(ctx, { devLocalOwner: true, detach: true }), /state exists/); const url = new URL(result.url); assert.equal(url.hostname, '127.0.0.1'); 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); // 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 => { const home = mkdtempSync(join(tmpdir(), 's1-')); const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') }); assert.equal(spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 's1test'], { env }).status, 0); const ctx = { configDir: join(home, '.config/mosaic-s1test'), dataRoot: join(home, '.mosaic-s1test'), instance: 's1test', env }; await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha'); t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); }); const start = spawnSync(join(REPO, 'scripts/mosaic'), ['console', 'start', '--dev-local-owner', '--detach', '--port', 'auto'], { env, encoding: 'utf8', timeout: 30000 }); assert.equal(start.status, 0, start.stderr); assert.match(JSON.parse(start.stdout).url, /^http:\/\/127\.0\.0\.1:\d+\/$/); const stop = spawnSync(join(REPO, 'scripts/mosaic'), ['console', 'stop'], { env, encoding: 'utf8', timeout: 30000 }); assert.equal(stop.status, 0, stop.stderr); assert.equal(JSON.parse(stop.stdout).stopped, true); }); // org-lan-bind: --dev-insecure-bind and --dev-allow-host. The Console binds 0.0.0.0 and the test // reaches it on 127.0.0.1 with an allowed Host name, as a LAN browser would. const hostGet = (port, host) => new Promise((resolve, reject) => { request({ host: '127.0.0.1', port, path: '/', headers: { host } }, res => { res.resume(); resolve(res.statusCode); }).on('error', reject).end(); }); async function instance(t) { const home = mkdtempSync(join(tmpdir(), 's1-')); const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') }); const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 's1test'], { env, encoding: 'utf8' }); assert.equal(setup.status, 0, setup.stderr); const ctx = { configDir: join(home, '.config/mosaic-s1test'), dataRoot: join(home, '.mosaic-s1test'), instance: 's1test', env }; await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha'); t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); }); const mosaic = args => spawnSync(join(REPO, 'scripts/mosaic'), args, { env, encoding: 'utf8', timeout: 30000 }); return { ctx, mosaic }; } test('a LAN console warns, records its bind and answers only its allowed names', { timeout: 60000 }, async t => { const { ctx, mosaic } = await instance(t); const warnings = []; const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'], warn: s => warnings.push(s) }); const url = new URL(result.url), port = url.port; assert.equal(url.hostname, '127.0.0.1'); assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(port))); const warning = `WARNING: dev owner Console listens on 0.0.0.0:${port} with no sign-in; anyone who can reach it acts as the owner.\n`; assert.deepEqual(warnings, [warning]); assert.equal(result.bind, '0.0.0.0'); assert.deepEqual(result.allowHosts, ['lan.test', '10.1.10.37']); const saved = JSON.parse(readFileSync(join(ctx.dataRoot, 'console/console.json'), 'utf8')); assert.deepEqual({ url: saved.url, bind: saved.bind, allowHosts: saved.allowHosts }, { url: result.url, bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'] }); // The serve process prints the same warning on its own stderr, which the Console log keeps. const logs = readdirSync(join(ctx.dataRoot, 'console')).filter(x => /^console-\d+-\d+\.log$/.test(x)); assert.equal(logs.length, 1); assert.ok(readFileSync(join(ctx.dataRoot, 'console', logs[0]), 'utf8').includes(warning), 'serve log has the warning'); assert.equal(await hostGet(port, `lan.test:${port}`), 200); assert.equal(await hostGet(port, `10.1.10.37:${port}`), 200); assert.equal(await hostGet(port, `127.0.0.1:${port}`), 200); assert.equal(await hostGet(port, `other.test:${port}`), 403); assert.equal(await hostGet(port, `lan.test:${Number(port) + 1}`), 403); const statusWarnings = []; const status = consoleStatus(ctx, { warn: s => statusWarnings.push(s) }); assert.deepEqual(status, { running: true, pid: result.pid, url: result.url, bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'] }); assert.deepEqual(statusWarnings, [warning]); const cli = mosaic(['console', 'status']); assert.equal(cli.status, 0, cli.stderr); assert.deepEqual(JSON.parse(cli.stdout), status); assert.equal(cli.stderr, warning); assert.equal((await consoleStop(ctx)).stopped, true); assert.deepEqual(consoleStatus(ctx), { running: false }); // A state file whose LAN fields do not validate is refused, not trusted. for (const bad of [{ bind: '0.0.0.0' }, { bind: '0.0.0.0', allowHosts: [] }, { bind: '0.0.0.0', allowHosts: ['a.test,b.test'] }, { bind: 'lan.test', allowHosts: ['lan.test'] }, { allowHosts: ['lan.test'] }]) { writeFileSync(join(ctx.dataRoot, 'console/console.json'), JSON.stringify({ pid: 2147483647, startTime: '1', url: 'http://127.0.0.1:1/', ...bad }), { mode: 0o600 }); assert.throws(() => consoleStatus(ctx), /console state is invalid/, JSON.stringify(bad)); } rmSync(join(ctx.dataRoot, 'console/console.json')); }); test('mosaic console start refuses the LAN flags without the dev owner and allowed names', { timeout: 60000 }, async t => { const { ctx, mosaic } = await instance(t); for (const [args, reason] of [ [['--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /--dev-insecure-bind needs --dev-local-owner/], [['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0'], /needs at least one --dev-allow-host/], [['--dev-local-owner', '--dev-allow-host', 'lan.test'], /--dev-allow-host needs --dev-insecure-bind/], [['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test:7350'], /without a port/], [['--dev-local-owner', '--dev-insecure-bind', '10.1.10.37', '--dev-allow-host', 'lan.test'], /must also be a --dev-allow-host name/], [['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'a.test', '--dev-allow-host', 'b.test'], /--dev-allow-host needs one value/], ]) { const r = mosaic(['console', 'start', '--detach', '--port', 'auto', ...args]); assert.equal(r.status, 4, `${args.join(' ')}: ${r.stderr}`); assert.match(r.stderr, reason, args.join(' ')); assert.doesNotMatch(r.stderr, /WARNING: dev owner/); assert.equal(r.stdout, ''); assert.equal(existsSync(join(ctx.dataRoot, 'console')), false, args.join(' ')); } await assert.rejects(consoleStart(ctx, { detach: true, bind: '0.0.0.0', allowHosts: ['lan.test'] }), /--dev-insecure-bind needs --dev-local-owner/); assert.equal(existsSync(join(ctx.dataRoot, 'console')), false); const none = mosaic(['console', 'status']); assert.equal(none.status, 0, none.stderr); assert.deepEqual(JSON.parse(none.stdout), { running: false }); assert.equal(mosaic(['console', 'status', '--json']).status, 4); }); // F3: the owner restarts the Console from Settings. A foreground `mosaic console start` runs the // Console with --supervised and relaunches it on the same arguments and address when it exits 75. const WEBUI = join(REPO, 'packages/webui/src/cli.mjs'); const sleep = ms => new Promise(r => setTimeout(r, ms)); const argvOf = pid => readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').slice(0, -1); function call(port, path, { method = 'GET', body = null, headers = {} } = {}) { const h = { host: `127.0.0.1:${port}`, ...(method === 'GET' ? {} : { origin: `http://127.0.0.1:${port}`, '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, body: json }); }); }); r.on('error', reject); r.end(body ?? undefined); }); } const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' }; test('a foreground console relaunches on the same address when the owner restarts it, and a stop ends it', { timeout: 90000 }, async t => { const { ctx } = await instance(t); const warnings = []; const result = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) }); t.after(async () => { result.stop(); await result.done; }); const port = Number(new URL(result.url).port); assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(port)); 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); assert.equal(typeof before.boot, 'string'); const stateFile = join(ctx.dataRoot, 'console/console.json'); const 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(JSON.parse(readFileSync(stateFile, 'utf8')).pid, result.pid); assert.equal(existsSync(trailFile), false); const answer = await call(port, '/api/console/restart', { method: 'POST', body: '{}' }); assert.equal(answer.status, 202); assert.deepEqual(answer.body, { outcome: 'restarting', boot: before.boot }); let saved; for (const deadline = Date.now() + 30000; ; await sleep(100)) { saved = JSON.parse(readFileSync(stateFile, 'utf8')); if (saved.pid !== result.pid) break; if (Date.now() > deadline) assert.fail('the Console was not relaunched'); } // Same address, same arguments; the auto port is now the port it had. assert.equal(saved.url, result.url); assert.deepEqual(argvOf(saved.pid), [process.execPath, WEBUI, 'serve', '--port', String(port), '--business', 'alpha', '--dev-local-owner', '--supervised']); assert.equal(statSync(stateFile).mode & 0o777, 0o600); const after = (await call(port, '/api/config')).body; assert.notEqual(after.boot, before.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.ok(Number.isFinite(Date.parse(line.at))); assert.deepEqual({ ...line, at: undefined }, { at: undefined, by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot }); assert.equal(statSync(trailFile).mode & 0o777, 0o600); assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']); assert.deepEqual(consoleStatus(ctx), { running: true, pid: saved.pid, url: result.url, bind: '127.0.0.1' }); result.stop(); assert.equal(await result.done, 0); assert.equal(consoleStatus(ctx).running, false); }); // D1 (#1873, readiness audit P1-1): Restart Console on a detached dev-owner Console. The start runs // the foreground supervision in a process of its own session; the Console is that process's child. const SUPERVISOR = join(REPO, 'packages/cli/src/console-supervisor.mjs'); const statOf = pid => readFileSync(`/proc/${pid}/stat`, 'utf8').replace(/^.*\) /, '').split(' '); test('a detached dev-owner console runs under a detached supervisor, so the owner restarts it in place', { timeout: 120000 }, async t => { const { ctx, mosaic } = await instance(t); const warnings = []; const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: s => warnings.push(s) }); t.after(() => { try { process.kill(result.supervisor, 'SIGTERM'); } catch {} }); assert.deepEqual(warnings, []); assert.deepEqual(Object.keys(result), ['url', 'pid', 'supervisor']); const port = Number(new URL(result.url).port); assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(port)); const sup = argvOf(result.supervisor); assert.deepEqual(sup.slice(0, 2), [process.execPath, SUPERVISOR]); assert.deepEqual(JSON.parse(sup[2]), { configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: 's1test', port: 'auto', bind: null, allowHosts: [] }); // The supervisor leads its own session; the Console is its child, started as in the foreground. assert.equal(Number(statOf(result.supervisor)[3]), result.supervisor); assert.equal(Number(statOf(result.pid)[1]), result.supervisor); assert.deepEqual(argvOf(result.pid), [process.execPath, WEBUI, 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner', '--supervised']); const before = (await call(port, '/api/config')).body; assert.equal(before.consoleRestart, true); const stateFile = join(ctx.dataRoot, 'console/console.json'), trailFile = join(ctx.dataRoot, 'console/actions.jsonl'); // Another origin or site restarts nothing. for (const headers of [{ origin: 'http://evil.example' }, { origin: null }, { 'sec-fetch-site': 'cross-site' }, { host: `evil.example:${port}` }]) { assert.equal((await call(port, '/api/console/restart', { method: 'POST', body: '{}', headers })).status, 403, JSON.stringify(headers)); } assert.equal(existsSync(trailFile), false); // The page's request, as `mosaic console restart` sends it: 202, then a new boot on the same address. const restarted = await consoleRestart(ctx); assert.equal(restarted.outcome, 'restarted'); assert.equal(restarted.from, before.boot); assert.notEqual(restarted.boot, before.boot); // The relaunched Console answers before the supervisor writes its state (relaunch), so wait for it. const saved = await relaunchedState(ctx, result.pid); assert.notEqual(saved.pid, result.pid); assert.equal(saved.url, result.url); assert.equal(Number(statOf(saved.pid)[1]), result.supervisor); assert.deepEqual(argvOf(saved.pid), [process.execPath, WEBUI, 'serve', '--port', String(port), '--business', 'alpha', '--dev-local-owner', '--supervised']); const after = (await call(port, '/api/config')).body; assert.equal(after.boot, restarted.boot); assert.equal(after.consoleRestart, true); const [line, ...more] = readFileSync(trailFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); assert.deepEqual(more, []); assert.deepEqual({ ...line, at: undefined }, { at: undefined, by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot }); // The supervisor's later warnings go to its log under the data root. const logs = readdirSync(join(ctx.dataRoot, 'console')).filter(x => /^supervisor-\d+-\d+\.log$/.test(x)); assert.equal(logs.length, 1); assert.equal(statSync(join(ctx.dataRoot, 'console', logs[0])).mode & 0o777, 0o600); assert.equal(readFileSync(join(ctx.dataRoot, 'console', logs[0]), 'utf8'), 'mosaic: console asked for a restart; relaunching on the same arguments\n'); // A second start is refused by the supervisor's start and says so here; nothing else starts. await assert.rejects(consoleStart(ctx, { devLocalOwner: true, detach: true }), e => e.exitCode === 3 && /console state exists/.test(e.message)); const cli = mosaic(['console', 'start', '--dev-local-owner', '--detach', '--port', 'auto']); assert.equal(cli.status, 3); assert.match(cli.stderr, /console state exists/); assert.equal(cli.stdout, ''); assert.deepEqual(consoleStatus(ctx), { running: true, pid: saved.pid, url: result.url, bind: '127.0.0.1' }); // A stop ends the Console and then the supervisor, cleanly. assert.deepEqual(await consoleStop(ctx), { stopped: true, pid: saved.pid }); assert.equal(await exitOf(result.done), 0); assert.deepEqual(consoleStatus(ctx), { running: false }); }); test('a supervisor stopped by a signal stops its detached dev-owner console', { timeout: 60000 }, async t => { const { ctx } = await instance(t); const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} }); t.after(() => { try { process.kill(result.supervisor, 'SIGTERM'); } catch {} }); result.stop(); assert.equal(await exitOf(result.done), 0); assert.equal(consoleStatus(ctx).running, false); }); // relaunch() writes console.json after the new Console answers; read it once it names a new pid. async function relaunchedState(ctx, oldPid) { const file = join(ctx.dataRoot, 'console/console.json'); for (const deadline = Date.now() + 15000; ; await new Promise(r => setTimeout(r, 50))) { const saved = JSON.parse(readFileSync(file, 'utf8')); if (saved.pid !== oldPid || Date.now() > deadline) return saved; } } // D1 fix round (build review P2): the starting CLI can die before it reads the supervisor's reply // (Ctrl-C, a wrapper's timeout). The reply is dropped, and the supervisor keeps supervising. test('a supervisor whose starting CLI is gone keeps supervising, so Restart Console still relaunches', { timeout: 60000 }, async t => { const { ctx } = await instance(t); const child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: 's1test', port: 'auto', bind: null, allowHosts: [] })], { env: ctx.env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); t.after(() => { try { process.kill(child.pid, 'SIGTERM'); } catch {} }); const exit = new Promise(resolve => child.once('exit', code => resolve(code))); let stderr = ''; child.stderr.setEncoding('utf8').on('data', c => { stderr += c; }); child.stdout.destroy(); let status = consoleStatus(ctx); for (const deadline = Date.now() + 30000; !status.running; status = consoleStatus(ctx)) { if (Date.now() > deadline) assert.fail(`the console did not start: ${stderr}`); await new Promise(r => setTimeout(r, 100)); } // The reply follows the state write; give it time to meet the closed pipe. await new Promise(r => setTimeout(r, 1000)); assert.equal(child.exitCode, null, stderr); assert.equal(Number(statOf(status.pid)[1]), child.pid); const restarted = await consoleRestart(ctx); assert.equal(restarted.outcome, 'restarted'); const after = await relaunchedState(ctx, status.pid); assert.notEqual(after.pid, status.pid); assert.equal(after.url, status.url); assert.equal(Number(statOf(after.pid)[1]), child.pid); assert.deepEqual(await consoleStop(ctx), { stopped: true, pid: after.pid }); assert.equal(await exit, 0); assert.doesNotMatch(stderr, /EPIPE|Unhandled/); }); // D1 fix round (security review P2): a stop signal while the supervisor holds the start lock is kept // until the start settles; then the Console stops, the lock is released and the supervisor exits. test('a supervisor signalled during its start stops the console it started and releases the start lock', { timeout: 60000 }, async t => { const { ctx } = await instance(t); const child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: 's1test', port: 'auto', bind: null, allowHosts: [] })], { env: ctx.env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); t.after(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }); const exit = new Promise(resolve => child.once('exit', (code, signal) => resolve({ code, signal }))); let stdout = '', stderr = ''; child.stdout.setEncoding('utf8').on('data', c => { stdout += c; }); child.stderr.setEncoding('utf8').on('data', c => { stderr += c; }); const lock = join(ctx.dataRoot, 'console/start.lock'); for (const deadline = Date.now() + 20000; !existsSync(lock); await new Promise(r => setTimeout(r, 2))) { if (Date.now() > deadline) assert.fail(`the supervisor never took the start lock: ${stderr}`); } process.kill(child.pid, 'SIGTERM'); assert.deepEqual(await exit, { code: 0, signal: null }, stderr); assert.equal(existsSync(lock), false); // As after a foreground stop by signal, the state file stays and reads stale, not running. assert.equal(consoleStatus(ctx).running, false); // The started Console was this supervisor's child, and it is gone. const reply = JSON.parse(stdout.trim().split('\n')[0]); assert.equal(existsSync(`/proc/${reply.receipt.pid}`), false); }); // D1 fix round (security review P1): an agent run never starts a dev-local-owner Console. // agent-guard (#1873): nor a Console without it; the human start is unchanged. test('an agent run cannot start a console, dev-local-owner or not; a human start is unchanged', { timeout: 60000 }, async t => { const { ctx, mosaic } = await instance(t); const agent = { ...ctx, env: { ...ctx.env, MOSAIC_RUN_ID: 'run-1' } }; for (const detach of [true, false]) { await assert.rejects(consoleStart(agent, { devLocalOwner: true, detach, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /only from a human shell/.test(e.message)); } // A clean environment given for the Console does not hide this process's own marker (re-check P1). process.env.MOSAIC_RUN_ID = 'run-2'; try { for (const detach of [true, false]) { await assert.rejects(consoleStart(ctx, { devLocalOwner: true, detach, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /found MOSAIC_RUN_ID/.test(e.message)); } } finally { delete process.env.MOSAIC_RUN_ID; } const agentCli = args => spawnSync(join(REPO, 'scripts/mosaic'), args, { env: { ...ctx.env, MOSAIC_AGENT_RUN: '1' }, encoding: 'utf8', timeout: 30000 }); const refused = agentCli(['console', 'start', '--dev-local-owner', '--detach', '--port', 'auto']); assert.equal(refused.status, 3); assert.equal(refused.stdout, ''); assert.match(refused.stderr, /only from a human shell/); const consoleDir = join(ctx.dataRoot, 'console'); assert.deepEqual(existsSync(consoleDir) ? readdirSync(consoleDir) : [], []); assert.deepEqual(consoleStatus(ctx), { running: false }); const plain = agentCli(['console', 'start', '--detach', '--port', 'auto']); assert.equal(plain.status, 3); assert.equal(plain.stdout, ''); assert.match(plain.stderr, /only from a human shell/); await assert.rejects(consoleStart(agent, { detach: true, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /found MOSAIC_RUN_ID/.test(e.message)); assert.deepEqual(existsSync(consoleDir) ? readdirSync(consoleDir) : [], []); const human = mosaic(['console', 'start', '--detach', '--port', 'auto']); assert.equal(human.status, 0, human.stderr); assert.deepEqual(Object.keys(JSON.parse(human.stdout)), ['url', 'pid']); assert.equal((await consoleStop(ctx)).stopped, true); }); // D1 security re-check P2: a supervisor that hangs holding the start lock is killed with its group // after the 60 s start timeout and 10 s grace. Its lock names it, so the start clears it and the // next start proceeds. The hook blocks the supervisor's event loop once it holds the lock (at its // console state read), so its stop handler never runs and only the group SIGKILL ends it. test('a detached start that times out clears the start lock of the supervisor it killed', { timeout: 150000 }, async t => { const { ctx } = await instance(t); const hook = join(dirname(ctx.dataRoot), 'hang-after-lock.mjs'); writeFileSync(hook, `import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module'; if (process.argv[1]?.endsWith('console-supervisor.mjs')) { const real = fs.openSync; fs.openSync = (...args) => { if (String(args[0]).endsWith('/console/console.json')) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); return real(...args); }; syncBuiltinESMExports(); }\n`); const hung = { ...ctx, env: { ...ctx.env, NODE_OPTIONS: `--import=${pathToFileURL(hook).href}` } }; const lock = join(ctx.dataRoot, 'console/start.lock'); const began = Date.now(); await assert.rejects(consoleStart(hung, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /^console failed to start; see console\/supervisor-\d+-\d+\.log under the data root$/.test(e.message)); assert.ok(Date.now() - began >= 69000, 'the start waited out its timeout and grace'); assert.equal(existsSync(lock), false); assert.deepEqual(consoleStatus(ctx), { running: false }); // The next start is not blocked by the killed supervisor's lock. const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} }); assert.equal((await consoleStop(ctx)).stopped, true); assert.equal(await exitOf(result.done), 0); }); // Without --dev-local-owner a detached start is what it was before D1: no supervisor, restart refused. test('a detached console without the dev owner has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => { const { ctx, mosaic } = await instance(t); const warnings = []; const result = await consoleStart(ctx, { detach: true, port: 'auto', warn: s => warnings.push(s) }); assert.deepEqual(warnings, []); assert.deepEqual(Object.keys(result), ['url', 'pid']); const port = Number(new URL(result.url).port); assert.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: '{}' }); // 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); }); // E2E6 fix 4: console start and stop clear a start.lock whose named supervisor (pid and start time) is // gone, and never one whose holder still runs or that names no holder. test('E2E6 a start.lock whose holder is gone is cleared by stop and by start; a live or unnamed one refuses and stays', { timeout: 60000 }, async t => { const { startTimeOf } = await import('../src/host.mjs'); const { ctx } = await instance(t); const dir = join(ctx.dataRoot, 'console'), lock = join(dir, 'start.lock'); mkdirSync(dir, { recursive: true, mode: 0o700 }); // A process this test starts and lets end: its pid and start time name a supervisor that is gone. const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { stdio: 'ignore' }); await new Promise(r => child.once('spawn', r)); const deadHolder = { pid: child.pid, startTime: startTimeOf(child.pid) }; assert.match(deadHolder.startTime, /^\d+$/); child.kill('SIGKILL'); await new Promise(r => child.once('exit', r)); const warnings = []; writeFileSync(lock, JSON.stringify(deadHolder), { mode: 0o600 }); assert.deepEqual(await consoleStop(ctx, { warn: s => warnings.push(s) }), { stopped: false }); assert.deepEqual(warnings, [`mosaic: cleared a console start lock left by process ${child.pid}, which is gone\n`]); assert.equal(existsSync(lock), false); assert.deepEqual(readdirSync(dir).filter(x => x.startsWith('start.lock')), []); // A live pid with another start time is a reused pid: its holder is gone too. writeFileSync(lock, JSON.stringify({ pid: process.pid, startTime: '1' }), { mode: 0o600 }); warnings.length = 0; const result = await consoleStart(ctx, { detach: true, port: 'auto', warn: s => warnings.push(s) }); assert.deepEqual(warnings, [`mosaic: cleared a console start lock left by process ${process.pid}, which is gone\n`]); assert.equal(existsSync(lock), false, 'the start released its own lock'); assert.equal((await consoleStop(ctx)).stopped, true); assert.ok(Number.isSafeInteger(result.pid)); // A live holder refuses on start and stop, and the lock stays as it was. const live = JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid) }); writeFileSync(lock, live, { mode: 0o600 }); await assert.rejects(consoleStop(ctx), e => e.message === `console start lock is held by process ${process.pid}, which still runs; wait for its start or stop to finish`); await assert.rejects(consoleStart(ctx, { detach: true, port: 'auto' }), /which still runs/); assert.equal(readFileSync(lock, 'utf8'), live); // A lock that names no holder (empty: a holder between its open and its write; or bad JSON) stays too. for (const body of ['', '{"pid":', JSON.stringify({ pid: 0, startTime: '1' }), JSON.stringify({ pid: child.pid, startTime: 7 })]) { writeFileSync(lock, body, { mode: 0o600 }); await assert.rejects(consoleStop(ctx), /console start lock exists and names no holder/); assert.equal(readFileSync(lock, 'utf8'), body); } // f6c review P3-1: a lock its holder marked released (a clear put it back after the release) is // cleared; a released marker with anything else in it names no holder and stays. writeFileSync(lock, JSON.stringify({ released: true }), { mode: 0o600 }); warnings.length = 0; assert.deepEqual(await consoleStop(ctx, { warn: s => warnings.push(s) }), { stopped: false }); assert.deepEqual(warnings, ['mosaic: cleared a console start lock its holder had released\n']); assert.equal(existsSync(lock), false); const odd = JSON.stringify({ released: true, pid: 1 }); writeFileSync(lock, odd, { mode: 0o600 }); await assert.rejects(consoleStop(ctx), /console start lock exists and names no holder/); assert.equal(readFileSync(lock, 'utf8'), odd); rmSync(lock); });