#!/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; }