The org dashboard lists the installed system-type modules with their own labels and descriptions, marks which are enabled, and gives the owner an enable or disable control through plugins.set behind the owner fence, with the revision and the transaction receipt. The project page selects from the organization's enabled set through project.system-types.set and shows a disabled selection as inactive, as system-types.console reports. Both routes go through the per-org Console call path and its admission checks. docs/plans/2026-11-10_system-type-toolsets-design.md proposes how a launch turns a project's active system types into toolsets under the role, mission and task ceilings. Design only; no roles/ or contract change is applied.
232 lines
18 KiB
JavaScript
232 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
// 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 { 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 } 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 { 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 { 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] [--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);
|
|
else throw new Error(`unknown option: ${flag}`);
|
|
}
|
|
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 });
|
|
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 });
|
|
// 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,
|
|
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;
|
|
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;
|
|
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, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, agentLevels, 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.`);
|
|
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;
|
|
}
|