Files
stack/packages/webui/tests/org-autonomy-write.test.mjs
jason.woltje 1947a056fb fix(console): D1 review fixes for the detached dev-owner supervisor (#1873)
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.
2026-11-11 14:16:09 -06:00

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 },
]);
});