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 <[email protected]>
This commit is contained in:
2026-10-15 17:50:59 -05:00
co-authored by Claude Opus 5.5
parent ae74c25fdd
commit 467a313c7d
11 changed files with 180 additions and 8 deletions
+4 -2
View File
@@ -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");
}
+6 -2
View File
@@ -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(),
});
+66
View File
@@ -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');
});
+10 -1
View File
@@ -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 });
@@ -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"] });
});
+3
View File
@@ -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 <id>] [--json] [--tester]
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json] [--tester]
@@ -396,6 +398,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
const [verb, ...rest] = argv;
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
if (verb === "talk") return talk(rest, io, deps);
if (verb === "follow") return talk(rest, io, deps, true);
if (verb === "stop") return stop(rest, io, deps);
+41
View File
@@ -0,0 +1,41 @@
// `mosaic plugins` (vikunja:19/305, REQ-PLUG-1): read and switch the organization file's plug-in
// modules through the broker's human-only plugins and plugins.set verbs. The broker refuses an
// agent, a reader and a tester, a stale revision, a bad shape and a module that doesn't exist, and
// the change is logged with its actor in the organization's changes.jsonl.
import { readFileSync } from "node:fs";
import { CliError } from "./errors.mjs";
export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] | set --file PATCH [--business ID]";
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json") out.flags.add(arg);
else if (["--file", "--business"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(PLUGINS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(PLUGINS_USAGE, 4);
else out.positional.push(arg);
}
return out;
}
// PATCH is { "revision": <from a read>, "plugins": { "<kind>": <name or [names]> } }.
export async function pluginsCommand(args, io, callFor) {
const parsed = parse(args);
const verb = parsed.positional[0] ?? "view";
if (parsed.positional.length > 1 || !["view", "set"].includes(verb) || (verb === "set") !== Boolean(parsed.values["--file"])) {
throw new CliError(PLUGINS_USAGE, 4);
}
try {
const call = callFor(parsed);
const result = verb === "set"
? await call("plugins.set", JSON.parse(readFileSync(parsed.values["--file"], "utf8")))
: await call("plugins", {});
io.stdout.write(JSON.stringify(result, null, 2) + "\n");
} catch (error) {
if (error instanceof CliError) throw error;
throw new CliError(error.message, 2);
}
}
+10
View File
@@ -45,3 +45,13 @@ test('post-119 example and Jason fixture migrate with checked inherited models;
writeJson(map,mapping);await assert.rejects(main(args,io({...f.env,MOSAIC_CONFIG:join(root,'missing.json')}),{}),/system config/);assert.equal(readFileSync(file,'utf8'),jasonBytes);assert.equal(statSync(file).mtimeMs,jasonMt);writeJson(f.config,'invalid JSON');await assert.rejects(run(),/system config/);assert.equal(readFileSync(file,'utf8'),jasonBytes);assert.equal(statSync(file).mtimeMs,jasonMt);
writeJson(f.config,configBytes);assert.equal(readFileSync(f.config,'utf8'),configBytes);assert.equal(readFileSync(file,'utf8'),jasonBytes);
});
test('plugins view/set use the human bus transport and pass only the explicit patch (vikunja:19/305)',async t=>{
const root=tmp(t),patch={revision:'a'.repeat(64),plugins:{'search-source':'searxng-url'}},file=writeJson(join(root,'plugins.json'),patch),calls=[];
const deps={system:{dataRoot:root},transport:business=>(verb,args)=>{calls.push({business,verb,args});return {revision:'b'.repeat(64),plugins:args.plugins??{}};}};
const x=io();await main(['plugins','--business','acme','--json'],x,deps);await main(['plugins','set','--business','acme','--file',file],io(),deps);
assert.deepEqual(calls,[{business:'acme',verb:'plugins',args:{}},{business:'acme',verb:'plugins.set',args:patch}]);assert.equal(JSON.parse(x.out.text).revision,'b'.repeat(64));
await assert.rejects(main(['plugins','--business','acme'],io({CODEX_THREAD_ID:'fixture-agent'}),deps),/agent/i);
for(const bad of [['plugins','set','--business','acme'],['plugins','view','--file',file],['plugins','nope'],['plugins','--write']])await assert.rejects(main(bad,io(),deps),/mosaic plugins/);
assert.equal(calls.length,2);
});
+7 -2
View File
@@ -45,8 +45,13 @@ calls the kind.
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.
kind: `mosaic plugins` shows the setting and its revision, and
`mosaic plugins set --file PATCH` (`{ "revision": ..., "plugins": {...} }`)
changes it through the broker's human-only `plugins` and `plugins.set`
verbs. The setter refuses a bad shape, a name with no module in this tree
(`unknown-plugin`) and a stale revision, and logs actor and old and new
names in `changes.jsonl`. A running broker reads the setting at bus start
(REQ-WEB-3's reload will change that), so restart the bus after a switch.
Adding a module changes no core file.
+15 -1
View File
@@ -10,7 +10,7 @@
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";
import { KINDS, NAME, PluginError, namesFor } from "./kinds.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -71,6 +71,20 @@ async function loadModule(spec, name, barrier) {
return Object.freeze({ name, module });
}
// True when packages/plugins/<kind>/<name>/index.mjs exists in this tree as a real file, with no
// symlink on the way down. The setters use it to refuse a name no module answers to before it is
// written; the loader's own checks still run at load.
export function hasModule(kind, name) {
try {
if (!Object.hasOwn(KINDS, kind) || !NAME.test(name)) return false;
for (const dir of [ROOT, join(ROOT, kind), join(ROOT, kind, name)]) component(dir, true);
component(join(ROOT, kind, name, "index.mjs"), false);
return true;
} catch {
return false;
}
}
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([])]));
+6
View File
@@ -103,3 +103,9 @@ test("the searxng modules end the request when the broker's AbortSignal fires",
assert.ok(destroyed >= 1, name);
}
});
test("hasModule: true for a shipped module, false for a missing one, an unknown kind or a bad name", async () => {
const { hasModule } = await import("../src/loader.mjs");
for (const name of ["harness-builtin", "searxng-bundled", "searxng-url"]) assert.equal(hasModule("search-source", name), true, name);
for (const [kind, name] of [["search-source", "ghost"], ["made-up", "searxng-url"], ["search-source", "../x"], ["search-source", "Searxng"], ["usage-source", "searxng-url"]]) assert.equal(hasModule(kind, name), false, `${kind}/${name}`);
});