// What the host and the human commands read before they touch the bus: // the system config (through scripts/mosaic-config.mjs, as `mosaic business` // does), the business file, and the boot message for the broker process. // Everything here reads and refuses; nothing writes. import { stackBinding } from "../../tasks/src/builtin.mjs"; import { spawnSync } from "node:child_process"; import { existsSync, lstatSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { managedState } from "../../organization/src/project-bootstrap.mjs"; import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../organization/src/index.mjs"; import { loadRootAgents } from "../../organization/src/agent-levels.mjs"; import { materializeCopies, withCopies } from "../../organization/src/agent-copies.mjs"; import { organizationWriter } from "../../organization/src/organization.mjs"; import { systemConfigProblem } from "../../organization/src/system-config-problem.mjs"; import { businessFilePath } from "../../organization/src/organization-config.mjs"; import { busOrganization } from "../../bus/src/organization-context.mjs"; import { BusError } from "../../bus/src/broker.mjs"; import { CliError } from "./errors.mjs"; import { accessContextFor } from "./access.mjs"; export const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); // The broker's socket. One broker per data root: the bus store takes // `/bus/writer.lock` (packages/bus/src/store.mjs). export const socketPath = (dataRoot) => join(dataRoot, "bus", "broker.sock"); // Linux sockaddr_un.sun_path holds at most 107 bytes plus its terminator. // Check every socket at the boundary, before starting any host or child. export function orgPaths(root) { if (typeof root !== "string" || !isAbsolute(root) || resolve(root) !== root || root.includes("\0")) { throw new CliError("organization root must be a canonical absolute path", 3); } const busDir = join(root, "bus"), hostDir = join(root, "bus-host"); const socket = join(busDir, "broker.sock"); if ([socket, join(hostDir, "launch.sock"), join(hostDir, "git.sock")].some((p) => Buffer.byteLength(p) > 107)) { throw new CliError("organization socket path is too long", 3); } return { socket, busDir, runs: join(root, "runs"), launches: join(root, "launches"), sessions: join(root, "sessions"), workspaces: join(root, "workspaces"), threads: join(root, "threads"), hostDir }; } // #1873: mosaic-config's stderr can quote the config's path, the data root or a config value, so // it is never relayed. An instance refusal keeps its code and the instance names, which passed // the instance pattern; a missing config keeps its bootstrap hint; anything else reads as // `mosaic org config` reads it: a known field and its constraint, the config named by label. const INSTANCE = "[a-z][a-z0-9]{1,15}"; function instanceProblem(problem, label) { const body = problem.replace(/^mosaic-config: /, ""); let m = new RegExp(`^instance-root-mismatch: .+ is claimed by instance (${INSTANCE})(?:, not (${INSTANCE})|; select it with --instance \\1)$`).exec(body); if (m) return `instance-root-mismatch: the data root is claimed by instance ${m[1]}${m[2] ? `, not ${m[2]}` : `; select it with --instance ${m[1]}`}`; m = new RegExp(`^instance-root-unclaimed: .+ holds no instance\\.json naming (${INSTANCE}) \\(scripts/bootstrap\\.sh --instance \\1 claims a new root\\)$`).exec(body); if (m) return `instance-root-unclaimed: the data root holds no instance.json naming ${m[1]} (scripts/bootstrap.sh --instance ${m[1]} claims a new root)`; if (body.startsWith("instance-root-unclaimed: ")) return "instance-root-unclaimed: the data root's instance.json is not a readable, valid marker"; m = new RegExp(`^instance-config-mismatch: --instance (${INSTANCE}) names `).exec(body); if (m) return `instance-config-mismatch: --instance ${m[1]} names its own config, but MOSAIC_CONFIG names another`; if (body.startsWith("instance-config-mismatch: ")) return "instance-config-mismatch: --instance names its own config, but MOSAIC_CONFIG names another"; if (body.startsWith("instance-name-invalid: ")) return `instance-name-invalid: an instance name must match /^${INSTANCE}$/`; if (body.startsWith("configuration not found: ")) { m = new RegExp(`\\(run (scripts/bootstrap\\.sh(?: --instance ${INSTANCE})?) to create it\\)$`).exec(body); return `configuration not found: ${label}${m ? ` (run ${m[1]} to create it)` : ""}`; } return null; } export function loadSystem({ env = process.env, instance = null } = {}) { const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate", ...(instance === null ? [] : ["--instance", instance])], { encoding: "utf8", maxBuffer: 1024 * 1024, env, }); if (proc.status !== 0) { const label = instance !== null && instance !== "dev" ? `the config of instance ${instance}` : env.MOSAIC_CONFIG ? "the system config MOSAIC_CONFIG names" : "the default system config"; const known = instanceProblem(String(proc.stderr ?? "").trim(), label); throw new CliError(known ? `system config problem (mosaic-config exit ${proc.status}): ${known}` : systemConfigProblem(proc, env, label), 3); } try { return JSON.parse(proc.stdout); } catch { throw new CliError("system config: mosaic-config printed something that isn't JSON", 3); } } export function rolesDir(env = process.env) { return resolve(env.MOSAIC_ROLES_DIR || join(REPO, "roles")); } // A business-package or adapter refusal is a config problem: exit 3. function business(fn) { try { return fn(); } catch (error) { if (error instanceof BusinessError) throw new CliError(error.message, 3); if (error instanceof BusError) throw new CliError(`business adapter refused: ${error.code}`, 3); throw error; } } // config.trackers[] (lead decision 70): plain data from the // tracker.* variables. tracker.project is a project-layer variable, so the // entry comes from the one declared project whose file sets it. No // tracker.baseUrl, or no project naming a tracker project, means no entry // and no task verbs; two projects naming one each refuse, since the boot // shape holds one tracker project per business. A product the request lifecycle is still building // (Task 2, #797) holds the factory project file until its seed commit lands, so a reserved entry is // skipped; a finished one is checked like any other and omits tracker.project, so the root board stays // the one tracker project and no second adapter is made for a product. function trackerFor(system, b, warn, dataRoot) { if (!b.tracker) return null; const first = Object.keys(b.roles)[0]; const base = resolveInstance({ system, business: b, instance: first }).vars; if (base["tracker.baseUrl"] === undefined) return null; const named = []; for (const [id, entry] of Object.entries(b.projects)) { if (entry.kind === 'library') continue; if (dataRoot && managedState({ dataRoot, business: b.id, project: id, entry }) === 'reserved') continue; if (!existsSync(projectFilePath(entry.root))) continue; const project = loadProject(entry.root); if (project.id !== id) throw new CliError(`project file of project ${id} has id that does not match the declared project key`, 3); const vars = resolveInstance({ system, business: b, project, instance: first }).vars; if (vars["tracker.project"] !== undefined) named.push({ id, vars }); } if (named.length > 1) { throw new CliError(`business ${b.id}: projects ${named.map((n) => n.id).join(", ")} each set tracker.project; the broker takes one tracker project per business`, 3); } if (named.length === 0) { warn(`business ${b.id} sets tracker.baseUrl, but no project file sets tracker.project; the broker gets no task verbs`); return null; } const v = named[0].vars; return { tracker: { baseUrl: v["tracker.baseUrl"], project: v["tracker.project"], pollSeconds: v["tracker.pollSeconds"], reconcileMinutes: v["tracker.reconcileMinutes"], descendants: v["tracker.descendants"], }, builtinBinding: stackBinding({ business: b.id, project: named[0].id, root: b.projects[named[0].id].root, tracker_project: v["tracker.project"] }) }; } // FED R6 (#1800, sage-3142): the runtime's `access`, { file, context }. The broker reads the file // with R1's loadAccess on a user request that finds the file's stat changed, and on its 30 s timer // (FED R4, #1801), so a bad file leaves every user with no grant and the owner's bus still boots // (sage-3153). The context is the cli's accessContextFor, peers included. No file, or a context // that can't be built, means no `access`: no user binds, as before R6. function accessFor(system, env, warn) { const file = accessFilePath(configDir(env)); try { lstatSync(file); } catch { return null; } let context; try { context = accessContextFor(system, env); } catch (e) { // #1873: the error's message can quote a path; only a plain code is relayed. const code = typeof e?.code === "string" && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(e.code) ? ` (${e.code})` : ""; warn(`access.json is present but its context can't be read, so no granted user binds${code}`); return null; } // sage-3153: one line at boot when the loader refuses the file, so the owner sees why no user // binds. The loader's messages can quote the file, so the line names only the file, by label, and // a code from its exit code: 4 is a file it could not open as a regular file, anything else // invalid. #1873: no host path. try { loadAccess(file, context); } catch (e) { const code = e instanceof BusinessError && e.exitCode !== 4 ? "access-invalid" : "access-unreadable"; warn(`access file refused at boot (${code}), no granted user binds until it is fixed: access.json in the config directory`); } return { file, context }; } export const consoleOwners = (sso) => sso.providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, iss: p.issuer, sub: s.sub }))); // The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one // business. The host holds a reader capability for the notifier and binds // launches later through bindLaunch, so `launches` starts empty. // #1873 A3: the organization's root and project agent copies, read once here. The root file is read // through loadRootAgents and the organization file through the organization's own writer; the // snapshot is plain data that the broker and the launcher re-apply to every later read of the // organization file, so a root file change takes effect at the next boot only. A root file that // refuses hides the root level (design section 6): no root copy, and the organization still boots. export function copiesAtBoot({ businessId, env = process.env, warn = () => {}, dataRoot = null }) { const dir = configDir(env), roles = rolesDir(env); loadBusiness(businessId, { dir, rolesDir: roles, dataRoot }); const { document } = organizationWriter({ dir, rolesDir: roles, dataRoot }).read(businessId); let root = null; try { root = loadRootAgents(dir, { rolesDir: roles }); } catch (error) { if (!(error instanceof BusinessError)) throw error; warn(`root agent file refused, so ${businessId} runs no root copies until it is fixed and the bus restarts: ${error.message}`); } const snapshot = materializeCopies({ orgId: businessId, doc: document, root, rolesDir: roles }); return { snapshot, business: withCopies(document, businessFilePath(businessId, dir), { rolesDir: roles, snapshot, dataRoot }) }; } export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env = process.env, warn = () => {} }) { return business(() => { const vars = systemVars(system); const { snapshot, business: b } = copiesAtBoot({ businessId, env, warn, dataRoot:system.dataRoot }); const resolved = {}; for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: vars, business: b, instance }); const config = { dataRoot: orgRoot, ...(orgRoot === system.dataRoot ? {} : { instanceRoot: system.dataRoot, org: { id: b.id }, instanceDataRoot: system.dataRoot, instanceConfigDir: configDir(env) }), ...(system.proxy ? { proxy: system.proxy } : {}), businesses: { [b.id]: busOrganization(b, resolved) }, organization: {dir:configDir(env),rolesDir:rolesDir(env),system:vars}, launches: [], readers: [b.id], // Present only when the organization has a copy, so a boot with none is the boot it was. ...(Object.keys(snapshot.copies).length ? { copies: snapshot } : {}), }; const tracker = trackerFor(vars, b, warn, orgRoot); if (tracker) { config.trackers = { [b.id]: tracker.tracker }; if (tracker.builtinBinding) config.builtinBinding = tracker.builtinBinding; } const access = accessFor(system, env, warn); if (access) config.access = access; // Row 252 (#1753, Webby N1): every console.sso subject is an owner, by provider id, issuer and sub. // The loader checked console.sso; no new config field names owners. if (system.console?.sso) config.consoleOwners = consoleOwners(system.console.sso); return config; }); }