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

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

11 KiB

Row 131 / #1626: the plug-in layer, kind registry and loader (round 2)

Builder: Dewey. Brief: docs/plans/2026-10-14_prd-1.2-build.md, step 6b. Requirements: REQ-PLUG-1 (the plug-in layer), REQ-LIMIT-4 (the first two kinds, usage-source and limit-check). The plugins write goes through step 5's write path (REQ-ROLE-3). Base: see base.txt. Source is in a detached worktree; nothing here is pushed, wired into a host process or written to Jason's organization file.

Governing rulings: lead decision 143 (in-repo modules only until Jason rules on Vikunja #286; no path from config) and sage-0301 (choices 1, 3, 4 and 5 as proposed; choice 2 changed).

Round 2 (sage-0314)

Round 1: Darkwing approved (28561, N1 and N2); Filbert asked for changes (28560, F1 and F2). Sage ruled on both in sage-0314. Each item below is a change on top of round 1 (agents/dewey/work/queue-131/r1/, committed 09447eb67).

Item Change Test Mutant
F1a plugin-settings.mjs set() refuses with invalid-plugins unless plugins has prototype Object.prototype or null. A Date or a Map has no own keys and passed checkPlugins. refusal list gains new Date(0) and a Map, still checking unchanged bytes and an empty log; the success test writes a null-prototype object 46
F1b organization.mjs write() (row 122's writer, extended for this one change by sage-0314): after building the bytes it runs validateBusinessDocument on JSON.parse(bytes), and changes() logs that parsed value. "the writer checks the bytes it writes": a Date in plugins sent straight to writer.write, past the setter, throws BusinessError; bytes unchanged, log empty 47
F2 (Darkwing N1) loader.mjs: one helper, fsCall, wraps the lstat in component() and both realpath calls. ENOENT and ENOTDIR stay missing; any other error refuses as unreadable, so load() logs it and keeps the kind's old modules instead of rejecting. chmod 000 on the module directory (lstat of index.mjs); after the checked barrier, chmod 000 on the module directory (realpath of index.mjs) and on <root>/packages (realpath of packages/plugins). Skipped as root. 43, 44, 45
N2 (doc-only) packages/plugins/README.md, limits: a module's top-level code runs at import, before the export and interface-version checks; a refused module has still run and Node's cache keeps it. The check list names unreadable. none (doc) none
Control mutants.py first runs each distinct test set on the unmutated source and stops if any fails. MUTANTS.md, control table

Two choices, recorded:

  • The in-memory validate stays. write() still runs validateBusinessDocument(document) before JSON.stringify, then validates the parsed bytes. Without the first, a BigInt or a circular value would throw a raw TypeError from JSON.stringify instead of a coded refusal. Sage's wording was "build bytes first, then validate the parse"; this does that and keeps the earlier check.
  • changes(old, written) has no mutant of its own. For any document that passes both validations, written and document give the same changes() result, so no test can tell changes(old, document) apart. Mutant 47 covers the validation, which is what refuses.

The <root>/packages case loads usage-source only: a locked packages directory would refuse limit-check too, so the test names one kind and checks limit-check switched off by its absent key.

organization.mjs is now in the candidate (14 paths). Rows 121 and 123 don't list it. packages/business/README.md gains one sentence on the writer checking its bytes (row 123 also edits that file).

What ships

Path Change
packages/plugins/package.json new package @mosaic/plugins
packages/plugins/src/kinds.mjs kind registry: usage-source and limit-check, interface version 1, each switched on by a list; checkPlugins for the organization file's plugins key
packages/plugins/src/loader.mjs createLoader({log}) → {load, loaded, modules}
packages/plugins/README.md kinds, export contract, adding a module, the key, the loader and its limits
packages/plugins/tests/{helpers,kinds.test,loader.test}.mjs 21 tests
packages/business/src/business.mjs plugins in TOP_KEYS; checked with checkPlugins; returned frozen ({} when absent)
packages/business/src/plugin-settings.mjs the host setter: the only writer of plugins, through organizationWriter
packages/business/tests/plugin-settings.test.mjs 4 tests: schema, setter success and log, setter refusals, writer refuses what JSON can't carry
packages/business/README.md the plugins key and its writer; the writer checks the bytes it writes
packages/business/src/organization.mjs write() validates and logs JSON.parse(bytes) (F1b)
scripts/stack-copy.sh whole packages/plugins in COPY_PATHS (approved, sage-0301)
packages/cli/tests/stack-copy.test.mjs the copied-path list includes the plug-in sources

No module of either kind ships. No core module imports a plug-in module by name: business.mjs and plugin-settings.mjs import only kinds.mjs, and nothing imports loader.mjs outside its tests. No limit ceiling is in code (Q150).

sage-0301 rulings, as built

  1. One writer. pluginSettings({dir, rolesDir}).set(id, {revision, plugins}, actor) is the only writer. No broker verb, mosaic command, Settings form or chief-of-staff write was added.
  2. A refused write throws a code and logs nothing. set checks the request shape (invalid-request: anything but revision and plugins, or a revision that isn't 64 hex) and the value (invalid-plugins, from checkPlugins) before the writer runs. The writer validates the whole document before its prepared entry, so nothing reaches changes.jsonl. The test checks the file bytes are unchanged and the log is empty after thirteen refusals (a Date and a Map added in round 2). A committed write logs actor, transaction and old and new names per kind, the same way a limit change does (plugins with old null when the key was absent; plugins.<kind> with whole old and new lists after that). Load-time refusals go through the loader's log callback.
  3. Last good modules per kind. A loader instance holds, per kind, the modules from its last good load. Any refusal for a kind keeps them. A kind refused on its first load has nothing on. The test loads ["meter","absent"] so a half-loaded list would show. load() and loaded() report what each kind actually has on. Every refusal calls log once with {event: "plugin-refused", kind, name, code, message, kept}; the first-load test checks kind and name. The loader is not wired into any host process.
  4. Export contract. Every module exports interfaceVersion; usage-source exports readTurn; limit-check exports mayLaunch and mustStop; both kinds switch on a list. The README carries the contract. These names bind row 124. Argument and return shapes are left to the calling rows.
  5. What is checked. packages/plugins, <kind>, <name> (each an lstat: exists, a directory, not a symlink) and index.mjs (exists, a regular file, not a symlink); not the ancestors of packages/plugins. Just before import, realpath(index.mjs) must equal realpath(packages/plugins)/<kind>/<name>/index.mjs, and the import uses that expected path. An error other than ENOENT or ENOTDIR from those reads (EACCES, ELOOP) refuses as unreadable (round 2, F2).

The check-then-import gap

For Darkwing, in those words: there is a check-then-import gap. The lstat checks and the realpath comparison run, then import() opens the file by path. Someone who can write packages/plugins as the host's user can swap index.mjs (or a directory above it) between the realpath check and the open inside import(). Node has no import-from-descriptor, so the loader cannot close the gap. The checks catch mistakes, stale links and a link planted before load; they do not stop a writer racing the host as the same user, and such a writer could edit any core file anyway. The test with the checked barrier swaps index.mjs for a symlink after the lstat checks and shows the realpath check refusing it; it cannot show the narrower window after realpath. The README states the same limit. Two related limits: a module runs with the host's authority once imported (the loader does not sandbox it), and Node caches modules by URL, so a changed index.mjs is not re-read until restart.

The packages/plugins symlink case is only reachable when Node keeps symlinked paths (--preserve-symlinks); otherwise import.meta.url is already the real path. The test runs that case in a child node --preserve-symlinks.

Acceptance, mapped

Acceptance item Where
a test for each refusal, old setting kept loader.test.mjs: bad name, missing (absent dir, empty dir, dir as index), symlink (name dir, index, kind dir, packages/plugins, realpath swap), load-failed (syntax error, top-level throw), missing export (absent, non-function, half of limit-check, no interfaceVersion), interface version (2, string "1"), unreadable (lstat, realpath of index.mjs, realpath of packages/plugins); each through refusesKeepingOld, which checks the kept setting, the one refusal entry and the one log call
fixture module of each kind switched on from a test organization file, no core file changed loader.test.mjs first test: a business document built with business test helpers, validated by validateBusinessDocument, its plugins passed to the loader; fixture modules written into a temp copy of the package
one mutant per check, each failing a test MUTANTS.md, mutants.py, mutants/results.json
plugins key in the schema, written through step 5's write path and logged like a limit change plugin-settings.test.mjs

REQ ids are in each test name.

Gate (decision 136, targeted)

Approved list (sage-0301): plugins, business, bus, webui, cli, each node --test tests/*.test.mjs (the command land-work.sh runs), via gate.sh. Round 2 run 2026-10-15T16:41:04Z to 16:42:25Z in the slot Sage gave in sage-0316, on the candidate as shipped in build.patch. Logs: gate/, summary gate-run.log.

Package Tests Pass Fail Skipped
plugins 21 21 0 0
business 92 92 0 0
bus 171 171 0 0
webui (includes pinned-copy.test.mjs) 112 112 0 0
cli (includes stack-copy.test.mjs) 195 195 0 0

591 tests, 0 failed, 0 skipped: the chmod tests ran (not as root).

Not run: the other packages and the shell suites; Sage's full gate at the landing target covers them (decision 136). BASE..refactor HEAD touches none of the candidate's 14 paths (checked at packet time; see the commit message).

Mutants: control runs all pass; 47 of 47 killed (MUTANTS.md).

Overlap

Row 123 (Researcher) also edits packages/business/README.md. Sage orders the landings; the second row rebases.