commit 90443879dff42a72d777124e42fe205c30d5afd7 Author: Jason Woltje Date: Thu Oct 15 17:32:08 2026 -0500 plugins: mosaic plugins and the plugins verbs switch a module through the human-only path (vikunja:19/305, row 145) Jason-approved merge, rebased: git diff 813671596..f4d7b91a9 from work/vikunja-19-305, stacked on 295. Business reviewer verdict 57d7f0b7 approve on f4d7b91a9; Jason resolved merge decision 0eebf83f via the CLI. Conflict resolution only. packages/bus/src/broker.mjs conflicted with row 123 (89fd2d6fe): both sides extend the limits branch of request(). Kept row 123's organization.set role path and ORGANIZATION_KEYS, and added 305's plugins and plugins.set to the verb list and its keys lines. plugins and plugins.set take the human-only path as in 305: role is true only for organization.set, the actor is the human, and the verb reaches the organization writer unchanged. organization.set is unchanged. packages/plugins/tests/searxng.test.mjs conflicted with 307: both add a test at the end of the file. Kept 307's AbortSignal test, then 305's hasModule test. Three other paths that refactor also changed merged without conflict, with the source commit's +/- lines; the other six paths are the source blobs. Filbert reviews the rebased diff. Co-Authored-By: Claude Opus 5.5 --- packages/bus/src/broker.mjs | 6 ++- packages/bus/src/runtime.mjs | 8 ++- packages/bus/tests/plugins-verb.test.mjs | 66 ++++++++++++++++++++++++ packages/business/src/plugin-settings.mjs | 11 +++- packages/business/tests/plugin-settings.test.mjs | 12 +++++ packages/cli/src/cli.mjs | 3 ++ packages/cli/src/plugins.mjs | 41 +++++++++++++++ packages/cli/tests/limits.test.mjs | 10 ++++ packages/plugins/README.md | 9 +++- packages/plugins/src/loader.mjs | 16 +++++- packages/plugins/tests/searxng.test.mjs | 6 +++ 11 files changed, 180 insertions(+), 8 deletions(-) diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 274a19b44..703fb6373 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -855,14 +855,15 @@ export class Broker { const args = request.args ?? {}; if (!object(args)) fail('invalid-request'); this.#secretCheck(args); - if (["limits", "limits.set", "organization.set"].includes(request.verb)) { + if (["limits", "limits.set", "organization.set", "plugins", "plugins.set"].includes(request.verb)) { // Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose // file lists organization.change. It takes the same keys, runs the same write path and // logs the same receipt; only the actor differs: the role definition and instance. const role = request.verb === "organization.set"; if (!role && !s.human) fail("human-required"); if (!this.#organization) fail("not-configured"); - if (request.verb === "limits") keys(args, []); + if (["limits", "plugins"].includes(request.verb)) keys(args, []); + else if (request.verb === "plugins.set") keys(args, ["revision", "plugins"], ["revision", "plugins"]); else keys(args, ORGANIZATION_KEYS, ["revision"]); let actor = s.human; // This branch runs outside the store transaction below, so the grant is checked before the writer @@ -878,6 +879,7 @@ export class Broker { } catch (e) { if (e.code === "revision-conflict") fail("revision-conflict"); if (e.code?.startsWith("organization-")) fail(e.code); + if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code); if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") { fail("invalid-request"); } diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 1185456f4..260bf4321 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -6,7 +6,8 @@ import { Credentials } from './credentials.mjs'; import { serve } from './server.mjs'; import { launchSettings } from '../../business/src/launch-settings.mjs'; import { readSessions } from '../../seat/src/session.mjs'; -import { createLoader } from '../../plugins/src/loader.mjs'; +import { createLoader, hasModule } from '../../plugins/src/loader.mjs'; +import { pluginSettings } from '../../business/src/plugin-settings.mjs'; import { webService } from './web.mjs'; import { verifyHuman } from './human.mjs'; import { verifyTester } from './tester.mjs'; @@ -58,11 +59,14 @@ export async function startBroker({ ]; credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot }); store = new Store(dataRoot); + // vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs + // plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused. + const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null; const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot)}):null; const broker = new Broker({ store, businesses, - organization:settings?(id,verb,args,actor)=>verb==='limits'?settings.read(id):settings.set(id,args,actor):null, + organization:settings?(id,verb,args,actor)=>verb==='limits'?settings.read(id):verb==='limits.set'?settings.set(id,args,actor):verb==='plugins'?plugins.read(id):plugins.set(id,args,actor):null, secretCheck: (value) => credentials.assertClean(value), credentialStatus: () => credentials.status(), }); diff --git a/packages/bus/tests/plugins-verb.test.mjs b/packages/bus/tests/plugins-verb.test.mjs new file mode 100644 index 000000000..aeec1663d --- /dev/null +++ b/packages/bus/tests/plugins-verb.test.mjs @@ -0,0 +1,66 @@ +// vikunja:19/305 (REQ-PLUG-1): the plug-in switch is a human-only broker action, logged with its +// actor, and a module that doesn't exist is refused. Same path as the limits verbs. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Store } from '../src/store.mjs'; +import { Broker } from '../src/broker.mjs'; + +const biz = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { pm: { authority: { withinRole: [], crossRole: [] } }, cto: { authority: { withinRole: [], crossRole: [] } } } } }; + +test('plugins and plugins.set refuse every non-human session and a forged actor, and pass only the patch with the verified actor', (t) => { + const root = mkdtempSync(join(tmpdir(), 'plugins-verb-')); + const store = new Store(root); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const calls = []; + const b = new Broker({ store, businesses: biz, organization: (id, verb, args, actor) => (calls.push([id, verb, args, actor]), { ok: true }) }); + const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const role = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' }); + const reader = b.bindReader({ business: 'demo' }); + const patch = { revision: 'a'.repeat(64), plugins: { 'search-source': 'searxng-url' } }; + for (const cap of [role, reader]) for (const verb of ['plugins', 'plugins.set']) assert.throws(() => b.request(cap, { verb, args: verb === 'plugins' ? {} : patch }), /human-required/); + assert.equal(calls.length, 0); + assert.throws(() => b.request(human, { verb: 'plugins.set', args: { ...patch, actor: 'forged' } }), /invalid-request/); + assert.throws(() => b.request(human, { verb: 'plugins.set', args: { revision: patch.revision } }), /invalid-request/); + assert.throws(() => b.request(human, { verb: 'plugins', args: { x: 1 } }), /invalid-request/); + b.request(human, { verb: 'plugins' }); + b.request(human, { verb: 'plugins.set', args: patch }); + assert.deepEqual(calls, [['demo', 'plugins', {}, 'jason'], ['demo', 'plugins.set', patch, 'jason']]); +}); + +test('runtime: the switch changes the organization file, the receipt carries the actor, and a bad shape, an unknown module and a stale revision are refused with nothing written', async (t) => { + const { startBroker } = await import('../src/runtime.mjs'); + const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs'); + const root = mkdtempSync(join(tmpdir(), 'plugins-runtime-')); + const rolesDir = rolesCopy(root); + writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo')); + mkdirSync(join(root, 'data'), { mode: 0o700 }); + const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } }); + t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); }); + const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const file = join(root, 'businesses/demo.json'); + const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); + const view = bus.broker.request(human, { verb: 'plugins' }); + assert.deepEqual(view.plugins, {}); + const done = bus.broker.request(human, { verb: 'plugins.set', args: { revision: view.revision, plugins: { 'search-source': 'searxng-url' } } }); + assert.deepEqual(done.plugins, { 'search-source': 'searxng-url' }); + assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).plugins, { 'search-source': 'searxng-url' }); + const last = log().at(-1); + assert.deepEqual([last.actor, last.state], ['jason', 'committed']); + assert.deepEqual(last.changes, [{ path: 'plugins', old: null, new: { 'search-source': 'searxng-url' }, oldPresent: false, newPresent: true }]); + const before = readFileSync(file, 'utf8'); + const entries = log().length; + const code = (args) => { try { bus.broker.request(human, { verb: 'plugins.set', args }); } catch (e) { return e.code; } return null; }; + assert.equal(code({ revision: done.revision, plugins: { 'search-source': 'no-such-module' } }), 'unknown-plugin'); + assert.equal(code({ revision: done.revision, plugins: { 'search-source': ['searxng-url'] } }), 'invalid-plugins'); + assert.equal(code({ revision: done.revision, plugins: { 'made-up': [] } }), 'invalid-plugins'); + assert.equal(code({ revision: view.revision, plugins: { 'search-source': 'harness-builtin' } }), 'revision-conflict'); + assert.equal(readFileSync(file, 'utf8'), before); + assert.equal(log().length, entries, 'a refused write logs nothing'); + // A real module switches, and the old value is in the receipt. + const next = bus.broker.request(human, { verb: 'plugins.set', args: { revision: done.revision, plugins: { 'search-source': 'harness-builtin' } } }); + assert.deepEqual(log().at(-1).changes.map((c) => [c.path, c.old, c.new]), [['plugins.search-source', 'searxng-url', 'harness-builtin']]); + assert.equal(next.plugins['search-source'], 'harness-builtin'); +}); diff --git a/packages/business/src/plugin-settings.mjs b/packages/business/src/plugin-settings.mjs index 9a576bdd7..6a0264419 100644 --- a/packages/business/src/plugin-settings.mjs +++ b/packages/business/src/plugin-settings.mjs @@ -7,7 +7,9 @@ import { organizationWriter, OrganizationError } from "./organization.mjs"; import { isPlainObject } from "./util.mjs"; import { PluginError, checkPlugins } from "../../plugins/src/kinds.mjs"; -export function pluginSettings({ dir, rolesDir }) { +// `exists(kind, name)`, when given, refuses a name no module answers to (the host passes the +// loader's hasModule). Without it the setter checks only the shape, as before. +export function pluginSettings({ dir, rolesDir, exists = null }) { const writer = organizationWriter({ dir, rolesDir }); function read(id) { @@ -35,6 +37,13 @@ export function pluginSettings({ dir, rolesDir }) { if (e instanceof PluginError) throw new OrganizationError("invalid-plugins", e.message); throw e; } + if (exists) { + for (const [kind, setting] of Object.entries(args.plugins)) { + for (const name of Array.isArray(setting) ? setting : [setting]) { + if (!exists(kind, name)) throw new OrganizationError("unknown-plugin", `no ${kind} module named ${name} in this tree`); + } + } + } const next = structuredClone(writer.read(id).document); next.plugins = structuredClone(args.plugins); const receipt = writer.write(id, next, { revision: args.revision, actor }); diff --git a/packages/business/tests/plugin-settings.test.mjs b/packages/business/tests/plugin-settings.test.mjs index 437eb5e31..756c7957e 100644 --- a/packages/business/tests/plugin-settings.test.mjs +++ b/packages/business/tests/plugin-settings.test.mjs @@ -103,3 +103,15 @@ test("REQ-PLUG-1 REQ-ROLE-3 a refused setter write throws a code, changes nothin assert.equal(readFileSync(f.file, "utf8"), bytes); assert.deepEqual(events(f), []); }); + +test("vikunja:19/305 with an exists check, the setter refuses a module no tree file answers to, before anything is written", () => { + const f = fixture(); + const settings = pluginSettings({ dir: f.root, rolesDir: f.rolesDir, exists: (kind, name) => kind === "usage-source" && name === "meter" }); + const view = settings.read("acme"); + const before = events(f).length; + for (const plugins of [{ "usage-source": ["ghost"] }, { "limit-check": ["cap"] }, { "usage-source": ["meter", "ghost"] }]) { + assert.throws(() => settings.set("acme", { revision: view.revision, plugins }, "jason"), e => e instanceof OrganizationError && e.code === "unknown-plugin", JSON.stringify(plugins)); + } + assert.equal(events(f).length, before); + assert.deepEqual(settings.set("acme", { revision: view.revision, plugins: { "usage-source": ["meter"] } }, "jason").plugins, { "usage-source": ["meter"] }); +}); diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index fbca789a9..60a0ba29b 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -14,6 +14,7 @@ import { createInterface } from "node:readline/promises"; import { fileURLToPath } from "node:url"; import { BINDING_NAME } from "../../discord/src/binding.mjs"; import { limitsCommand, LIMITS_USAGE } from "./limits.mjs"; +import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs"; import { CliError } from "./errors.mjs"; import { bootConfig, loadSystem, socketPath } from "./config.mjs"; import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs"; @@ -28,6 +29,7 @@ import { SeatError } from "../../seat/src/seat.mjs"; export const USAGE = `usage: ${LIMITS_USAGE} + ${PLUGINS_USAGE} mosaic inbox [--business ] [--json] [--tester] mosaic decide