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:
@@ -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,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(),
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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"] });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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([])]));
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user