259 lines
11 KiB
JavaScript
259 lines
11 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
|
|
// core packages/plugins/<kind>/<name>/index.mjs from its own tree, plus
|
|
// hash-pinned installed modules from <instance dataRoot>/plugins only. PL1
|
|
// (#1873), owner-authorized amendment of lead decision 143. No arbitrary
|
|
// config path is accepted. A loaded module runs with the host's authority.
|
|
//
|
|
// A core refusal keeps the kind's last good modules and is logged. A refused
|
|
// installed plugin is omitted; valid core modules in its kind still load. A kind refused
|
|
// on its first load has nothing switched on. `loaded()` reports what each
|
|
// kind actually has switched on.
|
|
import { lstatSync, readdirSync, 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";
|
|
import { verifiedPlugin, readPluginLock, claimPluginImport } from "./pinned.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, dataRoot = null) {
|
|
if (dataRoot !== null && !hasModule(spec.kind, name)) return loadPinnedModule(spec, name, barrier, dataRoot);
|
|
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, dataRoot = null) {
|
|
if (dataRoot !== null && !hasModule(kind, name)) {
|
|
try { verifiedPlugin(dataRoot, name, kind); return true; } catch { return false; }
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
// The directories packages/plugins/<kind>/ holds in this tree (stream S4). A kind whose modules are
|
|
// data, like theme, has no setting to switch a module on: installing one is putting its directory
|
|
// here, so the tree is the list. `names` fit NAME, sorted; a symlinked entry is kept so the load
|
|
// refuses it by name. `invalid` are directories that don't fit NAME. Plain files (a README) are
|
|
// ignored. A missing kind directory is an empty list; a symlinked one is refused.
|
|
export function discoverModules(kind) {
|
|
if (!Object.hasOwn(KINDS, kind)) refuse("unknown-kind", `unknown kind ${JSON.stringify(kind)}`);
|
|
const kindDir = join(ROOT, kind);
|
|
component(ROOT, true);
|
|
try {
|
|
lstatSync(kindDir);
|
|
} catch (e) {
|
|
if (e.code === "ENOENT") return { names: [], invalid: [] };
|
|
}
|
|
component(kindDir, true);
|
|
const entries = fsCall(kindDir, dir => readdirSync(dir, { withFileTypes: true }));
|
|
const names = [], invalid = [];
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
(NAME.test(entry.name) ? names : invalid).push(entry.name);
|
|
}
|
|
return { names: names.sort(), invalid: invalid.sort() };
|
|
}
|
|
|
|
// Loads every installed module of one kind through the same checks as a switched-on module
|
|
// (loadModule, unchanged). One refused module is reported and never hides the others, and nothing
|
|
// here changes what createLoader has switched on. Node caches an import by path, so a module
|
|
// whose file changed is read again only after a restart; a removed directory is gone at once.
|
|
export async function loadInstalled(kind, { barrier = () => {}, dataRoot = null } = {}) {
|
|
if (!Object.hasOwn(KINDS, kind)) refuse("unknown-kind", `unknown kind ${JSON.stringify(kind)}`);
|
|
const spec = KINDS[kind];
|
|
const modules = [], refused = [];
|
|
const report = (name, e) => {
|
|
if (!(e instanceof PluginError)) throw e;
|
|
refused.push({ event: "plugin-refused", kind, name: e.plugin ?? name, code: e.code, message: e.message });
|
|
};
|
|
let found;
|
|
try {
|
|
found = discoverModules(kind);
|
|
} catch (e) {
|
|
report(null, e);
|
|
return { modules, refused };
|
|
}
|
|
for (const name of found.invalid) report(name, new PluginError("bad-name", `${kind}/${name} doesn't match ${NAME}`, name));
|
|
if (dataRoot !== null) {
|
|
try { for (const name of Object.keys(readPluginLock(dataRoot).plugins)) {
|
|
try { if (verifiedPlugin(dataRoot, name).manifest.kinds.includes(kind) && !found.names.includes(name)) found.names.push(name); } catch(e) { report(name,e); }
|
|
} } catch(e) { report(null,e); }
|
|
}
|
|
for (const name of found.names) {
|
|
try {
|
|
modules.push(await loadModule(spec, name, barrier, dataRoot));
|
|
} catch (e) {
|
|
report(name, e);
|
|
}
|
|
}
|
|
return { modules, refused };
|
|
}
|
|
|
|
export function createLoader({ log, barrier = () => {}, dataRoot = null }) {
|
|
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) : [],
|
|
};
|
|
if (dataRoot !== null) {
|
|
const affected=kind===null ? [...current.keys()] : current.has(kind) ? [kind] : [];
|
|
for(const checkedKind of affected)current.set(checkedKind, Object.freeze(current.get(checkedKind).filter(m => {
|
|
if (!m.installed) return true;
|
|
try { verifiedPlugin(dataRoot,m.name,checkedKind); return true; } catch { return false; }
|
|
})));
|
|
entry.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, dataRoot));
|
|
} catch (e) {
|
|
refused(kind, name, e, report);
|
|
if (dataRoot !== null && !hasModule(kind,name)) continue;
|
|
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 };
|
|
}
|
|
|
|
// Shared pure plug-in setting classification; no check implementation is loaded.
|
|
export { isDailyModelQuota } from "./model-quota.mjs";
|
|
|
|
// Verify before every import, including cache hits. Recheck after the test barrier and import.
|
|
async function loadPinnedModule(spec, name, barrier, dataRoot) {
|
|
let checked = verifiedPlugin(dataRoot,name,spec.kind);
|
|
barrier("checked", {kind:spec.kind,name});
|
|
checked = verifiedPlugin(dataRoot,name,spec.kind);
|
|
const entry = join(checked.dir,spec.kind,"index.mjs");
|
|
claimPluginImport(checked);
|
|
let module;
|
|
try { module=await import(pathToFileURL(entry).href + '?pin=' + checked.manifest.sha256); }
|
|
catch { refuse("load-failed","installed plugin load refused",name); }
|
|
verifiedPlugin(dataRoot,name,spec.kind);
|
|
for(const key of spec.exports) if(typeof module[key]!=="function") refuse("missing-export","installed plugin export refused",name);
|
|
if(module.interfaceVersion!==spec.interfaceVersion) refuse("interface-version","installed plugin interface refused",name);
|
|
return Object.freeze({name,module,installed:true});
|
|
}
|