Build review P2: the supervisor ignores a lost reply pipe and keeps supervising. Security P2: a stop signal during start is held until the start settles, and the CLI's start timeout signals the supervisor's process group. Security P1 (part): console start --dev-local-owner refuses inside an agent run. Tests cover review, confirm and reverse on the org autonomy route; TOOLS.md and the webui README are corrected.
174 lines
13 KiB
JavaScript
174 lines
13 KiB
JavaScript
// D1 (#1873, readiness audit P0-2): on a Console started with --dev-local-owner, an organization's
|
|
// autonomy page read `writable:false` and refused every owner save. The organization options built in
|
|
// cli.mjs carried no writer the autonomy route could use. This runs the real cli.mjs serve
|
|
// --dev-local-owner against a real organization record; only the bus transports cli.mjs builds are
|
|
// replaced (loader hook), so no bus or human proof is involved.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdirSync, readFileSync, renameSync, chmodSync, writeFileSync } from 'node:fs';
|
|
import { request } from 'node:http';
|
|
import { join } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { tmp, fixture } from '../../cli/tests/helpers.mjs';
|
|
import { fakeOidc } from './helpers/fake-oidc.mjs';
|
|
import { ownerAccounts } from './helpers/owner-accounts.mjs';
|
|
import { SESSION_COOKIE } from '../src/sso.mjs';
|
|
import { createOrg } from '../../organization/src/org-service.mjs';
|
|
import { orgPaths } from '../../cli/src/config.mjs';
|
|
import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs';
|
|
|
|
const AUTONOMY = { revision: 3, autonomy: { default: 'near' } };
|
|
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
|
|
|
|
// `sso`: a deployed Console instead (--sso --send, a fake OP on loopback, the owner signed in).
|
|
async function consoleFor(t, { sso = false } = {}) {
|
|
const root = tmp(t, 'o-'), f = fixture(root), home = join(root, 'home'), configDir = join(home, '.config', 'mosaic-test');
|
|
mkdirSync(join(home, '.config'), { recursive: true }); renameSync(join(root, 'config'), configDir);
|
|
chmodSync(configDir, 0o700); chmodSync(join(configDir, 'businesses'), 0o700);
|
|
writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'test' }));
|
|
const doc = structuredClone(f.doc); doc.id = 'alpha'; doc.human = DEV_OWNER.sub; doc.projects.stack.root = '/__mosaic_org__/alpha/projects/stack';
|
|
const org = await createOrg({ configDir, dataRoot: f.dataRoot, instance: 'test', principal: DEV_OWNER }, 'alpha', doc);
|
|
const log = join(root, 'calls.jsonl'); writeFileSync(log, '');
|
|
const busURL = new URL('../src/bus.mjs', import.meta.url).href, orgBusURL = new URL('../../cli/src/org-bus.mjs', import.meta.url).href;
|
|
// The console transport answers the autonomy verbs from fixed rows and logs (kind, business, socket, verb, args, subject).
|
|
const busShim = join(root, 'bus-shim.mjs'), orgBusShim = join(root, 'org-bus-shim.mjs'), hook = join(root, 'hook.mjs');
|
|
writeFileSync(busShim, `import { appendFileSync } from 'node:fs';
|
|
import { BusReadError } from ${JSON.stringify(busURL)};
|
|
export * from ${JSON.stringify(busURL)};
|
|
const record = (kind, o, verb, args, extra = {}) => appendFileSync(${JSON.stringify(log)}, JSON.stringify({ kind, business: o.business ?? null, socket: o.socket ?? null, verb, args, ...extra }) + '\\n');
|
|
export const busCaller = o => async (verb, args) => { record('human', o, verb, args); return verb === 'plugins' ? { plugins: { channel: ['cli', 'webui'] } } : []; };
|
|
export const consoleCall = o => async (verb, args, { subject }) => {
|
|
record('console', o, verb, args, { subject });
|
|
if (verb === 'autonomy.get.console') return { revision: 3, autonomy: { default: 'in' } };
|
|
if (verb === 'autonomy.set.console') return { revision: 4, autonomy: args.autonomy, receipt: { action: 'autonomy.set', revision: 4 } };
|
|
if (['autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console'].includes(verb)) return { answered: verb };
|
|
throw new BusReadError('unavailable', 'fixture: ' + verb);
|
|
};
|
|
`);
|
|
writeFileSync(orgBusShim, `export * from ${JSON.stringify(orgBusURL)};\nexport const busStatus = async () => ({ healthy: true });\n`);
|
|
writeFileSync(hook, `import { registerHooks } from 'node:module';
|
|
registerHooks({ resolve(specifier, context, next) {
|
|
if (context.parentURL?.endsWith('/webui/src/cli.mjs')) {
|
|
if (specifier === './bus.mjs') return { url: ${JSON.stringify(pathToFileURL(busShim).href)}, shortCircuit: true };
|
|
if (specifier === '../../cli/src/org-bus.mjs') return { url: ${JSON.stringify(pathToFileURL(orgBusShim).href)}, shortCircuit: true };
|
|
}
|
|
return next(specifier, context);
|
|
} });
|
|
`);
|
|
const env = { ...f.env, HOME: home, MOSAIC_CONFIG: join(configDir, 'config.json'), NODE_OPTIONS: `--import=${pathToFileURL(hook).href}` };
|
|
let op = null, a = null;
|
|
if (sso) {
|
|
op = await fakeOidc(); t.after(() => op.close());
|
|
a = op.issuer('a');
|
|
const provider = { id: a.name, issuer: a.issuer, clientId: a.clientId, clientSecret: { slot: a.name }, algs: [...new Set(a.keys.map(k => k.alg))], subjects: [{ sub: `user-${a.name}`, name: `person-${a.name}` }] };
|
|
const config = JSON.parse(readFileSync(env.MOSAIC_CONFIG, 'utf8'));
|
|
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify({ ...config, console: { sso: { publicOrigin: ORIGIN, providers: [provider] } } }));
|
|
mkdirSync(join(configDir, 'secrets', 'mosaic-stack'), { recursive: true, mode: 0o700 });
|
|
writeFileSync(join(configDir, 'secrets', 'mosaic-stack', a.name), `${a.clientSecret}\n`, { mode: 0o600 });
|
|
writeFileSync(join(configDir, 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: ownerAccounts({ providers: [provider] }).load().users.map(u => ({ ...u, created: '2026-11-10T00:00:00Z' })), memberships: [], invites: [] }), { mode: 0o600 });
|
|
writeFileSync(join(root, 'ca.pem'), op.cert);
|
|
env.NODE_EXTRA_CA_CERTS = join(root, 'ca.pem');
|
|
}
|
|
const mode = sso ? ['--sso', '--send'] : ['--dev-local-owner'];
|
|
const child = spawn(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...mode], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let out = ''; child.stdout.on('data', c => out += c); child.stderr.on('data', c => out += c);
|
|
t.after(() => { if (child.exitCode === null) child.kill('SIGTERM'); });
|
|
for (let i = 0; i < 300 && !/Mosaic Console: (http:\/\/127\.0\.0\.1:\d+\/)/.test(out); i++) {
|
|
if (child.exitCode !== null) break;
|
|
await new Promise(r => setTimeout(r, 50));
|
|
}
|
|
const base = /Mosaic Console: (http:\/\/127\.0\.0\.1:(\d+)\/)/.exec(out);
|
|
assert.ok(base, `the Console did not start:\n${out}`);
|
|
const calls = () => readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
|
|
return { port: Number(base[2]), calls, org, op, a, rootSocket: join(f.dataRoot, 'bus', 'broker.sock') };
|
|
}
|
|
|
|
function call(port, path, { method = 'GET', body = null, headers = {}, host = `127.0.0.1:${port}`, origin = `http://${host}` } = {}) {
|
|
const h = { host, ...(method === 'GET' ? {} : { origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }), ...headers };
|
|
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
|
|
return new Promise((resolve, reject) => {
|
|
const r = request({ host: '127.0.0.1', port, path, method, headers: h }, res => {
|
|
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
|
|
res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch {} resolve({ status: res.statusCode, headers: res.headers, body: json }); });
|
|
});
|
|
r.on('error', reject); r.end(body ?? undefined);
|
|
});
|
|
}
|
|
|
|
test('the dev-local owner reads an organization autonomy page as writable and a save reaches that organization bus', { timeout: 60000 }, async t => {
|
|
const { port, calls, org } = await consoleFor(t);
|
|
const subject = { provider: DEV_OWNER.provider, iss: DEV_OWNER.iss, sub: DEV_OWNER.sub };
|
|
const read = await call(port, '/api/orgs/alpha/autonomy');
|
|
assert.equal(read.status, 200, JSON.stringify(read.body));
|
|
assert.deepEqual(read.body, { revision: 3, autonomy: { default: 'in' }, writable: true });
|
|
const saved = await call(port, '/api/orgs/alpha/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY) });
|
|
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
|
assert.deepEqual(saved.body, { revision: 4, autonomy: { default: 'near' }, receipt: { action: 'autonomy.set', revision: 4 }, writable: true });
|
|
const socket = orgPaths(org.root).socket;
|
|
assert.deepEqual(calls().filter(c => c.kind === 'console').map(({ business, socket: s, verb, args, subject: who }) => ({ business, socket: s, verb, args, subject: who })), [
|
|
{ business: 'alpha', socket, verb: 'autonomy.get.console', args: {}, subject },
|
|
{ business: 'alpha', socket, verb: 'autonomy.set.console', args: AUTONOMY, subject },
|
|
]);
|
|
// Review, confirm and reverse go to the same organization bus as the owner (D1 fix round).
|
|
for (const [step, body, verb] of [['review', { decisionClass: 'merge' }, 'autonomy.review.console'], ['confirm', { id: 'd-1', confirm: true, reason: 'checked' }, 'decision.confirm.console'], ['reverse', { id: 'd-1' }, 'decision.reverse.console']]) {
|
|
const r = await call(port, `/api/orgs/alpha/autonomy/${step}`, { method: 'POST', body: JSON.stringify(body) });
|
|
assert.equal(r.status, 200, `${step}: ${JSON.stringify(r.body)}`);
|
|
assert.deepEqual(r.body, { answered: verb, writable: true });
|
|
assert.deepEqual(calls().filter(c => c.kind === 'console').at(-1), { kind: 'console', business: 'alpha', socket, verb, args: body, subject });
|
|
}
|
|
// The write keeps the route's own checks: another site saves nothing.
|
|
const before = calls().length;
|
|
for (const headers of [{ origin: 'http://evil.example' }, { 'sec-fetch-site': 'cross-site' }]) {
|
|
const r = await call(port, '/api/orgs/alpha/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY), headers });
|
|
assert.equal(r.status, 403, JSON.stringify(headers));
|
|
}
|
|
assert.equal(calls().length, before);
|
|
// Request intake and project remote writes stay unavailable for the organizations release (README inventory).
|
|
const config = (await call(port, '/api/orgs/alpha/config')).body;
|
|
assert.equal(config.requestWrites, false); assert.equal(config.projectRemoteWrites, false);
|
|
});
|
|
|
|
test('the organization autonomy writer passes the four autonomy verbs and nothing else', async () => {
|
|
// Imported here so the other tests in this file also run against BASE, which has no such export.
|
|
const { AUTONOMY_WRITE_VERBS, autonomyCaller } = await import('../src/autonomy-http.mjs');
|
|
const seen = [];
|
|
const write = autonomyCaller(async (verb, args, options) => { seen.push(verb); return { verb, args, options }; });
|
|
for (const verb of AUTONOMY_WRITE_VERBS) assert.equal((await write(verb, {}, { subject: {} })).verb, verb);
|
|
assert.deepEqual(AUTONOMY_WRITE_VERBS, ['autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console']);
|
|
for (const verb of ['autonomy.get.console', 'request.draft.console', 'project.remote.set.console', 'decision.resolve.console', 'hire.start.console', 'prd.revoke.console']) {
|
|
await assert.rejects(write(verb, {}, { subject: {} }), /does not call/);
|
|
}
|
|
assert.deepEqual(seen, AUTONOMY_WRITE_VERBS);
|
|
});
|
|
|
|
// Without --dev-local-owner nothing changed (D1 acceptance). A deployed Console (--sso --send) has no
|
|
// organization routes, and its own autonomy route still reads and writes through the instance's request
|
|
// transport (the root bus, no business), for its signed-in owner, exactly as before D1.
|
|
test('a deployed --sso --send Console is unchanged: no organization autonomy route, and the root route keeps the request transport', { timeout: 60000 }, async t => {
|
|
const { port, calls, op, a, rootSocket } = await consoleFor(t, { sso: true });
|
|
const login = await call(port, '/auth/login', { host: HOST });
|
|
assert.equal(login.status, 303);
|
|
const loginCookie = [login.headers['set-cookie'] ?? []].flat()[0].split(';')[0];
|
|
const granted = op.grant(a, login.headers.location);
|
|
const state = new URL(login.headers.location).searchParams.get('state');
|
|
const signed = await call(port, `/auth/callback?${new URLSearchParams({ code: granted.code, state })}`, { host: HOST, headers: { cookie: loginCookie } });
|
|
const cookie = [signed.headers['set-cookie'] ?? []].flat().find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0];
|
|
assert.ok(cookie, `no session: ${signed.status}`);
|
|
const orgRead = await call(port, '/api/orgs/alpha/autonomy', { host: HOST, headers: { cookie } });
|
|
assert.equal(orgRead.status, 404); assert.deepEqual(orgRead.body, { error: 'not found' });
|
|
const orgSave = await call(port, '/api/orgs/alpha/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY), host: HOST, origin: ORIGIN, headers: { cookie } });
|
|
assert.equal(orgSave.status, 403); assert.deepEqual(orgSave.body, { error: 'read-only', message: 'the Console preview is read-only' });
|
|
const read = await call(port, '/api/autonomy', { host: HOST, headers: { cookie } });
|
|
assert.equal(read.status, 200, JSON.stringify(read.body));
|
|
assert.deepEqual(read.body, { revision: 3, autonomy: { default: 'in' }, writable: true });
|
|
const saved = await call(port, '/api/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY), host: HOST, origin: ORIGIN, headers: { cookie } });
|
|
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
|
assert.deepEqual(saved.body, { revision: 4, autonomy: { default: 'near' }, receipt: { action: 'autonomy.set', revision: 4 }, writable: true });
|
|
const subject = { provider: a.name, iss: a.issuer, sub: `user-${a.name}` };
|
|
assert.deepEqual(calls().filter(c => c.kind === 'console').map(({ business, socket, verb, subject: who }) => ({ business, socket, verb, subject: who })), [
|
|
{ business: null, socket: rootSocket, verb: 'autonomy.get.console', subject },
|
|
{ business: null, socket: rootSocket, verb: 'autonomy.set.console', subject },
|
|
]);
|
|
});
|