plugins: the plug-in layer, kind registry and loader (row 131, #1626)
packages/plugins holds the kind registry and the loader for in-repo plug-in modules (REQ-PLUG-1). The first two kinds are usage-source and limit-check (REQ-LIMIT-4). The business file's plugins key turns modules on per kind, through plugin-settings.mjs and step 5's write path (REQ-ROLE-3). A refused load is logged and keeps the kind's old modules. Any filesystem error other than ENOENT or ENOTDIR refuses as unreadable. The organization writer validates the bytes it writes as well as the document. stack-copy ships packages/plugins. Nothing is wired into a host process yet. Candidate 466c835f7dce79d20c7ab74c197a52b70e7fdeb0efae0456222dc6d0a633f219, Dewey packet80797a539(round 2), base0bdd573ff. Filbert approved round 2 in 28568 and Darkwing in 28570. Landing gate atf7d249634: 24 suites green, 0 failed. 14 package suites passed 1586 tests (plugins 21, business 92, bus 171, cli 195, webui 112). The 10 script suites passed. Follow-up: P19 task 293, coded writer refusals. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f7d249634b
commit
eaeb5ebf2b
14 files changed
+864
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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), []);
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
@@ -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.
|
||||
@@ -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/" }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user