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]>
1015 lines
50 KiB
Diff
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"
|