D2 security re-check P1 (agent-guard). mosaic bus start|stop|restart and mosaic console start (with or without --dev-local-owner), stop and restart refuse inside an agent run with refuseInsideAgent's refusal (exit 3), as bus recover does. The check reads the given environment and this process's own, so a sanitized io.env does not hide a marker; bus recover and console restart now check both too. consoleStart refuses for every Console. Status verbs stay open. No new marker list and no new human gate. The Console's Restart bus runs org-bus.mjs in the Console process; its new host goes through the guarded CLI entry with the Console's own environment. Restart Console relaunches under the supervisor without the CLI.
420 lines
29 KiB
JavaScript
420 lines
29 KiB
JavaScript
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);
|
|
});
|