Compare commits

...
Author SHA1 Message Date
jason.woltje e588fb32cb test(cli): band the P10 inner timer at the supervisor, not around cleanup (#1873)
The row299 root-side pair (mos-1409) spans supervisor start-up and
clean_session, which scans every /proc entry on the host. With about
1,850 processes that adds 0.6 to 1.6 s, so the unchanged inner probe read
2.5 to 3.9 s and failed the [1.25, 2.75) band, while the 1 s inner-timer
mutant read 2.06 to 2.38 s and passed. Widening the bound alone would
have kept that mutant alive.

A second observer pair, also confined to the private test copy, brackets
only the inner timer: begin just before the supervisor starts its sleep,
end at the start of finish(). It writes only for the SO_PEERCRED peer and
at most one pair. The unchanged band now applies to that interval
(2.01 s on base, 1.00 s and 3.00 s for the 1 s and 3 s mutants, so both
fail). The root pair remains required and must contain the timer pair;
probe-inner-timeout already places it before the outer bound.
2026-11-11 00:30:10 -06:00
jason.woltje e3577e9d31 fix(case2): give the case2 fixture host the request-host ops (#1873)
test-case2.sh failed 8 of 21 steps on base from S3b on. Bisect names
f23a998ee (#797, "close project archive admission races") as the first
bad commit: it made every launch call request.admission and updated each
in-process launcher fixture except scripts/case2/launch.mjs. The fixture
host refused the op with invalid-host-request, so no launch started; the
S6/S7 "unauthenticated" and S8/S10 reds followed from the missing launch
capability, not from a separate session change.

The fixture now spreads requestHostOps from packages/cli/tests, the shared
map every other fixture uses, so it tracks the production protocol.
2026-11-11 00:30:10 -06:00
jason.woltje 99ce579e3c fix(plugins): keep imports on the verified package pin (#1873) 2026-11-11 00:11:13 -06:00
jason.woltje b44b14c7df feat(plugins): install pinned instance packages and declared tools (#1873) 2026-11-11 00:11:13 -06:00
48 changed files with 1177 additions and 113 deletions

No files matched your search

+29 -4
View File
@@ -9,7 +9,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -533,7 +533,7 @@ function checkBusiness(name, b) {
if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole))
fail('invalid-authority');
const all = [...a.withinRole, ...a.crossRole];
if (new Set(all).size !== all.length || all.some((v) => !ACTIONS.includes(v) || GATED.has(v)))
if (new Set(all).size !== all.length || all.some((v) => (!ACTIONS.includes(v) && !pluginName(v)) || GATED.has(v)))
fail('invalid-authority');
}
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
@@ -593,6 +593,7 @@ export class Broker {
#builtin;
#builtinBinding;
#organization;
#pluginService;
#credentialVerbs;
#proxyInit;
#proxyPage;
@@ -655,7 +656,7 @@ export class Broker {
#processing;
#taskTracker;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
@@ -678,6 +679,7 @@ export class Broker {
this.#secretCheck = secretCheck;
this.#credentialStatus = credentialStatus;
this.#organization = organization;
this.#pluginService = pluginService;
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
this.#credentialVerbs = credentialVerbs;
this.#proxyInit = proxyInit;
@@ -2151,6 +2153,13 @@ export class Broker {
}
bindLaunch(record) {
const { key, session, old, body } = this.#launchProof(record);
const launchRole=this.#business(record.business).roles[record.role];
const pluginCapability=[...(launchRole.tools ?? []),...(launchRole.authority?.withinRole ?? []),...(launchRole.authority?.crossRole ?? []),...Object.keys(launchRole.credentials ?? {})].find(pluginName);
if(pluginCapability) {
if(!this.#pluginService)throw new BusError('plugin-disabled',pluginName(pluginCapability));
try { this.#pluginService.check(record.business,record.role); }
catch(e) { throw new BusError(e.code === 'plugin-disabled' ? e.code : 'invalid-request', e.code === 'plugin-disabled' ? e.plugin : undefined); }
}
if (!old) this.#processingWork();
if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness');
if (!old && record.thread !== undefined && this.#archived(record.business, this.#threadRow(session, record.thread).project)) fail('project-archived');
@@ -4668,7 +4677,14 @@ export class Broker {
fail('role-end-refused');
}
#classification(s, action, target = null) {
if (!ACTIONS.includes(action)) fail('unknown-action');
if (!ACTIONS.includes(action)) {
const name=pluginName(action);
const held=this.#business(s.business).roles[s.role]?.authority;
if(!name || ![...(held?.withinRole ?? []),...(held?.crossRole ?? [])].includes(action))fail('unknown-action');
if(!this.#pluginService)throw new BusError('plugin-disabled',name);
let checked;try {checked=this.#pluginService.check(s.business,s.role);}catch{throw new BusError('plugin-disabled',name);}
if(!checked.view.registry.actions.includes(action))throw new BusError('plugin-disabled',name);
}
if (
this.#hardWait(s, action, target) ||
(action === 'role.launch' && this.#business(s.business).launch?.by !== s.role)
@@ -5915,6 +5931,15 @@ export class Broker {
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-init-failed'), request); });
}
this.#secretCheck(args);
if (request.verb === 'plugin.tool') {
if(s.human || s.reader || s.tester || s.user || s.console)fail('agent-required');
keys(args,['tool','arguments'],['tool','arguments']);
this.#store.transaction(()=>this.#agent(s));
if(!this.#pluginService)throw new BusError('plugin-disabled',pluginName(args.tool) ?? undefined);
return this.#pluginService.call(s.business,s.role,args.tool,args.arguments).then(result=>{
this.#secretCheck(result);return result;
}).catch(e=>{throw this.#refused(s,new BusError(['plugin-disabled','invalid-request','not-authorized'].includes(e.code)?e.code:'adapter-failed',e.code==='plugin-disabled'?e.plugin:undefined),{verb:'plugin.tool'});});
}
if (request.verb === 'processing.halt' || request.verb === 'processing.resume')
return this.#requestProcessing(s, request.verb, structuredClone(args));
if (request.verb === 'processing.status') {
+2 -2
View File
@@ -67,7 +67,7 @@ class Failure extends Error {
// lineages, each the current, retiring and earlier ids of one agent. A daily key quota follows
// the lineage that holds its id, so it outlives rotation and re-issue. Both run only while a
// daily key quota is set and a check is switched on. Without keyLineages they refuse.
export function createLimits({ store, read, log, makeLoader = createLoader, keyForSubject = () => null,
export function createLimits({ store, read, pluginDataRoot = null, log, makeLoader = createLoader, keyForSubject = () => null,
keyLineages = () => { throw new Failure('proxy-key-refused'); } }) {
if (typeof read !== 'function' || typeof log !== 'function') throw new TypeError('createLimits needs read and log');
const loaders = new Map();
@@ -103,7 +103,7 @@ export function createLimits({ store, read, log, makeLoader = createLoader, keyF
return validLineages(value) ? value : null;
}
const loaderFor = business => {
if (!loaders.has(business)) loaders.set(business, makeLoader({ log }));
if (!loaders.has(business)) loaders.set(business, makeLoader({ log, dataRoot:pluginDataRoot }));
return loaders.get(business);
};
+2 -2
View File
@@ -4,7 +4,7 @@ import { createLoader } from '../../plugins/src/loader.mjs';
const validId = s => typeof s === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(s);
const validModel = s => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(s);
const disabled = () => ({ status: 'disabled', observedAt: null, models: [], notices: [] });
export function createModelCatalogue({ client, settings, now = Date.now, makeLoader = createLoader, proxyLock = null }) {
export function createModelCatalogue({ client, settings, pluginDataRoot = null, now = Date.now, makeLoader = createLoader, proxyLock = null }) {
const cache = new Map(), flights = new Map(); let generation = 0, writing = 0;
function invalidate() { generation++; cache.clear(); }
function begin() { writing++; invalidate(); }
@@ -19,7 +19,7 @@ export function createModelCatalogue({ client, settings, now = Date.now, makeLoa
try {
if (off(id)) return disabled();
const names = [...settings(id)['model-provider']];
const loader = makeLoader({ log: () => {} });
const loader = makeLoader({ log: () => {}, dataRoot:pluginDataRoot });
const result = await loader.load({ 'model-provider': names });
if (result.refused.length || loader.snapshot('model-provider').length !== names.length) throw Error();
const c = client(); if (!c) throw Error();
@@ -19,6 +19,7 @@ export function busOrganization(business, resolved) {
out.roles[role].network = r.limits.network;
out.roles[role].authority = structuredClone(r.limits.authority);
// REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs.
out.roles[role].tools = [...(r.limits.tools ?? [])];
out.roles[role].web = (r.limits.tools ?? []).includes('web');
// Row 210 (#1707, design D5): resolveInstance leaves host.<id> keys out of the session's
// credentials. The bus child uses them, so they come back here from the business entry.
+2 -2
View File
@@ -8,8 +8,8 @@ const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
const ID = /^[a-f0-9]{12}$/;
const plain = n => n <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(n) : String(n);
// makeLoader is the plug-in loader's factory; a test wraps it to see what a module is handed.
export function createProxyUsage({ store, read, client, records, log, now = Date.now, makeLoader = createLoader, enableDrain = false }) {
const loader = makeLoader({ log });
export function createProxyUsage({ store, read, pluginDataRoot = null, client, records, log, now = Date.now, makeLoader = createLoader, enableDrain = false }) {
const loader = makeLoader({ log, dataRoot:pluginDataRoot });
let due = 0, previous = '', pending = null, closed = false;
const states = new Map();
// #778: per enabled business, the batches taken off the proxy queue and then not written, since
+2
View File
@@ -8,6 +8,8 @@ const group = (codes, rail, nextStep) => {
entries.push([code, guidance]);
}
};
group("plugin-disabled", "R3/R6/R10: installed, pinned and enabled plugin capabilities", "Ask the instance owner to verify the installed plugin and the organization owner to enable it. A replacement of an already loaded package needs a host restart; then relaunch the agent.");
group("plugin-restart-required", "R3/R10: one imported package pin per host process", "Ask the instance owner to restart the host after replacing an already loaded package, then retry through the supported path.");
group(
"access-refused access-revoked share-exists share-not-covered share-not-found tester-dev-only tester-off tester-refused tester-required tester-target-not-running thread-not-allowed thread-not-owned",
"R7: caller access and scoped session boundary",
+32 -22
View File
@@ -1,3 +1,5 @@
import { pluginService } from '../../plugins/src/tools.mjs';
import { readPluginLock } from '../../plugins/src/pinned.mjs';
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
import { createRequirementCatalog } from './requirement-catalog.mjs';
import { readPrdMaterial } from '../../project/src/prd.mjs';
@@ -71,10 +73,10 @@ export const ACCESS_SWEEP_MS = 30_000;
// web.fetch still works. A plug-in that refuses to load is logged and leaves search off; it never
// stops the broker. Under harness-builtin the harness serves web itself, so the broker gets no
// service and refuses the web verbs.
export async function webServices(businesses, { log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), deps } = {}) {
export async function webServices(businesses, { log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), deps, dataRoot = null } = {}) {
const services = new Map();
for (const [name, b] of Object.entries(businesses)) {
const loader = createLoader({ log });
const loader = createLoader({ log, dataRoot });
await loader.load(b.plugins ?? {});
const [source] = loader.modules('search-source');
// "harness-builtin" is served by the harness itself, not the broker: no broker web verbs.
@@ -90,7 +92,7 @@ export async function webServices(businesses, { log = (e) => process.stderr.writ
// checks a write runs, which are synchronous. A module that doesn't load, or answers anything but a
// list of names, answers null, which refuses the write. The tree is pinned, so a module changes only
// with a re-pin, and a re-pin restarts the bus.
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`) } = {}) {
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), dataRoot = null } = {}) {
const levels = new Map();
let names = [];
try {
@@ -98,8 +100,12 @@ export async function harnessLevels({ log = (e) => process.stderr.write(`plugins
} catch (e) {
log({ event: 'harness-levels-unread', message: e.message });
}
if (dataRoot !== null) {
try { names = [...new Set([...names,...Object.keys(readPluginLock(dataRoot).plugins).filter(name=>hasModule('harness',name,dataRoot))])]; }
catch { log({event:'harness-levels-unread',message:'installed harness discovery refused'}); }
}
for (const name of names) {
const loader = createLoader({ log });
const loader = createLoader({ log, dataRoot });
if ((await loader.load({ harness: [name] })).refused.length) continue;
try {
const list = loader.modules('harness')[0].levels();
@@ -291,7 +297,7 @@ export async function startBroker({
if (copies !== null && (typeof copies !== 'object' || Array.isArray(copies) || typeof copies.org !== 'string'
|| !Object.hasOwn(businesses ?? {}, copies.org) || copies.copies === null || typeof copies.copies !== 'object' || Array.isArray(copies.copies)))
throw new BusError('invalid-host-request');
const loadOrg = id => loadWithCopies(id, { dir: organization.dir, rolesDir: organization.rolesDir, snapshot: copies?.org === id ? copies : null });
const loadOrg = id => loadWithCopies(id, { dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot, snapshot: copies?.org === id ? copies : null });
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
throw new BusError('invalid-host-request');
@@ -406,20 +412,20 @@ export async function startBroker({
// 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 levels=organization?await harnessLevels():()=>null;
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot ?? dataRoot,exists:(kind,name)=>hasModule(kind,name,instanceRoot ?? dataRoot)}):null;
const levels=organization?await harnessLevels({dataRoot:instanceRoot ?? dataRoot}):()=>null;
// Row 333 R4 (#1831): the writer's key lineage lookup, ids only, from this bus's own credential
// records. A daily key quota moved within one agent's lineage keeps its since and its count.
const keyLineage=(business,keyId)=>modelKeyLineage(credentials.status(),business,keyId);
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot),levels,keyLineage}):null;
modelCatalogues = modelCatalogue ?? (settings ? createModelCatalogue({ client: () => modelProxyClient, settings: id => settings.current(id).plugins ?? {}, proxyLock }) : null);
modelCatalogues = modelCatalogue ?? (settings ? createModelCatalogue({ pluginDataRoot:instanceRoot ?? dataRoot, client: () => modelProxyClient, settings: id => settings.current(id).plugins ?? {}, proxyLock }) : null);
// Resolve legacy table entries with the enabled modules' declarations. A
// missing or ambiguous mapping never becomes an available route.
async function catalogueFor(id, options = {}) {
const snapshot = settings.writer.read(id);
const current = settings.current(id);
const names = current.plugins?.['model-provider'] ?? [];
const loader = makeProviderLoader({log:()=>{}});
const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}});
const loaded = await loader.load({'model-provider': names});
const catalogue = await modelCatalogues.read(id, options);
if (loaded.refused.length || settings.writer.read(id).revision !== snapshot.revision) return {...catalogue, status:'unavailable', legacy:{}};
@@ -479,16 +485,16 @@ export async function startBroker({
return { revoked: services, outsideStore: how === 'fire' ? outside : [] };
}
// Row 165 (#1662): the human adds and changes role instances through instances and instances.set.
const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) }) : null;
const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) }) : null;
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels,revoke,
...(proxyOrganization ? { writer: proxyOrganization.writer, revoke: proxyOrganization.revoke(revoke) } : {})}):null;
const usageLog = entry => process.stderr.write(`usage: ${JSON.stringify(entry)}\n`);
const keyForRun = (business, run) => modelRuns.get(business + ':' + run)?.keyId ?? null;
// Row 333 R3 (#1831): the usage view marks key quotas that match no hired agent; limits answers.
let orphanedKeys = () => null;
const usage = await createUsage({ store, dataRoot, businesses, log: usageLog, keyForRun,
const usage = await createUsage({ store, dataRoot, pluginDataRoot:instanceRoot ?? dataRoot, businesses, log: usageLog, keyForRun,
proxyView: business => proxyUsage?.view(business) ?? null, keyOrphans: business => orphanedKeys(business) });
proxyUsage = createProxyUsage({ store, log: usageLog, client: async () => {
proxyUsage = createProxyUsage({ store, pluginDataRoot:instanceRoot ?? dataRoot, log: usageLog, client: async () => {
if (!modelKeys) return null;
await modelKeys.verifyIdentity();
return modelProxyClient;
@@ -498,7 +504,7 @@ export async function startBroker({
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at
// each ask, so a limit, a reset or a switched-on check takes effect without a restart.
const limitLog = entry => process.stderr.write(`limits: ${JSON.stringify(entry)}\n`);
const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog,
const limits = createLimits({ store, pluginDataRoot:instanceRoot ?? dataRoot, read: id => settings ? settings.current(id) : businesses[id], log: limitLog,
keyForSubject: (business, subject) => modelUsageKey(credentials.status(), modelRuns, business, subject),
keyLineages: business => modelKeyLineages(credentials.status(), business) });
orphanedKeys = business => limits.orphanedKeys(business);
@@ -591,7 +597,7 @@ export async function startBroker({
// receipt-backed addition under the organization lock: the file must still be this business plus that entry,
// with the credentials, bots, libraries, roles and prior mappings the bus booted with. Anything else leaves
// the product pending. Both readiness records must exist before even the broker's reload begins.
const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) : null;
const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) : null;
const known = new Map(Object.entries(businesses).map(([id, b]) => [id, canonicalJson(b.projects ?? {})]));
function activateManagedProject({ business: id, project, receipt, organization_revision }) {
if (!writer || !booted.has(id) || !Object.hasOwn(businesses, id)) throw new BusError('unknown-business');
@@ -692,6 +698,7 @@ export async function startBroker({
trackerOf.set(id, named ?? ((trackers ? tracked : tasks !== null) ? 'vikunja' : 'store'));
}
broker = new Broker({
pluginService: pluginService({dataRoot:instanceRoot ?? dataRoot,read:id=>organization ? loadOrg(id) : businesses[id]}),
taskTracker: (id) => trackerOf.get(id),
workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }),
requirementsHost,
@@ -760,7 +767,7 @@ export async function startBroker({
// configured reads as unreadable, so requireCitation is true), and thread.messages' `last`.
guard: (context, body, options) => libraryModule.guardReply({ ...context, dataRoot }, body, options),
replyLimits: id => libraryModule.resolveReplyLimits(organization
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }).readRaw(id) : { readable: false }),
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }).readRaw(id) : { readable: false }),
last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths),
GuardError: libraryModule.GuardError,
},
@@ -791,7 +798,7 @@ export async function startBroker({
known.set(id, canonicalJson(next.projects));
return getProjectRemote(writer, id, args.project);
}
if (verb === 'system-types.installed') return installedSystemTypes();
if (verb === 'system-types.installed') return installedSystemTypes({dataRoot:instanceRoot ?? dataRoot});
if (verb === 'system-types.current') return settings.current(id).plugins?.['system-type'] ?? [];
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='plugins')return {...plugins.read(id), available:{harness:levels.names ?? []}};
@@ -832,7 +839,10 @@ export async function startBroker({
});
result=settings.setRoutes(id,args,actor);
}
else if(verb==='plugins.set'){result=plugins.set(id,args,actor);modelCatalogues?.invalidate();}
else if(verb==='plugins.set') {
if(actor==='tester' && Object.entries(args.plugins ?? {}).some(([kind,value])=>(Array.isArray(value)?value:[value]).some(name=>name!==null && !hasModule(kind,name)))) throw new BusError('human-required');
result=plugins.set(id,args,actor);modelCatalogues?.invalidate();
}
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
else if(verb==='veto.set')result=instances.veto(id,args,actor);
// Row 298: the human's grant scope, through the instance writer.
@@ -868,7 +878,7 @@ export async function startBroker({
return settings;
} : null,
credentialVerbs: organization
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) })
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) })
: null,
// Row 65: a new launch binds only on a harness its business switches on, read from the file
// as it stands now; an unreadable file switches none on.
@@ -941,7 +951,7 @@ export async function startBroker({
let factory = null;
if (name === 'vikunja' && tasks) factory = tasks;
else if (name !== 'vikunja' || [...Object.values(businesses)].some((b) => b?.plugins?.['task-tracker'] === 'vikunja')) {
const loader = createLoader({ log: pluginLog });
const loader = createLoader({ dataRoot:instanceRoot ?? dataRoot, log: pluginLog });
const { refused } = await loader.load({ 'task-tracker': name });
const [module] = loader.modules('task-tracker');
if (!refused.length && module) factory = module.adapter({ trackers: trackers ?? {} });
@@ -963,7 +973,7 @@ export async function startBroker({
return one.handle(cap, verb, args);
};
}
const web = await webServices(businesses);
const web = await webServices(businesses,{dataRoot:instanceRoot ?? dataRoot});
// Row 210 (#1707, design D7 step 6): clear what a killed bus child left, before anyone can call.
if (runRoot) sweepRunDir(runRoot);
// Row 234 (r6 X3): end the enrollments a killed bus child left, before anyone can call.
@@ -1216,13 +1226,13 @@ export async function startBroker({
if (vars[prefix + 'model'] !== modelKey || vars[prefix + 'harness'] !== harness) return refused('model-provider-unavailable');
const entry = current.launch?.models?.[modelKey];
if (!entry || canonicalJson(entry) !== canonicalJson(expected ?? null) || entry.provider !== provider || entry.model !== model) return refused('model-provider-unavailable');
const harnessLoader = createLoader({log:()=>{}});
const harnessLoader = createLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}});
if ((await harnessLoader.load({harness:[harness]})).refused.length) return refused('model-provider-unavailable');
const countsAccount = harnessLoader.modules('harness')[0].countsAccount();
if (typeof countsAccount !== 'boolean' || account !== (countsAccount ? current.launch?.account?.name ?? null : null)) return refused('model-provider-unavailable');
const names = current.plugins?.['model-provider'];
if (!Array.isArray(names) || !names.length || (entry.module && !names.includes(entry.module))) return refused('model-provider-unavailable');
const loader = makeProviderLoader({log:()=>{}}), loaded = await loader.load({'model-provider':entry.module ? [entry.module] : names});
const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}}), loaded = await loader.load({'model-provider':entry.module ? [entry.module] : names});
if (loaded.refused.length) return refused('model-provider-unavailable');
const selected = selectModelProvider(loader.snapshot('model-provider'), entry);
if (!selected) return refused('model-provider-unavailable');
+2 -2
View File
@@ -53,10 +53,10 @@ export function turnRecords(store, business) {
};
});
}
export async function createUsage({ store, businesses, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) {
export async function createUsage({ store, businesses, pluginDataRoot = null, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) {
const loaders = new Map();
for (const [id, b] of Object.entries(businesses)) {
const loader = createLoader({ log });
const loader = createLoader({ log, dataRoot:pluginDataRoot });
await loader.load(b.plugins ?? {});
loaders.set(id, loader);
}
+1 -1
View File
@@ -41,7 +41,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
// Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
"import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/proxy-page-vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
+6 -1
View File
@@ -18,6 +18,7 @@ import { interviewCommand, INTERVIEW_USAGE, northStarCommand, NORTH_STAR_USAGE }
import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TALK_USAGE, THREAD_USAGE, THREADS_USAGE } from "./thread.mjs";
import { sessionCommand, SESSION_USAGE } from "./session.mjs";
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
import { pluginInstallCommand } from "./plugin-install.mjs";
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
import { proxyCommand } from "./model-proxy.mjs";
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
@@ -220,7 +221,7 @@ function callFor(parsed, io, deps) {
// Only the converted project/thread surface uses a channel. Management stays on callFor.
function channelCallFor(parsed, io, deps) {
const call = callFor(parsed, io, deps);
const channel = createChannelCaller({ name: "cli", call,
const channel = createChannelCaller({ name: "cli", call, dataRoot:(deps.system ?? loadSystem({env:io.env})).dataRoot,
log: entry => io.stderr.write(`mosaic: channel module refused (${entry.code})\n`),
});
return Object.assign(async (verb, args = {}) => {
@@ -759,6 +760,10 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (verb === 'accounts') return accountsCommand(rest, io, { system: deps.system ?? loadSystem({ env: io.env }), configDir: deps.accountsConfigDir });
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
if (verb === "plugins" && ["install","list","remove"].includes(rest[0])) {
refuseInsideAgent(io.env);
return pluginInstallCommand(rest,io,{system:deps.system ?? loadSystem({env:io.env}),dir:deps.accountsConfigDir});
}
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 199 (#1697): issue and rotate read the token on stdin.
if (verb === "proxy") return proxyCommand(rest, io, { system: () => deps.system ?? loadSystem({ env: io.env }), callFor: p => callFor(p, io, deps), prompt: deps.promptProxyKey });
+5 -5
View File
@@ -153,10 +153,10 @@ export const consoleOwners = (sso) =>
// snapshot is plain data that the broker and the launcher re-apply to every later read of the
// organization file, so a root file change takes effect at the next boot only. A root file that
// refuses hides the root level (design section 6): no root copy, and the organization still boots.
export function copiesAtBoot({ businessId, env = process.env, warn = () => {} }) {
export function copiesAtBoot({ businessId, env = process.env, warn = () => {}, dataRoot = null }) {
const dir = configDir(env), roles = rolesDir(env);
loadBusiness(businessId, { dir, rolesDir: roles });
const { document } = organizationWriter({ dir, rolesDir: roles }).read(businessId);
loadBusiness(businessId, { dir, rolesDir: roles, dataRoot });
const { document } = organizationWriter({ dir, rolesDir: roles, dataRoot }).read(businessId);
let root = null;
try { root = loadRootAgents(dir, { rolesDir: roles }); }
catch (error) {
@@ -164,13 +164,13 @@ export function copiesAtBoot({ businessId, env = process.env, warn = () => {} })
warn(`root agent file refused, so ${businessId} runs no root copies until it is fixed and the bus restarts: ${error.message}`);
}
const snapshot = materializeCopies({ orgId: businessId, doc: document, root, rolesDir: roles });
return { snapshot, business: withCopies(document, businessFilePath(businessId, dir), { rolesDir: roles, snapshot }) };
return { snapshot, business: withCopies(document, businessFilePath(businessId, dir), { rolesDir: roles, snapshot, dataRoot }) };
}
export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env = process.env, warn = () => {} }) {
return business(() => {
const vars = systemVars(system);
const { snapshot, business: b } = copiesAtBoot({ businessId, env, warn });
const { snapshot, business: b } = copiesAtBoot({ businessId, env, warn, dataRoot:system.dataRoot });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: vars, business: b, instance });
const config = {
+7 -7
View File
@@ -261,7 +261,7 @@ export function createLauncher({
}
// Row 64 (#1557): where a role's skills are read from; scripts/skill.sh activate fills it.
const skillsDir = join(system.instanceRoot ?? dataRoot, "skills-enabled");
const loadOrg = () => loadWithCopies(host.business, { dir: configDir(env), rolesDir: rolesDir(env), snapshot: copies?.org === host.business ? copies : null });
const loadOrg = () => loadWithCopies(host.business, { dir: configDir(env), rolesDir: rolesDir(env), dataRoot:system.instanceRoot ?? dataRoot, snapshot: copies?.org === host.business ? copies : null });
let b = loadOrg();
const vars = systemVars(system);
@@ -358,9 +358,9 @@ export function createLauncher({
const admissionGeneration = admission().generation;
let businessRevision;
try {
const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env)}).read(host.business);
const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env),dataRoot:system.instanceRoot ?? dataRoot}).read(host.business);
businessRevision = snapshot.revision;
b = withCopies(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env), snapshot: copies?.org === host.business ? copies : null});
b = withCopies(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env), dataRoot:system.instanceRoot ?? dataRoot, snapshot: copies?.org === host.business ? copies : null});
} catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
// #1873 A3: an unbound or refused copy never launches. The message names the copy, its project
@@ -382,16 +382,16 @@ export function createLauncher({
throw new Refusal("legacy-run-live", `a run of ${instance} with no session is still live`);
let resolved;
try {
resolved = resolveInstance({ system: vars, business: b, instance });
resolved = resolveInstance({ system: vars, business: b, instance, enforcePlugins:true, pluginRoot:system.instanceRoot ?? dataRoot });
} catch (e) {
throw new Refusal("unsupported-harness", e.message);
throw new Refusal(e.code === "plugin-disabled" ? "plugin-disabled" : "unsupported-harness", e.code === "plugin-disabled" ? e.plugin : e.message);
}
// Resolve the business's current provider modules with a fresh loader; a refused
// load never inherits a previous business's authority.
const providerNames = b.plugins?.['model-provider'] ?? [];
const providerModules = new Map();
if (providerNames.length) {
const loader = makeLoader({ log: entry => record({ ...entry, instance }) });
const loader = makeLoader({ dataRoot:system.instanceRoot ?? dataRoot, log: entry => record({ ...entry, instance }) });
const loaded = await loader.load({ 'model-provider': providerNames });
if (loaded.refused.length) throw new Refusal('model-provider-unavailable', 'model provider module refused');
for (const { name, module } of loader.snapshot('model-provider')) providerModules.set(name, module);
@@ -427,7 +427,7 @@ export function createLauncher({
}
// One harness through a fresh loader, so a refusal never leaves an earlier load's module in use.
const harnessOf = async (m) => {
const loader = makeLoader({ log: (entry) => record({ ...entry, instance }) });
const loader = makeLoader({ dataRoot:system.instanceRoot ?? dataRoot, log: (entry) => record({ ...entry, instance }) });
const { refused } = await loader.load({ harness: [m.harness] });
if (refused.length) throw new Refusal("harness-refused", `harness ${m.harness}: ${refused.map((r) => `${r.code}: ${r.message}`).join("; ")}`);
const module = loader.modules("harness")[0];
+38
View File
@@ -0,0 +1,38 @@
// Instance-level shell verbs. Intentionally separate from pluginsCommand's bus transport.
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { CliError } from './errors.mjs';
import { refuseInsideAgent } from './transport.mjs';
import { configDir } from '../../organization/src/organization-config.mjs';
import { assertAccountsOwner } from '../../organization/src/accounts.mjs';
import { withRegistry } from '../../organization/src/org-registry.mjs';
import { readJsonFile } from '../../organization/src/util.mjs';
import { installPlugin, removePlugin } from '../../plugins/src/install.mjs';
import { readPluginLock } from '../../plugins/src/pinned.mjs';
export async function pluginInstallCommand(args,io,{system,dir=configDir(io.env)}) {
refuseInsideAgent(io.env);
try {
assertAccountsOwner(dir);
const [verb,value,...rest]=args;
if(!['install','list','remove'].includes(verb))throw new CliError('mosaic plugins install SOURCE --sha256 HASH [--commit FULL_SHA] | list | remove NAME',4);
const opts={};
if(verb==='install') {
for(let i=0;i<rest.length;i+=2){if(!['--sha256','--commit'].includes(rest[i]) || !rest[i+1] || Object.hasOwn(opts,rest[i]))throw new CliError('invalid plugin install options',4);opts[rest[i]]=rest[i+1];}
if(!value || !opts['--sha256'])throw new CliError('install requires SOURCE and --sha256 HASH',4);
}else if(rest.length || (verb==='list'?value!==undefined:!value))throw new CliError('invalid plugin management arguments',4);
let result;
if(verb==='list') result=readPluginLock(system.dataRoot);
else if(verb==='install')result=installPlugin({dataRoot:system.dataRoot,source:value,commit:opts['--commit'],expectedHash:opts['--sha256'],print:line=>io.stdout.write(line+'\n')});
else result=await withRegistry({configDir:dir,instance:system.instance ?? dir.split('/').pop().replace(/^mosaic-/, '')},()=>{
// All organization files, including archived organizations, must be readable. The registry
// lock prevents a simultaneous create from disappearing from this check.
let files=[];try{files=readdirSync(join(dir,'businesses'));}catch(e){if(e.code!=='ENOENT')throw e;}
return removePlugin({dataRoot:system.dataRoot,name:value,enabled:name=>files.filter(n=>n.endsWith('.json')).some(n=>{
const doc=readJsonFile(join(dir,'businesses',n),'organization file');
return Object.values(doc.plugins ?? {}).flat().includes(name);
})});
},{write:false});
io.stdout.write(JSON.stringify(result,null,2)+'\n');
}catch(e){if(e instanceof CliError)throw e;throw new CliError(`plugin management refused: ${/^[a-z-]+$/.test(e.code ?? '')?e.code:'plugin-refused'}`,3);}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { readFileSync } from "node:fs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester]";
export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester] | install SOURCE --sha256 HASH [--commit FULL_SHA] | list | remove NAME";
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
+36 -1
View File
@@ -5,6 +5,13 @@ const LEGACY = '# row276-legacy-begin';
// Stable even when a mutant removes the outer timeout from this command.
const LAUNCH = ' LC_ALL=C ';
const WAIT = ' wait "$child" 2>/dev/null || rc=$?\n';
// org-base-reds: the root pair above also spans supervisor start-up and clean_session, a scan of
// every host /proc entry whose cost grows with the host's process count. The timer pair brackets
// only the inner timer: from just before the supervisor starts its sleep to the start of finish().
const SUPERVISOR = '# row253-supervisor-begin\n';
const SUPERVISOR_END = '# row253-supervisor-end\n';
const TIMER = '"$sleep_bin" "$seconds" & timer=$!\n';
const FINISH = 'finish() {\n local rc="$?"\n';
export const P10_OBSERVERS = Object.freeze({
begin: ` # row299-observer-begin-start
@@ -20,6 +27,26 @@ export const P10_OBSERVERS = Object.freeze({
printf 'end %s\\n' "$row299_up" >> "$ROW299_TIMING_FILE"; } 2>/dev/null || :
fi
# row299-observer-end-end
`,
// Supervisor-side pair. Only the P10 peer's arguments carry the peer-credential option name.
// The pattern is split so this block never adds that literal to the supervisor text: the fake
// sudo stalls any command whose arguments contain it, and the supervisor text is an argument of
// every capture. finish() has no arguments of its own, so its stamp follows exactly one begin
// and is written at most once.
timerBegin: `# row299-timer-begin-start
if [ -n "\${ROW299_TIMER_FILE-}" ] && [[ "$*" == *SO_PEER*CRED* ]]; then
{ read -r row299_up row299_spare < /proc/uptime &&
printf 'begin %s\\n' "$row299_up" >> "$ROW299_TIMER_FILE"; } 2>/dev/null || :
fi
# row299-timer-begin-end
`,
timerEnd: ` # row299-timer-end-start
if [ -n "\${ROW299_TIMER_FILE-}" ]; then
{ read -r row299_up row299_spare < /proc/uptime &&
[[ "$(< "$ROW299_TIMER_FILE")" == begin* && "$(< "$ROW299_TIMER_FILE")" != *end* ]] &&
printf 'end %s\\n' "$row299_up" >> "$ROW299_TIMER_FILE"; } 2>/dev/null || :
fi
# row299-timer-end-end
`,
});
@@ -40,7 +67,15 @@ export function instrumentP10Probe(source) {
uniqueIndex(capture, WAIT);
capture = capture.replace(LAUNCH, P10_OBSERVERS.begin + LAUNCH);
capture = capture.replace(WAIT, WAIT + P10_OBSERVERS.end);
return source.slice(0, start) + capture + source.slice(end);
source = source.slice(0, start) + capture + source.slice(end);
const from = uniqueIndex(source, SUPERVISOR), to = uniqueIndex(source, SUPERVISOR_END);
if (to <= from) throw new Error('P10 observer supervisor scope is reversed');
let supervisor = source.slice(from, to);
uniqueIndex(supervisor, TIMER);
uniqueIndex(supervisor, FINISH);
supervisor = supervisor.replace(TIMER, P10_OBSERVERS.timerBegin + TIMER);
supervisor = supervisor.replace(FINISH, FINISH + P10_OBSERVERS.timerEnd);
return source.slice(0, from) + supervisor + source.slice(to);
}
export function removeP10Observers(source) {
+46 -5
View File
@@ -378,6 +378,9 @@ test('row299 P10 timing copy preserves shipped bytes and isolates observer varia
assert.deepEqual([...block.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)=/g)],[],'no other assignments in observer blocks');
assert.doesNotMatch(block,/\$ROOT|\$\{ROOT/,'fixture root cannot supply the clock');
}
// The fake sudo keys its faults on these argument markers; observers must not add or remove any.
for(const marker of['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
assert.equal(copy.split(marker).length,source.split(marker).length,`observers keep the ${marker} count`);
const changedTimer=source.replace('tester_capture 2 4 128','tester_capture 1 4 128');
assert.equal(removeP10Observers(instrumentP10Probe(changedTimer)),changedTimer,'copy keeps current timer mutants');
});
@@ -402,6 +405,34 @@ capture`;
assert.match(read(clock),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
});
test('row299 P10 timer observers keep supervisor status and record one P10 pair',t=>{
const dir=tmp(t,'p10-timer-'),clock=join(dir,'clock'),trapFile=join(dir,'trap');
put(clock,'',0o600);
// Same shape as TESTER_SUPERVISOR: positional arguments are the setsid command, finish() has none.
const program=`set +m
finish() {
local rc="$?"
${P10_OBSERVERS.timerEnd} printf trap > "$ROW299_TRAP_FILE"
exit "$rc"
}
trap finish EXIT
${P10_OBSERVERS.timerBegin}printf stdout-without-newline
printf stderr-without-newline >&2
exit 81`;
const supervise=(...args)=>spawnSync('/bin/bash',['-c',program,'--',...args],{encoding:'utf8',env:{...process.env,ROW299_TIMER_FILE:clock,ROW299_TRAP_FILE:trapFile}});
let r=supervise('node','-e','query');
assert.equal(r.status,81);assert.equal(read(clock),'','a supervisor without the P10 peer records nothing');
r=supervise('python','-c','getsockopt(SO_PEERCRED)');
assert.equal(r.status,81);
assert.equal(r.stdout,'stdout-without-newline');
assert.equal(r.stderr,'stderr-without-newline');
assert.equal(read(trapFile),'trap');
assert.match(read(clock),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
const pair=read(clock);
r=supervise('node','-e','later');
assert.equal(r.status,81);assert.equal(read(clock),pair,'a later supervisor cannot add a second end');
});
test('row299 P10 observer records are complete and ordered',()=>{
assert.equal(readProbeSeconds('begin 100.01\nend 102.03\n'),2.02);
const invalid={
@@ -433,23 +464,33 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
if(fault==='inner')put(join(h.hc,'peer-hang'),'');
if(fault==='outer'){put(join(h.hc,'stall-probe'),'');put(join(h.hc,'stall-output'),'200 997 997\n');}
if(fault==='overflow'){put(join(h.hc,'peer-output'),'200 997 997\n'+'X'.repeat(256));put(join(h.hc,'peer-status'),'0');}
let observedScript=SCRIPT,timingFile;
let observedScript=SCRIPT,timingFile,timerFile;
if(fault==='inner'||fault==='outer'){
observedScript=join(h.dir,'case1-p10-observed.sh');timingFile=join(h.hc,'probe-uptime');
put(timingFile,'',0o600);put(observedScript,instrumentP10Probe(read(SCRIPT)),0o700);
// The outer fault stalls before the supervisor starts, so only the inner fault has a timer pair.
if(fault==='inner'){timerFile=join(h.hc,'timer-uptime');put(timerFile,'',0o600);}
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
}
const stop=fixtureGuard(t,files,7000,timingFile);
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
const observe={...(timingFile?{ROW299_TIMING_FILE:timingFile}:{}),...(timerFile?{ROW299_TIMER_FILE:timerFile}:{})};
const start=performance.now(),r=run(h,observe,undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
assert.equal(r.status,0,r.out+r.err);
const reason=fault==='inner'?'inner-timeout':fault==='outer'?'outer-timeout':'output-limit';
assert.match(r.out,new RegExp('P10 tester-socket-squat unnamed boundary=failed reason=probe-'+reason+' '));
assert.doesNotMatch(r.out,/boundary=observed/);
let probeSeconds=seconds;
let probeSeconds=seconds,timerSeconds;
if(timingFile)assert.doesNotThrow(()=>{probeSeconds=readProbeSeconds(read(timingFile));},'P10 observer pair is required');
if(fault==='inner'||fault==='outer')t.diagnostic(JSON.stringify({fault,mode,baselineSeconds,faultSeconds:seconds,probeSeconds}));
if(fault==='inner')assert.ok(probeSeconds>=1.25&&probeSeconds<2.75,`${mode}: direct probe ${probeSeconds}s; baseline ${baselineSeconds}s is diagnostic only`);
if(timerFile)assert.doesNotThrow(()=>{timerSeconds=readProbeSeconds(read(timerFile));},'P10 timer observer pair is required');
if(fault==='inner'||fault==='outer')t.diagnostic(JSON.stringify({fault,mode,baselineSeconds,faultSeconds:seconds,probeSeconds,timerSeconds}));
// org-base-reds: the band applies to the inner timer itself. The root pair adds supervisor start-up and
// clean_session's whole-host /proc scan, which grows with the host's process count, so it must contain
// the timer pair but has no band of its own; probe-inner-timeout above already places it before the outer bound.
if(fault==='inner'){
assert.ok(timerSeconds>=1.25&&timerSeconds<2.75,`${mode}: inner timer ${timerSeconds}s (root probe ${probeSeconds}s); baseline ${baselineSeconds}s is diagnostic only`);
assert.ok(probeSeconds>=timerSeconds,`${mode}: root probe ${probeSeconds}s must contain the inner timer ${timerSeconds}s`);
}
if(fault==='outer'){
assert.ok(probeSeconds>=3.25&&probeSeconds<4.75,`${mode}: direct probe ${probeSeconds}s; baseline ${baselineSeconds}s is diagnostic only`);
if(mode==='hop')assert.ok(recordedLivePids(files[0]).length>0,'stalled supervisor is R3 residual');
+4 -2
View File
@@ -38,10 +38,12 @@ const IMPORT=new RegExp([
`${DYNAMIC.source}\\s*['"](\\.{1,2}\\/[^'"]+)['"]\\s*\\)`,
].join('|'),'g');
const DYNAMIC_LITERAL=/^import\s*\(\s*(['"])[^'"\n]*\1\s*\)/;
// The plug-in loader's one computed import, allowed only in that file and only in this exact form. It loads
// The plug-in loader's computed imports, allowed only in that file and these exact forms. Core loads
// packages/plugins/<kind>/<name>/index.mjs after checking that path, and a real copy ships all of
// packages/plugins, so a fixture that never loads a plug-in needs nothing more from it.
const LOADER_FILE='packages/plugins/src/loader.mjs',LOADER_IMPORT=/^import\(pathToFileURL\(expected\)\.href\)/;
// PL1 adds the checked instance package entry with its verified hash as a cache key. Installed
// bytes belong to the instance, not to a release fixture's import closure.
const LOADER_FILE='packages/plugins/src/loader.mjs',LOADER_IMPORT=/^import\(pathToFileURL\(expected\)\.href\)|^import\(pathToFileURL\(entry\)\.href \+ '\?pin=' \+ checked\.manifest\.sha256\)/;
const REGEX_AFTER='(,=:[!&|?{};+-*%<>~^';
const inert=(literals,index)=>literals.some(([from,to])=>index>=from&&index<to);
const sources=(dir)=>readdirSync(dir,{withFileTypes:true}).flatMap((e)=>e.isDirectory()?sources(join(dir,e.name)):e.name.endsWith('.mjs')?[join(dir,e.name)]:[]);
@@ -43,6 +43,14 @@ test('the loader exception holds only for the loader file and its exact computed
assert.throws(()=>organizationImportedFiles(tree({[`${ORG}a.mjs`]:"import '../../plugins/src/loader.mjs';\n",'repo/packages/plugins/src/loader.mjs':"await import(x);\n"})),/not a plain string literal/);
});
test('PL1 pinned import exception holds only for the checked loader entry and exact hash cache key',()=>{
const computed="await import(pathToFileURL(entry).href + '?pin=' + checked.manifest.sha256);\n";
const at=(file,text=computed)=>tree({[`${ORG}a.mjs`]:`import '../../plugins/src/${file}';\n`,[`repo/packages/plugins/src/${file}`]:text});
assert.deepEqual(organizationImportedFiles(at('loader.mjs')),['packages/plugins/src/loader.mjs']);
assert.throws(()=>organizationImportedFiles(at('other.mjs')),/not a plain string literal/);
for(const changed of [computed.replace('entry','arbitrary'),computed.replace('?pin=','?unchecked='),computed.replace('checked.manifest.sha256','input')])assert.throws(()=>organizationImportedFiles(at('loader.mjs',changed)),/not a plain string literal/);
});
test('every static form and a literal dynamic import is found, and the imports of what it finds in turn', ()=>{
const repo=withOrg([
"import x from '../../other/src/from.mjs';",
+1 -1
View File
@@ -41,7 +41,7 @@ export function webPlan(resolved, harness, builtinWeb) {
}
return { source, builtin: granted && source === "harness-builtin" };
}
export const builtinTools = (tools) => tools.filter((t) => t !== "web");
export const builtinTools = (tools) => tools.filter((t) => t !== "web" && !t.includes("."));
export const SOURCES = Object.freeze({
piExtension: fileURLToPath(new URL("./pi-extension.mjs", import.meta.url)),
claudeGate: fileURLToPath(new URL("./claude-gate.mjs", import.meta.url)),
+2 -1
View File
@@ -363,11 +363,12 @@ export function toolServer({ path, tools, client, local }) {
if (!tool) return answer({ ok: false, error: "unknown-tool" });
const args = request.args && typeof request.args === "object" && !Array.isArray(request.args) ? request.args : {};
try {
const result = Object.hasOwn(local, tool.verb) ? await local[tool.verb](args, { signal: controller.signal }) : await client.call(tool.verb, args);
const result = Object.hasOwn(local, tool.verb) ? await local[tool.verb](args, { signal: controller.signal }) : await client.call(tool.verb, tool.pluginTool ? {tool:tool.pluginTool,arguments:args} : args);
answer({ ok: true, result });
} catch (e) {
answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed",
...refusalGuidance(e.code),
...(e.code === "plugin-disabled" && /^[a-z][a-z0-9-]{0,31}$/.test(e.reason ?? "") ? {plugin:e.reason} : {}),
...(typeof e.reason === "string" && request.tool === "advance_request" && tool.verb === "request.advance" && e.code === "request-evidence-required"
? { reason: e.reason.slice(0, 300) } : tool.verb === "record.write" && e.code === "project-record-invalid" && typeof e.reason === "string"
? { reason: e.reason.slice(0, 300) } : {}) });
+3 -1
View File
@@ -96,7 +96,9 @@ const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESU
// "working.land" go to the host's git socket.
export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false, library = false }) {
const held = new Set([...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole]);
const tools = [];
const tools = (resolved.pluginTools ?? []).filter(t=>resolved.limits.tools.includes(t.name)).map(t=>({
name:t.name, verb:"plugin.tool", pluginTool:t.name, action:null, description:t.description, parameters:t.arguments,
}));
const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters });
if (held.has("message.send")) {
add("send_message", "message.send", "message.send", "Send a message to another role instance, or to \"human\". Your final answer to a message is sent back to its sender for you; use this for anything else. A reply to an answer gets no automatic answer; use class REQUEST to ask a follow-up.", obj({
+9 -9
View File
@@ -188,11 +188,11 @@ function projectCopyRefusal(copy, document, levels) {
// dropped: the file's own instance wins. Returns a frozen business whose `copies` key is the
// snapshot's view, with each copy's state as it is in this document; a business with no copies and
// no project agents is plain validateBusinessDocument.
export function withCopies(document, file, { rolesDir, snapshot }) {
export function withCopies(document, file, { rolesDir, snapshot, dataRoot = null }) {
const copies = snapshot?.copies ?? {};
if (Object.keys(copies).length === 0 && !hasProjectAgents(document)) return validateBusinessDocument(document, file, { rolesDir });
if (Object.keys(copies).length === 0 && !hasProjectAgents(document)) return validateBusinessDocument(document, file, { rolesDir, dataRoot });
// The document's own refusals come first, whole.
validateBusinessDocument(document, file, { rolesDir });
validateBusinessDocument(document, file, { rolesDir, dataRoot });
const orgId = snapshot?.org ?? document.id;
const levels = resolveAgentLevels({ orgId, doc: document, root: { agents: snapshot?.rootAgents ?? {} }, rolesDir });
const unions = {};
@@ -234,25 +234,25 @@ export function withCopies(document, file, { rolesDir, snapshot }) {
if (refused) { view[instance] = { ...view[instance], state: "refused", reason: refused }; continue; }
const entry = entryOf(copy, copy.project === null && narrows[copy.name] ? narrowVars("root", copy.vars, narrows[copy.name]) : copy.vars);
try {
validateBusinessDocument({ ...narrowed, roles: { ...roles, [instance]: entry } }, file, { rolesDir });
validateBusinessDocument({ ...narrowed, roles: { ...roles, [instance]: entry } }, file, { rolesDir, dataRoot });
join[instance] = entry;
} catch (error) {
if (error?.name !== "BusinessError") throw error;
view[instance] = { ...view[instance], state: "refused", reason: reasonOf(error, file, orgId, document, copy) };
}
}
const business = validateBusinessDocument({ ...narrowed, roles: { ...roles, ...join } }, file, { rolesDir });
const business = validateBusinessDocument({ ...narrowed, roles: { ...roles, ...join } }, file, { rolesDir, dataRoot });
return deepFreeze({ ...business, ...(Object.keys(copies).length ? { copies: view } : {}), unions });
}
// The organization with its copies, as a bus or launcher loads it. The plain load keeps
// loadBusiness's file checks; when there are copies or project agents, the document is read again
// through the organization's own writer and validated with them, and only that second read is used.
export function loadWithCopies(id, { dir, rolesDir, snapshot }) {
const plain = loadBusiness(id, { dir, rolesDir });
const { document } = organizationWriter({ dir, rolesDir }).read(id);
export function loadWithCopies(id, { dir, rolesDir, snapshot, dataRoot = null }) {
const plain = loadBusiness(id, { dir, rolesDir, dataRoot });
const { document } = organizationWriter({ dir, rolesDir, dataRoot }).read(id);
if ((!snapshot || Object.keys(snapshot.copies ?? {}).length === 0) && !hasProjectAgents(document)) return plain;
return withCopies(document, businessFilePath(id, dir), { rolesDir, snapshot });
return withCopies(document, businessFilePath(id, dir), { rolesDir, snapshot, dataRoot });
}
// Why `instance` can't work in code project `project` of this business, as { code, message }, or
+10 -2
View File
@@ -34,7 +34,7 @@ const AUTHORITY_KEYS = Object.freeze(["withinRole", "crossRole"]);
const MESSAGING_KEYS = Object.freeze(["sendTo", "receiveFrom"]);
const PERSONALITY_KEYS = Object.freeze(["summary", "traits", "voice"]);
const CREDENTIAL_KEYS = Object.freeze(["service", "scopes"]);
const PLUGIN_KEYS = Object.freeze(["name", "tools", "actions", "services"]);
const PLUGIN_KEYS = Object.freeze(["name", "tools", "actions", "services", "roleTemplates"]);
const PLUGIN_SERVICE_KEYS = Object.freeze(["name", "scopes"]);
// Step 6's length bounds, in characters or entries. The design gives none, so P1 sets them. The
@@ -137,7 +137,14 @@ function checkPlugin(plugin, i, core) {
const names = [...tools, ...actions, ...services.map((s) => s.name.slice(name.length + 1))];
const twice = names.find((n, j) => names.indexOf(n) !== j);
if (twice !== undefined) refuse(`${where} declares ${JSON.stringify(full(twice))} twice`);
return { name, tools: tools.map(full), actions: actions.map(full), services };
if (plugin.roleTemplates !== undefined && !Array.isArray(plugin.roleTemplates)) refuse("invalid plugin role templates");
const roleTemplates=(plugin.roleTemplates ?? []).map(t=>{
if(!isPlain(t) || Object.keys(t).sort().join(",")!=="file,name" || typeof t.name!=="string" || !DECLARED_NAME.test(t.name) || coreName(t.name)
|| typeof t.file!=="string" || !/^[a-zA-Z0-9._/-]+$/.test(t.file) || t.file.split("/").some(p=>!p || p==="." || p==="..")) refuse("invalid plugin role template");
return {name:full(t.name),file:t.file};
});
if(new Set(roleTemplates.map(t=>t.name)).size!==roleTemplates.length)refuse("duplicate plugin role template");
return { name, tools: tools.map(full), actions: actions.map(full), services, roleTemplates };
}
// The registry checkComposition reads: the core set plus each plugin's declared set. The plugin
@@ -162,6 +169,7 @@ export function buildRegistry({ plugins = [] } = {}) {
routed: [...ROUTED, ...pluginActions],
services,
plugins: declared,
roleTemplates: declared.flatMap(p=>p.roleTemplates),
});
BUILT.add(registry);
return registry;
+4 -2
View File
@@ -8,6 +8,8 @@ import { refuse } from "./errors.mjs";
import { credentialKind, isHostKind } from "./vocabulary.mjs";
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
import { pluginName } from "../../plugins/src/organization.mjs";
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
export const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" });
const WARN_DAYS = 7;
@@ -21,10 +23,10 @@ const DAY_MS = 24 * 60 * 60 * 1000;
// can't use the form. A `file` reference into the agents' store must be the
// agent's own path (`credential-not-yours`).
export function parseCredentialRef(ref, service, where, { store = null } = {}) {
if (!credentialKind(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
if (!credentialKind(service) && !pluginName(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
requireObject(ref, where);
// Row 210 (#1707, design D1): an ssh key doesn't expire, so host.<id> takes rotateBy as gitea does.
const dateKey = isHostKind(service) ? "rotateBy" : DATE_KEY[service];
const dateKey = isHostKind(service) ? "rotateBy" : (DATE_KEY[service] ?? "rotateBy");
rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where);
const hasFile = ref.file !== undefined;
const hasEnv = ref.env !== undefined;
@@ -150,7 +150,7 @@ function createdCredentials(old, instance, definition, args) {
// services) revokes the instance's credentials for a fire or a delete and throws
// credential-revoke-failed when it can't. Null in a writer with no bus (the CLI's), which revokes
// nothing. Row 239 (#1745): writer is the organization writer, injected only by tests.
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir }) }) {
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir, dataRoot:system?.dataRoot ?? null }) }) {
// The definitions in rolesDir and whether add and set accept each one.
function definitions() {
@@ -168,18 +168,18 @@ const ROUTE_FIELDS = Object.freeze(["harness", "model", "thinking"]);
// Without it, a write that adds a daily key quota refuses proxy-key-refused; no other write asks.
export function launchSettings({ dir, rolesDir, system, sessions = () => [], now = () => new Date().toISOString(), levels = () => null,
keyLineage = () => { throw new OrganizationError("proxy-key-refused"); } }) {
const writer = organizationWriter({ dir, rolesDir });
const writer = organizationWriter({ dir, rolesDir, dataRoot:system?.dataRoot ?? null });
// The validated business as the file stands now (row 125: the limit checks read it at each ask).
function current(id) {
return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir });
return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
}
// The file before a write, validated, for checkRoutes; null when it no longer validates, so every
// route is checked.
function before(id, document) {
try {
return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
} catch (e) {
if (e.name === "BusinessError") return null;
throw e;
@@ -188,7 +188,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
function view(id) {
const { document, revision } = writer.read(id);
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
if (!business.launch) throw new OrganizationError("launch-not-configured");
const instances = Object.fromEntries(Object.keys(business.roles).map(instance => {
const vars = resolveInstance({ business, system, instance }).vars;
@@ -305,7 +305,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
if (next.roles[instance]?.definition !== entry.definition) throw new OrganizationError("organization-definition-refused");
}
// Check every instance's route before publishing the complete document.
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...view(id), transaction: receipt.transaction };
@@ -317,7 +317,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
// doesn't set reads null; its launch refuses no-route.
function routes(id) {
const { document, revision } = writer.read(id);
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
if (!business.launch) throw new OrganizationError("launch-not-configured");
const harnesses = Array.isArray(business.plugins?.harness) ? [...business.plugins.harness] : [];
const named = new Set(harnesses);
@@ -397,7 +397,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
next.roles[instance].vars = vars;
}
if (canonicalJson(next) === canonicalJson(old.document)) throw new OrganizationError("organization-no-change");
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...routes(id), transaction: receipt.transaction };
@@ -228,3 +228,6 @@ export async function orgLock(ctx, id, { clear = false } = {}) {
}
}
}
// PL1: the organization primitive set, derived from enabled, verified manifests only.
export { organizationPlugins as buildOrganizationRegistry } from "../../plugins/src/organization.mjs";
@@ -13,6 +13,7 @@ import { loadRole } from "./role.mjs";
import { END_PROTECTED } from "./vocabulary.mjs";
import { checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
import { hasModule } from "../../plugins/src/loader.mjs";
import { readPluginLock } from "../../plugins/src/pinned.mjs";
import { checkMapping } from "../../library/src/mapping.mjs";
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
import { checkAppearance } from "./appearance.mjs";
@@ -238,7 +239,7 @@ function checkRetired(retired, roles, file) {
// Validate a parsed business document read from `file`. `rolesDir` is where
// role definitions live. Returns a frozen business with its definitions.
export function validateOrganizationDocument(document, file, { rolesDir }) {
export function validateOrganizationDocument(document, file, { rolesDir, dataRoot = null }) {
requireObject(document, "business file");
rejectUnknownKeys(document, TOP_KEYS, "business file");
if (document.businessVersion !== 1) refuse(`business file "businessVersion" must be 1 (${file})`);
@@ -333,8 +334,13 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
// here; whether each module loads is the loader's check, at load time.
const plugins = document.plugins === undefined ? {}
: structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
let installedNames = new Set();
if (dataRoot !== null && plugins["system-type"]?.some(id=>!hasModule("system-type",id))) {
try { installedNames = new Set(Object.keys(readPluginLock(dataRoot).plugins)); }
catch { installedNames = null; } // the loader reports an unreadable lock; core still loads
}
for (const id of plugins["system-type"] ?? []) {
if (!hasModule("system-type", id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
if (!hasModule("system-type", id) && installedNames !== null && !installedNames.has(id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
}
const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage);
const appearance = checkAppearance(document.appearance, `${file} appearance`);
@@ -349,14 +355,14 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
// Load businesses/<id>.json from the config directory. The file holds
// authority, so it must belong to this user and not be writable by group
// or other.
export function loadOrganization(id, { dir = configDir(), rolesDir }) {
export function loadOrganization(id, { dir = configDir(), rolesDir, dataRoot = null }) {
if (!rolesDir) throw new Error("loadBusiness needs rolesDir");
const file = organizationFilePath(id, dir);
const document = readJsonFile(file, "business file", (stat) => {
if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`);
if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
});
return validateOrganizationDocument(document, file, { rolesDir });
return validateOrganizationDocument(document, file, { rolesDir, dataRoot });
}
// The old names (rename design r2, module map): the same functions, not copies.
+4 -4
View File
@@ -140,7 +140,7 @@ export function setReplyLimits(writer, id, args, actor) {
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
}
export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = () => {} }) {
const evidence = join(dir, "businesses", ".history");
const logFile = id => join(evidence, id, "changes.jsonl");
// Keep the writer's established busy refusal when the shared registry lock
@@ -155,7 +155,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const file = businessFilePath(id, dir);
const bytes = regular(file);
const document = parseJsonFileText(bytes, file, "organization file");
validateBusinessDocument(document, file, { rolesDir });
validateBusinessDocument(document, file, { rolesDir, dataRoot });
return { document, revision: sha256(bytes) };
}
@@ -297,7 +297,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
modelQuotaFence(old, document, actor);
usageFence(old, document, actor);
systemTypesFence(old, document, actor);
validateBusinessDocument(document, file, { rolesDir });
validateBusinessDocument(document, file, { rolesDir, dataRoot });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
@@ -307,7 +307,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
modelQuotaFence(old, written, actor);
usageFence(old, written, actor);
systemTypesFence(old, written, actor);
validateBusinessDocument(written, file, { rolesDir });
validateBusinessDocument(written, file, { rolesDir, dataRoot });
if (beforeWrite) {
beforeWrite();
revoked = true;
@@ -4,13 +4,15 @@
// Writes go through the organization writer, which logs old and new names
// like a limit change. A write is a patch per kind (see set()): kinds it doesn't name are kept. A refused write throws a code and logs nothing.
import { organizationWriter, OrganizationError } from "./organization.mjs";
import { verifiedPlugin } from "../../plugins/src/pinned.mjs";
import { hasModule } from "../../plugins/src/loader.mjs";
import { isPlainObject } from "./util.mjs";
import { KINDS, PluginError, checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
// `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 });
export function pluginSettings({ dir, rolesDir, exists = null, dataRoot = null }) {
const writer = organizationWriter({ dir, rolesDir, dataRoot });
function read(id) {
const { document, revision } = writer.read(id);
@@ -51,6 +53,10 @@ export function pluginSettings({ dir, rolesDir, exists = null }) {
if (exists) {
for (const [kind, setting] of Object.entries(sets)) {
for (const name of Array.isArray(setting) ? setting : [setting]) {
if (dataRoot !== null && !hasModule(kind,name)) {
try { verifiedPlugin(dataRoot,name,kind); }
catch(e) { throw new OrganizationError(e.code === "missing-lock" ? "unknown-plugin" : "invalid-plugins", "installed plugin verification refused"); }
}
if (!exists(kind, name)) throw new OrganizationError("unknown-plugin", `no ${kind} module named ${name} in this tree`);
}
}
+13 -2
View File
@@ -3,6 +3,7 @@
// the record a launcher or the broker uses. Plain values: the most specific
// layer wins. Limits: every layer narrows, nothing widens.
import { organizationPlugins, assertPluginCapabilities } from "../../plugins/src/organization.mjs";
import { ownRails } from "./rails.mjs";
import { refuse } from "./errors.mjs";
import { ACTIONS, NETWORKS, isHostKind } from "./vocabulary.mjs";
@@ -31,11 +32,18 @@ function narrowerNetwork(a, b) {
// business loadBusiness(...) output
// project loadProject(...) output, or null
// instance a role instance the business file declares
export function resolveInstance({ system, business, project = null, instance }) {
export function resolveInstance({ system, business, project = null, instance, enforcePlugins = false, pluginRoot = system?.dataRoot ?? null }) {
const entry = Object.hasOwn(business.roles, instance) ? business.roles[instance] : null;
if (!entry) refuse(`business ${business.id} declares no role instance ${JSON.stringify(instance)}`);
const definition = business.definitions[entry.definition];
const pluginView = organizationPlugins(pluginRoot,business.plugins);
let disabledPlugin = null;
try { assertPluginCapabilities(definition,pluginView); } catch(e) {
if(enforcePlugins)throw e;
disabledPlugin=e.plugin;
}
const pluginTools = pluginView.manifests.flatMap(m=>m.tools).filter(t=>definition.tools.includes(t.name));
const layers = [
{ layer: "system", source: "system", vars: system },
{ layer: "business", source: `business:${business.id}`, vars: business.vars },
@@ -79,6 +87,9 @@ export function resolveInstance({ system, business, project = null, instance })
}
const resolved = {
...(pluginTools.length ? {pluginTools} : {}),
...(pluginView.registry.plugins.length ? {pluginActions:pluginView.registry.plugins.flatMap(p=>p.actions)} : {}),
...(disabledPlugin ? {disabledPlugin} : {}),
business: business.id,
project: projectId,
instance,
@@ -112,7 +123,7 @@ export function resolveInstance({ system, business, project = null, instance })
// Classify one action for a resolved instance: "within", "cross" or
// "gated". An action outside the vocabulary refuses.
export function classify(resolved, action) {
if (!ACTIONS.includes(action)) refuse(`unknown action: ${JSON.stringify(action)}`);
if (!ACTIONS.includes(action) && !resolved.pluginActions?.includes(action)) refuse(`unknown action: ${JSON.stringify(action)}`);
if (resolved.limits.authority.withinRole.includes(action)) return "within";
if (resolved.limits.authority.crossRole.includes(action)) return "cross";
return "gated";
+8 -4
View File
@@ -15,6 +15,8 @@ import {
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs";
import { pluginName } from "../../plugins/src/organization.mjs";
const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
const V1_KEYS = ["roleVersion", "name", "tools", "network"];
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
@@ -24,7 +26,7 @@ const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
function checkTools(tools) {
if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names');
return requireDistinctList(tools, "role tools", (tool) => {
if (!TOOLS.includes(tool)) refuse(`unsupported tool (supported: ${TOOLS.join(", ")})`);
if (!TOOLS.includes(tool) && !pluginName(tool)) refuse(`unsupported tool (supported: ${TOOLS.join(", ")})`);
});
}
@@ -39,7 +41,7 @@ function checkAuthority(authority) {
const lists = {};
for (const key of ["withinRole", "crossRole"]) {
lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => {
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}; expected an action from the role vocabulary`);
if (!ACTIONS.includes(action) && !pluginName(action)) refuse(`unknown action in authority.${key}; expected an action from the role vocabulary`);
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`);
});
}
@@ -132,13 +134,15 @@ function checkCredentials(credentials) {
// Row 210 (#1707, design D1): "host" takes no scopes; the business file names the hosts.
const host = entry.service === HOST_SERVICE;
rejectUnknownKeys(entry, host ? ["service"] : ["service", "scopes"], `role credentials[${i}]`);
if (!host && !SERVICES.includes(entry.service)) {
if (!host && !SERVICES.includes(entry.service) && !pluginName(entry.service)) {
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE].join(", ")}`);
}
if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`);
seen.add(entry.service);
if (host) return { service: HOST_SERVICE };
const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
const scopes = pluginName(entry.service) ? requireDistinctList(entry.scopes, "plugin credential scopes", s => {
if (typeof s !== "string" || !/^[a-z][a-z0-9_.:-]{0,63}$/.test(s)) refuse("invalid plugin credential scope");
}, {nonEmpty:true}) : entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
return { service: entry.service, scopes };
});
}
+9
View File
@@ -374,3 +374,12 @@ export function grantInScope(scope, body) {
}
return false;
}
// PL1: a capability's plugin namespace, without loading files or code. Kept pure because the
// runner's bus client imports the broker error type inside the existing sandbox binds.
export function pluginName(capability) {
const core = [...ACTIONS,...TOOLS,...SERVICES,HOST_SERVICE];
if(typeof capability!=="string" || core.includes(capability) || !/^[a-z][a-z0-9-]*\.[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/.test(capability))return null;
const name=capability.split(".")[0];
return [...core,...NETWORKS].some(n=>n.split(".")[0]===name) ? null : name;
}
+89
View File
@@ -505,3 +505,92 @@ A harness declares `outputPolicy({ proxy })` as `adapter`, `non-newline-stdout`,
from module directories, and malformed modules refuse discovery. An organization
selects modules with `plugins.system-type`; an omitted list enables none. The
initial five modules declare no toolsets yet.
### Instance-installed packages (PL1, #1873)
The instance owner may install trusted plugin code from the host shell:
```
mosaic plugins install ./package --sha256 <64 lowercase hex>
mosaic plugins install ./package.zip --sha256 <64 lowercase hex>
mosaic plugins install https://example.invalid/plugin.git --commit <40 lowercase hex> --sha256 <64 lowercase hex>
mosaic plugins list
mosaic plugins remove <name>
```
These commands read the selected system config's `dataRoot`, use
`<dataRoot>/plugins/<name>`, and publish `<dataRoot>/plugins.lock.json`.
They have no bus, Console, organization, tester, or agent installation path.
Removal holds the organization registry lock and refuses if any organization
file still enables the package. Disable it through the existing owner-only
`plugins.set` before removal. The installer prints the names of all host tools
before its first instance write. Plugin code runs as the instance uid; installing
it is a trust decision, and a hash pin does not make code safe.
Every package has this strict `plugin.json` shape (all fields required):
```json
{
"name": "example",
"version": "1.0.0",
"kinds": ["usage-source"],
"interfaceVersions": {"usage-source": 1},
"sha256": "<64 lowercase hex>",
"source": {"path": "./source", "version": "1.0.0"},
"signature": null,
"tools": [{
"name": "example.lookup",
"description": "Look up a local fixture",
"arguments": {"type": "object", "properties": {}, "required": [], "additionalProperties": false},
"runs": "sandbox",
"entry": "tools/lookup.mjs"
}],
"actions": ["example.publish"],
"services": [{"name": "example.account", "scopes": ["read"]}],
"roleTemplates": [{"name": "example.writer", "file": "templates/writer.json"}]
}
```
`source` takes exactly one of `path`/`url` and one of `version`/`commit`.
Git installs record their actual full commit and URL in the lock. `signature`
is reserved and must be null. Kinds and interface versions remain core policy;
each kind has `<kind>/index.mjs` with the usual exports. Every declaration has
the plugin namespace, and no declaration may reuse a core name or hard wait.
Templates are recorded as data; installation imports no role and hires nobody.
`pluginHash(directory)` in `src/pinned.mjs` computes the expected digest. Sort
relative regular-file paths lexically and hash the UTF-8 frame
`<path byte length>:<path>:<file byte length>:` followed by each file's bytes.
For `plugin.json`, replace only the top-level `sha256` value with 64 zeroes.
All other bytes, including whitespace and descriptions, are pinned. Symlinks,
hard links, special files, unsafe archive paths and duplicate archive entries
refuse. Files become 0444, directories 0555. Empty directories carry no code
and are omitted from the digest.
A declared tool exports `execute(arguments)` from its `entry` module. Argument
schemas use the bounded, reference-free subset checked in `src/manifest.mjs`;
unknown keywords refuse. Tool presence follows the resolved role, and the
broker rechecks the current organization setting and hash on every call.
Host tools run in the host process. Sandbox tools run in a fresh bubblewrap
namespace with the pinned package and Node runtime, no home, config, workspace
or network. They receive JSON on stdin and return JSON on stdout.
The loader checks the complete package against the lock before every installed
module import. A refusal logs static codes and the validated package name;
core modules continue. A bad pin evicts previously loaded installed modules.
Once a package path has been imported, the process latches its verified pin before
the first import attempt (including failed attempts). Removing and reinstalling a
different pin at that path refuses with `plugin-restart-required` until the host
restarts. The latch is shared by loaders and host tools, so cached helper modules
cannot run under a replacement pin. A fresh sandbox tool process has no old cache.
Executable dependencies must be bundled in the package and imported by relative
path (or an in-package file URL). A shared resolver guard refuses bare third-party
lookups, executable data URLs and paths outside that package; Node built-ins remain
available. This applies to static imports, dynamic imports and `createRequire`,
including sandbox tools. It constrains module resolution, not R10 host authority:
trusted host code can still use filesystem and other host APIs.
A role referencing a missing or disabled plugin refuses at launch with
`plugin-disabled` and the package name. Disabling never fires or rewrites an
agent; its next plugin tool call refuses with that same code.
+2 -2
View File
@@ -8,10 +8,10 @@ const report = entry => process.stderr.write(`channel: ${JSON.stringify({
event: entry.event, kind: entry.kind, name: entry.name, code: entry.code, kept: entry.kept,
})}\n`);
export function createChannelCaller({ name, call, log = report }) {
export function createChannelCaller({ name, call, log = report, dataRoot = null }) {
if (typeof name !== "string" || !NAME.test(name)) throw new PluginError("bad-name", "invalid channel name");
if (typeof call !== "function") throw new TypeError("channel needs a core caller");
const loader = createLoader({ log });
const loader = createLoader({ log, dataRoot });
return async (verb, args = {}) => {
const settings = await call("plugins", {});
if (!record(settings) || !record(settings.plugins)) {
+35
View File
@@ -0,0 +1,35 @@
// PL1: executable dependencies must be bundled inside the pinned package.
// This constrains module resolution, not the host authority granted by R10.
// Kept standalone so a sandbox tool can use this same guard in its fresh process.
import { isBuiltin, registerHooks } from 'node:module';
import { isAbsolute, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const roots = new Set();
let registered = false;
const inside = (root, path) => {
const rel = relative(root, path);
return rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel);
};
const refused = () => { throw new Error('plugin import refused'); };
export function guardPluginImports(directory) {
roots.add(resolve(directory));
if (registered) return;
registerHooks({resolve(specifier, context, nextResolve) {
let parent;
try { if (context.parentURL?.startsWith('file:')) parent = fileURLToPath(context.parentURL); }
catch { return refused(); }
const root = parent && [...roots].find(dir => inside(dir, parent));
if (!root || isBuiltin(specifier)) return nextResolve(specifier, context);
// Refuse bare lookups before Node searches unpinned ancestor node_modules.
if (!specifier.startsWith('./') && !specifier.startsWith('../') && !specifier.startsWith('/') && !specifier.startsWith('file:')) return refused();
try {
const candidate = new URL(specifier, context.parentURL);
if (candidate.protocol !== 'file:' || !inside(root, fileURLToPath(candidate))) return refused();
const result = nextResolve(specifier, context);
if (!result.url.startsWith('file:') || !inside(root, fileURLToPath(result.url))) return refused();
return result;
} catch { return refused(); }
}});
registered = true;
}
+107
View File
@@ -0,0 +1,107 @@
// Instance-uid, host-only management. No broker verb or organization writer calls this module.
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { bad, HASH } from './manifest.mjs';
import { KINDS } from './kinds.mjs';
import { hashFiles, pluginFiles, pluginPaths, readPlugin, readPluginLock, safeDirectory } from './pinned.mjs';
const run = (cmd,args,options={}) => {
const r=spawnSync(cmd,args,{encoding:'utf8',maxBuffer:40*1024*1024,timeout:120000,...options});
if(r.status!==0 || r.error) bad('source-refused'); return r.stdout;
};
// Validate the complete archive before creating a file. Never extract links, devices,
// duplicate names, absolute paths, dot components, or an archive's permission bits.
const EXTRACT = `import sys,zipfile,tarfile,os,stat,io
kind,src,dst=sys.argv[1:]
archive=zipfile.ZipFile(src) if kind=='zip' else tarfile.open(src)
rows=[]; seen=set(); total=0
for e in archive.infolist() if kind=='zip' else archive.getmembers():
n=e.filename if kind=='zip' else e.name
directory=e.is_dir() if kind=='zip' else e.isdir()
n=n.rstrip('/') if directory else n
if not n or n.startswith('/') or any(p in ('','..','.') for p in n.split('/')) or '\\\\' in n: raise ValueError('path')
if n in seen: raise ValueError('duplicate')
seen.add(n)
mode=e.external_attr>>16 if kind=='zip' else e.mode
if kind=='zip':
if stat.S_IFMT(mode) not in (0,stat.S_IFREG,stat.S_IFDIR): raise ValueError('type')
elif not (e.isfile() or e.isdir()): raise ValueError('type')
size=e.file_size if kind=='zip' else e.size
total+=size
if total>33554432 or len(seen)>2048: raise ValueError('size')
rows.append((e,n,directory))
for e,n,directory in rows:
p=os.path.join(dst,n)
if directory: os.makedirs(p,exist_ok=True)
else:
os.makedirs(os.path.dirname(p),exist_ok=True)
with open(p,'xb') as out: out.write(archive.read(e) if kind=='zip' else archive.extractfile(e).read())
`;
function sourceFiles(source,commit,scratch) {
if(commit!==undefined) {
if(!/^[a-f0-9]{40}$/.test(commit) || typeof source!=='string' || source.startsWith('-') || /[\x00-\x1f]/.test(source)) bad('commit-required');
const repo=join(scratch,'repo'),archive=join(scratch,'source.tar'),out=join(scratch,'out'); mkdirSync(out);
run('git',['-c','core.hooksPath=/dev/null','clone','--no-checkout','--no-local','--',source,repo]);
const actual=run('git',['-C',repo,'rev-parse','--verify',`${commit}^{commit}`]).trim(); if(actual!==commit) bad('source-refused');
run('git',['-C',repo,'archive','--format=tar',`--output=${archive}`,commit]);
run('python3',['-c',EXTRACT,'tar',archive,out]); return pluginFiles(out);
}
if(typeof source!=='string' || /^(https?:|ssh:|git:|git@)/.test(source)) bad('commit-required');
const st=lstatSync(source); if(st.isSymbolicLink()) bad('unsafe-path');
if(st.isDirectory()) return pluginFiles(source);
if(!st.isFile() || !source.endsWith('.zip')) bad('source-refused');
const out=join(scratch,'out');mkdirSync(out);run('python3',['-c',EXTRACT,'zip',source,out]); return pluginFiles(out);
}
function writableRemove(dir) {
if(!existsSync(dir))return;
for(const [p] of pluginFiles(dir)) chmodSync(join(dir,p),0o600);
const dirs=new Set(['']);for(const [p] of pluginFiles(dir)) {let parts=p.split('/');parts.pop();while(parts.length){dirs.add(parts.join('/'));parts.pop();}}
for(const p of dirs)chmodSync(join(dir,p),0o700);
rmSync(dir,{recursive:true});
}
function withInstallLock(dataRoot,fn) {
safeDirectory(dataRoot);const st=lstatSync(dataRoot);if(st.uid!==process.getuid() || st.mode&0o022)bad("plugin-root");const paths=pluginPaths(dataRoot);let fd;
try {fd=openSync(paths.mutex,'wx',0o600);}catch{bad('plugin-locked');}
try{return fn(paths);}finally{closeSync(fd);unlinkSync(paths.mutex);}
}
function writeLock(lock,doc) {
const temp=`${lock}.new`; try {writeFileSync(temp,JSON.stringify(doc,null,2)+'\n',{flag:'wx',mode:0o600});renameSync(temp,lock);}finally{if(existsSync(temp))unlinkSync(temp);}
}
export function installPlugin({dataRoot,source,commit,expectedHash,print=()=>{}}) {
if(!HASH.test(expectedHash))bad('expected-hash-required');
const scratch=mkdtempSync(join(tmpdir(),'pl1-'));
try {
const files=sourceFiles(source,commit,scratch),candidate=join(scratch,'candidate');mkdirSync(candidate);
for(const [p,bytes]of files){mkdirSync(join(candidate,p,'..'),{recursive:true});writeFileSync(join(candidate,p),bytes,{flag:'wx',mode:0o600});}
const {manifest}=readPlugin(candidate);
if(hashFiles(files)!==expectedHash || manifest.sha256!==expectedHash)bad('hash-mismatch',manifest.name);
for(const kind of Object.keys(KINDS)) if (existsSync(new URL(`../${kind}/${manifest.name}/index.mjs`,import.meta.url))) bad('core-name');
// This is before even the instance lock is created. Only validated names are printed.
for(const tool of manifest.tools.filter(t=>t.runs==='host'))print(`host tool: ${tool.name}`);
return withInstallLock(dataRoot,({root,lock})=>{
const doc=readPluginLock(dataRoot);if(Object.hasOwn(doc.plugins,manifest.name))bad('already-installed',manifest.name);
if(!existsSync(root))mkdirSync(root,{mode:0o700});safeDirectory(root);
const dest=join(root,manifest.name);if(existsSync(dest))bad('already-installed',manifest.name);
const stage=mkdtempSync(join(root,'.install-'));
try {
for(const[p,bytes]of files){mkdirSync(join(stage,p,'..'),{recursive:true,mode:0o755});writeFileSync(join(stage,p),bytes,{flag:'wx',mode:0o444});}
const dirs=new Set(['']);for(const[p]of files){let parts=p.split('/');parts.pop();while(parts.length){dirs.add(parts.join('/'));parts.pop();}}
for(const p of dirs)chmodSync(join(stage,p),0o555);
readPlugin(stage);renameSync(stage,dest);
doc.plugins[manifest.name]={version:manifest.version,source:commit === undefined ? manifest.source : {url:source,commit},sha256:expectedHash};
try{writeLock(lock,doc);}catch(e){writableRemove(dest);throw e;}
return {name:manifest.name,...doc.plugins[manifest.name]};
}finally{if(existsSync(stage))writableRemove(stage);}
});
}catch(e){if(e?.name==='PluginError')throw e;bad('install-refused');}finally{rmSync(scratch,{recursive:true,force:true});}
}
export function removePlugin({dataRoot,name,enabled}) {
if(typeof enabled!=='function')bad('organization-state-required');
return withInstallLock(dataRoot,({root,lock})=>{
const doc=readPluginLock(dataRoot);if(!Object.hasOwn(doc.plugins,name))bad('missing-lock');
if(enabled(name))bad('plugin-enabled',name);
// Remove the lock first: an interrupted removal can never leave unpinned code loadable.
delete doc.plugins[name];writeLock(lock,doc);writableRemove(join(root,name));return {removed:name};
});
}
+47 -10
View File
@@ -1,16 +1,19 @@
// The plug-in loader (REQ-PLUG-1). It switches on the modules the
// organization file's `plugins` object names, one key per kind, and loads
// only packages/plugins/<kind>/<name>/index.mjs from the tree this file runs
// from. It takes no path from config and loads nothing outside that tree
// (lead decision 143). A loaded module runs with the host's authority.
// core packages/plugins/<kind>/<name>/index.mjs from its own tree, plus
// hash-pinned installed modules from <instance dataRoot>/plugins only. PL1
// (#1873), owner-authorized amendment of lead decision 143. No arbitrary
// config path is accepted. A loaded module runs with the host's authority.
//
// A refusal keeps the kind's last good modules and is logged. A kind refused
// A core refusal keeps the kind's last good modules and is logged. A refused
// installed plugin is omitted; valid core modules in its kind still load. A kind refused
// on its first load has nothing switched on. `loaded()` reports what each
// kind actually has switched on.
import { lstatSync, readdirSync, realpathSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { KINDS, NAME, PluginError, namesFor } from "./kinds.mjs";
import { verifiedPlugin, readPluginLock, claimPluginImport } from "./pinned.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -38,7 +41,8 @@ function component(path, directory) {
}
}
async function loadModule(spec, name, barrier) {
async function loadModule(spec, name, barrier, dataRoot = null) {
if (dataRoot !== null && !hasModule(spec.kind, name)) return loadPinnedModule(spec, name, barrier, dataRoot);
const kindDir = join(ROOT, spec.kind);
const nameDir = join(kindDir, name);
const index = join(nameDir, "index.mjs");
@@ -74,7 +78,10 @@ async function loadModule(spec, name, barrier) {
// 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) {
export function hasModule(kind, name, dataRoot = null) {
if (dataRoot !== null && !hasModule(kind, name)) {
try { verifiedPlugin(dataRoot, name, kind); return true; } catch { return false; }
}
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);
@@ -113,7 +120,7 @@ export function discoverModules(kind) {
// (loadModule, unchanged). One refused module is reported and never hides the others, and nothing
// here changes what createLoader has switched on. Node caches an import by path, so a module
// whose file changed is read again only after a restart; a removed directory is gone at once.
export async function loadInstalled(kind, { barrier = () => {} } = {}) {
export async function loadInstalled(kind, { barrier = () => {}, dataRoot = null } = {}) {
if (!Object.hasOwn(KINDS, kind)) refuse("unknown-kind", `unknown kind ${JSON.stringify(kind)}`);
const spec = KINDS[kind];
const modules = [], refused = [];
@@ -129,9 +136,14 @@ export async function loadInstalled(kind, { barrier = () => {} } = {}) {
return { modules, refused };
}
for (const name of found.invalid) report(name, new PluginError("bad-name", `${kind}/${name} doesn't match ${NAME}`, name));
if (dataRoot !== null) {
try { for (const name of Object.keys(readPluginLock(dataRoot).plugins)) {
try { if (verifiedPlugin(dataRoot, name).manifest.kinds.includes(kind) && !found.names.includes(name)) found.names.push(name); } catch(e) { report(name,e); }
} } catch(e) { report(null,e); }
}
for (const name of found.names) {
try {
modules.push(await loadModule(spec, name, barrier));
modules.push(await loadModule(spec, name, barrier, dataRoot));
} catch (e) {
report(name, e);
}
@@ -139,7 +151,7 @@ export async function loadInstalled(kind, { barrier = () => {} } = {}) {
return { modules, refused };
}
export function createLoader({ log, barrier = () => {} }) {
export function createLoader({ log, barrier = () => {}, dataRoot = null }) {
if (typeof log !== "function") throw new TypeError("createLoader needs a log callback");
const current = new Map(Object.keys(KINDS).map(kind => [kind, Object.freeze([])]));
let queue = Promise.resolve();
@@ -151,6 +163,14 @@ export function createLoader({ log, barrier = () => {} }) {
event: "plugin-refused", kind, name: e.plugin ?? name, code: e.code, message: e.message,
kept: kind === null ? loaded() : current.has(kind) ? current.get(kind).map(m => m.name) : [],
};
if (dataRoot !== null) {
const affected=kind===null ? [...current.keys()] : current.has(kind) ? [kind] : [];
for(const checkedKind of affected)current.set(checkedKind, Object.freeze(current.get(checkedKind).filter(m => {
if (!m.installed) return true;
try { verifiedPlugin(dataRoot,m.name,checkedKind); return true; } catch { return false; }
})));
entry.kept = kind===null ? loaded() : current.has(kind) ? current.get(kind).map(m=>m.name) : [];
}
log(entry);
report.push(entry);
}
@@ -180,9 +200,10 @@ export function createLoader({ log, barrier = () => {} }) {
let ok = true;
for (const name of names) {
try {
next.push(await loadModule(spec, name, barrier));
next.push(await loadModule(spec, name, barrier, dataRoot));
} catch (e) {
refused(kind, name, e, report);
if (dataRoot !== null && !hasModule(kind,name)) continue;
ok = false;
break;
}
@@ -219,3 +240,19 @@ export function createLoader({ log, barrier = () => {} }) {
// Shared pure plug-in setting classification; no check implementation is loaded.
export { isDailyModelQuota } from "./model-quota.mjs";
// Verify before every import, including cache hits. Recheck after the test barrier and import.
async function loadPinnedModule(spec, name, barrier, dataRoot) {
let checked = verifiedPlugin(dataRoot,name,spec.kind);
barrier("checked", {kind:spec.kind,name});
checked = verifiedPlugin(dataRoot,name,spec.kind);
const entry = join(checked.dir,spec.kind,"index.mjs");
claimPluginImport(checked);
let module;
try { module=await import(pathToFileURL(entry).href + '?pin=' + checked.manifest.sha256); }
catch { refuse("load-failed","installed plugin load refused",name); }
verifiedPlugin(dataRoot,name,spec.kind);
for(const key of spec.exports) if(typeof module[key]!=="function") refuse("missing-export","installed plugin export refused",name);
if(module.interfaceVersion!==spec.interfaceVersion) refuse("interface-version","installed plugin interface refused",name);
return Object.freeze({name,module,installed:true});
}
+73
View File
@@ -0,0 +1,73 @@
// PL1 (#1873): strict, non-executing manifest validation. All errors are static;
// neither a malformed field nor plugin free text is ever included in a refusal.
import { KINDS, NAME, PluginError } from './kinds.mjs';
import { buildRegistry } from '../../organization/src/composition.mjs';
export const HASH = /^[a-f0-9]{64}$/;
const object = v => v !== null && typeof v === 'object' && !Array.isArray(v);
export const bad = (code = 'invalid-manifest', name = null) => { throw new PluginError(code, `plugin refused: ${code}`, name); };
const keys = (v, allowed, required = allowed) => {
if (!object(v) || Object.keys(v).some(k => !allowed.includes(k)) || required.some(k => !Object.hasOwn(v, k))) bad();
};
const text = (v, max = 2048) => { if (typeof v !== 'string' || !v.length || v.length > max || /[\x00-\x1f]/.test(v)) bad(); };
export function pluginPath(v) {
if (typeof v !== 'string' || v.length > 200 || !/^[a-zA-Z0-9._/-]+$/.test(v) || v.split('/').some(p => !p || p === '.' || p === '..') || v.startsWith('/')) bad('unsafe-path');
return v;
}
// Deliberately bounded JSON Schema subset. No remote references, executable validators,
// or unknown keywords. The same schema is enforced before dispatch, not just shown to a model.
export function checkSchema(s, depth = 0) {
keys(s, ['type', 'description', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems'], ['type']);
if (depth > 12 || !['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'].includes(s.type)) bad();
if (s.description !== undefined) text(s.description);
if (s.enum !== undefined && (!Array.isArray(s.enum) || !s.enum.length || s.enum.some(v => v !== null && !['string','number','boolean'].includes(typeof v)))) bad();
for (const k of ['minLength','maxLength','minItems','maxItems']) if (s[k] !== undefined && (!Number.isSafeInteger(s[k]) || s[k] < 0)) bad();
for (const k of ['minimum','maximum']) if (s[k] !== undefined && !Number.isFinite(s[k])) bad();
if (s.type === 'object') {
if (!object(s.properties) || s.additionalProperties !== false || !Array.isArray(s.required) || new Set(s.required).size !== s.required.length || s.required.some(k => typeof k !== 'string' || !Object.hasOwn(s.properties,k))) bad();
for (const [k,v] of Object.entries(s.properties)) { if (!/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(k)) bad(); checkSchema(v, depth+1); }
} else if (s.properties !== undefined || s.required !== undefined || s.additionalProperties !== undefined) bad();
if (s.type === 'array') checkSchema(s.items, depth+1);
else if (s.items !== undefined) bad();
return s;
}
export function validArguments(v, s) {
if (s.enum && !s.enum.some(x => x === v)) return false;
if (s.type === 'object') return object(v) && Object.keys(v).every(k => Object.hasOwn(s.properties,k) && validArguments(v[k],s.properties[k])) && s.required.every(k => Object.hasOwn(v,k));
if (s.type === 'array') return Array.isArray(v) && v.length >= (s.minItems ?? 0) && v.length <= (s.maxItems ?? 10000) && v.every(x => validArguments(x,s.items));
if (s.type === 'null') return v === null;
if (s.type === 'integer' ? !Number.isSafeInteger(v) : typeof v !== s.type) return false;
if (typeof v === 'string') return v.length >= (s.minLength ?? 0) && v.length <= (s.maxLength ?? 1000000);
if (typeof v === 'number') return Number.isFinite(v) && v >= (s.minimum ?? -Infinity) && v <= (s.maximum ?? Infinity);
return true;
}
export function declaredSet(m) {
const bare = n => n.slice(m.name.length + 1);
return { name:m.name, roleTemplates:m.roleTemplates.map(t=>({name:bare(t.name),file:t.file})), tools:m.tools.map(t => bare(t.name)), actions:m.actions.map(bare), services:m.services.map(s => ({name:bare(s.name),scopes:s.scopes})) };
}
export function checkManifest(m) {
keys(m, ['name','version','kinds','interfaceVersions','sha256','source','signature','tools','actions','services','roleTemplates']);
if (typeof m.name !== 'string' || !NAME.test(m.name) || m.name.length > 32) bad();
if (typeof m.version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(m.version) || !HASH.test(m.sha256)) bad();
if (!Array.isArray(m.kinds) || !m.kinds.length || new Set(m.kinds).size !== m.kinds.length || m.kinds.some(k => !Object.hasOwn(KINDS,k))) bad('unknown-kind');
keys(m.interfaceVersions,m.kinds);
for (const k of m.kinds) if (m.interfaceVersions[k] !== KINDS[k].interfaceVersion) bad('interface-version');
keys(m.source,['url','path','commit','version'],[]);
if (Object.hasOwn(m.source,'url') === Object.hasOwn(m.source,'path') || Object.hasOwn(m.source,'commit') === Object.hasOwn(m.source,'version')) bad();
for (const v of Object.values(m.source)) text(v);
if (m.source.commit !== undefined && !/^[a-f0-9]{40}$/.test(m.source.commit)) bad();
if (m.signature !== null) bad(); // reserved, never claims verification
for (const k of ['tools','actions','services','roleTemplates']) if (!Array.isArray(m[k]) || m[k].length > 128) bad();
const full = n => { if (typeof n !== 'string' || !n.startsWith(`${m.name}.`) || !/^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$/.test(n) || n.length > 64) bad(); };
for (const t of m.tools) {
keys(t,['name','description','arguments','runs','entry']); full(t.name); text(t.description); checkSchema(t.arguments);
if (t.arguments.type !== 'object' || !['sandbox','host'].includes(t.runs)) bad(); pluginPath(t.entry);
if (!t.entry.endsWith('.mjs')) bad();
}
for (const a of m.actions) full(a);
for (const s of m.services) { keys(s,['name','scopes']); full(s.name); }
const seen = new Set([...m.tools.map(t=>t.name),...m.actions,...m.services.map(s=>s.name)]);
for (const r of m.roleTemplates) { keys(r,['name','file']); full(r.name); pluginPath(r.file); if (seen.has(r.name)) bad(); seen.add(r.name); }
try { buildRegistry({plugins:[declaredSet(m)]}); } catch { bad(); }
return structuredClone(m);
}
+30
View File
@@ -0,0 +1,30 @@
// Non-executing organization view. Disabled/missing capabilities carry only a validated name.
import { buildRegistry } from '../../organization/src/composition.mjs';
import { declaredSet, bad } from './manifest.mjs';
import { verifiedPlugin } from './pinned.mjs';
import { hasModule } from './loader.mjs';
export {pluginName} from "../../organization/src/vocabulary.mjs";
import {pluginName} from "../../organization/src/vocabulary.mjs";
export function organizationPlugins(dataRoot,settings = {}) {
const names=new Set(Object.entries(settings).flatMap(([kind,v])=>(Array.isArray(v)?v:[v]).filter(name=>!hasModule(kind,name))));
const manifests=[],refused=[];
if(dataRoot!==null) for(const name of names){try {const m=verifiedPlugin(dataRoot,name).manifest;
if(m.kinds.every(k=>(Array.isArray(settings[k])?settings[k]:[settings[k]]).includes(name)))manifests.push(m);
}catch {refused.push(name);}}
const registry=buildRegistry({plugins:manifests.map(declaredSet)});
return {registry,manifests,refused,roleTemplates:manifests.flatMap(m=>m.roleTemplates)};
}
export function assertPluginCapabilities(role,view) {
const check = (cap,registered) => {
const name=pluginName(cap);if(!name)return;
if(!view.manifests.some(m=>m.name===name))bad('plugin-disabled',name);
if(!registered)bad('invalid-request');
};
for(const tool of role.tools ?? [])check(tool,view.registry.tools.includes(tool));
for(const action of [...(role.authority?.withinRole ?? []),...(role.authority?.crossRole ?? [])])check(action,view.registry.actions.includes(action));
const credentials=Array.isArray(role.credentials) ? role.credentials : Object.keys(role.credentials ?? {}).map(service=>({service}));
for(const credential of credentials) {
const {service,scopes}=credential;check(service,Object.hasOwn(view.registry.services,service));
if(pluginName(service) && scopes!==undefined && (!Array.isArray(scopes) || scopes.some(s=>!view.registry.services[service].scopes.includes(s))))bad('invalid-request');
}
}
+107
View File
@@ -0,0 +1,107 @@
// PL1: only these derived instance paths may supply installed plugin bytes.
import { createHash } from 'node:crypto';
import { constants, openSync, closeSync, fstatSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
import { isAbsolute, join, resolve } from 'node:path';
import { NAME } from './kinds.mjs';
import { bad, checkManifest, HASH } from './manifest.mjs';
import { guardPluginImports } from './imports.mjs';
// Node caches transitive modules by canonical path. Never attribute old cached
// code to a replacement pin, even after a failed import or a new loader instance.
const importedPins = new Map();
function checkImportedPin(dir, hash, name) {
if (importedPins.has(dir) && importedPins.get(dir) !== hash) bad('plugin-restart-required',name);
}
export function claimPluginImport({dir,manifest}) {
checkImportedPin(dir,manifest.sha256,manifest.name);
guardPluginImports(dir);
importedPins.set(dir,manifest.sha256); // before the first import attempt
}
export function pluginPaths(dataRoot) {
if (typeof dataRoot !== 'string' || !isAbsolute(dataRoot) || resolve(dataRoot) !== dataRoot) bad('plugin-root');
return {root:join(dataRoot,'plugins'),lock:join(dataRoot,'plugins.lock.json'),mutex:join(dataRoot,'plugins.install.lock')};
}
export function safeDirectory(path) {
let current = '/';
for (const part of resolve(path).split('/').filter(Boolean)) {
current = join(current,part); const st = lstatSync(current);
if (st.isSymbolicLink() || !st.isDirectory()) bad('unsafe-path');
}
}
function readRegular(path,expected=null) {
let fd;try {
fd=openSync(path,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
const st=fstatSync(fd);
if(!st.isFile() || st.nlink!==1 || (expected && (st.ino!==expected.ino || st.dev!==expected.dev)) || st.size>32*1024*1024)bad('unsafe-path');
return readFileSync(fd);
}finally{if(fd!==undefined)closeSync(fd);}
}
export function pluginFiles(dir) {
safeDirectory(dir);
const files = []; let size = 0;
function walk(rel = '') {
for (const n of readdirSync(join(dir,rel)).sort()) {
if (!/^[a-zA-Z0-9._-]+$/.test(n)) bad('unsafe-path');
const path = rel ? `${rel}/${n}` : n, st = lstatSync(join(dir,path));
if (st.uid!==process.getuid() || st.mode&0o022 || st.isSymbolicLink() || (!st.isDirectory() && !st.isFile()) || st.nlink > (st.isDirectory() ? Infinity : 1)) bad('unsafe-path');
if (st.isDirectory()) walk(path);
else { size += st.size; if (size > 32*1024*1024 || files.length > 2048) bad('plugin-too-large'); files.push([path,readRegular(join(dir,path),st)]); }
}
}
walk(); return files.sort(([a],[b]) => a < b ? -1 : a > b ? 1 : 0);
}
// Hash every byte, replacing only the top-level sha256 string with 64 zeroes to
// break the manifest's self-reference. Formatting and all other metadata stay pinned.
function manifestBytes(bytes) {
let text,value;try{text=new TextDecoder('utf-8',{fatal:true,ignoreBOM:true}).decode(bytes);value=JSON.parse(text);}catch{bad();}
if(!value || typeof value!=="object" || !HASH.test(value.sha256))bad();
let depth=0,found=0,result=text;
for(let i=0;i<text.length;i++) {
if(text[i]==='{' || text[i]==='['){depth++;continue;}
if(text[i]==='}' || text[i]===']'){depth--;continue;}
if(text[i]!== '\"')continue;
const start=i++;for(;i<text.length;i++){if(text[i]==='\\'){i++;continue;}if(text[i]==='\"')break;}
if(depth!==1 || JSON.parse(text.slice(start,i+1))!=="sha256")continue;
const rest=/^\s*:\s*"([a-f0-9]{64})"/.exec(text.slice(i+1));
if(!rest)continue;
const offset=i+1+rest[0].lastIndexOf(rest[1]);result=result.slice(0,offset)+'0'.repeat(64)+result.slice(offset+64);found++;
}
if(found!==1)bad();return Buffer.from(result);
}
export function hashFiles(files) {
const h=createHash('sha256');
for (let [path,bytes] of files) {
if(path==='plugin.json')bytes=manifestBytes(bytes);
h.update(`${Buffer.byteLength(path)}:${path}:${bytes.length}:`).update(bytes);
}
return h.digest('hex');
}
export const pluginHash = dir => hashFiles(pluginFiles(dir));
export function readPlugin(dir) {
const files=pluginFiles(dir); let manifest;
try { manifest=checkManifest(JSON.parse(files.find(([p])=>p==='plugin.json')?.[1])); } catch { bad('invalid-manifest'); }
if (hashFiles(files)!==manifest.sha256) bad('hash-mismatch',manifest.name);
const names=new Set(files.map(([p])=>p));
for (const p of [...manifest.kinds.map(k=>`${k}/index.mjs`),...manifest.tools.map(t=>t.entry),...manifest.roleTemplates.map(t=>t.file)]) if (!names.has(p)) bad('missing-file',manifest.name);
return {manifest,files};
}
export function readPluginLock(dataRoot) {
const {lock}=pluginPaths(dataRoot); safeDirectory(dataRoot);
const rootStat=lstatSync(dataRoot);if(rootStat.uid!==process.getuid() || rootStat.mode&0o022)bad('plugin-root');
let doc;
try { const st=lstatSync(lock); if (st.isSymbolicLink() || !st.isFile() || st.uid!==process.getuid() || st.mode&0o022) bad('invalid-lock'); doc=JSON.parse(readRegular(lock,st)); }
catch (e) { if(e.code==='ENOENT') return {version:1,plugins:{}}; bad('invalid-lock'); }
if (!doc || doc.version!==1 || Object.keys(doc).some(k=>!['version','plugins'].includes(k)) || !doc.plugins || Array.isArray(doc.plugins) || typeof doc.plugins!=='object') bad('invalid-lock');
for(const [n,p] of Object.entries(doc.plugins)) if(n.length>32 || !NAME.test(n) || !p || Object.keys(p).sort().join(',')!=='sha256,source,version' || !HASH.test(p.sha256) || typeof p.version!=='string') bad('invalid-lock');
return doc;
}
export function verifiedPlugin(dataRoot,name,kind=null) {
if(typeof name!=='string' || name.length>32 || !NAME.test(name)) bad('bad-name');
try {
const pin=readPluginLock(dataRoot).plugins[name]; if(!pin) bad('missing-lock',name);
const {root}=pluginPaths(dataRoot); const dir=join(root,name); const result=readPlugin(dir);
if(result.manifest.name!==name || result.manifest.sha256!==pin.sha256 || result.manifest.version!==pin.version) bad('hash-mismatch',name);
if(kind && !result.manifest.kinds.includes(kind)) bad('unknown-kind',name);
checkImportedPin(dir,result.manifest.sha256,name);
return {...result,dir};
} catch(e) { bad(['missing-lock','hash-mismatch','unknown-kind','unsafe-path','invalid-lock','plugin-restart-required'].includes(e.code)?e.code:'plugin-unavailable',name); }
}
+2 -2
View File
@@ -2,8 +2,8 @@ import { loadInstalled } from './loader.mjs';
import { NAME } from './kinds.mjs';
// Discovery is fail-closed: malformed or unloadable installed modules cannot be advertised.
export async function installedSystemTypes() {
const { modules, refused } = await loadInstalled('system-type');
export async function installedSystemTypes({dataRoot = null} = {}) {
const { modules, refused } = await loadInstalled('system-type',{dataRoot});
if (refused.length) throw Object.assign(new Error('system type module refused'), { code: 'system-type-invalid' });
return modules.map(({ name, module }) => {
const value = module.systemType();
+2 -2
View File
@@ -111,8 +111,8 @@ export function checkTheme(value, name = null) {
// Every installed theme module that loads and checks, as [{ id, displayName, tokens }] sorted by
// id, and what was refused. Each refusal goes to `log` as the loader's plugin-refused entry.
// Nothing names the themes: loadInstalled lists packages/plugins/theme/.
export async function installedThemes({ log = () => {}, barrier } = {}) {
const { modules, refused } = await loadInstalled("theme", { barrier });
export async function installedThemes({ log = () => {}, barrier, dataRoot = null } = {}) {
const { modules, refused } = await loadInstalled("theme", { barrier, dataRoot });
const themes = [], problems = [...refused];
for (const { name, module } of modules) {
try {
+61
View File
@@ -0,0 +1,61 @@
// Trusted host dispatch for declared tools. No plugin code is imported until the
// current organization setting and the complete installed package pass verification.
import { spawn } from 'node:child_process';
import { existsSync, realpathSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
import { organizationPlugins, assertPluginCapabilities, pluginName } from './organization.mjs';
import { verifiedPlugin, claimPluginImport } from './pinned.mjs';
import { bad, validArguments } from './manifest.mjs';
const SCRIPT = `let input=''; for await(const b of process.stdin)input+=b;
const {guardPluginImports}=await import('file:///plugin-guard.mjs');guardPluginImports('/plugin');
const {entry,args}=JSON.parse(input); const m=await import(entry);
process.stdout.write(JSON.stringify(await m.execute(args)));`;
function sandboxTool(dir,entry,args) {
// A fresh mount/network/user namespace with only runtime libraries and the pinned package.
// No home, config, workspace, sockets or inherited environment is exposed.
const binds=['/usr','/lib','/lib64','/bin'].filter(existsSync).flatMap(p=>['--ro-bind',realpathSync(p),p]);
return new Promise((resolve,reject)=>{
const child=spawn('bwrap',['--die-with-parent','--unshare-all','--new-session','--proc','/proc','--dev','/dev','--tmpfs','/tmp',...binds,'--ro-bind',realpathSync(process.execPath),'/node','--ro-bind',fileURLToPath(new URL('./imports.mjs',import.meta.url)),'/plugin-guard.mjs','--ro-bind',dir,'/plugin','--chdir','/plugin','--clearenv','/node','--input-type=module','-e',SCRIPT],{env:{PATH:'/usr/bin:/bin'},stdio:['pipe','pipe','pipe']});
let out='',size=0;const timer=setTimeout(()=>child.kill('SIGKILL'),30000);
child.stdout.on('data',b=>{size+=b.length;if(size>1024*1024)child.kill('SIGKILL');else out+=b;});
child.stderr.resume();child.on('error',()=>{clearTimeout(timer);reject(new Error('sandbox refused'));});
child.stdin.on('error',()=>{});child.stdin.end(JSON.stringify({entry:`file:///plugin/${entry}`,args}));
child.on('close',code=>{clearTimeout(timer);try{if(code!==0 || size>1024*1024)throw Error();resolve(JSON.parse(out));}catch{reject(new Error('sandbox refused'));}});
});
}
export function pluginService({dataRoot,read}) {
function check(business,instance) {
const b=read(business);const entry=b.roles?.[instance];let role=b.definitions?.[entry?.definition] ?? entry;
if(!role)bad('plugin-disabled');
role=structuredClone(role);
for(const vars of [b.vars,entry?.vars]) {
if(Array.isArray(vars?.['limits.tools']))role.tools=(role.tools ?? []).filter(t=>vars['limits.tools'].includes(t));
if(Array.isArray(vars?.['limits.authority']) && role.authority)for(const key of ['withinRole','crossRole'])role.authority[key]=(role.authority[key] ?? []).filter(a=>vars['limits.authority'].includes(a));
}
const view=organizationPlugins(dataRoot,b.plugins);assertPluginCapabilities(role,view);return {role,view};
}
async function call(business,instance,name,args) {
const prefix=pluginName(name);if(!prefix)bad('invalid-request');
const {role,view}=check(business,instance);
if(!role.tools?.includes(name))bad('not-authorized');
const tool=view.manifests.flatMap(m=>m.tools).find(t=>t.name===name);if(!tool)bad('plugin-disabled',prefix);
if(!validArguments(args,tool.arguments))bad('invalid-request');
const checked=verifiedPlugin(dataRoot,prefix),{dir,manifest}=checked;
if(tool.runs==='sandbox') {
try {return await sandboxTool(dir,tool.entry,structuredClone(args));}
catch {bad('plugin-tool-failed',prefix);}
}
claimPluginImport(checked);
let module;
try {module=await import(pathToFileURL(join(dir,tool.entry)).href+`?pin=${manifest.sha256}`);}
catch {bad('plugin-tool-failed',prefix);}
// A concurrent disable while import was in flight must refuse before execute.
check(business,instance);verifiedPlugin(dataRoot,prefix);
if(typeof module.execute!=='function')bad('missing-export');
// Plugin code cannot impersonate a trusted refusal by throwing a matching error name.
try {return await module.execute(structuredClone(args));}
catch {bad('plugin-tool-failed',prefix);}
}
return {check,call};
}
+3
View File
@@ -27,6 +27,9 @@ export function tree(t) {
t.after(() => rmSync(root, { recursive: true, force: true }));
const pkg = join(root, "packages", "plugins");
cpSync(SRC, join(pkg, "src"), { recursive: true });
const org = join(root,"packages","organization","src");
mkdirSync(org,{recursive:true});
for (const file of ["composition.mjs","vocabulary.mjs","errors.mjs","util.mjs"]) cpSync(join(SRC,"..","..","organization","src",file),join(org,file));
const write = (kind, name, body) => {
const dir = join(pkg, kind, name);
mkdirSync(dir, { recursive: true });
+1
View File
@@ -105,6 +105,7 @@ test("REQ-PLUG-1 a symlinked packages/plugins refuses (run with --preserve-symli
t.after(() => rmSync(other, { recursive: true, force: true }));
mkdirSync(join(other, "packages"));
symlinkSync(s.pkg, join(other, "packages", "plugins"));
symlinkSync(join(s.root,"packages","organization"), join(other,"packages","organization"));
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) });
+298
View File
@@ -0,0 +1,298 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { checkManifest } from '../src/manifest.mjs';
import { pluginHash, readPluginLock, verifiedPlugin } from '../src/pinned.mjs';
import { installPlugin, removePlugin } from '../src/install.mjs';
import { createLoader } from '../src/loader.mjs';
import { organizationPlugins, assertPluginCapabilities } from '../src/organization.mjs';
import { pluginService } from '../src/tools.mjs';
import { typedTools } from '../../harness/src/tools.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { pluginInstallCommand } from '../../cli/src/plugin-install.mjs';
import { pluginSettings } from '../../organization/src/plugin-settings.mjs';
import { hasModule } from '../src/loader.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
const SECRET='sk-test-NOT-A-SECRET-1234';
function fixture(t) {
const root=mkdtempSync(join(tmpdir(),'pl1-')),src=join(root,'src'),dataRoot=join(root,'data');
mkdirSync(src);mkdirSync(dataRoot);mkdirSync(join(src,'usage-source'));mkdirSync(join(src,'tools'));
t.after(()=>{const thaw=p=>{const st=lstatSync(p);if(st.isSymbolicLink())return;if(st.isDirectory()){chmodSync(p,0o700);for(const n of readdirSync(p))thaw(join(p,n));}};thaw(root);rmSync(root,{recursive:true,force:true});});
writeFileSync(join(src,'usage-source','index.mjs'),'export const interfaceVersion=1; export const readTurn=()=>"fixture";');
writeFileSync(join(src,'tools','echo.mjs'),`export const execute=(args)=>({answer:args.text}); // ${SECRET}`);
const manifest={name:'acmeplug',version:'1.0.0',kinds:['usage-source'],interfaceVersions:{'usage-source':1},sha256:'0'.repeat(64),source:{path:'fixture',version:'1.0.0'},signature:null,tools:[{name:'acmeplug.echo',description:SECRET,arguments:{type:'object',properties:{text:{type:'string'}},required:['text'],additionalProperties:false},runs:'host',entry:'tools/echo.mjs'}],actions:['acmeplug.publish'],services:[{name:'acmeplug.account',scopes:['read']}],roleTemplates:[{name:'acmeplug.writer',file:'writer.json'}]};
writeFileSync(join(src,'writer.json'),'{}');
const seal=()=>{writeFileSync(join(src,'plugin.json'),JSON.stringify(manifest));manifest.sha256=pluginHash(src);writeFileSync(join(src,'plugin.json'),JSON.stringify(manifest));return manifest.sha256;};
const hash=seal();const install=(source=src,extra={})=>installPlugin({dataRoot,source,expectedHash:hash,...extra});
return {root,src,dataRoot,manifest,seal,hash,install};
}
const refused=code=>e=>e.code===code && !e.message.includes(SECRET);
test('PL1 strict fields, kinds, core names, prefixes, hard waits and traversal refuse without echo',t=>{
const f=fixture(t);
for(const mutate of [m=>m.extra=SECRET,m=>m.tools[0].extra=SECRET,m=>m.kinds=['unknown'],m=>m.tools[0].name='read',m=>m.tools[0].name='acmeplug.read',m=>m.actions=['acmeplug.deploy'],m=>m.actions=['acmeplug.git.push.remote'],m=>m.roleTemplates[0].file='../file',m=>m.tools[0].arguments.$ref=SECRET,m=>m.name='git']){
const m=structuredClone(f.manifest);mutate(m);assert.throws(()=>checkManifest(m),e=>!e.message.includes(SECRET));
}
});
test('PL1 directory install is pinned and read-only; host tools printed before any instance write',t=>{
const f=fixture(t),printed=[];const entry=f.install(f.src,{print:line=>{assert.deepEqual(readPluginLock(f.dataRoot).plugins,{});assert.equal(existsSync(join(f.dataRoot,'plugins')),false);printed.push(line);}});
assert.equal(entry.sha256,f.hash);assert.deepEqual(printed,['host tool: acmeplug.echo']);
for(const p of ['plugins/acmeplug','plugins/acmeplug/tools'])assert.equal(lstatSync(join(f.dataRoot,p)).mode&0o777,0o555);
assert.equal(lstatSync(join(f.dataRoot,'plugins/acmeplug/plugin.json')).mode&0o777,0o444);
assert.equal(verifiedPlugin(f.dataRoot,'acmeplug').manifest.sha256,f.hash);
});
test('PL1 mismatch and symlink sources refuse before instance writes',t=>{
const f=fixture(t);assert.throws(()=>f.install(f.src,{expectedHash:'f'.repeat(64)}),refused('hash-mismatch'));
symlinkSync('/etc/passwd',join(f.src,'escape'));assert.throws(()=>f.install(),refused('unsafe-path'));
assert.equal(existsSync(join(f.dataRoot,'plugins')),false);
});
test('PL1 zip traversal, symlinks, and git URL without a full commit refuse',t=>{
const f=fixture(t);const zip=join(f.root,'bad.zip');
for(const script of ["z.writestr('../escape','bad')","e=zipfile.ZipInfo('link');e.create_system=3;e.external_attr=(0o120777<<16);z.writestr(e,'/etc/passwd')"]){
assert.equal(spawnSync('python3',['-c',`import zipfile\nz=zipfile.ZipFile(${JSON.stringify(zip)},'w')\n${script}\nz.close()`]).status,0);
assert.throws(()=>f.install(zip),refused('source-refused'));
}
assert.throws(()=>f.install('https://example.invalid/repo.git'),refused('commit-required'));
assert.throws(()=>f.install(f.src,{commit:'abc'}),refused('commit-required'));
});
test('PL1 directory, generated zip and local bare git at full commit land the same hash',t=>{
const f=fixture(t);const zip=join(f.root,'source.zip');
assert.equal(spawnSync('python3',['-c',`import zipfile,os\nz=zipfile.ZipFile(${JSON.stringify(zip)},'w')\nfor root,dirs,files in os.walk(${JSON.stringify(f.src)}):\n for n in files:z.write(os.path.join(root,n),os.path.relpath(os.path.join(root,n),${JSON.stringify(f.src)}))\nz.close()`]).status,0);
const git=(args)=>{const r=spawnSync('git',args,{encoding:'utf8'});assert.equal(r.status,0,r.stderr);return r.stdout.trim();};
git(['init',f.src]);git(['-C',f.src,'add','.']);git(['-C',f.src,'-c','user.name=Fixture','-c','[email protected]','commit','-m','fixture']);
const commit=git(['-C',f.src,'rev-parse','HEAD']),bare=join(f.root,'bare.git');git(['clone','--bare',f.src,bare]);
// The local directory used for install intentionally has no .git administrative files.
rmSync(join(f.src,'.git'),{recursive:true});
for(const [source,opts]of [[f.src,{}],[zip,{}],[bare,{commit}]]){
const d=mkdtempSync(join(f.root,'instance-'));const r=installPlugin({dataRoot:d,source,expectedHash:f.hash,...opts});assert.equal(r.sha256,f.hash);assert.equal(readPluginLock(d).plugins.acmeplug.sha256,f.hash);
}
});
test('PL1 changed file/missing lock refuses next load and evicts installed code, core keeps running',async t=>{
const f=fixture(t);f.install();const logs=[],loader=createLoader({dataRoot:f.dataRoot,log:e=>logs.push(e)});
const settings={'usage-source':['transcript','acmeplug'],harness:['pi']};assert.equal((await loader.load(settings)).refused.length,0);
const file=join(f.dataRoot,'plugins/acmeplug/tools/echo.mjs');chmodSync(file,0o600);writeFileSync(file,SECRET);
const result=await loader.load(settings);assert.equal(result.refused[0].code,'hash-mismatch');assert.deepEqual(loader.loaded()['usage-source'],['transcript']);assert.deepEqual(loader.loaded().harness,['pi']);
assert.ok(!JSON.stringify(logs).includes(SECRET));
rmSync(join(f.dataRoot,'plugins.lock.json'));assert.equal((await loader.load(settings)).refused[0].code,'missing-lock');
});
test('PL1 registry includes only enabled verified declarations, services and templates',t=>{
const f=fixture(t);f.install();const view=organizationPlugins(f.dataRoot,{'usage-source':['acmeplug']});
assert.ok(view.registry.tools.includes('acmeplug.echo'));assert.ok(view.registry.routed.includes('acmeplug.publish'));assert.deepEqual(view.registry.services['acmeplug.account'].scopes,['read']);assert.equal(view.roleTemplates[0].name,'acmeplug.writer');
for(const settings of [{},{'usage-source':['missing']}])assert.throws(()=>assertPluginCapabilities({tools:['acmeplug.echo']},organizationPlugins(f.dataRoot,settings)),e=>e.code==='plugin-disabled' && e.plugin==='acmeplug' && !e.message.includes(SECRET));
});
test('PL1 disabled or missing tools/actions refuse at launch with name only, enabled tool is bundled',t=>{
const f=fixture(t);f.install();const definition={name:'writer',contractPath:'/contract',tools:['acmeplug.echo'],network:'none',authority:{withinRole:['acmeplug.publish'],crossRole:[]},credentials:[],messaging:null,skills:[]};
const business={id:'acme',roles:{writer:{definition:'writer',vars:{},credentials:{}}},definitions:{writer:definition},vars:{},arbiters:{}};
const args={system:{dataRoot:f.dataRoot},business,instance:'writer',enforcePlugins:true};
assert.throws(()=>resolveInstance(args),e=>e.code==='plugin-disabled' && e.plugin==='acmeplug');
business.plugins={'usage-source':['acmeplug']};const r=resolveInstance(args);assert.equal(typedTools({resolved:r}).find(t=>t.name==='acmeplug.echo').pluginTool,'acmeplug.echo');
business.plugins={};assert.throws(()=>resolveInstance(args),refused('plugin-disabled'));
});
test('PL1 running tool calls recheck enabled setting, pin, arguments and held tool',async t=>{
const f=fixture(t);f.install();const role={tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}};let enabled=true;
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:enabled?{'usage-source':['acmeplug']}:{},roles:{writer:role}})});
assert.deepEqual(await service.call('acme','writer','acmeplug.echo',{text:'hello'}),{answer:'hello'});
await assert.rejects(service.call('acme','writer','acmeplug.echo',{unexpected:SECRET}),refused('invalid-request'));
enabled=false;await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:SECRET}),e=>e.code==='plugin-disabled' && e.plugin==='acmeplug' && !e.message.includes(SECRET));
});
test('PL1 remove refuses enabled organizations; host CLI rejects organization/tester options and agents',async t=>{
const f=fixture(t);f.install();assert.throws(()=>removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>true}),refused('plugin-enabled'));
const dir=join(f.root,'config');mkdirSync(dir,{mode:0o700});let out='';const io={env:{},stdout:{write:v=>out+=v}};
for(const args of [['install',f.src,'--sha256',f.hash,'--org','acme'],['install',f.src,'--sha256',f.hash,'--tester']])await assert.rejects(pluginInstallCommand(args,io,{system:{dataRoot:f.dataRoot},dir}));
await assert.rejects(pluginInstallCommand(['list'],{...io,env:{MOSAIC_RUN_ID:'fixture'}},{system:{dataRoot:f.dataRoot},dir}));
writeJson(join(dir,'businesses','acme.json'),{plugins:{'usage-source':['acmeplug']}});
await assert.rejects(pluginInstallCommand(['remove','acmeplug'],io,{system:{dataRoot:f.dataRoot},dir}),e=>e.message.includes('plugin-enabled'));
});
test('PL1 enabling a tampered installed plugin refuses before organization write',t=>{
const f=fixture(t);f.install();const doc=businessDoc(f.root),rolesDir=rolesCopy(f.root);writeJson(join(f.root,'businesses','acme.json'),doc);
const settings=pluginSettings({dir:f.root,rolesDir,dataRoot:f.dataRoot,exists:(k,n)=>hasModule(k,n,f.dataRoot)});
const before=settings.read('acme');const file=join(f.dataRoot,'plugins/acmeplug/tools/echo.mjs');chmodSync(file,0o600);writeFileSync(file,SECRET);
assert.throws(()=>settings.set('acme',{revision:before.revision,plugins:{'usage-source':['acmeplug']}},'jason'),refused('invalid-plugins'));
assert.deepEqual(settings.read('acme'),before);
});
test('PL1 sandbox tools execute with no home or network namespace access',async t=>{
const f=fixture(t);f.manifest.tools[0].runs='sandbox';
writeFileSync(join(f.src,'tools/echo.mjs'),`import {existsSync} from 'node:fs';export const execute=()=>({home:existsSync('/home'),config:existsSync('/mnt/storage')});`);
const hash=f.seal();f.install(f.src,{expectedHash:hash});
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})});
assert.deepEqual(await service.call('acme','writer','acmeplug.echo',{text:'hello'}),{home:false,config:false});
});
test('PL1 broker launch and running dispatch enforce enable state; organization install and member/agent enable refuse',async t=>{
const {Store}=await import('../../bus/src/store.mjs');const {Broker}=await import('../../bus/src/broker.mjs');
const f=fixture(t);f.install();const authority={withinRole:[],crossRole:[]};
const business={id:'acme',human:'owner',plugins:{'usage-source':['acmeplug']},arbiters:{technical:'chief',delivery:'chief'},roles:{chief:{authority},writer:{tools:['acmeplug.echo'],authority}}};
const service=pluginService({dataRoot:f.dataRoot,read:()=>business});const store=new Store(f.dataRoot);t.after(()=>store.close());
const b=new Broker({store,businesses:{acme:business},pluginService:service});
const cap=b.bindLaunch({business:'acme',role:'writer',run:'writer-1',harness:'pi'});b.request(cap,{verb:'role.claim'});
assert.deepEqual(await b.request(cap,{verb:'plugin.tool',args:{tool:'acmeplug.echo',arguments:{text:'working'}}}),{answer:'working'});
const member=b.bindReader({business:'acme'});
for(const c of [cap,member])assert.throws(()=>b.request(c,{verb:'plugins.set',args:{revision:'a'.repeat(64),plugins:{'usage-source':['acmeplug']}}}),/human-required/);
assert.throws(()=>b.request(cap,{verb:'plugins.install',args:{source:SECRET}}),e=>!e.message.includes(SECRET));
business.plugins={};
await assert.rejects(b.request(cap,{verb:'plugin.tool',args:{tool:'acmeplug.echo',arguments:{text:SECRET}}}),e=>e.code==='plugin-disabled' && e.reason==='acmeplug' && !e.message.includes(SECRET));
assert.throws(()=>b.bindLaunch({business:'acme',role:'writer',run:'writer-2',harness:'pi'}),e=>e.code==='plugin-disabled' && e.reason==='acmeplug');
assert.equal(b.bindLaunch({business:'acme',role:'chief',run:'chief-1',harness:'pi'}).length,64,'core can still launch');
});
test('PL1 registry preserves capability categories and credential scope ceilings',t=>{
const f=fixture(t);f.install();const view=organizationPlugins(f.dataRoot,{'usage-source':['acmeplug']});
for(const role of [{tools:['acmeplug.publish']},{authority:{withinRole:['acmeplug.echo']}},{credentials:[{service:'acmeplug.account',scopes:['admin']}]}])assert.throws(()=>assertPluginCapabilities(role,view),refused('invalid-request'));
});
test('PL1 manifest whitespace is pinned; remove succeeds after the owner disables it',t=>{
const f=fixture(t);f.install();const file=join(f.dataRoot,'plugins/acmeplug/plugin.json');chmodSync(file,0o600);writeFileSync(file,readFileSync(file,'utf8')+'\n');
assert.throws(()=>verifiedPlugin(f.dataRoot,'acmeplug'),refused('hash-mismatch'));
removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>false});
assert.deepEqual(readPluginLock(f.dataRoot).plugins,{});assert.equal(existsSync(join(f.dataRoot,'plugins/acmeplug')),false);
});
test('PL1 CLI host install/list/remove never calls a bus transport',async t=>{
const {main}=await import('../../cli/src/cli.mjs');const f=fixture(t),dir=join(f.root,'config');mkdirSync(dir,{mode:0o700});
let output='';const io={env:{},stdout:{write:v=>output+=v},stderr:{write:()=>{}}};const deps={system:{dataRoot:f.dataRoot},accountsConfigDir:dir,transport:()=>{throw Error('bus must not be used');}};
await main(['plugins','install',f.src,'--sha256',f.hash],io,deps);assert.match(output,/host tool: acmeplug.echo/);assert.ok(!output.includes(SECRET));
output='';await main(['plugins','list'],io,deps);assert.equal(JSON.parse(output).plugins.acmeplug.sha256,f.hash);
output='';await main(['plugins','remove','acmeplug'],io,deps);assert.deepEqual(JSON.parse(output),{removed:'acmeplug'});
});
test('PL1 a running plugin tool obeys a newly narrowed tool ceiling',async t=>{
const f=fixture(t);f.install();const b={plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]},vars:{}}}};
const service=pluginService({dataRoot:f.dataRoot,read:()=>b});assert.deepEqual(await service.call('acme','writer','acmeplug.echo',{text:'before'}),{answer:'before'});
b.roles.writer.vars['limits.tools']=['read'];await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:'after'}),refused('not-authorized'));
});
test('PL1 an installed package cannot reuse a core module name from another kind',t=>{
const f=fixture(t);f.manifest.name='pi';for(const tool of f.manifest.tools)tool.name=tool.name.replace('acmeplug.','pi.');f.manifest.actions=['pi.publish'];f.manifest.services[0].name='pi.account';f.manifest.roleTemplates[0].name='pi.writer';
const hash=f.seal();assert.throws(()=>f.install(f.src,{expectedHash:hash}),refused('core-name'));
assert.equal(existsSync(join(f.dataRoot,'plugins')),false);
});
test('PL1 malformed settings cannot retain an installed module whose pin is now bad',async t=>{
const f=fixture(t);f.install();const loader=createLoader({dataRoot:f.dataRoot,log:()=>{}});await loader.load({'usage-source':['transcript','acmeplug'],harness:['pi']});
const file=join(f.dataRoot,'plugins/acmeplug/tools/echo.mjs');chmodSync(file,0o600);writeFileSync(file,SECRET);
await loader.load(null);assert.deepEqual(loader.loaded()['usage-source'],['transcript']);assert.deepEqual(loader.loaded().harness,['pi']);
});
test('PL1 runtime discovers installed harness levels from the instance root',async t=>{
const {harnessLevels}=await import('../../bus/src/runtime.mjs');
const {KINDS}=await import('../src/kinds.mjs');
const f=fixture(t);f.manifest.kinds=['harness'];f.manifest.interfaceVersions={harness:1};
mkdirSync(join(f.src,'harness'));
writeFileSync(join(f.src,'harness/index.mjs'),KINDS.harness.exports.map(name=>`export function ${name}(){return ${name==='levels'?"['high']":'null'};}`).join('\n')+'\nexport const interfaceVersion=1;');
const hash=f.seal();f.install(f.src,{expectedHash:hash});
const levels=await harnessLevels({dataRoot:f.dataRoot,log:()=>{}});
assert.deepEqual(levels('acmeplug'),['high']);assert.ok(levels.names.includes('pi'));
});
test('PL1 invalid UTF-8 manifest bytes refuse instead of normalizing outside the pin',t=>{
const f=fixture(t),path=join(f.src,'plugin.json'),bytes=readFileSync(path);
bytes[bytes.indexOf(SECRET)]=0xff;writeFileSync(path,bytes);
assert.throws(()=>pluginHash(f.src),refused('invalid-manifest'));
});
test('PL1 tool exceptions cannot impersonate trusted refusals or echo payload bytes',async t=>{
const f=fixture(t);
writeFileSync(join(f.src,'tools/echo.mjs'),`export function execute(){throw Object.assign(new Error(${JSON.stringify(SECRET)}),{name:'PluginError',code:'plugin-disabled',plugin:${JSON.stringify(SECRET)}});}`);
const hash=f.seal();f.install(f.src,{expectedHash:hash});
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})});
await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:'hello'}),e=>e.code==='plugin-tool-failed' && e.plugin==='acmeplug' && !e.message.includes(SECRET));
});
test('PL1 A3 organization copy readers preserve the derived installed-plugin root',async t=>{
const {withCopies,loadWithCopies}=await import('../../organization/src/agent-copies.mjs');
const f=fixture(t);f.manifest.kinds=['system-type'];f.manifest.interfaceVersions={'system-type':1};
mkdirSync(join(f.src,'system-type'));writeFileSync(join(f.src,'system-type/index.mjs'),'export const interfaceVersion=1;export function systemType(){return {}};');
const hash=f.seal();f.install(f.src,{expectedHash:hash});
const doc=businessDoc(f.root),rolesDir=rolesCopy(f.root);doc.plugins={...doc.plugins,'system-type':['acmeplug']};writeJson(join(f.root,'businesses/acme.json'),doc);
const options={rolesDir,dataRoot:f.dataRoot,snapshot:null};
assert.deepEqual(withCopies(doc,join(f.root,'businesses/acme.json'),options).plugins['system-type'],['acmeplug']);
assert.deepEqual(loadWithCopies('acme',{...options,dir:f.root}).plugins['system-type'],['acmeplug']);
const installed=join(f.dataRoot,'plugins/acmeplug/system-type/index.mjs');chmodSync(installed,0o600);writeFileSync(installed,SECRET);
// Config checks names only; the loader owns bad-pin refusal and preserves core.
assert.deepEqual(withCopies(doc,join(f.root,'businesses/acme.json'),options).plugins['system-type'],['acmeplug']);
doc.plugins['system-type']=['defualt'];
assert.throws(()=>withCopies(doc,join(f.root,'businesses/acme.json'),options),/unknown module/);
});
test('PL1 replacement pins refuse across loader and host tools until a fresh process',async t=>{
for(const first of ['loader','tool']) {
const f=fixture(t);
writeFileSync(join(f.src,'helper.mjs'),'export const value=1;');
writeFileSync(join(f.src,'usage-source/index.mjs'),"import {value} from '../helper.mjs';export const interfaceVersion=1;export const readTurn=()=>value;");
writeFileSync(join(f.src,'tools/echo.mjs'),"import {value} from '../helper.mjs';export const execute=()=>value;");
const pin=f.seal();f.install(f.src,{expectedHash:pin});
const settings={'usage-source':['transcript','acmeplug'],harness:['pi']};
const loader=createLoader({dataRoot:f.dataRoot,log:()=>{}});
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:settings,roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})});
if(first==='loader'){await loader.load(settings);assert.equal(loader.modules('usage-source')[1].readTurn(),1);}
else assert.equal(await service.call('acme','writer','acmeplug.echo',{text:'before'}),1);
removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>false});
writeFileSync(join(f.src,'helper.mjs'),'export const value=2;');f.manifest.version='2.0.0';
const next=f.seal();assert.notEqual(next,pin);f.install(f.src,{expectedHash:next});
// Verification, new loaders, and the other import path share one process latch.
assert.throws(()=>verifiedPlugin(f.dataRoot,'acmeplug'),refused('plugin-restart-required'));
await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:'after'}),refused('plugin-disabled'));
const freshLoader=createLoader({dataRoot:f.dataRoot,log:()=>{}}),result=await freshLoader.load(settings);
assert.equal(result.refused[0].code,'plugin-restart-required');
assert.deepEqual(result.loaded['usage-source'],['transcript']);assert.deepEqual(result.loaded.harness,['pi']);
await loader.load(null);assert.deepEqual(loader.loaded()['usage-source'],first==='loader'?['transcript']:[]);
const script=`import {createLoader} from ${JSON.stringify(import.meta.resolve('../src/loader.mjs'))};
const loader=createLoader({dataRoot:process.argv[1],log:()=>{}});const r=await loader.load({'usage-source':['acmeplug']});
console.log(JSON.stringify({refused:r.refused,value:loader.modules('usage-source')[0].readTurn()}));`;
const child=spawnSync(process.execPath,['--input-type=module','-e',script,f.dataRoot],{encoding:'utf8'});
assert.equal(child.status,0,child.stderr);assert.deepEqual(JSON.parse(child.stdout),{refused:[],value:2});
}
});
test('PL1 even a failed import claims its package pin before dependencies enter the cache',async t=>{
const f=fixture(t);writeFileSync(join(f.src,'helper.mjs'),'export const value=1;');
writeFileSync(join(f.src,'usage-source/index.mjs'),"import '../helper.mjs';throw Error('fixture');");
let pin=f.seal();f.install(f.src,{expectedHash:pin});
const loader=createLoader({dataRoot:f.dataRoot,log:()=>{}});
assert.equal((await loader.load({'usage-source':['acmeplug']})).refused[0].code,'load-failed');
removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>false});
writeFileSync(join(f.src,'usage-source/index.mjs'),'export const interfaceVersion=1;export const readTurn=()=>2;');
pin=f.seal();f.install(f.src,{expectedHash:pin});
assert.equal((await loader.load({'usage-source':['transcript','acmeplug']})).refused[0].code,'plugin-restart-required');
assert.deepEqual(loader.loaded()['usage-source'],['transcript']);
});
test('PL1 plugin imports refuse unpinned bare, escaping, dynamic and require dependencies',async t=>{
for(const spec of ['extdep','../../../outside.mjs']) {
const f=fixture(t),marker=join(f.dataRoot,'executed');
const outside=`import {writeFileSync} from 'node:fs';writeFileSync(${JSON.stringify(marker)},'ran');export const value=1;`;
mkdirSync(join(f.dataRoot,'node_modules/extdep'),{recursive:true});
writeFileSync(join(f.dataRoot,'node_modules/extdep/package.json'),JSON.stringify({type:'module',main:'index.mjs'}));
writeFileSync(join(f.dataRoot,'node_modules/extdep/index.mjs'),outside);writeFileSync(join(f.dataRoot,'outside.mjs'),outside);
writeFileSync(join(f.src,'usage-source/index.mjs'),`import ${JSON.stringify(spec)};export const interfaceVersion=1;export const readTurn=()=>1;`);
const pin=f.seal();f.install(f.src,{expectedHash:pin});
const result=await createLoader({dataRoot:f.dataRoot,log:()=>{}}).load({'usage-source':['transcript','acmeplug'],harness:['pi']});
assert.equal(result.refused[0].code,'load-failed');assert.deepEqual(result.loaded['usage-source'],['transcript']);assert.deepEqual(result.loaded.harness,['pi']);assert.equal(existsSync(marker),false);
}
for(const body of ["return (await import('../../../outside.mjs')).value;","return require('../../../outside.cjs');"]) {
const f=fixture(t),marker=join(f.dataRoot,'executed');
writeFileSync(join(f.dataRoot,'outside.mjs'),`import {writeFileSync} from 'node:fs';writeFileSync(${JSON.stringify(marker)},'ran');export const value=1;`);
writeFileSync(join(f.dataRoot,'outside.cjs'),`require('node:fs').writeFileSync(${JSON.stringify(marker)},'ran');module.exports=1;`);
writeFileSync(join(f.src,'tools/echo.mjs'),`import {createRequire} from 'node:module';const require=createRequire(import.meta.url);export async function execute(){${body}}`);
const pin=f.seal();f.install(f.src,{expectedHash:pin});
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})});
await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:SECRET}),refused('plugin-tool-failed'));assert.equal(existsSync(marker),false);
}
});
test('PL1 sandbox tools also refuse executable data URLs outside the pinned package',async t=>{
const f=fixture(t);f.manifest.tools[0].runs='sandbox';
writeFileSync(join(f.src,'tools/echo.mjs'),"export async function execute(){return (await import('data:text/javascript,export default 42')).default;}");
const pin=f.seal();f.install(f.src,{expectedHash:pin});
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})});
await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:SECRET}),refused('plugin-tool-failed'));
});
test('PL1 lock package names use the manifest length bound',t=>{
const f=fixture(t);writeFileSync(join(f.dataRoot,'plugins.lock.json'),JSON.stringify({version:1,plugins:{['a'.repeat(33)]:{version:'1.0.0',source:f.manifest.source,sha256:f.hash}}}),{mode:0o600});
assert.throws(()=>readPluginLock(f.dataRoot),refused('invalid-lock'));
assert.throws(()=>verifiedPlugin(f.dataRoot,'a'.repeat(33)),refused('bad-name'));
});
+4
View File
@@ -8,6 +8,7 @@ import { startBroker } from '../../packages/bus/src/runtime.mjs';
import { grantDecision } from '../../packages/bus/tests/host-fixtures.mjs';
import { launchDoc } from '../../packages/organization/tests/helpers.mjs';
import { fakeHarnesses, fixture } from '../../packages/cli/tests/helpers.mjs';
import { requestHostOps } from '../../packages/cli/tests/request-host-ops.mjs';
import { bootConfig, loadSystem } from '../../packages/cli/src/config.mjs';
import { createLauncher } from '../../packages/cli/src/launcher.mjs';
import { SESSION_CREDENTIALS } from '../../packages/seat/src/session.mjs';
@@ -101,6 +102,9 @@ export async function launchFixture(root, entry, { fetchToken, seatToken, humanT
landHeld: m => runtime.landHeld(m.record), landHolders: m => runtime.landHolders(m.business),
landRelease: m => runtime.landRelease(m.record), sessionClears: m => runtime.sessionClears(m.business),
lifecycleRequests: m => runtime.lifecycleRequests(m.business),
// f23a998ee (#797) made every launch call request.admission; this fixture was the one host
// left without the request ops, so S3b's launch was refused. Shared with the cli fixtures.
...requestHostOps(runtime),
};
const wrap = fn => async value => {
try { return await fn(value); }