// #1873 T1: org: definitions at launch, and `mosaic org definitions`. import test from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { createLauncher } from '../src/launcher.mjs'; import { main } from '../src/cli.mjs'; import { orgCommand } from '../src/org.mjs'; import { bootConfig, loadSystem } from '../src/config.mjs'; import { BusError } from '../../bus/src/broker.mjs'; import { startBroker } from '../../bus/src/runtime.mjs'; import { initializeControl } from '../../processing-control/src/state.mjs'; import { launchLogFile } from '../../seat/src/session.mjs'; import { entryFromTemplate, readTemplate, templateHash } from '../../organization/src/definitions.mjs'; import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs'; import { fixture, io, tmp, withoutHostCredentials } from './helpers.mjs'; const SCOUT = { rev: 1, role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.\n' }; const stop = () => Object.assign(Error('fixture authorization stop'), { code: 'fixture-policy' }); const host = { business: 'acme', beforeClose: () => {}, op: async (x) => { if (x.op === 'identity') return { business: 'acme', role: 'pm' }; if (x.op === 'mayLaunch') return { allow: true }; if (x.op === 'authorizeLaunch') throw stop(); if (x.op === 'definitionScan') return { held: null }; return {}; } }; test('T1 an org: instance passes every launch check, and a missing definition refuses naming instance and definition', async (t) => { const root = tmp(t); // The org's own copy of the coder template, so the instance's credentials and tracker still match. const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder')); const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } })); const file = join(root, 'config/businesses/acme.json'); withoutHostCredentials(f.env, { piKey: true }); initializeControl(f.dataRoot); const launcher = createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} }); // Resolve, route, contract (the definition's instructions) and skills all pass; the fixture stops at authorization. assert.equal((await launcher.fromSession('fixture-cap', 'coder')).error, 'fixture-policy'); // The chief names a definition the file doesn't declare: the whole file refuses, so a session's // launch and the chief's launch both refuse. writeJson(file, { ...f.doc, roles: { ...f.doc.roles, chief: { ...f.doc.roles.chief, definition: 'org:ghost' } } }); assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'organization-refused' }); await assert.rejects(launcher.launchPm(), (e) => e.code === 'organization-refused' && /roles\.chief\.definition names org:ghost/.test(e.message)); const event = JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1)); assert.equal(event.code, 'organization-refused'); assert.equal(event.instance, 'chief'); assert.match(event.why, /definition-missing: .*roles\.chief\.definition names org:ghost/); await launcher.close?.(); // A host does not start on a file that names a missing definition. assert.throws(() => createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} }), /org:ghost/); }); // The fixture bus holds SENTINEL as coder's gitea token, so the bus's secret check refuses it. const SENTINEL = 'sk-test-NOT-A-SECRET-1234'; function files(dir) { return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []); } test('T1 mosaic org definitions: writes go through the bus and its secret check, each prints its receipt, and an agent shell refuses', async (t) => { const root = tmp(t); const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder')); const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } })); const file = join(root, 'config/businesses/acme.json'); const changes = join(root, 'config/businesses/.history/acme/changes.jsonl'); writeFileSync(f.doc.roles.coder.credentials.gitea.file, SENTINEL); const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env }); const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization }); t.after(() => b.close()); const human = b.broker.bindHuman({ business: 'acme', human: f.doc.human, via: 'cli', outsideAgent: true }); const calls = []; // The human transport's shape: a bus refusal arrives as its code. const deps = { system: { dataRoot: f.dataRoot }, transport: () => async (verb, args = {}) => { calls.push(verb); try { return structuredClone(b.broker.request(human, { verb, args })); } catch (e) { if (!(e instanceof BusError)) throw e; throw Object.assign(new Error(e.code), { code: e.code }); } } }; const run = async (args, env = f.env) => { const x = io(env); await main(['org', 'definitions', ...args], x, deps); return JSON.parse(x.out.text); }; const hash = templateHash(f.roles, 'coder'); assert.ok((await run(['templates'])).some((h) => h.template === 'coder' && h.sha256 === hash)); const imported = await run(['acme', 'import', 'coder', '--as', 'copy']); assert.deepEqual({ ...imported, revision: 0, transaction: 0 }, { verb: 'import', organization: 'acme', definition: 'copy', rev: 1, revision: 0, transaction: 0, from: { template: 'coder', sha256: hash } }); const spec = join(root, 'scout.json'); writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: SCOUT.instructions })); assert.equal((await run(['acme', 'invent', 'scout', '--from', spec])).rev, 1); writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: 'Read, then report.\n' })); const edited = await run(['acme', 'edit', 'scout', '--from', spec, '--if-rev', '1']); assert.equal(edited.verb, 'edit'); assert.equal(edited.rev, 2); assert.deepEqual((await run(['acme', 'list'])).definitions.map((d) => [d.name, d.rev]), [['builder', 1], ['copy', 1], ['scout', 2]]); assert.equal((await run(['acme', 'show', 'scout'])).instructions, 'Read, then report.\n'); assert.equal((await run(['acme', 'diff', 'copy'])).changed, false); assert.equal((await run(['acme', 'remove', 'scout', '--if-rev', '2'])).removedRev, 2); assert.deepEqual(calls, ['definition.import', 'definition.invent', 'definition.edit', 'definition.remove']); const committed = readFileSync(changes, 'utf8').trim().split('\n').map((l) => JSON.parse(l)).filter((e) => e.state === 'committed').slice(-4); assert.deepEqual(committed.map((e) => e.actor), Array(4).fill(f.doc.human)); // A definition holding a token the bus holds refuses on invent and on edit, in valid JSON, and // the token reaches no message, no organization file and nothing the bus wrote. const before = readFileSync(file); const beforeChanges = readFileSync(changes); writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: `Use ${SENTINEL} to report.\n` })); for (const args of [['acme', 'invent', 'leak', '--from', spec], ['acme', 'edit', 'copy', '--from', spec, '--if-rev', '1']]) { await assert.rejects(run(args), (e) => e.code === 'credential-leak' && e.exitCode === 2 && !e.message.includes(SENTINEL) && !String(e.stack).includes(SENTINEL)); } assert.deepEqual(readFileSync(file), before); assert.deepEqual(readFileSync(changes), beforeChanges); for (const path of files(f.dataRoot)) assert.ok(!readFileSync(path).includes(SENTINEL), path); // An unparsable file never echoes; a key the file's checks refuse is named before the bus is // called; a stale --if-rev and an in-use definition refuse with what they mean. writeFileSync(spec, `{"role": ${SENTINEL}`); await assert.rejects(run(['acme', 'invent', 'other', '--from', spec]), (e) => e.exitCode === 2 && !e.message.includes('sk-test')); writeFileSync(spec, JSON.stringify({ role: { ...SCOUT.role, network: 'everywhere' }, instructions: 'x\n' })); const asked = calls.length; await assert.rejects(run(['acme', 'invent', 'other', '--from', spec]), /definitions\.other/); assert.equal(calls.length, asked); await assert.rejects(run(['acme', 'remove', 'copy', '--if-rev', '7']), (e) => /^definition-rev-conflict: definition copy is not at rev 7/.test(e.message)); await assert.rejects(run(['acme', 'remove', 'builder', '--if-rev', '1']), (e) => /^definition-in-use: definition builder is named by coder/.test(e.message)); assert.deepEqual(readFileSync(file), before); // Inside an agent run every write refuses before it reads anything or calls the bus; reads still work. const agent = { ...f.env, MOSAIC_RUN_ID: 'run-1' }; const sent = calls.length; for (const args of [['acme', 'import', 'reviewer'], ['acme', 'invent', 'x', '--from', spec], ['acme', 'edit', 'copy', '--from', spec, '--if-rev', '1'], ['acme', 'remove', 'copy', '--if-rev', '1']]) { await assert.rejects(run(args, agent), (e) => e.exitCode === 3 && /human shell/.test(e.message)); } assert.equal(calls.length, sent); assert.deepEqual(readFileSync(file), before); assert.equal((await run(['acme', 'list'], agent)).definitions.length, 2); // With no bus call to make, a write refuses rather than writing without the secret check. const x = io(f.env); await assert.rejects(orgCommand(['definitions', 'acme', 'remove', 'copy', '--if-rev', '1'], x), (e) => e.exitCode === 3 && /organization bus/.test(e.message)); assert.deepEqual(readFileSync(file), before); }); // E2E6 fix 2: an unknown definition, an unknown organization, a bad id and a bad name each end in // one usage or refusal line: no stack trace and no host path. test('E2E6 mosaic org definitions: unknown names, an unknown org and a bad id give one line, no stack and no path', async (t) => { const root = tmp(t); const f = fixture(root, 'acme'); const { run } = await import('../src/cli.mjs'); const cases = [ [['acme', 'show', 'ghost'], 3, 'mosaic: definition-missing: definitions declares no ghost; list them with mosaic org definitions acme list'], [['acme', 'diff', 'ghost'], 3, 'mosaic: definition-missing: definitions declares no ghost; list them with mosaic org definitions acme list'], [['acme', 'show', 'Bad Name'], 3, 'mosaic: definition-missing: definitions declares no definition by that name; list them with mosaic org definitions acme list'], [['nosuch', 'list'], 3, 'mosaic: unknown-organization: this instance has no organization nosuch; list them with mosaic org list'], [['nosuch', 'show', 'x'], 3, 'mosaic: unknown-organization: this instance has no organization nosuch; list them with mosaic org list'], ]; for (const [args, exit, line] of cases) { const x = io(f.env); assert.equal(await run(['org', 'definitions', ...args], x, {}), exit, args.join(' ')); assert.equal(x.err.text, `${line}\n`, args.join(' ')); assert.ok(!x.err.text.includes(root) && !/\n\s+at /.test(x.err.text), args.join(' ')); } for (const args of [['Bad/Id', 'list'], ['../x', 'show', 'y']]) { const x = io(f.env); assert.equal(await run(['org', 'definitions', ...args], x, {}), 4, args.join(' ')); assert.match(x.err.text, /^mosaic: invalid organization id: an id is 1 to 64 of a-z/); assert.match(x.err.text, /\nmosaic org definitions templates \| definitions ID list/); assert.ok(!x.err.text.includes(root) && !/\n\s+at /.test(x.err.text)); } }); test('E2E6 definitionsFailure: a raw filesystem error is named by its operation and code, a message path is cut, an unknown error says only that it failed', async () => { const { definitionsFailure } = await import('../src/org-definitions.mjs'); const fsError = (code, syscall) => Object.assign(new Error(`${code}: no such file, open '/home/someone/.config/x/acme.json'`), { code, syscall, path: '/home/someone/.config/x/acme.json' }); let e = definitionsFailure(fsError('EACCES', 'open'), 'acme'); assert.deepEqual([e.code, e.exitCode, e.message], ['organization-unreadable', 3, 'organization-unreadable: cannot open the file of organization acme (EACCES)']); e = definitionsFailure(Object.assign(new Error("organization file /srv/x/acme.json is not valid"), { code: 'organization-invalid' }), 'acme'); assert.equal(e.message, 'organization-invalid: organization file is not valid'); e = definitionsFailure(new TypeError('Cannot read properties of undefined'), 'acme'); assert.deepEqual([e.code, e.exitCode, e.message], ['definitions-failed', 1, 'definitions-failed: the definitions command failed']); // f6c review P3-2: only the organization file missing means no organization. e = definitionsFailure(fsError('ENOENT', 'open'), 'acme'); assert.deepEqual([e.code, e.message], ['unknown-organization', 'unknown-organization: this instance has no organization acme; list them with mosaic org list']); const rolesGone = Object.assign(new Error("ENOENT: no such file or directory, scandir '/home/someone/roles'"), { code: 'ENOENT', syscall: 'scandir', path: '/home/someone/roles' }); e = definitionsFailure(rolesGone, 'templates'); assert.deepEqual([e.code, e.exitCode, e.message], ['definitions-unreadable', 3, 'definitions-unreadable: cannot scandir a file the definitions command reads (ENOENT)']); e = definitionsFailure(rolesGone, 'acme'); assert.equal(e.code, 'organization-unreadable'); // f6c review P3-3: a path after ':' or inside backticks is cut. e = definitionsFailure(Object.assign(new Error('organization file:/srv/x/acme.json is not valid'), { code: 'organization-invalid' }), 'acme'); assert.equal(e.message, 'organization-invalid: organization file: is not valid'); e = definitionsFailure(Object.assign(new Error('bad role at `/srv/roles/x.json`'), { code: 'organization-invalid' }), 'acme'); assert.equal(e.message, 'organization-invalid: bad role at ``'); });