Files
jason.woltje 079ced93f3 feat(packs): pack, board and workflow verbs, routes and CLI words (#1873 SP4)
Design section 8. The bus answers packs, pack.preview, pack.apply,
board.set and workflow.set to the organization's human, and their
.console forms to the owner's Console cap. The broker runs the section 3
merge, the plan digest (pack-plan-stale on a mismatch) and the held
credential check. The organization writer keeps packs, board and
workflows out of the generic write and refuses definition writes to
every actor name an agent carries, project agents included.

mosaic org pack list|preview|apply, board set and workflow set|remove
go over the organization's bus as its human and refuse inside an agent
shell before any file is read. The Console serves GET /api/orgs/<id>/packs,
packs/preview, packs/apply, board and workflows/<name> (POST and
DELETE) to the root owner's session only, writes under --send only.
2026-11-12 16:51:46 -06:00

280 lines
22 KiB
JavaScript

#!/usr/bin/env node
import { assertBuildStart, assertDevInstance, assertDeployed } from '../../cli/src/deployment.mjs';
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
import { createChat } from './chat.mjs';
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
import { organizationConsole } from './organizations.mjs';
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
import { SYSTEM_TYPE_VERBS } from './system-types-http.mjs';
import { packsCaller } from './packs-http.mjs';
import { autonomyCaller } from './autonomy-http.mjs';
import { consoleAccess } from './access.mjs';
import { accessContextFor } from '../../cli/src/access.mjs';
import { accessFilePath, loadAccess } from '../../organization/src/access.mjs';
import { startTesterSocket } from './tester-socket.mjs';
import { TESTER_CLI } from '../../cli/src/transport.mjs';
import { views } from '../../bus/src/views.mjs';
import { loadSystem, socketPath, orgPaths, REPO, rolesDir } from '../../cli/src/config.mjs';
import { busStatus as orgBusStatus, busRevive as orgBusRevive } from '../../cli/src/org-bus.mjs';
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
import { showOrg } from '../../organization/src/org-service.mjs';
import { configDir } from '../../organization/src/organization-config.mjs';
import { loadRootAgents } from '../../organization/src/agent-levels.mjs';
import { consoleReads, redactor } from './reads.mjs';
import { createDevOwner } from './dev-owner.mjs';
import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.mjs';
import { loadAccounts } from '../../organization/src/accounts.mjs';
import { createSso, readSlot } from './sso.mjs';
import { readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { isAbsolute, join } from 'node:path';
import { actionTrail } from './action-trail.mjs';
import { reviveBuses } from './bus-revive.mjs';
import { createProxyAdmin, orgCatalogue } from './proxy-admin.mjs';
import { busesStopped } from '../../cli/src/proxy-admin-host.mjs';
import { agentLevelsWriteRoutes } from './agent-levels-write.mjs';
import { RESTART_EXIT } from '../../cli/src/org-console.mjs';
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
import { selectorScope } from '../../organization/src/selector.mjs';
// The bus verifies the Console's exact same-tree script in /proc, whose argv preserves a
// relative shell argument even though Node's process.argv resolves it. Re-exec the same
// program with its absolute path before opening anything; preserve PID, ancestry and env so
// the existing human and Console proof checks remain unchanged.
if (process.platform === 'linux') {
const raw = readFileSync('/proc/self/cmdline', 'utf8').split('\0');
let at = 1 + process.execArgv.length;
if (raw[at] === '--') at += 1; // Node leaves its option delimiter out of execArgv.
const script = raw[at];
if (script && !isAbsolute(script)) {
if (typeof process.execve !== 'function') throw new Error('relative Console launch requires process.execve; use an absolute script path');
process.execve(process.execPath, [process.execPath, ...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)], process.env);
}
}
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--supervised] [--proxy-unit-dir PATH] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null, supervised = false;
const devNames = [];
// --org and --business are one selector in this parser's scope (rename design r2). A flag-shaped
// or invalid value keeps the old wording.
const selector = selectorScope({
valid: id => ID_PATTERN.test(id),
refuse: (kind, why) => { throw new Error(kind === 'invalid' || kind === 'flag' ? 'business must be a business id' : why); },
});
while (args.length) {
const flag = args.shift();
if (selector.owns(flag)) { selector.take(flag, args.shift()); continue; }
// Row 76 (#1570): the one flag without a value.
if (flag === '--send') { sendOn = true; continue; }
// Q175 phase 1 (#1690): the Console signs people in itself, from console.sso in config.json.
if (flag === '--dev-local-owner') { devOwner = true; continue; }
if (flag === '--sso') { ssoOn = true; continue; }
if (flag === '--tester-socket') { testerSocket = true; continue; }
// F3: `mosaic console start` supervises this process and relaunches it on exit 75.
if (flag === '--supervised') { supervised = true; continue; }
if (flag === '--tester-port') throw new Error('--tester-port retired; activate explicitly with --tester-socket');
const value = args.shift();
if (!value) throw new Error(`missing value for ${flag}`);
if (flag === '--port') {
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
options.port = Number(value);
} else if (flag === '--board') options.board = value;
// Row 77 (#1571): the canonical checkout the queue read runs from, when
// serve runs from a pinned copy; everything else loads from the copy.
else if (flag === '--checkout') {
let dir = false;
try { dir = isAbsolute(value) && statSync(value).isDirectory(); } catch { /* refused below */ }
if (!dir) throw new Error('--checkout must be the absolute path of a directory');
options.checkout = value;
}
// Row 74 (#1568): the preview mode, all three or none, checked before any config is read.
else if (flag === '--public-host') preview.publicHost = value;
else if (flag === '--auth-header') preview.authHeader = value;
else if (flag === '--allow-user') preview.allowUser = value;
else if (flag === '--proof-header') proofFlags.proofHeader = value;
else if (flag === '--proof-file') proofFlags.proofFile = value;
// org-lan-bind: the dev owner on the LAN, no sign-in. Both flags or neither, and only with
// --dev-local-owner (devLanOptions below, before any config is read).
else if (flag === '--dev-insecure-bind') {
if (devBind !== null) throw new Error('--dev-insecure-bind takes one address');
devBind = value;
} else if (flag === '--dev-allow-host') devNames.push(value);
// #1873: where the Proxy page installs the proxy user unit; proxy-service.sh's default otherwise.
else if (flag === '--proxy-unit-dir') {
if (!isAbsolute(value)) throw new Error('--proxy-unit-dir must be an absolute path');
options.proxyUnitDir = value;
}
else throw new Error(`unknown option: ${flag}`);
}
const stamp = assertBuildStart({ serve: true, sso: ssoOn, devOwner, devBind, devNames });
if (selector.given) options.business = selector.value;
if (devOwner && (ssoOn || testerSocket || Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--dev-local-owner cannot be combined with --sso, preview, proof or --tester-socket');
const lan = devLanOptions({ devOwner, bind: devBind, names: devNames });
const instance = devOwner ? devOwnerInstance() : null;
// The local-development console command has no separate --send flag.
if (devOwner) sendOn = true;
if (ssoOn && (Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*');
options.preview = previewOptions(preview);
if (sendOn && !ssoOn && !devOwner) throw new Error('--send needs --sso; header preview is read-only');
const proof = proofOptions(proofFlags, options.preview);
// The bus views (slice 1 S5) need the system config. Without it the
// board still serves and the bus routes answer not-configured.
// The Queue, Business and Settings reads (row 54) need no bus; without
// the system config, Business can't resolve authority and Settings has
// no notifier. A refusal printed here names no path (redactor).
// --send without a bus refuses to start rather than serve a composer that can't send.
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, libraryRevert = null, librarySave = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, decisionWrite = null, requestRead = null, requestWrite = null, busLine, system = null;
let processing = null, ownerCall = null;
try {
system = loadSystem({ instance });
if (devOwner) assertDevInstance(system);
if (stamp) {
assertDeployed(system);
loadAccounts(join(configDir(), 'accounts.json'), { providers: system.console?.sso?.providers ?? [] });
}
const { dataRoot } = system;
const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
// FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs).
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, libraryRevert, librarySave, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall } = consoleBus(call, { send: sendOn }));
// Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with
// --sso and --send.
if ((ssoOn || devOwner) && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
// #797 / #1839 Task 3: the request intake controls use the same console wrapper (the signed-in
// (provider, iss, sub) is the session's, never the client's). Reads need --sso, writes --sso and
// --send. Workspace reads and project remote operations use this owner-only, verb-fenced transport.
if (ssoOn || devOwner) requestRead = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
if ((ssoOn || devOwner) && sendOn) requestWrite = requestRead;
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
} catch (err) {
const why = redactor({ configDir: configDir() })(err.message);
if (ssoOn || devOwner) throw new Error(`${devOwner ? "--dev-local-owner" : "--sso"} needs the system config (${why})`);
if (sendOn) throw new Error(`--send needs the bus configured (${why})`);
if (testerSocket) throw new Error(`--tester-socket needs the bus configured (${why})`);
busLine = `Bus: not configured (${why})`;
}
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
// The sign-in config is checked again here, then each slot file, before anything listens. A
// provider that can't be reached starts degraded; one whose discovery is refused stops the start.
let sso = devOwner ? createDevOwner() : null, userOptions = null;
if (ssoOn) {
if (!system.console?.sso) throw new Error('--sso needs console.sso in the system config');
const secrets = new Map(system.console.sso.providers.map(p => [p.id, readSlot(configDir(), p, { repo: REPO, checkout: options.checkout ?? null })]));
// FED R6 (#1800): access.json users sign in too. R1's loader reads the file on every check,
// with the same context as `mosaic access`; a missing or refused file signs nobody in.
const users = consoleAccess({
load: () => loadAccess(accessFilePath(configDir()), accessContextFor(system, process.env)),
business: busBusiness({ dataRoot: system.dataRoot, business: options.business ?? null }),
});
sso = await createSso(system.console.sso, { secrets, users, accounts: { load: () => loadAccounts(join(configDir(), 'accounts.json'), { providers: system.console.sso.providers }) } });
// Each request of a granted user's session gets the bus options over that user's own
// user-cli transport. The human transport above serves only the chat's `plugins` read (C6).
userOptions = userOptionsFor({ owner: ownerCall, send: sendOn, transport: user => userCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, user }) });
}
const organizations = devOwner ? organizationConsole({
ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance },
listOrgs, showOrg, resolveOrg, orgPaths, busStatus: orgBusStatus,
// U1 (#1873): the cross-organization read passes `timeoutMs` so a slow bus's children are
// killed at its deadline. The org pages pass none and keep the transport default.
options: ({ id, root, paths, session, timeoutMs, signal, deadline }) => {
const bound = timeoutMs === undefined ? {} : { timeoutMs, signal, deadline };
const owner = busCaller({ dataRoot: root, socket: paths.socket, business: id, ...bound });
const call = session.user === undefined ? owner : userCaller({ dataRoot: root, socket: paths.socket, business: id, user: session.user, ...bound });
return {
...consoleBus(call, { send: sendOn, ...(session.user === undefined ? {} : { owner }) }),
decisionWrite: session.user === undefined && sendOn ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
// The owner reads request status on the selected organization's bus, as on the root Console. Request
// writes stay off: the README "Console actions" inventory marks them unavailable for the organizations release.
requestRead: session.user === undefined ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
// Phase P2 (#1873): the owner's System types writes, the organization's enable switch (plugins.set
// with its revision) and a project's selection, over the owner's human transport under --send only.
systemTypesWrite: session.user === undefined && sendOn ? agentCaller(owner, SYSTEM_TYPE_VERBS) : null,
// D1 (#1873): the owner's autonomy writes (save, review, confirm, reverse) on this organization's bus.
// These options exist only on a --dev-local-owner Console (`organizations` above). The fence passes
// those four verbs and nothing else the console transport carries.
autonomyWrite: session.user === undefined && sendOn ? autonomyCaller(consoleCall({ dataRoot: root, socket: paths.socket, business: id })) : null,
// Starter packs SP4 (#1873): the owner's pack apply, board and workflow writes, the three write
// .console verbs only. Preview and the pack list are reads over requestRead.
packsWrite: session.user === undefined && sendOn ? packsCaller(consoleCall({ dataRoot: root, socket: paths.socket, business: id })) : null,
reads: consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: id, orgRoot: root }),
};
},
}) : null;
// #1873 A1: the root agent level, read fresh on each request; the Agents grid's only new read.
const agentLevels = organizations ? { loadRoot: () => loadRootAgents(configDir(), { rolesDir: rolesDir() }), rolesDir: rolesDir(), ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance } } : null;
// #1873: proxy setup for the instance owner (dev local owner, or the root owner under --sso).
// Instance-wide: one pinned release, one proxy.json, one unit. Each organization uses it (shared).
const proxyAdmin = system && (devOwner || ssoOn) ? createProxyAdmin({
dataRoot: system.dataRoot, configDir: configDir(), instance, unitDir: options.proxyUnitDir ?? null,
buses: () => busesStopped({ dataRoot: system.dataRoot, configDir: configDir() }),
orgs: async () => {
const catalogue = orgCatalogue;
if (!organizations) return [{ id: options.business ?? null, proxy: 'shared', catalogue: await catalogue(ownerCall ?? (async () => { throw new Error(); })) }];
const out = [];
for (const org of await listOrgs({ configDir: configDir(), dataRoot: system.dataRoot, instance })) {
if (org.state !== 'active') continue;
let paths; try { paths = orgPaths(org.root); } catch { out.push({ id: org.id, proxy: 'shared', catalogue: { status: 'bus-unavailable', models: null } }); continue; }
out.push({ id: org.id, proxy: 'shared', catalogue: await catalogue(busCaller({ dataRoot: org.root, socket: paths.socket, business: org.id, timeoutMs: 5000 })) });
}
return out;
},
}) : null;
let server, testerServer = null;
// F3: the restart route closes every listener and connection, then exits RESTART_EXIT for the
// supervisor to relaunch. Without --supervised the route answers not-supervised.
const restart = supervised ? () => {
sso?.close(); testerServer?.close();
setTimeout(() => process.exit(RESTART_EXIT), 2000).unref();
server.close(() => process.exit(RESTART_EXIT));
server.closeAllConnections();
} : null;
const trail = system ? actionTrail(join(system.dataRoot, 'console')) : null;
// #1873 A2: Settings > Agents writes for the root and project levels, beside the levels read.
const agentLevelRoutes = organizations ? [agentLevelsWriteRoutes({ configDir: configDir(), rolesDir: rolesDir(), trail })] : [];
try {
server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, proxyAdmin, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, agentLevels, routes: agentLevelRoutes, preselect: options.business ?? null });
} catch (err) { sso?.close(); throw err; }
if (testerSocket) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
try {
testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board: options.board,
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
processing: { read: () => call('processing.status') },
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
// Row 194 (#1692, D4.1): the tester launches, terminates and reads its own requests.
agentWrite: agentCaller(call, TESTER_AGENT_VERBS),
// Row 177 (#1675): the tester's own chat, through the tester CLI; 7332's stays the human's.
chat: createChat({ call }),
});
} catch (err) { sso?.close(); await new Promise(resolve => server.close(resolve)); throw err; }
}
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, read-only` : '';
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
// One line per provider: id, issuer, subject count and state. No client id, secret or sub.
const health = sso?.health() ?? {};
const ssoLines = sso && !devOwner ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send), grant Approve and Deny' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
const port = server.address().port;
if (devOwner) console.log(`DEV LOCAL OWNER MODE: no sign-in, every local request acts as the owner (named instance ${instance})`);
// 0.0.0.0 is reachable on loopback too; a specific address only at that address.
const urlHost = !lan || lan.bind === '0.0.0.0' ? '127.0.0.1' : lan.bind;
if (lan) {
console.error(`WARNING: dev owner Console listens on ${lan.bind}:${port} with no sign-in; anyone who can reach it acts as the owner.`);
console.log(`Listening: ${lan.bind}:${port}, allowed Host names ${lan.allowHosts.map(name => `${name}:${port}`).join(', ')}`);
}
console.log(`Mosaic Console: http://${urlHost}:${port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
// D2 (P0-3): the dev Console reattaches to the buses that still run and brings back the ones its
// unit stop killed. It runs after the Console answers, so a slow bus never delays the page.
if (organizations && trail) reviveBuses({ ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance }, listOrgs, busStatus: orgBusStatus, busRevive: orgBusRevive, trail, log: line => console.log(line) }).catch(() => console.log('bus revive: stopped on an unexpected error'));
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
process.exitCode = 2;
}