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.
415 lines
29 KiB
JavaScript
415 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.
|
|
test('an agent run cannot start a dev-local-owner console; a start without it is unchanged', { timeout: 60000 }, async t => {
|
|
const { ctx, mosaic } = await instance(t);
|
|
const agent = { ...ctx, env: { ...ctx.env, MOSAIC_RUN_ID: 'run-1' } };
|
|
for (const detach of [true, false]) {
|
|
await assert.rejects(consoleStart(agent, { devLocalOwner: true, detach, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /only from a human shell/.test(e.message));
|
|
}
|
|
// A clean environment given for the Console does not hide this process's own marker (re-check P1).
|
|
process.env.MOSAIC_RUN_ID = 'run-2';
|
|
try {
|
|
for (const detach of [true, false]) {
|
|
await assert.rejects(consoleStart(ctx, { devLocalOwner: true, detach, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /found MOSAIC_RUN_ID/.test(e.message));
|
|
}
|
|
} finally { delete process.env.MOSAIC_RUN_ID; }
|
|
const agentCli = args => spawnSync(join(REPO, 'scripts/mosaic'), args, { env: { ...ctx.env, MOSAIC_AGENT_RUN: '1' }, encoding: 'utf8', timeout: 30000 });
|
|
const refused = agentCli(['console', 'start', '--dev-local-owner', '--detach', '--port', 'auto']);
|
|
assert.equal(refused.status, 3); assert.equal(refused.stdout, ''); assert.match(refused.stderr, /only from a human shell/);
|
|
const consoleDir = join(ctx.dataRoot, 'console');
|
|
assert.deepEqual(existsSync(consoleDir) ? readdirSync(consoleDir) : [], []);
|
|
assert.deepEqual(consoleStatus(ctx), { running: false });
|
|
const plain = agentCli(['console', 'start', '--detach', '--port', 'auto']);
|
|
assert.equal(plain.status, 0, plain.stderr);
|
|
assert.deepEqual(Object.keys(JSON.parse(plain.stdout)), ['url', 'pid']);
|
|
assert.equal((await consoleStop(ctx)).stopped, true);
|
|
});
|
|
|
|
// D1 security re-check P2: a supervisor that hangs holding the start lock is killed with its group
|
|
// after the 60 s start timeout and 10 s grace. Its lock names it, so the start clears it and the
|
|
// next start proceeds. The hook blocks the supervisor's event loop once it holds the lock (at its
|
|
// console state read), so its stop handler never runs and only the group SIGKILL ends it.
|
|
test('a detached start that times out clears the start lock of the supervisor it killed', { timeout: 150000 }, async t => {
|
|
const { ctx } = await instance(t);
|
|
const hook = join(dirname(ctx.dataRoot), 'hang-after-lock.mjs');
|
|
writeFileSync(hook, `import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module';
|
|
if (process.argv[1]?.endsWith('console-supervisor.mjs')) { const real = fs.openSync;
|
|
fs.openSync = (...args) => { if (String(args[0]).endsWith('/console/console.json')) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); return real(...args); };
|
|
syncBuiltinESMExports(); }\n`);
|
|
const hung = { ...ctx, env: { ...ctx.env, NODE_OPTIONS: `--import=${pathToFileURL(hook).href}` } };
|
|
const lock = join(ctx.dataRoot, 'console/start.lock');
|
|
const began = Date.now();
|
|
await assert.rejects(consoleStart(hung, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} }),
|
|
e => e.exitCode === 3 && /^console failed to start; see console\/supervisor-\d+-\d+\.log under the data root$/.test(e.message));
|
|
assert.ok(Date.now() - began >= 69000, 'the start waited out its timeout and grace');
|
|
assert.equal(existsSync(lock), false);
|
|
assert.deepEqual(consoleStatus(ctx), { running: false });
|
|
// The next start is not blocked by the killed supervisor's lock.
|
|
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
|
|
assert.equal((await consoleStop(ctx)).stopped, true);
|
|
assert.equal(await exitOf(result.done), 0);
|
|
});
|
|
|
|
// Without --dev-local-owner a detached start is what it was before D1: no supervisor, restart refused.
|
|
test('a detached console without the dev owner has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => {
|
|
const { ctx, mosaic } = await instance(t);
|
|
const warnings = [];
|
|
const result = await consoleStart(ctx, { detach: true, port: 'auto', warn: s => warnings.push(s) });
|
|
assert.deepEqual(warnings, []);
|
|
assert.deepEqual(Object.keys(result), ['url', 'pid']);
|
|
const port = Number(new URL(result.url).port);
|
|
assert.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);
|
|
});
|