Files
stack/packages/plugins/src/loader.mjs
T
jason.woltjeandClaude Opus 5.5 008746b6ed plugins: channel kind for the CLI and Console chat paths (row 164, #1661)
Adds plugin kind `channel` (interfaceVersion 1, dispatch(request, {call}))
with cli and webui modules. Project and thread surfaces and thread
talk/follow in the CLI and the Console chat go through the selected
channel. An empty or absent channel selection is off: the CLI exits 3 and
the Console answers 503, each naming `mosaic plugins` and `mosaic plugins
set` with the business selector. Management plugins and plugins.set stay
on the core transport. The loader gains a synchronous snapshot(kind)
getter; loaded(), modules() and last-good handling are unchanged. No lock
is held across dispatch. serve.mjs, public/ and the bus are untouched.

Owner Rocko. Reviewer Dewey: r1 changes (comment 28980, B1: the recovery
sentence was never asserted), r2 approve (comment 28984) on candidate
manifest ae2fcfda...1fc8, the B1 assert killing mutant D1. Design accepted
by Mos (mos-0768, mos-0773). Fixture rewrite at project.test.mjs:47 acked
by Mos (mos-0789) on Dewey's line-by-line check: lines 48-51 identical,
one added assert. Sage gate on e5b10d275: 25 steps green, 2081 package tests, 156 queue node tests, 422 script selftests, 0 failed.

Live stays as pinned. The re-pin that takes this live puts `mosaic
plugins set` in Jason's window right after activate (mos-0768).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 14:24:42 -05:00

165 lines
6.5 KiB
JavaScript

// The plug-in loader (REQ-PLUG-1). It switches on the modules the
// organization file's `plugins` object names, one key per kind, and loads
// only packages/plugins/<kind>/<name>/index.mjs from the tree this file runs
// from. It takes no path from config and loads nothing outside that tree
// (lead decision 143). A loaded module runs with the host's authority.
//
// A refusal keeps the kind's last good modules and is logged. A kind refused
// on its first load has nothing switched on. `loaded()` reports what each
// kind actually has switched on.
import { lstatSync, realpathSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { KINDS, NAME, PluginError, namesFor } from "./kinds.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const refuse = (code, message, name) => {
throw new PluginError(code, message, name);
};
// Run one filesystem read. Any error becomes a refusal for this path, so load()
// logs it and keeps the kind's old modules (Darkwing N1, Filbert F2, sage-0314).
function fsCall(path, read) {
try {
return read(path);
} catch (e) {
if (e.code === "ENOENT" || e.code === "ENOTDIR") refuse("missing", `${path} is missing`);
refuse("unreadable", `${path} can't be read (${e.code ?? e.message})`);
}
}
// lstat one path component: it must exist, be the expected type, and not be a symlink.
function component(path, directory) {
const st = fsCall(path, lstatSync);
if (st.isSymbolicLink()) refuse("symlink", `${path} is a symlink`);
if (directory ? !st.isDirectory() : !st.isFile()) {
refuse("missing", `${path} is not a ${directory ? "directory" : "regular file"}`);
}
}
async function loadModule(spec, name, barrier) {
const kindDir = join(ROOT, spec.kind);
const nameDir = join(kindDir, name);
const index = join(nameDir, "index.mjs");
component(ROOT, true);
component(kindDir, true);
component(nameDir, true);
component(index, false);
barrier("checked", { kind: spec.kind, name });
// Last check before import: the resolved file is still the one checked
// above. Ancestors of packages/plugins are not checked (sage-0301).
const expected = join(fsCall(ROOT, realpathSync), spec.kind, name, "index.mjs");
const real = fsCall(index, realpathSync);
if (real !== expected) refuse("symlink", `${index} resolves to ${real}`);
let module;
try {
module = await import(pathToFileURL(expected).href);
} catch (e) {
refuse("load-failed", `${spec.kind} ${name} failed to load: ${e.message}`);
}
for (const key of ["interfaceVersion", ...spec.exports]) {
if (!Object.hasOwn(module, key)) refuse("missing-export", `${spec.kind} ${name} doesn't export ${key}`);
}
for (const key of spec.exports) {
if (typeof module[key] !== "function") refuse("missing-export", `${spec.kind} ${name} export ${key} isn't a function`);
}
if (module.interfaceVersion !== spec.interfaceVersion) {
refuse("interface-version",
`${spec.kind} ${name} implements interface ${module.interfaceVersion}, not ${spec.interfaceVersion}`);
}
return Object.freeze({ name, module });
}
// True when packages/plugins/<kind>/<name>/index.mjs exists in this tree as a real file, with no
// symlink on the way down. The setters use it to refuse a name no module answers to before it is
// written; the loader's own checks still run at load.
export function hasModule(kind, name) {
try {
if (!Object.hasOwn(KINDS, kind) || !NAME.test(name)) return false;
for (const dir of [ROOT, join(ROOT, kind), join(ROOT, kind, name)]) component(dir, true);
component(join(ROOT, kind, name, "index.mjs"), false);
return true;
} catch {
return false;
}
}
export function createLoader({ log, barrier = () => {} }) {
if (typeof log !== "function") throw new TypeError("createLoader needs a log callback");
const current = new Map(Object.keys(KINDS).map(kind => [kind, Object.freeze([])]));
let queue = Promise.resolve();
function refused(kind, name, e, report) {
if (!(e instanceof PluginError)) throw e;
// kept: what stays switched on, for the kind or (whole setting refused) for every kind.
const entry = {
event: "plugin-refused", kind, name: e.plugin ?? name, code: e.code, message: e.message,
kept: kind === null ? loaded() : current.has(kind) ? current.get(kind).map(m => m.name) : [],
};
log(entry);
report.push(entry);
}
async function apply(plugins) {
const report = [];
// A malformed whole setting refuses every kind; an unknown kind refuses only itself.
if (plugins === null || typeof plugins !== "object" || Array.isArray(plugins)) {
refused(null, null, new PluginError("bad-setting", "plugins must be an object with one key per kind"), report);
return { loaded: loaded(), refused: report };
}
for (const kind of Object.keys(plugins)) {
if (!Object.hasOwn(KINDS, kind)) {
refused(kind, null, new PluginError("unknown-kind", `plugins names unknown kind ${JSON.stringify(kind)}`), report);
}
}
for (const [kind, spec] of Object.entries(KINDS)) {
// An absent key switches the kind off.
let names = [];
try {
if (Object.hasOwn(plugins, kind)) names = namesFor(spec, plugins[kind], `plugins.${kind}`, refuse);
} catch (e) {
refused(kind, typeof plugins[kind] === "string" ? plugins[kind] : null, e, report);
continue;
}
const next = [];
let ok = true;
for (const name of names) {
try {
next.push(await loadModule(spec, name, barrier));
} catch (e) {
refused(kind, name, e, report);
ok = false;
break;
}
}
if (ok) current.set(kind, Object.freeze(next));
}
return { loaded: loaded(), refused: report };
}
// Loads run one at a time, so a kind's switch never interleaves.
function load(plugins) {
const run = queue.then(() => apply(plugins));
queue = run.catch(() => {});
return run;
}
function loaded() {
return Object.fromEntries([...current].map(([kind, modules]) => [kind, modules.map(m => m.name)]));
}
function modules(kind) {
if (!current.has(kind)) refuse("unknown-kind", `unknown kind ${JSON.stringify(kind)}`);
return current.get(kind).map(m => m.module);
}
// Capture names and modules together, without exposing mutable state or a dispatch lock.
function snapshot(kind) {
if (!current.has(kind)) refuse("unknown-kind", `unknown kind ${JSON.stringify(kind)}`);
return current.get(kind);
}
return { load, loaded, modules, snapshot };
}