Adds the harness plug-in kind (interface 1). packages/plugins/harness/pi and claude-code hold what core branched on: the adapter, thinking levels, preflight, version probe, sandbox mounts, builtins line, typed tool names, gate timeout, bundle files, adapter env, turn end and session-files path. Core reaches a module only through the loader. A session's route is the instance's harness, model and thinking vars, or the three fallback. vars; a missing one refuses no-route, and the system file's execution keys are never read for a session. The launcher checks the primary route, then the fallback: harness-off, harness-refused, thinking-unsupported, then preflight. A failed preflight falls back when a fallback is set, and any refusal of the fallback names the primary's failure too. Account checks run only when the route's module counts an account. The manifest records route, routeReason and harnessVersion, replacing piVersion and claudeVersion. pi gets --thinking, Claude Code --effort, both from MOSAIC_THINKING. The business file refuses a partial fallback. routes and routes.set are human-only bus verbs outside ORGANIZATION_KEYS; checkRoutes is shared by routes.set, instances.set and limits.set and checks only changed routes. The chief of staff changes an instance's model, and the assistant's whole vars (Q169), through limits.set, so its route write runs the same checks. A model outside launch.models refuses unknown-model, primary or fallback, and routes.set passes that code through. A new run binds only on a switched-on harness module name and records its transcript path, which the usage reader uses. Settings gains a Routing table with six selects per instance and loses the Role models fieldset; it posts changed rows to /api/routes. webui/src/bus.mjs is unchanged. Until Jason's `mosaic plugins set` adds "harness": ["claude-code"], a new launch refuses harness-off.
286 lines
15 KiB
JavaScript
286 lines
15 KiB
JavaScript
// 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, LIMIT_V1, 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"] };
|
|
// What loaded() reports for GOOD: search-source is a kind too, switched off here.
|
|
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [] };
|
|
|
|
// 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_LOADED);
|
|
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_LOADED, `${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, "search-source": [], channel: [], harness: [] });
|
|
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": [], "search-source": [], channel: [], harness: [] });
|
|
} 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 all three of its exports; report is version 2's (row 125).
|
|
s.write("limit-check", "half", "export const interfaceVersion = 2;\nexport function mayLaunch() {}\nexport function mustStop() {}\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.match(result.refused[0].message, /doesn't export report/);
|
|
assert.deepEqual(s.loader.loaded(), GOOD_LOADED);
|
|
});
|
|
|
|
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 REQ-LIMIT-3 a version 1 limit check refuses after the bump to 2, and the old setting stays", async t => {
|
|
const s = await loadedTree(t);
|
|
// A module written for version 1 lacks report; exports are checked before the version.
|
|
s.write("limit-check", "old", LIMIT_V1("old"));
|
|
// One that adds report but still says 1 refuses on the version.
|
|
s.write("limit-check", "old-plus", `${LIMIT_V1("old-plus")}export function report() { return []; }\n`);
|
|
for (const [name, code, message] of [["old", "missing-export", /doesn't export report/],
|
|
["old-plus", "interface-version", /implements interface 1, not 2/]]) {
|
|
const result = await s.loader.load({ "usage-source": ["meter"], "limit-check": [name] });
|
|
assert.deepEqual(result.refused.map(e => [e.kind, e.name, e.code]), [["limit-check", name, code]]);
|
|
assert.match(result.refused[0].message, message);
|
|
assert.deepEqual(result.refused[0].kept, ["cap"]);
|
|
assert.deepEqual(s.loader.loaded(), GOOD_LOADED);
|
|
assert.equal(s.loader.modules("limit-check")[0].mayLaunch(), "cap");
|
|
}
|
|
});
|
|
|
|
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"], "search-source": [], channel: [], harness: [] });
|
|
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_LOADED);
|
|
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_LOADED);
|
|
assert.deepEqual(s.loader.loaded(), GOOD_LOADED);
|
|
}
|
|
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"], "search-source": [], channel: [], harness: [] });
|
|
});
|
|
|
|
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/);
|
|
});
|