Files
jason.woltjeandClaude Opus 5.5 80797a5398 docs: add Dewey row 131 r2 packet (plug-in loader and setter fixes)
Round 2 for #1626 per sage-0314: F1a setter refuses a non-plain prototype,
F1b organization writer validates and logs JSON.parse(bytes), F2 loader
refuses unreadable paths, README top-level-code limit, control run.
Gate 591/0, 0 skipped; controls pass; mutants 47/47 killed (slot sage-0316).
Candidate manifest sha256 466c835f7dce79d20c7ab74c197a52b70e7fdeb0efae0456222dc6d0a633f219,
14 paths on base 0bdd573ff; no commit in base..1376caab9 touches them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:44:04 -05:00

1015 lines
50 KiB
Diff

diff --git a/packages/business/README.md b/packages/business/README.md
index 9a6baad3c..d2967cb33 100644
--- a/packages/business/README.md
+++ b/packages/business/README.md
@@ -100,6 +100,13 @@ instance whose definition holds `role.launch` within-role; otherwise the
file refuses. Only that instance keeps `role.launch`. Every other instance
loses it, so for them it's gated.
+`plugins` (optional) switches on plug-in modules, one key per kind from
+`packages/plugins/src/kinds.mjs`, each a list of module names matching
+`^[a-z][a-z0-9-]*$` with no name twice. An unknown kind or a bad name
+refuses the file. Absent means nothing is switched on. Whether a named
+module exists and loads is the plug-in loader's check, at load time; see
+`packages/plugins/README.md`.
+
### Chief-of-staff and assistant instances
Two roles stand above the PM (PRD 1.2; row 119, #1613).
@@ -227,7 +234,9 @@ file missing). The CLI adds 3 for a system config problem and 4 for usage.
`src/organization.mjs` is the trusted host write module. The caller supplies
a verified actor and the SHA-256 revision read earlier. It validates the whole
-document, refuses stale revisions and concurrent writers, keeps exact previous
+document, and again as the bytes it will write read back as JSON, so a value
+JSON can't carry (a `Date`, a `Map`) refuses instead of landing as something
+else; `changes.jsonl` logs those bytes. It refuses stale revisions and concurrent writers, keeps exact previous
bytes under `businesses/.history/<id>/<transaction>/previous.json`, then publishes
a fsynced temporary file by atomic rename. `changes.jsonl` records prepared and
committed transactions with actor, hashes and old/new values per changed field.
@@ -237,7 +246,10 @@ launches keep using the whole validated file on disk, without consulting receipt
confirm that its old writer is gone before clearing it; the host does not
race another recovering process by deleting locks automatically. Settings reads never repair evidence themselves.
`launch-settings.mjs` restricts human edits to limits, model table and instance
-model selections and validates effective selections before publication. Migration
+model selections and validates effective selections before publication. `plugin-settings.mjs`
+is the only writer of `plugins`: the host calls it, and no broker verb, `mosaic`
+command or Settings form reaches it. A refused `plugins` write throws
+`invalid-plugins` or `invalid-request` before the writer runs and logs nothing. Migration
uses none of this: it only prints a converted document (decision 145).
For inherited selections, print-only migration reads the existing system config
diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs
index cbe04a59b..49ac1fa45 100644
--- a/packages/business/src/business.mjs
+++ b/packages/business/src/business.mjs
@@ -9,12 +9,13 @@ import { checkModels, checkLimits } from "./launch.mjs";
import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
+import { checkPlugins } from "../../plugins/src/kinds.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs";
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
-const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch"];
+const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins"];
// The config directory: next to the system config, $MOSAIC_CONFIG or
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
@@ -178,9 +179,13 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
}
}
const launch = document.launch === undefined ? null : checkLaunch(document.launch, roles, definitions, file);
+ // REQ-PLUG-1: the module names switched on per kind. The names are checked
+ // here; whether each module loads is the loader's check, at load time.
+ const plugins = document.plugins === undefined ? {}
+ : structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
return deepFreeze({
- businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, definitions,
+ businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
});
}
diff --git a/packages/business/src/organization.mjs b/packages/business/src/organization.mjs
index 03ddc6714..2649d37a7 100644
--- a/packages/business/src/organization.mjs
+++ b/packages/business/src/organization.mjs
@@ -186,6 +186,10 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
validateBusinessDocument(document, file, { rolesDir });
const old = JSON.parse(oldBytes);
const bytes = JSON.stringify(document, null, 2) + "\n";
+ // Check and log exactly what lands on disk, for every setter: a value JSON
+ // can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
+ const written = JSON.parse(bytes);
+ validateBusinessDocument(written, file, { rolesDir });
const after = sha256(bytes);
const transaction = randomUUID();
const tx = join(home, transaction);
@@ -197,7 +201,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
syncDir(tx);
const event = {
transaction, actor, business: id, before, after,
- changes: changes(old, document), at: new Date().toISOString(),
+ changes: changes(old, written), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
diff --git a/packages/business/src/plugin-settings.mjs b/packages/business/src/plugin-settings.mjs
new file mode 100644
index 000000000..9a576bdd7
--- /dev/null
+++ b/packages/business/src/plugin-settings.mjs
@@ -0,0 +1,45 @@
+// Host setter for the organization file's `plugins` switch (REQ-PLUG-1,
+// REQ-ROLE-3). It is the only writer of that key: no broker verb, mosaic
+// command, Settings form or chief-of-staff write reaches it (sage-0301).
+// Writes go through the organization writer, which logs old and new names
+// like a limit change. A refused write throws a code and logs nothing.
+import { organizationWriter, OrganizationError } from "./organization.mjs";
+import { isPlainObject } from "./util.mjs";
+import { PluginError, checkPlugins } from "../../plugins/src/kinds.mjs";
+
+export function pluginSettings({ dir, rolesDir }) {
+ const writer = organizationWriter({ dir, rolesDir });
+
+ function read(id) {
+ const { document, revision } = writer.read(id);
+ return { business: id, revision, plugins: structuredClone(document.plugins ?? {}) };
+ }
+
+ function set(id, args, actor) {
+ if (!isPlainObject(args) || Object.keys(args).some(key => key !== "revision" && key !== "plugins")) {
+ throw new OrganizationError("invalid-request");
+ }
+ if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
+ throw new OrganizationError("invalid-request");
+ }
+ // A Date or a Map has no own keys and would pass checkPlugins, then serialize
+ // as something else (Filbert F1, sage-0314). Only a JSON-shaped object passes.
+ const shaped = args.plugins !== null && typeof args.plugins === "object";
+ const proto = shaped ? Object.getPrototypeOf(args.plugins) : undefined;
+ if (proto !== Object.prototype && proto !== null) {
+ throw new OrganizationError("invalid-plugins", "plugins must be a plain object with one key per kind");
+ }
+ try {
+ checkPlugins(args.plugins, "plugins");
+ } catch (e) {
+ if (e instanceof PluginError) throw new OrganizationError("invalid-plugins", e.message);
+ throw e;
+ }
+ const next = structuredClone(writer.read(id).document);
+ next.plugins = structuredClone(args.plugins);
+ const receipt = writer.write(id, next, { revision: args.revision, actor });
+ return { ...read(id), transaction: receipt.transaction };
+ }
+
+ return { writer, read, set };
+}
diff --git a/packages/business/tests/plugin-settings.test.mjs b/packages/business/tests/plugin-settings.test.mjs
new file mode 100644
index 000000000..437eb5e31
--- /dev/null
+++ b/packages/business/tests/plugin-settings.test.mjs
@@ -0,0 +1,105 @@
+// REQ-PLUG-1 / REQ-ROLE-3 (step 5 write path): the `plugins` key in the organization
+// file, written only through the host setter and logged like a limit change.
+import test, { after } from "node:test";
+import assert from "node:assert/strict";
+import { existsSync, readFileSync, rmSync } from "node:fs";
+import { join } from "node:path";
+import { businessDoc, rolesCopy, tmp as makeRoot, writeJson } from "./helpers.mjs";
+import { validateBusinessDocument } from "../src/business.mjs";
+import { pluginSettings } from "../src/plugin-settings.mjs";
+import { OrganizationError } from "../src/organization.mjs";
+import { BusinessError } from "../src/errors.mjs";
+
+const roots = [];
+after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true })));
+
+function fixture() {
+ const root = makeRoot();
+ roots.push(root);
+ const rolesDir = rolesCopy(root);
+ const doc = businessDoc(root, "acme");
+ const file = writeJson(join(root, "businesses", "acme.json"), doc);
+ return { root, rolesDir, doc, file, settings: pluginSettings({ dir: root, rolesDir }) };
+}
+
+const events = f => existsSync(f.settings.writer.logFile("acme"))
+ ? readFileSync(f.settings.writer.logFile("acme"), "utf8").trim().split("\n").map(JSON.parse) : [];
+
+test("REQ-PLUG-1 schema: plugins is optional, names known kinds and checked module names", () => {
+ const f = fixture();
+ const check = d => validateBusinessDocument(d, f.file, { rolesDir: f.rolesDir });
+ assert.deepEqual(check(f.doc).plugins, {});
+ const on = { ...structuredClone(f.doc), plugins: { "usage-source": ["meter"], "limit-check": [] } };
+ const business = check(on);
+ assert.deepEqual(business.plugins, on.plugins);
+ assert.ok(Object.isFrozen(business.plugins));
+ assert.ok(!Object.isFrozen(on.plugins), "the document itself stays mutable");
+ for (const plugins of [{ "made-up": [] }, { "usage-source": "meter" }, { "usage-source": ["Meter"] }, { "usage-source": ["a", "a"] }, []]) {
+ assert.throws(() => check({ ...structuredClone(f.doc), plugins }), BusinessError, JSON.stringify(plugins));
+ }
+});
+
+test("REQ-PLUG-1 REQ-ROLE-3 the setter writes plugins through the organization writer and logs old and new names", () => {
+ const f = fixture();
+ const view = f.settings.read("acme");
+ assert.deepEqual(view.plugins, {});
+ const done = f.settings.set("acme", { revision: view.revision, plugins: { "usage-source": ["meter"] } }, "jason");
+ assert.deepEqual(JSON.parse(readFileSync(f.file, "utf8")).plugins, { "usage-source": ["meter"] });
+ assert.deepEqual(done.plugins, { "usage-source": ["meter"] });
+ assert.notEqual(done.revision, view.revision);
+ let last = events(f).at(-1);
+ assert.equal(last.state, "committed");
+ assert.equal(last.actor, "jason");
+ assert.equal(last.transaction, done.transaction);
+ assert.deepEqual(last.changes, [{ path: "plugins", old: null, new: { "usage-source": ["meter"] }, oldPresent: false, newPresent: true }]);
+ f.settings.set("acme", { revision: done.revision, plugins: { "usage-source": ["meter", "other"], "limit-check": ["cap"] } }, "jason");
+ last = events(f).at(-1);
+ assert.deepEqual(last.changes.map(c => [c.path, c.old, c.new]), [
+ ["plugins.usage-source", ["meter"], ["meter", "other"]],
+ ["plugins.limit-check", null, ["cap"]],
+ ]);
+ // A null-prototype object is still a plain JSON object.
+ const bare = Object.assign(Object.create(null), { "limit-check": ["cap"] });
+ f.settings.set("acme", { revision: f.settings.read("acme").revision, plugins: bare }, "jason");
+ assert.deepEqual(JSON.parse(readFileSync(f.file, "utf8")).plugins, { "limit-check": ["cap"] });
+});
+
+test("REQ-PLUG-1 REQ-ROLE-3 the writer checks the bytes it writes: a Date in plugins is refused there too", () => {
+ // Without the setter's own check, the organization writer still refuses:
+ // it validates JSON.parse of the bytes, where the Date is a string.
+ const f = fixture();
+ const { revision } = f.settings.read("acme");
+ const bytes = readFileSync(f.file, "utf8");
+ assert.throws(() => f.settings.writer.write("acme", { ...structuredClone(f.doc), plugins: new Date(0) }, { revision, actor: "jason" }),
+ BusinessError);
+ assert.equal(readFileSync(f.file, "utf8"), bytes);
+ assert.deepEqual(events(f), []);
+});
+
+test("REQ-PLUG-1 REQ-ROLE-3 a refused setter write throws a code, changes nothing and logs nothing", () => {
+ const f = fixture();
+ const { revision } = f.settings.read("acme");
+ const bytes = readFileSync(f.file, "utf8");
+ const refusals = [
+ [{ revision, plugins: { "made-up": ["x"] } }, "invalid-plugins"],
+ [{ revision, plugins: { "usage-source": ["Bad"] } }, "invalid-plugins"],
+ [{ revision, plugins: { "usage-source": "meter" } }, "invalid-plugins"],
+ [{ revision, plugins: { "limit-check": ["cap", "cap"] } }, "invalid-plugins"],
+ [{ revision }, "invalid-plugins"],
+ [{ revision, plugins: [] }, "invalid-plugins"],
+ // No own keys, so checkPlugins alone passes them; JSON would write something else.
+ [{ revision, plugins: new Date(0) }, "invalid-plugins"],
+ [{ revision, plugins: new Map([["usage-source", ["meter"]]]) }, "invalid-plugins"],
+ [{ revision, plugins: {}, limits: {} }, "invalid-request"],
+ [{ revision: "nope", plugins: {} }, "invalid-request"],
+ [{ plugins: {} }, "invalid-request"],
+ [null, "invalid-request"],
+ [["x"], "invalid-request"],
+ ];
+ for (const [args, expected] of refusals) {
+ assert.throws(() => f.settings.set("acme", args, "jason"),
+ e => e instanceof OrganizationError && e.code === expected, JSON.stringify(args));
+ }
+ assert.equal(readFileSync(f.file, "utf8"), bytes);
+ assert.deepEqual(events(f), []);
+});
diff --git a/packages/cli/tests/stack-copy.test.mjs b/packages/cli/tests/stack-copy.test.mjs
index d98e771ac..d44ce5788 100644
--- a/packages/cli/tests/stack-copy.test.mjs
+++ b/packages/cli/tests/stack-copy.test.mjs
@@ -34,7 +34,7 @@ export function setup(t, realPin=false){
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
- for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json'])assert.ok(existsSync(join(copy,f)),f);
+ for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs'])assert.ok(existsSync(join(copy,f)),f);
const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755);
const existing=s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]);assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm);
git(s.repo,'update-ref','refs/remotes/origin/refactor',git(s.repo,'hash-object','-w','package.json'));
diff --git a/packages/plugins/README.md b/packages/plugins/README.md
new file mode 100644
index 000000000..855030a70
--- /dev/null
+++ b/packages/plugins/README.md
@@ -0,0 +1,114 @@
+# Plug-ins
+
+The plug-in layer (PRD REQ-PLUG-1, build step 6b, Gitea #1626). Every part
+the core calls out to is a plug-in kind. A module of a kind is a directory
+in this package, and the organization file says which modules are switched
+on. No core module imports a plug-in module by name; calls go through the
+loader.
+
+| Path | What it is |
+|---|---|
+| `src/kinds.mjs` | the kind registry and the check for the organization file's `plugins` key |
+| `src/loader.mjs` | loads the switched-on modules from this tree |
+| `<kind>/<name>/index.mjs` | one module of a kind |
+
+## Kinds
+
+| Kind | Interface | Switch | Exports |
+|---|---|---|---|
+| `usage-source` | 1 | list | `interfaceVersion`, `readTurn` |
+| `limit-check` | 1 | list | `interfaceVersion`, `mayLaunch`, `mustStop` |
+
+Every module exports `interfaceVersion`, equal to its kind's interface
+version, plus the kind's functions. PRD REQ-LIMIT-4 says what the two kinds
+do: a usage source reads what a session used after each turn, and a limit
+check answers whether a launch may start (`mayLaunch`) and whether a running
+session must stop at the end of its turn (`mustStop`). The arguments and
+return values of these functions are set by the rows that call them (row 124
+converts the first usage source). No module of either kind ships yet.
+
+Adding a kind, or changing a kind's exports, interface version or switch,
+is a core change: a reviewed edit to `src/kinds.mjs`, with the code that
+calls the kind.
+
+## Adding a module
+
+1. Pick a name matching `^[a-z][a-z0-9-]*$`.
+2. Create `packages/plugins/<kind>/<name>/index.mjs`. A real directory and
+ a real file: the loader refuses a symlink anywhere from
+ `packages/plugins` down, `index.mjs` included.
+3. Export `interfaceVersion` and every function the kind lists.
+4. Land it like any other change. A running stack sees it only after a
+ re-pin, because `scripts/stack-copy.sh` copies the whole
+ `packages/plugins` directory into the pinned copy.
+5. Switch it on in the organization file's `plugins` object, under its
+ kind. The host setter `packages/business/src/plugin-settings.mjs` is the
+ only writer of that key.
+
+Adding a module changes no core file.
+
+## The organization file's `plugins` key
+
+One key per kind. A list kind takes a list of names; a kind that switches
+on one module takes one name. An absent key switches the kind off.
+
+```json
+"plugins": { "usage-source": ["meter"], "limit-check": [] }
+```
+
+`validateBusinessDocument` checks the shape: known kinds, the name pattern,
+no name twice. It doesn't check that a module exists; the loader does that
+when it loads. The setter writes through the organization writer, which
+logs actor and old and new names in `changes.jsonl` like a limit change. A
+refused write throws `OrganizationError` (`invalid-plugins` or
+`invalid-request`) and logs nothing.
+
+## The loader
+
+```js
+import { createLoader } from "../packages/plugins/src/loader.mjs";
+
+const loader = createLoader({ log: entry => record(entry) });
+const { loaded, refused } = await loader.load(business.plugins);
+loader.modules("limit-check"); // the switched-on modules, in order
+loader.loaded(); // { kind: [names actually switched on] }
+```
+
+The loader finds this package from its own location and takes no path from
+config. Until Jason rules on Vikunja #286 it loads modules from this tree
+only (lead decision 143). For each name it checks, in order:
+
+1. the name matches `^[a-z][a-z0-9-]*$` (`bad-name`);
+2. `packages/plugins`, `<kind>`, `<name>` and `index.mjs` exist, are a
+ directory or a regular file as expected, and are not symlinks (`missing`,
+ `symlink`); ancestors of `packages/plugins` are not checked;
+3. just before import, `realpath(index.mjs)` is still the expected path
+ (`symlink`);
+4. the module imports (`load-failed`);
+5. it has every export, and the kind's functions are functions
+ (`missing-export`);
+6. `interfaceVersion` equals the kind's (`interface-version`).
+
+At steps 2 and 3, a path that can't be read for another reason, such as a
+permission error, refuses as `unreadable`.
+
+A refusal refuses the whole kind for that load: the kind keeps the modules
+it had, and the loader calls `log` with `{event: "plugin-refused", kind,
+name, code, message, kept}`. A kind refused on its first load has nothing
+switched on. An unknown kind refuses only itself; a `plugins` value that
+isn't an object refuses every kind. Loads run one at a time.
+
+Limits of the checks:
+
+- A module runs with the host's authority once imported. The checks keep
+ the loader inside this tree; they don't sandbox the module.
+- A module's top-level code runs at import, before the export and
+ interface-version checks. A module refused for `missing-export` or
+ `interface-version` has still run, and Node's cache keeps it.
+- Someone who can write this tree can swap a file between the realpath
+ check and the import. The checks catch mistakes and stale links, not a
+ writer racing the host as the same user.
+- Node caches a module by URL, so a changed `index.mjs` is not re-read
+ until the process restarts.
+- The loader is not wired into any host process yet. The rows that call
+ the first two kinds (124, 125, 126) wire it.
diff --git a/packages/plugins/package.json b/packages/plugins/package.json
new file mode 100644
index 000000000..2cbd5f028
--- /dev/null
+++ b/packages/plugins/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@mosaic/plugins",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Plug-in kind registry and in-repo module loader (PRD 1.2 row 131).",
+ "license": "UNLICENSED",
+ "type": "module",
+ "engines": { "node": ">=24" },
+ "exports": { "./kinds": "./src/kinds.mjs", "./loader": "./src/loader.mjs" },
+ "scripts": { "test": "node --test tests/" }
+}
diff --git a/packages/plugins/src/kinds.mjs b/packages/plugins/src/kinds.mjs
new file mode 100644
index 000000000..42b320a51
--- /dev/null
+++ b/packages/plugins/src/kinds.mjs
@@ -0,0 +1,66 @@
+// The plug-in kind registry (REQ-PLUG-1; the first two kinds are
+// REQ-LIMIT-4's). A kind names the exports its modules must provide, the
+// interface version they implement, and whether the organization file
+// switches on one module or a list. Adding a kind, or changing one, is a
+// core change: a reviewed edit to this file.
+
+export const NAME = /^[a-z][a-z0-9-]*$/;
+
+export const KINDS = Object.freeze({
+ // Reads one turn's usage for the limit checks (row 124 converts the first).
+ "usage-source": Object.freeze({
+ kind: "usage-source", interfaceVersion: 1, exports: Object.freeze(["readTurn"]), switch: "list",
+ }),
+ // Decides whether a launch may start and whether a running session must stop.
+ "limit-check": Object.freeze({
+ kind: "limit-check", interfaceVersion: 1, exports: Object.freeze(["mayLaunch", "mustStop"]), switch: "list",
+ }),
+});
+
+export class PluginError extends Error {
+ constructor(code, message, name = null) {
+ super(message);
+ this.code = code;
+ this.name = "PluginError";
+ this.plugin = name;
+ }
+}
+
+const raise = (code, message, name) => {
+ throw new PluginError(code, message, name);
+};
+
+// The module names one kind's setting switches on, checked against the
+// kind's cardinality and the name pattern. A list may be empty.
+export function namesFor(spec, setting, where, fail = raise) {
+ if (spec.switch === "one") {
+ if (typeof setting !== "string") fail("bad-setting", `${where} must be one module name`);
+ } else if (!Array.isArray(setting)) {
+ fail("bad-setting", `${where} must be a list of module names`);
+ }
+ const names = spec.switch === "one" ? [setting] : setting;
+ const seen = new Set();
+ for (const name of names) {
+ if (typeof name !== "string" || !NAME.test(name)) {
+ fail("bad-name", `${where} names ${JSON.stringify(name)}, which doesn't match ${NAME}`,
+ typeof name === "string" ? name : null);
+ }
+ if (seen.has(name)) fail("bad-setting", `${where} names ${name} twice`, name);
+ seen.add(name);
+ }
+ return names;
+}
+
+// The organization file's `plugins` object: one key per kind, each a known kind.
+export function checkPlugins(value, where, { kinds = KINDS, fail = raise } = {}) {
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
+ fail("bad-setting", `${where} must be an object with one key per kind`);
+ }
+ for (const [kind, setting] of Object.entries(value)) {
+ if (!Object.hasOwn(kinds, kind)) {
+ fail("unknown-kind", `${where} names unknown kind ${JSON.stringify(kind)}; adding a kind is a core change`);
+ }
+ namesFor(kinds[kind], setting, `${where}.${kind}`, fail);
+ }
+ return value;
+}
diff --git a/packages/plugins/src/loader.mjs b/packages/plugins/src/loader.mjs
new file mode 100644
index 000000000..cc56f3950
--- /dev/null
+++ b/packages/plugins/src/loader.mjs
@@ -0,0 +1,144 @@
+// 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, 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 });
+}
+
+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);
+ }
+
+ return { load, loaded, modules };
+}
diff --git a/packages/plugins/tests/helpers.mjs b/packages/plugins/tests/helpers.mjs
new file mode 100644
index 000000000..e74e875ee
--- /dev/null
+++ b/packages/plugins/tests/helpers.mjs
@@ -0,0 +1,38 @@
+// A scratch tree per test: a copy of this package's src/ under
+// <root>/packages/plugins, plus fixture modules. The loader finds modules
+// from its own location, so each tree's copy loads only that tree's modules.
+import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+export const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "..", "src");
+
+export const USAGE = name => `export const interfaceVersion = 1;
+export function readTurn() { return ${JSON.stringify(name)}; }
+`;
+export const LIMIT = name => `export const interfaceVersion = 1;
+export function mayLaunch() { return ${JSON.stringify(name)}; }
+export function mustStop() { return false; }
+`;
+
+export function tree(t) {
+ const root = mkdtempSync(join(tmpdir(), "mosaic-plugins-"));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const pkg = join(root, "packages", "plugins");
+ cpSync(SRC, join(pkg, "src"), { recursive: true });
+ const write = (kind, name, body) => {
+ const dir = join(pkg, kind, name);
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(join(dir, "index.mjs"), body);
+ return dir;
+ };
+ const loaderUrl = pathToFileURL(join(pkg, "src", "loader.mjs")).href;
+ async function loader(options = {}) {
+ const { createLoader } = await import(loaderUrl);
+ const logged = [];
+ const instance = createLoader({ log: entry => logged.push(entry), ...options });
+ return { ...instance, logged };
+ }
+ return { root, pkg, write, loader, loaderUrl };
+}
diff --git a/packages/plugins/tests/kinds.test.mjs b/packages/plugins/tests/kinds.test.mjs
new file mode 100644
index 000000000..15b641245
--- /dev/null
+++ b/packages/plugins/tests/kinds.test.mjs
@@ -0,0 +1,47 @@
+// REQ-PLUG-1: the kind registry and the organization file's `plugins` shape (row 131).
+import test from "node:test";
+import assert from "node:assert/strict";
+import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs";
+
+const code = expected => e => e instanceof PluginError && e.code === expected;
+
+test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source and limit-check at interface version 1, each a list", () => {
+ assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check"]);
+ assert.deepEqual({ ...KINDS["usage-source"], exports: [...KINDS["usage-source"].exports] },
+ { kind: "usage-source", interfaceVersion: 1, exports: ["readTurn"], switch: "list" });
+ assert.deepEqual({ ...KINDS["limit-check"], exports: [...KINDS["limit-check"].exports] },
+ { kind: "limit-check", interfaceVersion: 1, exports: ["mayLaunch", "mustStop"], switch: "list" });
+ assert.ok(Object.isFrozen(KINDS) && Object.isFrozen(KINDS["usage-source"]) && Object.isFrozen(KINDS["limit-check"].exports));
+ assert.equal(String(NAME), "/^[a-z][a-z0-9-]*$/");
+});
+
+test("REQ-PLUG-1 checkPlugins accepts one key per kind, each a list of names, empty included", () => {
+ for (const value of [{}, { "usage-source": [] }, { "usage-source": ["a", "b-2"], "limit-check": ["cap"] }]) {
+ assert.equal(checkPlugins(value, "plugins"), value);
+ }
+});
+
+test("REQ-PLUG-1 checkPlugins refuses bad shapes, unknown kinds, bad names and duplicates", () => {
+ for (const value of [null, [], "x", 3]) assert.throws(() => checkPlugins(value, "plugins"), code("bad-setting"));
+ assert.throws(() => checkPlugins({ "made-up": [] }, "plugins"), code("unknown-kind"));
+ assert.throws(() => checkPlugins({ toString: [] }, "plugins"), code("unknown-kind"));
+ assert.throws(() => checkPlugins({ "usage-source": "meter" }, "plugins"), code("bad-setting"));
+ for (const name of ["Meter", "1m", "m_x", "../x", "a/b", ".x", "", 7, null]) {
+ assert.throws(() => checkPlugins({ "usage-source": [name] }, "plugins"), code("bad-name"), String(name));
+ }
+ assert.throws(() => checkPlugins({ "limit-check": ["cap", "cap"] }, "plugins"), code("bad-setting"));
+});
+
+test("REQ-PLUG-1 a kind that switches on one module takes one name, not a list", () => {
+ const kinds = { single: { kind: "single", interfaceVersion: 1, exports: ["run"], switch: "one" } };
+ assert.ok(checkPlugins({ single: "only" }, "plugins", { kinds }));
+ assert.throws(() => checkPlugins({ single: ["only"] }, "plugins", { kinds }), code("bad-setting"));
+ assert.throws(() => checkPlugins({ single: "Only" }, "plugins", { kinds }), code("bad-name"));
+});
+
+test("REQ-PLUG-1 checkPlugins reports through the caller's fail callback", () => {
+ const seen = [];
+ const fail = (c, message) => { seen.push(c); throw new Error(message); };
+ assert.throws(() => checkPlugins({ "made-up": [] }, "x plugins", { fail }), /x plugins names unknown kind/);
+ assert.deepEqual(seen, ["unknown-kind"]);
+});
diff --git a/packages/plugins/tests/loader.test.mjs b/packages/plugins/tests/loader.test.mjs
new file mode 100644
index 000000000..3eb9e25f7
--- /dev/null
+++ b/packages/plugins/tests/loader.test.mjs
@@ -0,0 +1,265 @@
+// REQ-PLUG-1: the loader switches on in-repo modules only, and
+// every refusal keeps the kind's old modules and is logged (row 131).
+import test from "node:test";
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { chmodSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, unlinkSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { LIMIT, USAGE, tree } from "./helpers.mjs";
+import { businessDoc, rolesCopy, tmp as businessRoot, writeJson } from "../../business/tests/helpers.mjs";
+import { validateBusinessDocument } from "../../business/src/business.mjs";
+
+const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] };
+
+// A tree with one good module of each kind, already loaded.
+async function loadedTree(t, options) {
+ const s = tree(t);
+ s.write("usage-source", "meter", USAGE("meter"));
+ s.write("limit-check", "cap", LIMIT("cap"));
+ const loader = await s.loader(options);
+ const first = await loader.load(GOOD);
+ assert.deepEqual(first.refused, []);
+ assert.deepEqual(loader.loaded(), GOOD);
+ return { ...s, loader };
+}
+
+// Switch usage-source to `names`: the load must refuse with `code`, keep
+// ["meter"], log one entry naming the kind and `name`, and leave limit-check on.
+async function refusesKeepingOld(s, names, code, name, message) {
+ const before = s.loader.logged.length;
+ const result = await s.loader.load({ "usage-source": names, "limit-check": ["cap"] });
+ assert.deepEqual(s.loader.loaded(), GOOD, `${name}: old setting kept`);
+ assert.equal(result.refused.length, 1, name);
+ const [entry] = result.refused;
+ assert.equal(entry.code, code, `${name}: ${entry.message}`);
+ assert.equal(entry.kind, "usage-source");
+ assert.equal(entry.name, name);
+ assert.deepEqual(entry.kept, ["meter"]);
+ if (message) assert.match(entry.message, message);
+ assert.deepEqual(s.loader.logged.slice(before), [entry], `${name}: logged once`);
+ assert.equal(s.loader.modules("usage-source")[0].readTurn(), "meter");
+}
+
+test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization file, no core file changed", async t => {
+ const s = tree(t);
+ s.write("usage-source", "fixture-usage", USAGE("fixture-usage"));
+ s.write("limit-check", "fixture-limit", LIMIT("fixture-limit"));
+ const root = businessRoot();
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const doc = businessDoc(root, "acme");
+ doc.plugins = { "usage-source": ["fixture-usage"], "limit-check": ["fixture-limit"] };
+ const file = writeJson(join(root, "businesses", "acme.json"), doc);
+ const business = validateBusinessDocument(doc, file, { rolesDir: rolesCopy(root) });
+ const loader = await s.loader();
+ const result = await loader.load(business.plugins);
+ assert.deepEqual(result.refused, []);
+ assert.deepEqual(result.loaded, doc.plugins);
+ assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage");
+ assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit");
+ assert.equal(loader.modules("limit-check")[0].mustStop(), false);
+ assert.deepEqual(loader.logged, []);
+});
+
+test("REQ-PLUG-1 a name not matching ^[a-z][a-z0-9-]*$ refuses and keeps the old setting", async t => {
+ const s = await loadedTree(t);
+ for (const name of ["Meter", "1meter", "me_ter", "../cap", "meter/index", ".hidden", ""]) {
+ await refusesKeepingOld(s, ["meter", name], "bad-name", name);
+ }
+});
+
+test("REQ-PLUG-1 a missing module directory or index.mjs refuses and keeps the old setting", async t => {
+ const s = await loadedTree(t);
+ await refusesKeepingOld(s, ["meter", "absent"], "missing", "absent", /absent is missing/);
+ mkdirSync(join(s.pkg, "usage-source", "empty"));
+ await refusesKeepingOld(s, ["empty"], "missing", "empty", /index\.mjs is missing/);
+ mkdirSync(join(s.pkg, "usage-source", "dir-index", "index.mjs"), { recursive: true });
+ await refusesKeepingOld(s, ["dir-index"], "missing", "dir-index", /not a regular file/);
+});
+
+test("REQ-PLUG-1 a symlinked module directory or index.mjs refuses and keeps the old setting", async t => {
+ const s = await loadedTree(t);
+ const meter = join(s.pkg, "usage-source", "meter");
+ symlinkSync(meter, join(s.pkg, "usage-source", "linked"));
+ await refusesKeepingOld(s, ["linked"], "symlink", "linked", /usage-source\/linked is a symlink/);
+ s.write("usage-source", "link-index", USAGE("x"));
+ unlinkSync(join(s.pkg, "usage-source", "link-index", "index.mjs"));
+ symlinkSync(join(meter, "index.mjs"), join(s.pkg, "usage-source", "link-index", "index.mjs"));
+ await refusesKeepingOld(s, ["link-index"], "symlink", "link-index", /link-index\/index\.mjs is a symlink/);
+});
+
+test("REQ-PLUG-1 a symlinked kind directory refuses and keeps the old setting", async t => {
+ const s = await loadedTree(t);
+ const real = join(s.pkg, "usage-real");
+ renameSync(join(s.pkg, "usage-source"), real);
+ symlinkSync(real, join(s.pkg, "usage-source"));
+ await refusesKeepingOld(s, ["meter"], "symlink", "meter", /plugins\/usage-source is a symlink/);
+});
+
+test("REQ-PLUG-1 a symlinked packages/plugins refuses (run with --preserve-symlinks so the path keeps it)", async t => {
+ const s = tree(t);
+ s.write("usage-source", "meter", USAGE("meter"));
+ const other = mkdtempSync(join(tmpdir(), "mosaic-plugins-link-"));
+ t.after(() => rmSync(other, { recursive: true, force: true }));
+ mkdirSync(join(other, "packages"));
+ symlinkSync(s.pkg, join(other, "packages", "plugins"));
+ const url = new URL(`file://${join(other, "packages", "plugins", "src", "loader.mjs")}`).href;
+ const script = `const { createLoader } = await import(${JSON.stringify(url)});
+const logged = []; const loader = createLoader({ log: e => logged.push(e) });
+const r = await loader.load({ "usage-source": ["meter"] });
+console.log(JSON.stringify({ loaded: loader.loaded(), logged }));`;
+ const run = spawnSync(process.execPath, ["--preserve-symlinks", "--input-type=module", "-e", script], { encoding: "utf8" });
+ assert.equal(run.status, 0, run.stderr);
+ const out = JSON.parse(run.stdout);
+ assert.deepEqual(out.loaded["usage-source"], []);
+ assert.equal(out.logged[0].code, "symlink");
+ assert.match(out.logged[0].message, /packages\/plugins is a symlink/);
+});
+
+test("REQ-PLUG-1 index.mjs swapped for a symlink after the lstat checks refuses at the realpath check", async t => {
+ let swap = false;
+ const s = await loadedTree(t, {
+ barrier: (point, { name }) => {
+ if (!swap || point !== "checked" || name !== "swapped") return;
+ const index = join(s.pkg, "usage-source", "swapped", "index.mjs");
+ unlinkSync(index);
+ symlinkSync(join(s.pkg, "usage-source", "meter", "index.mjs"), index);
+ },
+ });
+ s.write("usage-source", "swapped", USAGE("swapped"));
+ swap = true;
+ await refusesKeepingOld(s, ["swapped"], "symlink", "swapped", /swapped\/index\.mjs resolves to .*meter\/index\.mjs/);
+});
+
+// Root reads through any mode, so these run only as a normal user.
+const asRoot = process.getuid?.() === 0;
+
+test("REQ-PLUG-1 a path that can't be read refuses as unreadable, is logged and keeps the old setting", { skip: asRoot }, async t => {
+ const s = await loadedTree(t);
+ const locked = s.write("usage-source", "locked", USAGE("locked"));
+ chmodSync(locked, 0o000);
+ try {
+ await refusesKeepingOld(s, ["locked"], "unreadable", "locked", /locked\/index\.mjs can't be read \(EACCES\)/);
+ } finally {
+ chmodSync(locked, 0o755);
+ }
+});
+
+test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses at either realpath", { skip: asRoot }, async t => {
+ // `late` locks its own directory (realpath of index.mjs); `later` locks
+ // <root>/packages (realpath of packages/plugins). Each is restored at once.
+ let lock = null;
+ const s = await loadedTree(t, {
+ barrier: (point, { name }) => {
+ if (point !== "checked" || (name !== "late" && name !== "later")) return;
+ lock = name === "late" ? join(s.pkg, "usage-source", "late") : join(s.root, "packages");
+ chmodSync(lock, 0o000);
+ },
+ });
+ s.write("usage-source", "late", USAGE("late"));
+ s.write("usage-source", "later", USAGE("later"));
+ try {
+ await refusesKeepingOld(s, ["late"], "unreadable", "late", /late\/index\.mjs can't be read \(EACCES\)/);
+ chmodSync(lock, 0o755);
+ // A locked <root>/packages would refuse limit-check too, so this load names usage-source only.
+ const before = s.loader.logged.length;
+ const result = await s.loader.load({ "usage-source": ["later"] });
+ assert.deepEqual(result.refused.map(e => [e.kind, e.name, e.code]), [["usage-source", "later", "unreadable"]]);
+ assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/);
+ assert.deepEqual(result.refused[0].kept, ["meter"]);
+ assert.deepEqual(s.loader.logged.slice(before), result.refused);
+ assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [] });
+ } finally {
+ if (lock) chmodSync(lock, 0o755);
+ }
+});
+
+test("REQ-PLUG-1 a module that fails to load refuses and keeps the old setting", async t => {
+ const s = await loadedTree(t);
+ s.write("usage-source", "syntax", "export const interfaceVersion = 1;\nexport function readTurn( {\n");
+ await refusesKeepingOld(s, ["syntax"], "load-failed", "syntax");
+ s.write("usage-source", "throws", "throw new Error('boom');\n");
+ await refusesKeepingOld(s, ["throws"], "load-failed", "throws", /boom/);
+});
+
+test("REQ-PLUG-1 a missing export refuses and keeps the old setting", async t => {
+ const s = await loadedTree(t);
+ s.write("usage-source", "no-read", "export const interfaceVersion = 1;\n");
+ await refusesKeepingOld(s, ["no-read"], "missing-export", "no-read", /doesn't export readTurn/);
+ s.write("usage-source", "no-version", "export function readTurn() {}\n");
+ await refusesKeepingOld(s, ["no-version"], "missing-export", "no-version", /doesn't export interfaceVersion/);
+ s.write("usage-source", "not-fn", "export const interfaceVersion = 1;\nexport const readTurn = 1;\n");
+ await refusesKeepingOld(s, ["not-fn"], "missing-export", "not-fn", /readTurn isn't a function/);
+ // limit-check needs both of its exports.
+ s.write("limit-check", "half", "export const interfaceVersion = 1;\nexport function mayLaunch() {}\n");
+ const result = await s.loader.load({ "usage-source": ["meter"], "limit-check": ["cap", "half"] });
+ assert.deepEqual(result.refused.map(e => [e.kind, e.name, e.code]), [["limit-check", "half", "missing-export"]]);
+ assert.deepEqual(s.loader.loaded(), GOOD);
+});
+
+test("REQ-PLUG-1 a different interface version refuses and keeps the old setting", async t => {
+ const s = await loadedTree(t);
+ s.write("usage-source", "v2", "export const interfaceVersion = 2;\nexport function readTurn() {}\n");
+ await refusesKeepingOld(s, ["v2"], "interface-version", "v2", /implements interface 2, not 1/);
+ s.write("usage-source", "v-string", "export const interfaceVersion = '1';\nexport function readTurn() {}\n");
+ await refusesKeepingOld(s, ["v-string"], "interface-version", "v-string");
+});
+
+test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and the log names kind and name", async t => {
+ const s = tree(t);
+ s.write("usage-source", "meter", USAGE("meter"));
+ s.write("limit-check", "cap", LIMIT("cap"));
+ const loader = await s.loader();
+ // meter loads, absent refuses: the whole kind stays off, not half on.
+ const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] });
+ assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"] });
+ assert.deepEqual(loader.loaded(), result.loaded);
+ assert.deepEqual(loader.modules("usage-source"), []);
+ assert.equal(loader.logged.length, 1);
+ assert.equal(loader.logged[0].event, "plugin-refused");
+ assert.equal(loader.logged[0].kind, "usage-source");
+ assert.equal(loader.logged[0].name, "absent");
+ assert.deepEqual(loader.logged[0].kept, []);
+});
+
+test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole setting keeps everything", async t => {
+ const s = await loadedTree(t);
+ let result = await s.loader.load({ ...GOOD, "made-up": ["x"] });
+ assert.deepEqual(result.refused.map(e => [e.kind, e.code]), [["made-up", "unknown-kind"]]);
+ assert.deepEqual(s.loader.loaded(), GOOD);
+ for (const kind of ["made-up", "toString"]) {
+ assert.throws(() => s.loader.modules(kind), e => e.code === "unknown-kind", kind);
+ }
+ for (const bad of [null, [], "meter"]) {
+ result = await s.loader.load(bad);
+ assert.deepEqual(result.refused.map(e => [e.kind, e.code]), [[null, "bad-setting"]]);
+ assert.deepEqual(result.refused[0].kept, GOOD);
+ assert.deepEqual(s.loader.loaded(), GOOD);
+ }
+ await refusesKeepingOld(s, "meter", "bad-setting", "meter");
+ await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/);
+ // An absent key switches the kind off.
+ result = await s.loader.load({ "limit-check": ["cap"] });
+ assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"] });
+});
+
+test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => {
+ const s = await loadedTree(t);
+ let release;
+ globalThis.__row131Gate = new Promise(resolve => { release = resolve; });
+ t.after(() => { delete globalThis.__row131Gate; });
+ s.write("usage-source", "slow", "await globalThis.__row131Gate;\nexport const interfaceVersion = 1;\nexport function readTurn() {}\n");
+ s.write("usage-source", "fast", USAGE("fast"));
+ const first = s.loader.load({ "usage-source": ["slow"] });
+ const second = s.loader.load({ "usage-source": ["fast"] });
+ await new Promise(resolve => setTimeout(resolve, 100));
+ release();
+ await Promise.all([first, second]);
+ assert.deepEqual(s.loader.loaded()["usage-source"], ["fast"]);
+});
+
+test("REQ-PLUG-1 the loader needs a log callback", async t => {
+ const s = tree(t);
+ const { createLoader } = await import(s.loaderUrl);
+ assert.throws(() => createLoader({}), /log callback/);
+});
diff --git a/scripts/stack-copy.sh b/scripts/stack-copy.sh
index f384a983d..769f91c5f 100755
--- a/scripts/stack-copy.sh
+++ b/scripts/stack-copy.sh
@@ -28,6 +28,8 @@ COPY_PATHS=(
packages/seat/src packages/tasks/src packages/tasks/fields.json packages/tasks/requirements.json
packages/webui/src packages/webui/README.md packages/cli/README.md
packages/queue/src
+ # Whole directory: every in-repo plug-in module ships with the copy (row 131).
+ packages/plugins
)
copy_commit() {
[ -d "$REPO/.git" ] || [ "$ALLOW_WORKTREE" = 1 ] || die "refusing: $REPO is not the canonical checkout (its .git is not a directory); run copy from the canonical checkout"