Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afe131444c | ||
|
|
0b52249cc3 | ||
|
|
b7c9eee603 | ||
|
|
1d07df0d61 | ||
|
|
be698e602b | ||
|
|
d085b16136 | ||
|
|
4c8bf9ca4e | ||
|
|
e588fb32cb | ||
|
|
e3577e9d31 | ||
|
|
99ce579e3c | ||
|
|
b44b14c7df |
No files matched your search
@@ -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([
|
||||
@@ -193,6 +193,15 @@ const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collabora
|
||||
export const BOARD_VERBS = Object.freeze(['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
|
||||
// The person's task-store writes, distinct from the agent's singular task.* actions.
|
||||
export const PERSON_TASK_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete']);
|
||||
// #1873 T1: the human's definition writes, each with its [allowed, required] argument keys. Kept here,
|
||||
// not imported, as the sandbox client loads this file; runtime.mjs maps each verb to the verb module
|
||||
// (organization/src/definition-verbs.mjs names the same four, and a test holds them equal).
|
||||
export const DEFINITION_BUS_KEYS = Object.freeze({
|
||||
'definition.import': [['revision', 'template', 'as'], ['revision', 'template']],
|
||||
'definition.invent': [['revision', 'name', 'definition'], ['revision', 'name', 'definition']],
|
||||
'definition.edit': [['revision', 'rev', 'name', 'definition'], ['revision', 'rev', 'name', 'definition']],
|
||||
'definition.remove': [['revision', 'rev', 'name'], ['revision', 'rev', 'name']],
|
||||
});
|
||||
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
|
||||
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
|
||||
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
||||
@@ -533,7 +542,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 +602,7 @@ export class Broker {
|
||||
#builtin;
|
||||
#builtinBinding;
|
||||
#organization;
|
||||
#pluginService;
|
||||
#credentialVerbs;
|
||||
#proxyInit;
|
||||
#proxyPage;
|
||||
@@ -655,7 +665,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 +688,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 +2162,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 +4686,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 +5940,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') {
|
||||
@@ -6057,7 +6091,7 @@ export class Broker {
|
||||
}
|
||||
if (request.verb === 'grants.scope' || request.verb === 'grants.scope.set')
|
||||
return this.#grantScopeVerb(s, request.verb, structuredClone(args));
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb) || Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
|
||||
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
||||
// file lists organization.change. It takes the same keys, runs the same write path and
|
||||
// logs the same receipt; only the actor differs: the role definition and instance.
|
||||
@@ -6112,6 +6146,13 @@ export class Broker {
|
||||
else if (request.verb === "library.limits.set") {
|
||||
keys(args, ["revision", "replyLimits"], ["revision", "replyLimits"]);
|
||||
this.#human(s);
|
||||
}
|
||||
// #1873 T1: the definition writes need org.hire. Until row 193, as #mayHuman, the business's
|
||||
// one human (the owner) passes; the tester is refused above and an agent here.
|
||||
else if (Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
|
||||
keys(args, ...DEFINITION_BUS_KEYS[request.verb]);
|
||||
this.#human(s);
|
||||
if (s.human !== this.#business(s.business).human) fail("human-required");
|
||||
} else {
|
||||
keys(args, ORGANIZATION_KEYS, ["revision"]);
|
||||
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
|
||||
@@ -6388,6 +6429,8 @@ export class Broker {
|
||||
if ((verb === "instance.hire" || verb === "instance.fire") && e.code === "definition-not-hireable") fail(e.code);
|
||||
// Row 194 (B7, webby-0094 N3): the limits writer's keep check, against the file, as well.
|
||||
if (e.code === "instance-kept") fail(e.code);
|
||||
// #1873 T1: the definition writer's refusals pass by code; a composition refusal stays invalid-request.
|
||||
if (Object.hasOwn(DEFINITION_BUS_KEYS, verb) && /^definition-[a-z-]+$/.test(e.code ?? "")) fail(e.code);
|
||||
// Row 194 (brief item 8): a fire's or a delete's revoke failed, and nothing was written.
|
||||
if ((verb === "instance.fire" || verb === "instances.set") && e.code === "credential-revoke-failed") fail(e.code);
|
||||
// Row 65: the route checks every write of an instance's route vars runs.
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -33,6 +35,8 @@ import { createProxyLock } from './proxy-lock.mjs';
|
||||
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
|
||||
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
|
||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
|
||||
import { definitionVerbs, DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
|
||||
import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs';
|
||||
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
|
||||
@@ -71,10 +75,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 +94,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 +102,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 +299,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 +414,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 +487,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 +506,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 +599,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 +700,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 +769,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 +800,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,13 +841,19 @@ 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.
|
||||
else if(verb==='grants.scope.set')result=instances.grantScope(id,args,actor);
|
||||
// C2-06 M11: the human's replyLimits, through the organization writer's fence.
|
||||
else if(verb==='library.limits.set')result=setReplyLimits(organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),id,args,actor);
|
||||
// #1873 T1: the human's definition writes. The broker passes only the business's human (the
|
||||
// owner, so ROLE_RIGHTS.owner); the store's assertClean is the held-secret check each write needs.
|
||||
else if(Object.hasOwn(DEFINITION_BUS_VERBS,verb))result=definitionVerbs({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),rolesDir:organization.rolesDir,assertClean:value=>credentials.assertClean(value)})[DEFINITION_BUS_VERBS[verb]](id,args,{actor,rights:[...ROLE_RIGHTS.owner],agent:false});
|
||||
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
|
||||
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
|
||||
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
|
||||
@@ -868,7 +883,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 +956,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 +978,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 +1231,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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// #1873 T1: the definition writes on the bus. Only the business's human at the cli reaches them,
|
||||
// and the writer runs the credential store's held-secret check on what it would write.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DEFINITION_BUS_KEYS } from '../src/broker.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
|
||||
const TOKEN = 'fixture-held-token-t1-5c0e9a';
|
||||
const SCOUT = { role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.\n' };
|
||||
|
||||
function files(dir) {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
|
||||
}
|
||||
|
||||
test('T1 the broker checks the same four definition verbs the runtime maps', () => {
|
||||
assert.deepEqual(Object.keys(DEFINITION_BUS_KEYS).sort(), Object.keys(DEFINITION_BUS_VERBS).sort());
|
||||
});
|
||||
|
||||
test('T1 definition writes: the human at the cli writes, every other caller refuses, and a held token never lands', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-def-bus-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = businessDoc(root, 'demo');
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), { ...doc, plugins: { ...doc.plugins, harness: ['pi'] } });
|
||||
const data = join(root, 'data');
|
||||
mkdirSync(data, { mode: 0o700 });
|
||||
const token = join(root, 'held.token');
|
||||
writeFileSync(token, TOKEN, { mode: 0o600 });
|
||||
chmodSync(token, 0o600);
|
||||
const bus = await startBroker({ dataRoot: data, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } },
|
||||
} } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const b = bus.broker;
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const revision = () => b.request(human, { verb: 'limits' }).revision;
|
||||
const invent = (name) => ({ verb: 'definition.invent', args: { revision: revision(), name, definition: SCOUT } });
|
||||
|
||||
// An agent, a reader and the tester never reach the writer.
|
||||
const before = readFileSync(file);
|
||||
const agent = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
|
||||
for (const cap of [agent, b.bindReader({ business: 'demo' })]) assert.throws(() => b.request(cap, invent('scout')), /human-required/);
|
||||
assert.throws(() => b.request(b.bindTester({ business: 'demo' }), invent('scout')), /tester-refused/);
|
||||
// Unknown and missing arguments refuse; an actor can't be forged through them.
|
||||
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { ...invent('scout').args, actor: 'mallory' } }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'definition.remove', args: { revision: revision(), name: 'scout' } }), /invalid-request/);
|
||||
assert.deepEqual(readFileSync(file), before);
|
||||
|
||||
// The human's invent commits, as the human, with a receipt that carries no role text.
|
||||
const receipt = b.request(human, invent('scout'));
|
||||
assert.deepEqual({ ...receipt, revision: 0, transaction: 0 }, { verb: 'invent', organization: 'demo', definition: 'scout', rev: 1, revision: 0, transaction: 0 });
|
||||
assert.equal(JSON.parse(readFileSync(file, 'utf8')).definitions.scout.instructions, SCOUT.instructions);
|
||||
const last = JSON.parse(readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').at(-1));
|
||||
assert.equal(last.actor, 'jason');
|
||||
assert.equal(last.state, 'committed');
|
||||
// A writer refusal passes by its code.
|
||||
assert.throws(() => b.request(human, invent('scout')), /definition-exists/);
|
||||
|
||||
// The bus refuses a request that carries the held token, and the writer refuses a template whose
|
||||
// contract carries it (the import request itself holds no token). Neither writes.
|
||||
const written = readFileSync(file);
|
||||
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { revision: revision(), name: 'leak', definition: { ...SCOUT, instructions: `Use ${TOKEN}.\n` } } }),
|
||||
(e) => e.code === 'credential-leak' && !e.message.includes(TOKEN));
|
||||
appendFileSync(join(rolesDir, 'coder.md'), `\nUse ${TOKEN} for the forge.\n`);
|
||||
assert.throws(() => b.request(human, { verb: 'definition.import', args: { revision: revision(), template: 'coder' } }),
|
||||
(e) => e.code === 'invalid-request' && !e.message.includes(TOKEN) && !String(e.stack).includes(TOKEN));
|
||||
assert.deepEqual(readFileSync(file), written);
|
||||
for (const path of [...files(data), ...files(join(root, 'businesses'))]) assert.ok(!readFileSync(path).includes(TOKEN), path);
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/definition-verbs.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/definition-verbs.mjs";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/definitions.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/definitions.mjs";
|
||||
@@ -154,11 +154,13 @@ function pidAlive(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
// Names the lock by label: its path sits beside the access file in the config
|
||||
// directory, and a refusal never quotes a host path (PR #1916).
|
||||
function lockRefusal(lock) {
|
||||
const pid = lockPid(lock);
|
||||
if (pid !== null && pidAlive(pid)) return BUSY;
|
||||
if (pid === null) return `refused: lock ${lock} is unreadable or not a pid; if no write is running, remove it by hand and try again`;
|
||||
return `refused: lock ${lock} is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
|
||||
if (pid === null) return `refused: access file lock is unreadable or not a pid; if no write is running, remove it by hand and try again`;
|
||||
return `refused: access file lock is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
|
||||
}
|
||||
|
||||
function takeLock(lock, fs) {
|
||||
|
||||
@@ -37,8 +37,10 @@ function parse(args) {
|
||||
function owns(user, owner, providers) {
|
||||
return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss);
|
||||
}
|
||||
function readOwner(root) {
|
||||
const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => {
|
||||
// The registry checked `id` against the id pattern, so the label may name it; the
|
||||
// root is a host path and never reaches a refusal (PR #1916).
|
||||
function readOwner(root, id) {
|
||||
const owner = readJsonFile(join(root, 'owner.json'), `organization owner of ${id}`, st => {
|
||||
if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3);
|
||||
});
|
||||
rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner');
|
||||
@@ -92,7 +94,7 @@ export async function accountsCommand(args, io, deps) {
|
||||
// writing owner.json, so any missing owner must block removal too.
|
||||
for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) {
|
||||
let owner;
|
||||
try { owner = readOwner(entry.root); }
|
||||
try { owner = readOwner(entry.root, entry.id); }
|
||||
catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; }
|
||||
if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -36,6 +37,7 @@ import { bootConfig, loadSystem, orgPaths, socketPath } from "./config.mjs";
|
||||
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
|
||||
import { businessFilePath, configDir } from "../../organization/src/index.mjs";
|
||||
import { busExit, humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs";
|
||||
import { definitionsCommand } from "./org-definitions.mjs";
|
||||
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
|
||||
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
|
||||
import { createLauncher } from "./launcher.mjs";
|
||||
@@ -220,7 +222,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 = {}) => {
|
||||
@@ -736,6 +738,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
// builds; `org validate FILE` stays orgCommand's draft check.
|
||||
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
|
||||
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// #1873 T1: definition writes go through the organization's bus, as the human.
|
||||
if (verb === 'org' && rest[0] === 'definitions') return definitionsCommand(rest.slice(1), io, { ...deps, callFor: (p) => callFor(p, io, deps) });
|
||||
if (verb === 'org') return orgCommand(rest, io, deps);
|
||||
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
|
||||
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
|
||||
@@ -759,6 +763,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 });
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -46,7 +46,7 @@ function noCredentials(env) {
|
||||
function ownedPrivate(path, directory) {
|
||||
const st = lstatSync(path);
|
||||
if (st.isSymbolicLink() || (directory ? !st.isDirectory() : !st.isFile()) || st.uid !== process.getuid() || (st.mode & 0o077))
|
||||
throw refuse('fixture-state-unsafe', `${path} is not a private ${directory ? 'directory' : 'file'} of this user`);
|
||||
throw refuse('fixture-state-unsafe', `${directory ? 'dev-fixture directory' : 'fixture file'} is not a private ${directory ? 'directory' : 'file'} of this user`);
|
||||
}
|
||||
|
||||
// The recorded fixture, or null when there is none. Anything malformed refuses.
|
||||
@@ -56,19 +56,19 @@ export function readFixture(dataRoot) {
|
||||
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
||||
let fd;
|
||||
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); }
|
||||
catch (e) { if (e.code === 'ENOENT') return null; throw refuse('fixture-state-unsafe', `${file} can't be opened (${e.code})`); }
|
||||
catch (e) { if (e.code === 'ENOENT') return null; throw refuse('fixture-state-unsafe', `fixture.json can't be opened (${e.code})`); }
|
||||
let doc;
|
||||
try {
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) throw refuse('fixture-state-unsafe', `${file} is not a private file of this user`);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) throw refuse('fixture-state-unsafe', 'fixture.json is not a private file of this user');
|
||||
doc = JSON.parse(readFileSync(fd, 'utf8'));
|
||||
} catch (e) { if (e instanceof CliError) throw e; throw refuse('fixture-state-invalid', `${file} is not JSON`); }
|
||||
} catch (e) { if (e instanceof CliError) throw e; throw refuse('fixture-state-invalid', 'fixture.json is not JSON'); }
|
||||
finally { closeSync(fd); }
|
||||
const m = LOOPBACK.exec(doc?.endpoint ?? '');
|
||||
if (!m) throw refuse('fixture-endpoint-not-loopback', `${file} names an endpoint that is not http://127.0.0.1:<port>`);
|
||||
if (!m) throw refuse('fixture-endpoint-not-loopback', 'fixture.json names an endpoint that is not http://127.0.0.1:<port>');
|
||||
if (doc.fixtureVersion !== 1 || doc.model !== 'deterministic' || Number(m[1]) !== doc.port || !Number.isSafeInteger(doc.pid) || doc.pid < 2
|
||||
|| typeof doc.instance !== 'string' || JSON.stringify(doc.entry) !== JSON.stringify(FIXTURE_ENTRY))
|
||||
throw refuse('fixture-state-invalid', `${file} is not a fixture record`);
|
||||
throw refuse('fixture-state-invalid', 'fixture.json is not a fixture record');
|
||||
return doc;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,12 +79,13 @@ import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, rmSync, statSync, symlinkSync, writeSync, chmodSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { basename, join } from "node:path";
|
||||
import { GIT_GRACE_MS, GIT_TIMEOUT_MS } from "../../seat/src/checkout.mjs";
|
||||
import { gitSocketPath } from "../../seat/src/session.mjs";
|
||||
import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs";
|
||||
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { ID_PATTERN } from "../../organization/src/vocabulary.mjs";
|
||||
import { withProcessing } from '../../processing-control/src/effects.mjs';
|
||||
|
||||
const HEADER_MAX = 4096;
|
||||
@@ -385,7 +386,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
if (!p) throw new Refusal("unknown-project");
|
||||
mkdirSync(logs, { recursive: true, mode: 0o700 });
|
||||
prepared = await prepareLand({ ...where, root: p.root, taskRef: task_ref, commit: facts.commit, dir: logs, name: id, rootHits, message: (m) => landMessage(facts, m) });
|
||||
if (!(await gate.classify(p.root, env))) throw new Refusal("gate-missing", `${p.root} isn't a mosaic-stack checkout with node_modules`);
|
||||
if (!(await gate.classify(p.root, env))) throw new Refusal("gate-missing", `project ${facts.project} isn't a mosaic-stack checkout with node_modules`);
|
||||
symlinkSync(join(p.root, "node_modules"), join(prepared.worktree, "node_modules"));
|
||||
} catch (e) {
|
||||
if (prepared) await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
|
||||
@@ -398,7 +399,9 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
const r = await gate.run({ worktree: prepared.worktree, logs, env: gateEnv(env), signal: AbortSignal.any([slot.abort.signal, scope.signal]) });
|
||||
scope.guard();
|
||||
if (r.aborted || slot.abort.signal.aborted) throw new Refusal("host-closing", "the host closed during the gate; nothing moved");
|
||||
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see ${r.log}`);
|
||||
// The detail reaches the role, so the log is named inside the bus host directory, never by
|
||||
// host path (PR #1916); its file name only when it passes the id pattern.
|
||||
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see land/${id}${ID_PATTERN.test(basename(r.log ?? "")) ? `/${basename(r.log)}` : ""} in the bus host directory`);
|
||||
await finishLand({ ...where, land: prepared, record: (m) => op({ op: "recordLand", ticket, land: { ...m, reviewed, base, patch_id } }) });
|
||||
} finally {
|
||||
await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
|
||||
@@ -503,7 +506,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
|
||||
async function listen() {
|
||||
if (existsSync(path)) {
|
||||
if (!lstatSync(path).isSocket()) throw new CliError(`${path} exists and isn't a socket`, 3);
|
||||
if (!lstatSync(path).isSocket()) throw new CliError("git socket path exists and isn't a socket", 3);
|
||||
rmSync(path);
|
||||
}
|
||||
server = createServer((socket) => {
|
||||
|
||||
@@ -54,10 +54,10 @@ function readState(file, found = () => {}) {
|
||||
state = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") return null;
|
||||
throw new CliError(`bus host state is unreadable: ${file}`, 3);
|
||||
throw new CliError("bus host state is unreadable", 3);
|
||||
}
|
||||
if (!state || !Number.isSafeInteger(state.pid) || typeof state.startTime !== "string" || typeof state.business !== "string") {
|
||||
throw new CliError(`bus host state is malformed: ${file}`, 3);
|
||||
throw new CliError("bus host state is malformed", 3);
|
||||
}
|
||||
return { ...state, live: startTimeOf(state.pid) === state.startTime };
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = ()
|
||||
const prior = readState(file, found);
|
||||
if (!prior) continue;
|
||||
if (prior.live) throw new CliError(`a bus host already runs for ${prior.business} (pid ${prior.pid})`, 3);
|
||||
if (refuseStale) throw new CliError(`stale bus host lock: ${file}`, 3);
|
||||
if (refuseStale) throw new CliError("stale bus host lock", 3);
|
||||
between();
|
||||
try {
|
||||
renameSync(file, aside);
|
||||
@@ -111,7 +111,7 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = ()
|
||||
}
|
||||
rmSync(aside, { force: true });
|
||||
}
|
||||
throw new CliError(`${file} kept changing; no bus host started`, 3);
|
||||
throw new CliError("bus host lock kept changing; no bus host started", 3);
|
||||
} finally {
|
||||
rmSync(tmp, { force: true });
|
||||
}
|
||||
|
||||
@@ -120,7 +120,9 @@ function setArgs(parsed) {
|
||||
try {
|
||||
args.vars = JSON.parse(readFileSync(values["--vars"], "utf8"));
|
||||
} catch (error) {
|
||||
throw new CliError(`--vars ${values["--vars"]}: ${error.message}`, 4);
|
||||
// Name the flag and the error kind, never the file's path or bytes (PR #1916).
|
||||
const code = typeof error?.code === "string" && /^E[A-Z0-9]+$/.test(error.code) ? error.code : "unreadable";
|
||||
throw new CliError(`--vars file: ${error instanceof SyntaxError ? "not valid JSON" : code}`, 4);
|
||||
}
|
||||
}
|
||||
if (launch !== undefined) args.launch = launch;
|
||||
|
||||
@@ -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);
|
||||
@@ -412,7 +412,7 @@ export function createLauncher({
|
||||
};
|
||||
const primary = routeOf("primary");
|
||||
const fallback = hasFallback(resolved) ? routeOf("fallback") : null;
|
||||
if (!resolved.contract) throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
|
||||
if (!resolved.contract && typeof resolved.instructions !== "string") throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
|
||||
// Row 64 (#1557): every skill on the role's list is enabled and readable before a run exists.
|
||||
try {
|
||||
readSkills(resolved.skills, skillsDir);
|
||||
@@ -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];
|
||||
@@ -723,7 +723,7 @@ export function createLauncher({
|
||||
models: b.launch.models,
|
||||
proxy: sessionProxy,
|
||||
resolver,
|
||||
contract: readFileSync(resolved.contract, "utf8"),
|
||||
contract: resolved.contract ? readFileSync(resolved.contract, "utf8") : resolved.instructions,
|
||||
arbiter: Object.values(b.arbiters).includes(instance),
|
||||
tracker,
|
||||
library: Object.values(b.projects ?? {}).some(project => project.kind === "library"),
|
||||
|
||||
@@ -162,28 +162,28 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
} catch (e) {
|
||||
// A dangling link in place of the directory fails mkdir with ENOENT.
|
||||
if (e.code === "ENOENT" && lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink: ${dir}`, 3);
|
||||
if ([...UNWRITABLE, "ENOTDIR"].includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code}): ${dir}`, 3);
|
||||
if (e.code === "ENOENT" && lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink`, 3);
|
||||
if ([...UNWRITABLE, "ENOTDIR"].includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code})`, 3);
|
||||
if (e.code !== "EEXIST") throw e;
|
||||
}
|
||||
const ds = lstatSync(dir);
|
||||
if (ds.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink: ${dir}`, 3);
|
||||
if (ds.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink`, 3);
|
||||
if (!ds.isDirectory() || ds.uid !== process.getuid() || (ds.mode & 0o077) !== 0) {
|
||||
throw new CliError(`notify journal directory must be mode 0700 and owned by this user: ${dir}`, 3);
|
||||
throw new CliError(`notify journal directory must be mode 0700 and owned by this user`, 3);
|
||||
}
|
||||
try {
|
||||
accessSync(dir, constants.W_OK | constants.X_OK);
|
||||
} catch (e) {
|
||||
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal directory is not writable (${e.code}): ${dir}`, 3);
|
||||
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal directory is not writable (${e.code})`, 3);
|
||||
throw e;
|
||||
}
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(file, O_RDWR | O_APPEND | O_CREAT | O_NOFOLLOW, 0o600);
|
||||
} catch (e) {
|
||||
if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink: ${file}`, 3);
|
||||
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal is not writable (${e.code}): ${file}`, 3);
|
||||
if (e.code === "EISDIR") throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3);
|
||||
if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink`, 3);
|
||||
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal is not writable (${e.code})`, 3);
|
||||
if (e.code === "EISDIR") throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user`, 3);
|
||||
throw e;
|
||||
}
|
||||
const sent = new Set();
|
||||
@@ -204,7 +204,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
|
||||
try {
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
|
||||
throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3);
|
||||
throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user`, 3);
|
||||
}
|
||||
const bytes = readFileSync(fd);
|
||||
const end = bytes.lastIndexOf(0x0a) + 1;
|
||||
@@ -218,7 +218,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
|
||||
r = null;
|
||||
}
|
||||
const bad = r === null ? "json" : badField(r);
|
||||
if (bad) throw new CliError(`notify journal line ${i + 1} is malformed (${bad}): ${file}`, 3);
|
||||
if (bad) throw new CliError(`notify journal line ${i + 1} is malformed (${bad})`, 3);
|
||||
count(r);
|
||||
});
|
||||
if (end < bytes.length) {
|
||||
|
||||
@@ -6,27 +6,28 @@ import { CliError } from "./errors.mjs";
|
||||
// The notifier's private config: `<dataRoot>/notify/<business>/notify.json`,
|
||||
// 0600, `{"notifyVersion": 1, "binding": "<discord binding>" | null}`.
|
||||
// Missing refuses, so a host never starts without someone deciding whether
|
||||
// it DMs; null runs the host without a notifier.
|
||||
// it DMs; null runs the host without a notifier. Refusals name the business
|
||||
// and the path under the data root, never the host path (PR #1916).
|
||||
export function readNotifyConfig(dataRoot, business) {
|
||||
const file = join(dataRoot, "notify", business, "notify.json");
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(file);
|
||||
} catch {
|
||||
throw new CliError(`no notifier config at ${file}; write {"notifyVersion": 1, "binding": "<discord binding>"} there, mode 0600, or "binding": null to run without DMs`, 3);
|
||||
throw new CliError(`no notifier config for ${business}: write {"notifyVersion": 1, "binding": "<discord binding>"} to notify/${business}/notify.json under the data root, mode 0600, or "binding": null to run without DMs`, 3);
|
||||
}
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
|
||||
throw new CliError(`notifier config must be a regular file, mode 0600, owned by this user: ${file}`, 3);
|
||||
throw new CliError(`notifier config for ${business} must be a regular file, mode 0600, owned by this user`, 3);
|
||||
}
|
||||
let doc;
|
||||
try {
|
||||
doc = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
throw new CliError(`notifier config is not JSON: ${file}`, 3);
|
||||
throw new CliError(`notifier config for ${business} is not JSON`, 3);
|
||||
}
|
||||
const keys = doc && typeof doc === "object" && !Array.isArray(doc) ? Object.keys(doc).sort().join(",") : "";
|
||||
if (keys !== "binding,notifyVersion" || doc.notifyVersion !== 1 || (doc.binding !== null && !(typeof doc.binding === "string" && BINDING_NAME.test(doc.binding)))) {
|
||||
throw new CliError(`notifier config must be exactly {"notifyVersion": 1, "binding": <binding name> | null}: ${file}`, 3);
|
||||
throw new CliError(`notifier config for ${business} must be exactly {"notifyVersion": 1, "binding": <binding name> | null}`, 3);
|
||||
}
|
||||
return doc.binding;
|
||||
}
|
||||
|
||||
@@ -138,7 +138,9 @@ export async function busStart(ctx, id, { detach = false, devLocalOwner = false,
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
}
|
||||
if (!exited) { child.kill('SIGTERM'); await done; }
|
||||
throw new CliError(`bus failed to start; see ${join(org.paths.hostDir, 'host.log')}`, 3);
|
||||
// Name the log by its place in the organization's runtime directory, never by host path (PR #1916);
|
||||
// resolveOrg admitted `id` only after the registry's id check.
|
||||
throw new CliError(`bus failed to start; see bus-host/host.log in the runtime directory of organization ${id}`, 3);
|
||||
}
|
||||
export async function busRestart(ctx, id) {
|
||||
const org = await target(ctx, id);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { processState } from '../../seat/src/process-state.mjs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { basename, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { cmdlineOf, startTimeOf } from './host.mjs';
|
||||
@@ -95,7 +95,8 @@ async function launch(ctx, args, lan, detach) {
|
||||
}
|
||||
await sleep(50);
|
||||
}
|
||||
fail(`console failed to start; see ${log}`);
|
||||
// The log is named under the data root, never by host path (PR #1916).
|
||||
fail(`console failed to start; see console/${basename(log)} under the data root`);
|
||||
} catch (e) {
|
||||
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
|
||||
throw e;
|
||||
@@ -193,11 +194,8 @@ export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } =
|
||||
const argv = cmdlineOf(s.pid);
|
||||
if (!argv || !argv.length) fail('console process identity is uncertain');
|
||||
const i = argv.indexOf(WEBUI);
|
||||
if (i < 0 || argv[i + 1] !== 'serve') {
|
||||
const serve = argv.indexOf('serve');
|
||||
const entry = serve > 0 ? ` ${argv[serve - 1]}` : '';
|
||||
fail(`console was started from another path${entry}; stop it there`);
|
||||
}
|
||||
// The other entry script is a host path, so the refusal never names it (PR #1916).
|
||||
if (i < 0 || argv[i + 1] !== 'serve') fail('console was started from another checkout; stop it there');
|
||||
process.kill(s.pid, 'SIGTERM');
|
||||
const deadline = Date.now() + 20000;
|
||||
while (Date.now() < deadline) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// #1873 T1: `mosaic org definitions`, the human verbs on an organization's own role definitions and
|
||||
// the template hash and diff reads. Reads run here, from the file. A write runs only from a human
|
||||
// shell (refuseInsideAgent) and goes through the organization's bus as its human: the bus holds the
|
||||
// credential store, whose held-secret check every definition write needs (R1), and no other process
|
||||
// holds the tokens it looks for. Each write prints its receipt.
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { definitionVerbs } from '../../organization/src/definition-verbs.mjs';
|
||||
import { checkDefinition } from '../../organization/src/definitions.mjs';
|
||||
import { rolesDir } from './config.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { busExit, refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const DEFINITIONS_USAGE = 'mosaic org definitions templates | definitions ID list | definitions ID show NAME | definitions ID diff NAME | definitions ID import TEMPLATE [--as NAME] | definitions ID invent NAME --from FILE | definitions ID edit NAME --from FILE --if-rev N | definitions ID remove NAME --if-rev N';
|
||||
|
||||
const WRITES = ['import', 'invent', 'edit', 'remove'];
|
||||
const VALUES = ['--as', '--from', '--if-rev'];
|
||||
|
||||
function parse(args) {
|
||||
const pos = [];
|
||||
const values = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (VALUES.includes(a)) {
|
||||
if (values[a] !== undefined || !args[i + 1] || args[i + 1].startsWith('--')) throw new CliError(`${a} needs one value`, 4);
|
||||
values[a] = args[++i];
|
||||
} else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4);
|
||||
else pos.push(a);
|
||||
}
|
||||
return { pos, values };
|
||||
}
|
||||
|
||||
async function definitionFile(file) {
|
||||
try {
|
||||
return JSON.parse(await readFile(file, 'utf8'));
|
||||
} catch (e) {
|
||||
// Never echo the file's text: it may hold what the composition check refuses.
|
||||
throw new CliError(`cannot read definition ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.code ?? 'unreadable'}`, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// The bus answers a refusal with its code only. These say what the code means for this request;
|
||||
// none quotes the definition's text.
|
||||
function refusal(code, verb, name, rev, read) {
|
||||
const why = {
|
||||
'definition-in-use': () => `definition ${name} is named by ${read().usedBy.join(', ')}; a change to a hired agent's role is a change decision`,
|
||||
'definition-rev-conflict': () => `definition ${name} is not at rev ${rev}`,
|
||||
'definition-exists': () => `definitions already declares ${name}`,
|
||||
'definition-missing': () => `definitions declares no ${name}`,
|
||||
'revision-conflict': () => 'the organization file changed while this ran; run it again',
|
||||
'credential-leak': () => `the ${verb} request holds a credential the organization's bus holds; nothing was written`,
|
||||
'invalid-request': () => `the bus refused the ${verb}: the definition fails the composition check or holds a held credential`,
|
||||
}[code];
|
||||
let detail = null;
|
||||
try { detail = why?.() ?? null; } catch { detail = null; }
|
||||
const error = new CliError(detail ? `${code}: ${detail}` : code, busExit(code));
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
// deps.callFor(parsed) returns the human's call(verb, args) for the organization parsed names (cli.mjs
|
||||
// passes its callFor). Without one, a write refuses: it never runs without the bus's secret check.
|
||||
export async function definitionsCommand(args, io, deps = {}) {
|
||||
const { pos, values } = parse(args);
|
||||
const allowed = (...keys) => { for (const k of Object.keys(values)) if (!keys.includes(k)) throw new CliError(`unexpected ${k}`, 4); };
|
||||
const need = (key) => { if (!values[key]) throw new CliError(`${key} is required`, 4); return values[key]; };
|
||||
const ifRev = () => { const r = need('--if-rev'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid --if-rev', 4); return Number(r); };
|
||||
const [id, verb, name] = pos;
|
||||
if (WRITES.includes(verb)) refuseInsideAgent(io.env);
|
||||
const writer = deps.writer ?? organizationWriter({ dir: configDir(io.env), rolesDir: rolesDir(io.env) });
|
||||
const verbs = definitionVerbs({ writer, rolesDir: rolesDir(io.env) });
|
||||
let result;
|
||||
if (id === 'templates' && pos.length === 1) { allowed(); result = verbs.templates(); }
|
||||
else if (verb === 'list' && pos.length === 2) { allowed(); result = verbs.list(id); }
|
||||
else if (['show', 'diff'].includes(verb) && pos.length === 3) { allowed(); result = verbs[verb](id, name); }
|
||||
else if (WRITES.includes(verb) && pos.length === 3) {
|
||||
let request, rev;
|
||||
if (verb === 'import') { allowed('--as'); request = { template: name, as: values['--as'] ?? name }; }
|
||||
else if (verb === 'invent') { allowed('--from'); request = { name, definition: await definitionFile(need('--from')) }; }
|
||||
else if (verb === 'edit') { allowed('--from', '--if-rev'); rev = ifRev(); request = { rev, name, definition: await definitionFile(need('--from')) }; }
|
||||
else { allowed('--if-rev'); rev = ifRev(); request = { rev, name }; }
|
||||
// The file's own checks first, so a bad key is named here; the bus repeats them with its secret check.
|
||||
if (request.definition) checkDefinition(name, { ...request.definition, rev: rev ?? 1 });
|
||||
if (typeof deps.callFor !== 'function') throw new CliError('a definition write goes through the organization bus; run it as `mosaic org definitions`', 3);
|
||||
const call = deps.callFor({ positional: [id], flags: new Set(), values: { '--business': id } });
|
||||
const { revision } = writer.read(id);
|
||||
try {
|
||||
result = await call(`definition.${verb}`, { revision, ...request });
|
||||
} catch (e) {
|
||||
if (typeof e?.code !== 'string' || e.code === '') throw e;
|
||||
throw refusal(e.code, verb, request.as ?? name, rev, () => verbs.show(id, request.as ?? name));
|
||||
}
|
||||
} else throw new CliError(DEFINITIONS_USAGE, 4);
|
||||
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
}
|
||||
@@ -9,11 +9,16 @@ import { CliError } from './errors.mjs';
|
||||
import { busStopForDelete } from './org-bus.mjs';
|
||||
import { consoleBusRestart } from './org-console.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
|
||||
import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.mjs';
|
||||
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
|
||||
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
|
||||
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.message}`, 2); } }
|
||||
// A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916).
|
||||
const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable';
|
||||
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read organization document: ${e instanceof SyntaxError ? 'not valid JSON' : errorCode(e)}`, 2); } }
|
||||
export async function orgCommand(args, io, deps = {}) {
|
||||
try {
|
||||
// #1873 T1: the definitions verbs take their own options.
|
||||
if (args[0] === 'definitions') return await definitionsCommand(args.slice(1), io, deps);
|
||||
const p = parse(args); const words = p.pos; let result; let accepted = [];
|
||||
const allowed = (flags, values) => { for (const f of p.flags) if (!flags.includes(f)) throw new CliError(`unexpected ${f}`, 4); for (const f of Object.keys(p.values)) if (!values.includes(f)) throw new CliError(`unexpected ${f}`, 4); };
|
||||
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
|
||||
@@ -41,5 +46,11 @@ export async function orgCommand(args, io, deps = {}) {
|
||||
else throw new CliError(ORG_USAGE, 4);
|
||||
} else throw new CliError(ORG_USAGE, 4);
|
||||
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
} catch (e) { if (e instanceof CliError) throw e; throw new CliError(`${e.code ? e.code + ': ' : ''}${e.message}`, 2); }
|
||||
} catch (e) {
|
||||
if (e instanceof CliError) throw e;
|
||||
// A raw Node filesystem error ("CODE: description, syscall 'path'") is named by its operation
|
||||
// and code, never by the host path in its message (PR #1916).
|
||||
const message = typeof e?.syscall === 'string' && typeof e.code === 'string' && e.message.startsWith(`${e.code}:`) ? `cannot ${e.syscall} (${errorCode(e)})` : e.message;
|
||||
throw new CliError(`${e.code ? e.code + ': ' : ''}${message}`, 2);
|
||||
}
|
||||
}
|
||||
@@ -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);}
|
||||
}
|
||||
@@ -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: {} };
|
||||
|
||||
@@ -63,7 +63,7 @@ const inside = (root, path) => { const r = relative(root, path); return r !== ''
|
||||
export function threadRecords(file, thread) {
|
||||
const log = fileEvidence(file);
|
||||
if (!log.ok && log.problem === 'missing') return { file, missing: true, launches: [], ends: [], malformed: 0 };
|
||||
if (!log.ok) throw new CliError(`run-evidence-refused: launch log ${file}: ${log.problem}`, 3);
|
||||
if (!log.ok) throw new CliError(`run-evidence-refused: launch log: ${log.problem}`, 3);
|
||||
const launches = [], ends = []; let malformed = 0;
|
||||
log.bytes.toString('utf8').split('\n').forEach((line, i) => {
|
||||
if (!line) return;
|
||||
@@ -186,7 +186,7 @@ export async function runCommand(rest, io, deps = {}) {
|
||||
lines.push(verify ? `verified: ${verified ? 'yes' : 'no'}` : 'not verified (pass --verify)');
|
||||
io.stdout.write(`${lines.join('\n')}\n`);
|
||||
}
|
||||
if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in ${log.file}`, 2);
|
||||
if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in the launch log`, 2);
|
||||
if (verify && !verified) throw new CliError('run evidence failed verification', 1);
|
||||
return report;
|
||||
}
|
||||
@@ -148,7 +148,7 @@ test("a lock left by a dead pid refuses, names the pid, and is not reclaimed", a
|
||||
const dead = spawnSync(process.execPath, ["-e", ""]);
|
||||
writeFileSync(`${s.file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`${s.file}\\.lock is left by pid ${dead.pid}, which is not running`));
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`^refused: access file lock is left by pid ${dead.pid}, which is not running`));
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${dead.pid}\n`);
|
||||
assert.equal(s.receipts(), "");
|
||||
@@ -165,13 +165,14 @@ test("a failed rename leaves the old bytes, removes the temp file, and writes no
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("a group-writable access file refuses with exit 3, names the file, and changes nothing", async (t) => {
|
||||
test("a group-writable access file refuses with exit 3, names the file by label, never by path, and changes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
chmodSync(s.file, 0o664);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /must not be writable by group or other/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
assert.match(err.message, new RegExp(s.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.equal(err.message, "refused: access file must not be writable by group or other (mode 664)");
|
||||
assert.ok(!err.message.includes(s.file), err.message);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { accountsCommand } from '../src/accounts.mjs';
|
||||
@@ -101,3 +101,16 @@ test('org-acct-anchor: init, list and remove refuse a 0777 ancestor before any l
|
||||
chmodSync(parent, 0o1777);
|
||||
await accountsCommand(['init'], io, deps); assert.match(output, /accounts init: u-[0-9a-f]{12}, revision 1/);
|
||||
});
|
||||
// PR #1916 (org-redact5): an unreadable owner file is named by the registry's checked
|
||||
// id, never by the organization root's host path.
|
||||
test('remove: an invalid or linked owner file is named by the org id, never by host path', async t => {
|
||||
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
||||
const root = join(s.configDir, SENTINEL, 'broken'); mkdirSync(root, { recursive: true, mode: 0o700 });
|
||||
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{not json', { mode: 0o600 }); });
|
||||
const clean = (pattern) => e => { assert.equal(e.exitCode, 3); assert.match(e.message, pattern); assert.ok(!e.message.includes(SENTINEL) && !e.message.includes(root), e.message); return true; };
|
||||
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken is not valid JSON$/));
|
||||
rmSync(join(root, 'owner.json')); writeFileSync(join(root, 'real.json'), '{}', { mode: 0o600 }); symlinkSync(join(root, 'real.json'), join(root, 'owner.json'));
|
||||
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken must be a regular, non-symbolic-link file$/));
|
||||
assert.equal(s.read().users.length, 2, 'nothing was removed');
|
||||
});
|
||||
@@ -72,7 +72,9 @@ test("an unknown key and a JSON syntax error each leave the bus down with the re
|
||||
assert.equal(s.business, "acme");
|
||||
assert.equal(s.file, file);
|
||||
assert.match(s.reason, reason);
|
||||
assert.ok(s.reason.includes(file) || /surprise/.test(s.reason));
|
||||
// The reason names the file by label, never by host path (PR #1916); the path stays in `file`.
|
||||
assert.ok(!s.reason.includes(file), s.reason);
|
||||
assert.ok(/surprise/.test(s.reason) || /business file acme\.json is not valid JSON/.test(s.reason), s.reason);
|
||||
// `bus status` shows it, in text and in JSON.
|
||||
const out = run(f, "bus", "status", "acme", "--json");
|
||||
assert.equal(out.status, 0, out.stderr);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Test-copy observations only: bracket the supervisor's inner timer before
|
||||
// clean_session scans the host's /proc. Keep all shipped control flow intact.
|
||||
import { readProbeSeconds } from './case-p10-timing-fixture.mjs';
|
||||
export { readProbeSeconds };
|
||||
const BEGIN = '# row253-supervisor-begin\n';
|
||||
const END = '# row253-supervisor-end\n';
|
||||
const TIMER = '"$sleep_bin" "$seconds" & timer=$!\n';
|
||||
const FINISH = 'finish() {\n local rc="$?"\n';
|
||||
function unique(source, anchor) {
|
||||
const at=source.indexOf(anchor);
|
||||
if(at<0 || source.indexOf(anchor,at+anchor.length)>=0) throw new Error('inner observer anchor must occur exactly once');
|
||||
return at;
|
||||
}
|
||||
function innerObservers(clock) {
|
||||
// The literal path survives env -i in the fake sudo. It is a private scratch
|
||||
// file, never a product input. Quote even paths containing shell metacharacters.
|
||||
const file="'"+clock.replaceAll("'", "'\\''")+"'";
|
||||
return {
|
||||
begin:`# inner-observer-begin\n{ read -r timing2_up timing2_spare < /proc/uptime &&\n printf 'begin %s\\n' "$timing2_up" >> ${file}; } 2>/dev/null || :\n# inner-observer-begin-end\n`,
|
||||
end:` # inner-observer-end\n { read -r timing2_up timing2_spare < /proc/uptime &&\n printf 'end %s\\n' "$timing2_up" >> ${file}; } 2>/dev/null || :\n # inner-observer-end-end\n`,
|
||||
};
|
||||
}
|
||||
export function instrumentInnerTimer(source,clock) {
|
||||
const from=unique(source,BEGIN),to=unique(source,END);
|
||||
if(to<=from)throw new Error('inner observer scope is reversed');
|
||||
let supervisor=source.slice(from,to);
|
||||
unique(supervisor,TIMER);unique(supervisor,FINISH);
|
||||
const blocks=innerObservers(clock);
|
||||
supervisor=supervisor.replace(TIMER,blocks.begin+TIMER).replace(FINISH,FINISH+blocks.end);
|
||||
return source.slice(0,from)+supervisor+source.slice(to);
|
||||
}
|
||||
export function removeInnerObservers(source,clock) {
|
||||
for(const block of Object.values(innerObservers(clock))){unique(source,block);source=source.replace(block,'');}
|
||||
return source;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { tmp } from './helpers.mjs';
|
||||
import { instrumentInnerTimer, removeInnerObservers, readProbeSeconds } from './case-inner-timing-fixture.mjs';
|
||||
const scripts=['case1-accept.sh','case2-unit.sh'];
|
||||
const read=name=>readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
|
||||
for(const name of scripts)test(`inner timing ${name}: private copy preserves shipped bytes and rejects anchor drift`,t=>{
|
||||
const clock=join(tmp(t,'timer-'),'clock');
|
||||
const source=read(name),copy=instrumentInnerTimer(source,clock);
|
||||
assert.equal(removeInnerObservers(copy,clock),read(name));
|
||||
for(const marker of ['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
|
||||
assert.equal(copy.split(marker).length,source.split(marker).length,marker);
|
||||
for(const anchor of ['# row253-supervisor-begin\n','# row253-supervisor-end\n','"$sleep_bin" "$seconds" & timer=$!\n','finish() {\n local rc="$?"\n']){
|
||||
assert.throws(()=>instrumentInnerTimer(source.replace(anchor,''),clock),/anchor/);
|
||||
assert.throws(()=>instrumentInnerTimer(source.replace(anchor,anchor+anchor),clock),/anchor/);
|
||||
}
|
||||
});
|
||||
for(const name of scripts)test(`inner timing ${name}: real supervisor retains streams status and EXIT cleanup`,t=>{
|
||||
const clock=join(tmp(t,'timer-'),"clock '$;literal");writeFileSync(clock,'');
|
||||
const source=instrumentInnerTimer(read(name),clock);
|
||||
const body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
|
||||
const result=spawnSync('/bin/bash',['-c',body+'\n/bin/bash -c "$TESTER_SUPERVISOR" -- 2 /usr/bin/setsid /usr/bin/sleep /bin/bash -c \'printf exact-out; printf exact-err >&2; /usr/bin/sleep 0.1; exit 7\''],{encoding:'utf8'});
|
||||
assert.equal(result.status,87,result.stderr);
|
||||
assert.equal(result.stdout,'exact-out');assert.equal(result.stderr,'exact-err');
|
||||
assert.match(readFileSync(clock,'utf8'),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
|
||||
// The same strict parser used by the timing bands rejects a guard's partial,
|
||||
// duplicate or reversed pair, rather than substituting wall time.
|
||||
for(const invalid of ['begin 10.00\n','end 11.00\n','begin 10.00\nend 11.00\nbegin 12.00\nend 13.00\n','begin 11.00\nend 10.00\n'])
|
||||
assert.throws(()=>readProbeSeconds(invalid));
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmp } from "./helpers.mjs";
|
||||
import { instrumentP10Probe, removeP10Observers, readProbeSeconds, P10_OBSERVERS } from "./case-p10-timing-fixture.mjs";
|
||||
import { instrumentInnerTimer } from "./case-inner-timing-fixture.mjs";
|
||||
import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource, LEGACY_CASES } from "./case-sudo-fixture.mjs";
|
||||
|
||||
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
@@ -378,6 +379,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 +406,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 +465,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');
|
||||
@@ -927,8 +969,8 @@ test("row 195 accept: the host check runs with --check and never --enforce", (t)
|
||||
|
||||
|
||||
// Row276 exercises the real legacy launcher with fixture-only commands, never sudo.
|
||||
function legacyBody() {
|
||||
const source=read(SCRIPT),start=source.indexOf('# row253-supervisor-begin'),end=source.indexOf('# row276-legacy-end');
|
||||
function legacyBody(source=read(SCRIPT)) {
|
||||
const start=source.indexOf('# row253-supervisor-begin'),end=source.indexOf('# row276-legacy-end');
|
||||
assert.ok(start>=0&&end>start,'legacy supervisor block exists');
|
||||
return source.slice(start,end);
|
||||
}
|
||||
@@ -950,11 +992,18 @@ for(const {tag} of LEGACY_CASES)test(`row276 ${tag} legacy inner timeout cleans
|
||||
const h=host(t),records=join(h.hc,'legacy-pids'),files=[join(h.hc,'session-pids'),records];
|
||||
put(join(h.hc,'session-mode'),mode);
|
||||
const file=join(h.dir,'blocked.mjs');put(file,blockingQuerySource(records,'signals')+'\nloadSystem();');
|
||||
const capture=cleanupFixture(t,files),stop=fixtureGuard(t,files,4500);
|
||||
const start=performance.now(),r=legacyCall(h,tag,[NODE,file]);capture();
|
||||
const clock=join(h.dir,'inner-clock');put(clock,'',0o600);
|
||||
const body=legacyBody(instrumentInnerTimer(read(SCRIPT),clock));
|
||||
const capture=cleanupFixture(t,files),stop=fixtureGuard(t,files,4500,clock);
|
||||
// The outer watchdog includes startup and cleanup; only the supervisor pair
|
||||
// measures the inner budget. Seven seconds also lets the 4.5s guard expose a missing timer.
|
||||
const start=performance.now(),r=legacyCall(h,tag,[NODE,file],{outer:7,body});capture();
|
||||
assert.equal(r.status,81,r.stdout+r.stderr);
|
||||
assert.equal(r.stderr,`case1-accept: ${tag} inner-timeout\n`);
|
||||
assert.ok((performance.now()-start)/1000<2,mode);
|
||||
const seconds=readProbeSeconds(read(clock)),wall=(performance.now()-start)/1000;
|
||||
assert.ok(seconds>=0.25&&seconds<1.25,`${tag}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
|
||||
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
|
||||
t.diagnostic(`${tag}/${mode}: inner ${seconds}s; wall ${wall}s`);
|
||||
assert.deepEqual(await pollFixtureGone(files),[],mode);await stop();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { businessDoc, systemConfig, writeJson } from "../../business/tests/helpers.mjs";
|
||||
import { binds, prepareRun, sandboxArgv, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { tmp } from "./helpers.mjs";
|
||||
import { instrumentInnerTimer, readProbeSeconds } from "./case-inner-timing-fixture.mjs";
|
||||
import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource } from "./case-sudo-fixture.mjs";
|
||||
|
||||
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
@@ -107,13 +108,13 @@ const libraryFlags = (h) => ["--port", "443", "--business", "acme", "--public-ho
|
||||
"--allow-user", "melanie", "--proof-header", "X-Proxy-Proof", "--proof-file", h.proof];
|
||||
const factoryFlags = () => ["--port", "443", "--business", "acme", "--tester-socket"];
|
||||
|
||||
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv } = {}) {
|
||||
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv, script: unitScript = UNIT } = {}) {
|
||||
const serve = flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h));
|
||||
const args = argv ?? ["--user", "mosaic", "--home", h.home, ...outer, "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
|
||||
const r = spawnSync(UNIT, args, {
|
||||
const r = spawnSync(unitScript, args, {
|
||||
encoding: "utf8",
|
||||
timeout: 30000,
|
||||
env: { PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env },
|
||||
env: { HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, LANG: process.env.LANG, PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env },
|
||||
});
|
||||
return { code: r.status, out: r.stdout, err: r.stderr };
|
||||
}
|
||||
@@ -197,12 +198,27 @@ for(const kind of ['sleep','signals','member']) test(`row253 C4 ${kind} query ha
|
||||
const member=join(h.root,'q ) '+String.fromCharCode(10)+'mem'),proof=join(h.root,'comm-proof');
|
||||
if(kind==='member')symlinkSync('/usr/bin/sleep',member);
|
||||
writeFileSync(join(copyDir(h),'packages/cli/src/config.mjs'),blockingQuerySource(queries,kind,member,proof));
|
||||
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files);
|
||||
const start=performance.now(),r=unit(h,{profile:'factory'}),seconds=(performance.now()-start)/1000;capture();
|
||||
const clock=join(h.root,'inner-clock'),observed=join(h.root,'observed/scripts/case2-unit.sh');
|
||||
mkdirSync(dirname(observed),{recursive:true});
|
||||
symlinkSync(join(REPO,'scripts/selector.sh'),join(dirname(observed),'selector.sh'));
|
||||
writeFileSync(clock,'',{mode:0o600});
|
||||
// The 7s outer watchdog spans startup and cleanup too. Give the private copy
|
||||
// cleanup room while retaining a separate band on the unchanged 5s inner timer.
|
||||
const source=read(UNIT),call='query="$(tester_capture 5 7 4096';
|
||||
assert.equal(source.split(call).length,2,'tester query budget anchor is unique');
|
||||
script(observed,instrumentInnerTimer(source.replace(call,call.replace(' 5 7 ',' 5 15 ')),clock));
|
||||
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files,9500,clock);
|
||||
const start=performance.now(),r=unit(h,{profile:'factory',script:observed,env:{
|
||||
CASE2_BUS_TEMPLATE:BUS_TEMPLATE,CASE2_WEBUI_TEMPLATE:WEBUI_TEMPLATE,
|
||||
CASE2_PROXY_TEMPLATE:join(REPO,'packages/cli/systemd/mosaic-proxy.service.in'),
|
||||
}}),wall=(performance.now()-start)/1000;capture();
|
||||
refuses(r,/^case2-unit: tester config query inner-timeout\n$/);
|
||||
const seconds=readProbeSeconds(read(clock));
|
||||
const [queryPid,querySid]=read(queries).trim().split('\n')[0].trim().split(/\s+/).map(Number);
|
||||
assert.equal(queryPid,querySid,`${kind}/${mode}: direct query PID must equal its kernel SID`);
|
||||
assert.ok(seconds<7,`${kind}/${mode}: ${seconds}s exceeds outer bound`);
|
||||
assert.ok(seconds>=4.25&&seconds<5.75,`${kind}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
|
||||
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
|
||||
t.diagnostic(`${kind}/${mode}: inner ${seconds}s; wall ${wall}s`);
|
||||
assert.deepEqual(await pollFixtureGone(files),[],mode);
|
||||
if(kind==='member')assert.ok(read(proof).includes('('+member.split('/').at(-1)+') '),'member comm with newline and closing parenthesis was observed');
|
||||
assert.equal(existsSync(h.log),false,mode);await stopGuard();
|
||||
|
||||
@@ -804,13 +804,27 @@ test("land: a failed gate is refused gate-failed with its step, told to the role
|
||||
assert.equal(ticket, TICKET);
|
||||
assert.equal(a.code, "gate-failed");
|
||||
assert.equal(a.notify, true);
|
||||
assert.match(a.detail, /^pkg-bus failed, see \/.*\/bus-host\/land\/land-[0-9a-f]{12}\/pkg-bus\.log$/);
|
||||
assert.match(a.detail, /^pkg-bus failed, see land\/land-[0-9a-f]{12}\/pkg-bus\.log in the bus host directory$/);
|
||||
assert.equal(w.git("rev-parse", "refs/heads/main"), w.main);
|
||||
assert.equal(w.git("cat-file", "-t", r.commit), "commit", "the squash was made, and only main didn't move");
|
||||
assert.deepEqual(w.of("recordLand"), []);
|
||||
assert.equal(w.trees(), 0);
|
||||
});
|
||||
|
||||
// PR #1916 (org-redact5): the detail reaches the role, so it never carries the log's host
|
||||
// path; a log file name outside the id pattern is dropped too.
|
||||
test("land: a failed gate's detail names the land directory, never the log's host path", async (t) => {
|
||||
const SENTINEL = "sk-test-NOT-A-SECRET-1234";
|
||||
let log = null;
|
||||
const w = await landSetup(t, { gate: { run: async ({ logs }) => ({ ok: false, step: "pkg-bus", log: (log = join(logs, SENTINEL, `${SENTINEL}.log`)) }) } });
|
||||
await w.land();
|
||||
await w.ended();
|
||||
const [[, a]] = w.of("landRefused");
|
||||
assert.equal(a.code, "gate-failed");
|
||||
assert.match(a.detail, /^pkg-bus failed, see land\/land-[0-9a-f]{12} in the bus host directory$/);
|
||||
assert.ok(!a.detail.includes(SENTINEL) && !a.detail.includes(log) && !a.detail.includes(w.f.dataRoot), a.detail);
|
||||
});
|
||||
|
||||
test("land: a record the broker refuses moves main back, and the landing is refused with the broker's code", async (t) => {
|
||||
const w = await landSetup(t);
|
||||
w.state.recordFails = "decision-consumed";
|
||||
|
||||
@@ -315,7 +315,7 @@ test("a host.lock that goes between the check and the read is tried again; one t
|
||||
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
|
||||
|
||||
writeFileSync(hostLock(dataRoot), "{not json", { mode: 0o600 });
|
||||
assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === `bus host state is unreadable: ${hostLock(dataRoot)}`);
|
||||
assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === "bus host state is unreadable");
|
||||
assert.equal(readFileSync(hostLock(dataRoot), "utf8"), "{not json");
|
||||
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
|
||||
});
|
||||
@@ -749,8 +749,14 @@ test("a stale organization writer lock refuses start and status without removing
|
||||
});
|
||||
|
||||
test("organization bus start refuses without a notifier config", async t => {
|
||||
// PR #1916 (org-redact5): the refusal names the log inside the organization's
|
||||
// runtime directory, never by host path. (A sentinel directory in the root would
|
||||
// push the bus socket path past the socket length limit, so the check is on the
|
||||
// fixture's own roots.)
|
||||
const f = await orgFixture(t, { notify: false });
|
||||
await assert.rejects(busStart(f.ctx, "acme", { detach: true }), /failed to start/i);
|
||||
await assert.rejects(busStart(f.ctx, "acme", { detach: true }), (e) => e.exitCode === 3
|
||||
&& e.message === "bus failed to start; see bus-host/host.log in the runtime directory of organization acme"
|
||||
&& !e.message.includes(f.dataRoot) && !e.message.includes(f.instanceRoot));
|
||||
assert.match(readFileSync(join(f.dataRoot, "bus-host", "host.log"), "utf8"), /no notifier config/);
|
||||
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
|
||||
});
|
||||
|
||||
@@ -21,10 +21,15 @@ function fixture(t) {
|
||||
};
|
||||
return { root, file, env, run, boot };
|
||||
}
|
||||
function redacted(result, file, status) {
|
||||
// label: the CLI refusal names the file by this label and never by host path (PR #1916).
|
||||
// Without it, the refusal comes from scripts/mosaic-config.mjs, which still names the file.
|
||||
function redacted(result, file, status, label) {
|
||||
assert.equal(result.status, status, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.ok(result.stderr.includes(file), result.stderr);
|
||||
if (label) {
|
||||
assert.ok(!result.stderr.includes(file), result.stderr);
|
||||
assert.ok(result.stderr.includes(label), result.stderr);
|
||||
} else assert.ok(result.stderr.includes(file), result.stderr);
|
||||
assert.match(result.stderr, /not valid JSON/);
|
||||
assert.doesNotMatch(result.stderr, /sk-test|Unexpected token/);
|
||||
}
|
||||
@@ -40,7 +45,7 @@ test('system config parse refusal and CLI relay omit parser bytes', t => {
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
return true;
|
||||
});
|
||||
redacted(f.run(cli, 'org', 'config', 'validate', 'alpha'), f.file, 3);
|
||||
redacted(f.run(cli, 'org', 'config', 'validate', 'alpha'), f.file, 3, 'configuration is not valid JSON; expected readable JSON: the system config MOSAIC_CONFIG names');
|
||||
});
|
||||
|
||||
test('named instance marker parse refusal omits parser bytes', t => {
|
||||
@@ -55,5 +60,5 @@ for (const operation of ['validate', 'create', 'update']) test(`org ${operation}
|
||||
const file = join(f.root, 'alpha.json'); writeFileSync(file, secret);
|
||||
const args = operation === 'validate' ? ['validate', file]
|
||||
: [operation, 'alpha', '--from', file, ...(operation === 'update' ? ['--if-revision', '1'] : [])];
|
||||
redacted(f.run(cli, 'org', ...args), file, 2);
|
||||
redacted(f.run(cli, 'org', ...args), file, 2, 'mosaic: cannot read organization document: not valid JSON\n');
|
||||
});
|
||||
@@ -46,14 +46,14 @@ for (const [field, edit] of configCases) test(`config ${field} omits the configu
|
||||
test('config-command refuses system config without relaying stderr file values', t => {
|
||||
const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); d.execution.model = secret + '\n'; writeFileSync(f.config, JSON.stringify(d));
|
||||
let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } });
|
||||
assert.equal(status, 3); assert.match(err, /system config problem \(mosaic-config exit 2\)/); assert.ok(err.includes(f.config), err); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/); assert.match(err, /execution.model/); assert.match(err, /expected a 1-200 character name/);
|
||||
assert.equal(status, 3); assert.match(err, /system config problem \(mosaic-config exit 2\)/); assert.ok(!err.includes(f.config), err); assert.match(err, /the system config MOSAIC_CONFIG names/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/); assert.match(err, /execution.model/); assert.match(err, /expected a 1-200 character name/);
|
||||
});
|
||||
test('project id mismatch omits the file value in config-command', t => {
|
||||
const root = scratch(t), f = fixture(root), file = join(f.doc.projects.stack.root, '.mosaic', 'project.json');
|
||||
mkdirSync(join(f.doc.projects.stack.root, '.mosaic'));
|
||||
writeFileSync(file, JSON.stringify({ projectVersion: 1, id: secret.toLowerCase() }));
|
||||
let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } });
|
||||
assert.equal(status, 2); assert.ok(err.includes(file), err); assert.match(err, /id/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/);
|
||||
assert.equal(status, 2); assert.ok(!err.includes(file), err); assert.match(err, /project file of project stack has id that does not match the declared project key/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
for (const [field, edit] of [
|
||||
['dataRoot', d => { d.dataRoot = `/x/${secret}/../y`; }],
|
||||
@@ -80,7 +80,7 @@ cp.spawnSync = () => ({ status: 2, stderr: ${JSON.stringify(childStderr)}, stdou
|
||||
const { runConfigCommand } = await import(${JSON.stringify(new URL('../../organization/src/config-command.mjs', import.meta.url).href)});
|
||||
process.exitCode = runConfigCommand(['validate', 'acme'], { env: { MOSAIC_CONFIG: ${JSON.stringify(file)} } });`;
|
||||
const r = spawnSync(process.execPath, ['--input-type=module', '-e', code], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 3, r.stderr); assert.ok(r.stderr.includes(file), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
assert.equal(r.status, 3, r.stderr); assert.ok(!r.stderr.includes(file), r.stderr); assert.match(r.stderr, /: the system config MOSAIC_CONFIG names\n$/); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
|
||||
import { bootConfig, loadSystem } from '../src/config.mjs';
|
||||
|
||||
@@ -358,7 +358,7 @@ test("the journal: a line with a wrong type refuses with exit 3 and names the fi
|
||||
for (const [field, r] of bad) {
|
||||
const text = `${JSON.stringify(dm)}\n${JSON.stringify(r)}\n`;
|
||||
writeFileSync(file, text);
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal line 2 is malformed (${field}): ${file}`, `${field}: ${JSON.stringify(r)}`);
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal line 2 is malformed (${field})` && !e.message.includes(file), `${field}: ${JSON.stringify(r)}`);
|
||||
assert.equal(readFileSync(file, "utf8"), text, "a refusal changes nothing");
|
||||
}
|
||||
const good = [dm, digest, { ...dm, outcome: "refused", messageId: null, status: 403 }, { ...dm, outcome: "gave-up", messageId: null }, { ...digest, outcome: "unknown", messageId: null }];
|
||||
@@ -377,7 +377,7 @@ test("the journal: a symlinked directory refuses and says it is a link", (t) =>
|
||||
const file = journalPath(root, "demo");
|
||||
mkdirSync(dirname(dirname(file)), { mode: 0o700 });
|
||||
symlinkSync(real, dirname(file));
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink: ${dirname(file)}`);
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink` && !e.message.includes(dirname(file)));
|
||||
assert.deepEqual(readdirSync(real), [], "nothing was created through the link");
|
||||
});
|
||||
|
||||
@@ -386,13 +386,13 @@ test("the journal: a dangling directory link, a parent that is a file and a jour
|
||||
const dangling = journalPath(root, "demo");
|
||||
mkdirSync(dirname(dirname(dangling)), { mode: 0o700 });
|
||||
symlinkSync(join(root, "missing"), dirname(dangling));
|
||||
assert.throws(() => openJournal(dangling), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink: ${dirname(dangling)}`);
|
||||
assert.throws(() => openJournal(dangling), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink` && !e.message.includes(dirname(dangling)));
|
||||
const under = join(root, "plain", "notify", "demo", "sent.jsonl");
|
||||
writeFileSync(join(root, "plain"), "");
|
||||
assert.throws(() => openJournal(under), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (ENOTDIR): ${dirname(under)}`);
|
||||
assert.throws(() => openJournal(under), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (ENOTDIR)` && !e.message.includes(dirname(under)));
|
||||
const asDir = journalPath(root, "acme");
|
||||
mkdirSync(asDir, { recursive: true, mode: 0o700 });
|
||||
assert.throws(() => openJournal(asDir), (e) => e.exitCode === 3 && e.message === `notify journal must be a regular file, mode 0600, owned by this user: ${asDir}`);
|
||||
assert.throws(() => openJournal(asDir), (e) => e.exitCode === 3 && e.message === `notify journal must be a regular file, mode 0600, owned by this user` && !e.message.includes(asDir));
|
||||
});
|
||||
|
||||
test("the journal: an append after the file was swapped for a symlink refuses and writes nothing through it", (t) => {
|
||||
@@ -414,18 +414,18 @@ test("the journal: a directory it cannot write or create refuses with exit 3 and
|
||||
// Modes are restored in finally: tmp()'s cleanup runs first among the after hooks.
|
||||
chmodSync(dirname(file), 0o500);
|
||||
try {
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory is not writable (EACCES): ${dirname(file)}`);
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory is not writable (EACCES)` && !e.message.includes(dirname(file)));
|
||||
} finally {
|
||||
chmodSync(dirname(file), 0o700);
|
||||
}
|
||||
openJournal(file);
|
||||
chmodSync(file, 0o400);
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal is not writable (EACCES): ${file}`);
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal is not writable (EACCES)` && !e.message.includes(file));
|
||||
const parent = join(root, "notify");
|
||||
const other = journalPath(root, "acme");
|
||||
chmodSync(parent, 0o500);
|
||||
try {
|
||||
assert.throws(() => openJournal(other), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (EACCES): ${dirname(other)}`);
|
||||
assert.throws(() => openJournal(other), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (EACCES)` && !e.message.includes(dirname(other)));
|
||||
} finally {
|
||||
chmodSync(parent, 0o700);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// #1873 T1: org:<name> definitions at launch, and `mosaic org definitions`.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'node:path';
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createLauncher } from '../src/launcher.mjs';
|
||||
import { main } from '../src/cli.mjs';
|
||||
import { orgCommand } from '../src/org.mjs';
|
||||
import { bootConfig, loadSystem } from '../src/config.mjs';
|
||||
import { BusError } from '../../bus/src/broker.mjs';
|
||||
import { startBroker } from '../../bus/src/runtime.mjs';
|
||||
import { initializeControl } from '../../processing-control/src/state.mjs';
|
||||
import { launchLogFile } from '../../seat/src/session.mjs';
|
||||
import { entryFromTemplate, readTemplate, templateHash } from '../../organization/src/definitions.mjs';
|
||||
import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { fixture, io, tmp, withoutHostCredentials } from './helpers.mjs';
|
||||
|
||||
const SCOUT = { rev: 1, role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.\n' };
|
||||
const stop = () => Object.assign(Error('fixture authorization stop'), { code: 'fixture-policy' });
|
||||
const host = { business: 'acme', beforeClose: () => {}, op: async (x) => {
|
||||
if (x.op === 'identity') return { business: 'acme', role: 'pm' };
|
||||
if (x.op === 'mayLaunch') return { allow: true };
|
||||
if (x.op === 'authorizeLaunch') throw stop();
|
||||
return {};
|
||||
} };
|
||||
|
||||
test('T1 an org: instance passes every launch check, and a missing definition refuses naming instance and definition', async (t) => {
|
||||
const root = tmp(t);
|
||||
// The org's own copy of the coder template, so the instance's credentials and tracker still match.
|
||||
const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder'));
|
||||
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
|
||||
const file = join(root, 'config/businesses/acme.json');
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
initializeControl(f.dataRoot);
|
||||
const launcher = createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} });
|
||||
// Resolve, route, contract (the definition's instructions) and skills all pass; the fixture stops at authorization.
|
||||
assert.equal((await launcher.fromSession('fixture-cap', 'coder')).error, 'fixture-policy');
|
||||
|
||||
// The chief names a definition the file doesn't declare: the whole file refuses, so a session's
|
||||
// launch and the chief's launch both refuse.
|
||||
writeJson(file, { ...f.doc, roles: { ...f.doc.roles, chief: { ...f.doc.roles.chief, definition: 'org:ghost' } } });
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'organization-refused' });
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.code === 'organization-refused' && /roles\.chief\.definition names org:ghost/.test(e.message));
|
||||
const event = JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1));
|
||||
assert.equal(event.code, 'organization-refused');
|
||||
assert.equal(event.instance, 'chief');
|
||||
assert.match(event.why, /definition-missing: .*roles\.chief\.definition names org:ghost/);
|
||||
await launcher.close?.();
|
||||
|
||||
// A host does not start on a file that names a missing definition.
|
||||
assert.throws(() => createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} }), /org:ghost/);
|
||||
});
|
||||
|
||||
// The fixture bus holds SENTINEL as coder's gitea token, so the bus's secret check refuses it.
|
||||
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
||||
function files(dir) {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
|
||||
}
|
||||
|
||||
test('T1 mosaic org definitions: writes go through the bus and its secret check, each prints its receipt, and an agent shell refuses', async (t) => {
|
||||
const root = tmp(t);
|
||||
const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder'));
|
||||
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
|
||||
const file = join(root, 'config/businesses/acme.json');
|
||||
const changes = join(root, 'config/businesses/.history/acme/changes.jsonl');
|
||||
writeFileSync(f.doc.roles.coder.credentials.gitea.file, SENTINEL);
|
||||
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
|
||||
const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
|
||||
t.after(() => b.close());
|
||||
const human = b.broker.bindHuman({ business: 'acme', human: f.doc.human, via: 'cli', outsideAgent: true });
|
||||
const calls = [];
|
||||
// The human transport's shape: a bus refusal arrives as its code.
|
||||
const deps = { system: { dataRoot: f.dataRoot }, transport: () => async (verb, args = {}) => {
|
||||
calls.push(verb);
|
||||
try { return structuredClone(b.broker.request(human, { verb, args })); }
|
||||
catch (e) { if (!(e instanceof BusError)) throw e; throw Object.assign(new Error(e.code), { code: e.code }); }
|
||||
} };
|
||||
const run = async (args, env = f.env) => { const x = io(env); await main(['org', 'definitions', ...args], x, deps); return JSON.parse(x.out.text); };
|
||||
const hash = templateHash(f.roles, 'coder');
|
||||
assert.ok((await run(['templates'])).some((h) => h.template === 'coder' && h.sha256 === hash));
|
||||
|
||||
const imported = await run(['acme', 'import', 'coder', '--as', 'copy']);
|
||||
assert.deepEqual({ ...imported, revision: 0, transaction: 0 }, { verb: 'import', organization: 'acme', definition: 'copy', rev: 1, revision: 0, transaction: 0, from: { template: 'coder', sha256: hash } });
|
||||
const spec = join(root, 'scout.json');
|
||||
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: SCOUT.instructions }));
|
||||
assert.equal((await run(['acme', 'invent', 'scout', '--from', spec])).rev, 1);
|
||||
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: 'Read, then report.\n' }));
|
||||
const edited = await run(['acme', 'edit', 'scout', '--from', spec, '--if-rev', '1']);
|
||||
assert.equal(edited.verb, 'edit');
|
||||
assert.equal(edited.rev, 2);
|
||||
assert.deepEqual((await run(['acme', 'list'])).definitions.map((d) => [d.name, d.rev]), [['builder', 1], ['copy', 1], ['scout', 2]]);
|
||||
assert.equal((await run(['acme', 'show', 'scout'])).instructions, 'Read, then report.\n');
|
||||
assert.equal((await run(['acme', 'diff', 'copy'])).changed, false);
|
||||
assert.equal((await run(['acme', 'remove', 'scout', '--if-rev', '2'])).removedRev, 2);
|
||||
assert.deepEqual(calls, ['definition.import', 'definition.invent', 'definition.edit', 'definition.remove']);
|
||||
const committed = readFileSync(changes, 'utf8').trim().split('\n').map((l) => JSON.parse(l)).filter((e) => e.state === 'committed').slice(-4);
|
||||
assert.deepEqual(committed.map((e) => e.actor), Array(4).fill(f.doc.human));
|
||||
|
||||
// A definition holding a token the bus holds refuses on invent and on edit, in valid JSON, and
|
||||
// the token reaches no message, no organization file and nothing the bus wrote.
|
||||
const before = readFileSync(file);
|
||||
const beforeChanges = readFileSync(changes);
|
||||
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: `Use ${SENTINEL} to report.\n` }));
|
||||
for (const args of [['acme', 'invent', 'leak', '--from', spec], ['acme', 'edit', 'copy', '--from', spec, '--if-rev', '1']]) {
|
||||
await assert.rejects(run(args), (e) => e.code === 'credential-leak' && e.exitCode === 2 && !e.message.includes(SENTINEL) && !String(e.stack).includes(SENTINEL));
|
||||
}
|
||||
assert.deepEqual(readFileSync(file), before);
|
||||
assert.deepEqual(readFileSync(changes), beforeChanges);
|
||||
for (const path of files(f.dataRoot)) assert.ok(!readFileSync(path).includes(SENTINEL), path);
|
||||
|
||||
// An unparsable file never echoes; a key the file's checks refuse is named before the bus is
|
||||
// called; a stale --if-rev and an in-use definition refuse with what they mean.
|
||||
writeFileSync(spec, `{"role": ${SENTINEL}`);
|
||||
await assert.rejects(run(['acme', 'invent', 'other', '--from', spec]), (e) => e.exitCode === 2 && !e.message.includes('sk-test'));
|
||||
writeFileSync(spec, JSON.stringify({ role: { ...SCOUT.role, network: 'everywhere' }, instructions: 'x\n' }));
|
||||
const asked = calls.length;
|
||||
await assert.rejects(run(['acme', 'invent', 'other', '--from', spec]), /definitions\.other/);
|
||||
assert.equal(calls.length, asked);
|
||||
await assert.rejects(run(['acme', 'remove', 'copy', '--if-rev', '7']), (e) => /^definition-rev-conflict: definition copy is not at rev 7/.test(e.message));
|
||||
await assert.rejects(run(['acme', 'remove', 'builder', '--if-rev', '1']), (e) => /^definition-in-use: definition builder is named by coder/.test(e.message));
|
||||
assert.deepEqual(readFileSync(file), before);
|
||||
|
||||
// Inside an agent run every write refuses before it reads anything or calls the bus; reads still work.
|
||||
const agent = { ...f.env, MOSAIC_RUN_ID: 'run-1' };
|
||||
const sent = calls.length;
|
||||
for (const args of [['acme', 'import', 'reviewer'], ['acme', 'invent', 'x', '--from', spec], ['acme', 'edit', 'copy', '--from', spec, '--if-rev', '1'], ['acme', 'remove', 'copy', '--if-rev', '1']]) {
|
||||
await assert.rejects(run(args, agent), (e) => e.exitCode === 3 && /human shell/.test(e.message));
|
||||
}
|
||||
assert.equal(calls.length, sent);
|
||||
assert.deepEqual(readFileSync(file), before);
|
||||
assert.equal((await run(['acme', 'list'], agent)).definitions.length, 2);
|
||||
|
||||
// With no bus call to make, a write refuses rather than writing without the secret check.
|
||||
const x = io(f.env);
|
||||
await assert.rejects(orgCommand(['definitions', 'acme', 'remove', 'copy', '--if-rev', '1'], x), (e) => e.exitCode === 3 && /organization bus/.test(e.message));
|
||||
assert.deepEqual(readFileSync(file), before);
|
||||
});
|
||||
@@ -40,8 +40,10 @@ test('console stop quarantines dead and wrong-process states without signaling',
|
||||
});
|
||||
|
||||
test('live console from another entry path remains recorded and is not signalled', { timeout: 30000 }, async t => {
|
||||
const f = await fixture(), dir = join(f.ctx.dataRoot, 'console'), alias = join(f.root, 'console.mjs');
|
||||
mkdirSync(dir, { mode: 0o700 });
|
||||
// The other entry sits under the sentinel directory: the refusal must name neither (PR #1916).
|
||||
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
||||
const f = await fixture(), dir = join(f.ctx.dataRoot, 'console'), alias = join(f.root, SENTINEL, 'console.mjs');
|
||||
mkdirSync(dir, { mode: 0o700 }); mkdirSync(join(f.root, SENTINEL), { mode: 0o700 });
|
||||
symlinkSync(new URL('../../webui/src/cli.mjs', import.meta.url).pathname, alias);
|
||||
const child = spawn(process.execPath, [alias, 'serve', '--port', '0'], { env: humanEnv({ HOME: f.root, MOSAIC_CONFIG: join(f.ctx.configDir, 'config.json') }), stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { const ended = once(child, 'exit'); child.kill('SIGTERM'); await ended; } });
|
||||
@@ -53,7 +55,7 @@ test('live console from another entry path remains recorded and is not signalled
|
||||
const record = { pid: child.pid, startTime: startTimeOf(child.pid), url };
|
||||
json(join(dir, 'console.json'), record);
|
||||
await assert.rejects(consoleStart(f.ctx), /console state exists/);
|
||||
await assert.rejects(consoleStop(f.ctx), e => /another path/.test(e.message) && e.message.includes(alias));
|
||||
await assert.rejects(consoleStop(f.ctx), e => { assert.equal(e.exitCode, 3); assert.equal(e.message, 'console was started from another checkout; stop it there'); assert.ok(!e.message.includes(SENTINEL) && !e.message.includes(alias), e.message); return true; });
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(dir, 'console.json'), 'utf8')), record);
|
||||
assert.equal(startTimeOf(child.pid), record.startTime);
|
||||
assert.equal(readdirSync(dir).filter(x => x.startsWith('console.json.stale-')).length, 0);
|
||||
|
||||
@@ -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';",
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
// PR #1916 redaction (org-redact5): a CLI refusal names the file by its label,
|
||||
// never by host path. Every fixture here sits under a directory named for the
|
||||
// sentinel, so a refusal that quoted the path would carry it.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { gitSocketPath } from "../../seat/src/session.mjs";
|
||||
import { withLock } from "../src/access.mjs";
|
||||
import { fixtureFile, readFixture } from "../src/dev-fixture.mjs";
|
||||
import { createGitSocket } from "../src/git-socket.mjs";
|
||||
import { consoleStart, consoleStop } from "../src/org-console.mjs";
|
||||
import { claimHostLock, hostLock } from "../src/host.mjs";
|
||||
import { journalPath, openJournal } from "../src/notifier.mjs";
|
||||
import { readNotifyConfig } from "../src/notify-config.mjs";
|
||||
import { orgCommand } from "../src/org.mjs";
|
||||
import { instancesCommand } from "../src/instances.mjs";
|
||||
import { FIXTURE_ENTRY } from "../../plugins/model-provider/fixture/index.mjs";
|
||||
|
||||
const SENTINEL = "sk-test-NOT-A-SECRET-1234";
|
||||
const ROOT_SKIP = process.getuid() === 0 && "root ignores the modes";
|
||||
|
||||
function under(t) {
|
||||
const top = mkdtempSync(join(tmpdir(), "redact-"));
|
||||
t.after(() => rmSync(top, { recursive: true, force: true }));
|
||||
const root = join(top, SENTINEL);
|
||||
mkdirSync(root, { mode: 0o700 });
|
||||
return root;
|
||||
}
|
||||
|
||||
// The refusal is exactly `message` (a string) or matches it (a RegExp), with exit
|
||||
// code `exitCode`, and carries neither the sentinel nor any of `paths`.
|
||||
function refusal(message, { exitCode = 3, paths = [], code } = {}) {
|
||||
return (error) => {
|
||||
assert.equal(error.name, "CliError");
|
||||
assert.equal(error.exitCode, exitCode);
|
||||
if (code !== undefined) assert.equal(error.code, code);
|
||||
if (message instanceof RegExp) assert.match(error.message, message);
|
||||
else assert.equal(error.message, message);
|
||||
assert.ok(!error.message.includes(SENTINEL), error.message);
|
||||
for (const path of paths) assert.ok(!error.message.includes(path), error.message);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
// --- access.json lock ---
|
||||
|
||||
test("access lock: an unreadable lock is named by label, never by host path", (t) => {
|
||||
const file = join(under(t), "access.json");
|
||||
writeFileSync(`${file}.lock`, "not-a-pid\n", { mode: 0o600 });
|
||||
assert.throws(() => withLock(file, () => assert.fail("must refuse")),
|
||||
refusal("refused: access file lock is unreadable or not a pid; if no write is running, remove it by hand and try again", { paths: [file] }));
|
||||
});
|
||||
|
||||
test("access lock: a lock left by a dead pid is named by label, never by host path", (t) => {
|
||||
const file = join(under(t), "access.json");
|
||||
const dead = spawnSync(process.execPath, ["-e", ""]);
|
||||
writeFileSync(`${file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
|
||||
assert.throws(() => withLock(file, () => assert.fail("must refuse")),
|
||||
refusal(`refused: access file lock is left by pid ${dead.pid}, which is not running; if no write is running, remove it by hand and try again`, { paths: [file] }));
|
||||
});
|
||||
|
||||
// --- bus host lock ---
|
||||
|
||||
const MINE = { pid: process.pid, startTime: "1", business: "acme", startedAt: "2026-11-10T00:00:00.000Z" };
|
||||
const STALE = { pid: 999999999, startTime: "1", business: "acme", startedAt: "2026-11-09T00:00:00.000Z" };
|
||||
|
||||
function hostRoot(t, lock) {
|
||||
const dataRoot = join(under(t), "data");
|
||||
mkdirSync(join(dirname(hostLock(dataRoot))), { recursive: true, mode: 0o700 });
|
||||
if (lock !== undefined) writeFileSync(hostLock(dataRoot), lock, { mode: 0o600 });
|
||||
return dataRoot;
|
||||
}
|
||||
|
||||
test("host lock: an unreadable host.lock refuses by label, never by host path", (t) => {
|
||||
const dataRoot = hostRoot(t, "{not json");
|
||||
assert.throws(() => claimHostLock(dataRoot, MINE), refusal("bus host state is unreadable", { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("host lock: a malformed host.lock refuses by label, never by host path", (t) => {
|
||||
const dataRoot = hostRoot(t, JSON.stringify({ pid: "x" }));
|
||||
assert.throws(() => claimHostLock(dataRoot, MINE), refusal("bus host state is malformed", { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("host lock: a stale host.lock refused on request is named by label, never by host path", (t) => {
|
||||
const dataRoot = hostRoot(t, JSON.stringify(STALE));
|
||||
assert.throws(() => claimHostLock(dataRoot, MINE, { refuseStale: true }), refusal("stale bus host lock", { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("host lock: a lock that keeps changing is named by label, never by host path", (t) => {
|
||||
const dataRoot = hostRoot(t, JSON.stringify(STALE));
|
||||
let n = 0;
|
||||
const race = () => writeFileSync(hostLock(dataRoot), JSON.stringify({ ...STALE, startedAt: `2026-11-09T00:00:${String(++n % 60).padStart(2, "0")}.000Z` }), { mode: 0o600 });
|
||||
assert.throws(() => claimHostLock(dataRoot, MINE, { between: race }), refusal("bus host lock kept changing; no bus host started", { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
// --- notify journal ---
|
||||
|
||||
test("notify journal: a directory link, dangling or not, is refused by label", (t) => {
|
||||
const root = under(t);
|
||||
const dangling = journalPath(root, "demo");
|
||||
mkdirSync(dirname(dirname(dangling)), { recursive: true, mode: 0o700 });
|
||||
symlinkSync(join(root, "missing"), dirname(dangling));
|
||||
assert.throws(() => openJournal(dangling), refusal("notify journal directory must not be a symlink", { paths: [dangling] }));
|
||||
const real = join(root, "real");
|
||||
mkdirSync(real, { mode: 0o700 });
|
||||
const linked = journalPath(root, "acme");
|
||||
symlinkSync(real, dirname(linked));
|
||||
assert.throws(() => openJournal(linked), refusal("notify journal directory must not be a symlink", { paths: [linked] }));
|
||||
});
|
||||
|
||||
test("notify journal: a parent that is a file is refused by its error code", (t) => {
|
||||
const root = under(t);
|
||||
writeFileSync(join(root, "plain"), "");
|
||||
const file = join(root, "plain", "notify", "demo", "sent.jsonl");
|
||||
assert.throws(() => openJournal(file), refusal("notify journal directory cannot be created (ENOTDIR)", { paths: [file] }));
|
||||
});
|
||||
|
||||
test("notify journal: a directory of the wrong mode is refused by label", (t) => {
|
||||
const file = journalPath(under(t), "demo");
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
|
||||
chmodSync(dirname(file), 0o755);
|
||||
assert.throws(() => openJournal(file), refusal("notify journal directory must be mode 0700 and owned by this user", { paths: [file] }));
|
||||
});
|
||||
|
||||
test("notify journal: a directory it cannot write or create is refused by its error code", { skip: ROOT_SKIP }, (t) => {
|
||||
const root = under(t);
|
||||
const file = journalPath(root, "demo");
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
chmodSync(dirname(file), 0o500);
|
||||
try {
|
||||
assert.throws(() => openJournal(file), refusal("notify journal directory is not writable (EACCES)", { paths: [file] }));
|
||||
} finally {
|
||||
chmodSync(dirname(file), 0o700);
|
||||
}
|
||||
const parent = dirname(dirname(file));
|
||||
const other = journalPath(root, "acme");
|
||||
chmodSync(parent, 0o500);
|
||||
try {
|
||||
assert.throws(() => openJournal(other), refusal("notify journal directory cannot be created (EACCES)", { paths: [other] }));
|
||||
} finally {
|
||||
chmodSync(parent, 0o700);
|
||||
}
|
||||
});
|
||||
|
||||
test("notify journal: a journal link, an unwritable journal and a directory journal are refused by label", { skip: ROOT_SKIP }, (t) => {
|
||||
const root = under(t);
|
||||
const file = journalPath(root, "demo");
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(root, "elsewhere.jsonl"), "", { mode: 0o600 });
|
||||
symlinkSync(join(root, "elsewhere.jsonl"), file);
|
||||
assert.throws(() => openJournal(file), refusal("notify journal must not be a symlink", { paths: [file] }));
|
||||
rmSync(file);
|
||||
writeFileSync(file, "", { mode: 0o400 });
|
||||
chmodSync(file, 0o400);
|
||||
assert.throws(() => openJournal(file), refusal("notify journal is not writable (EACCES)", { paths: [file] }));
|
||||
const asDir = journalPath(root, "acme");
|
||||
mkdirSync(asDir, { recursive: true, mode: 0o700 });
|
||||
assert.throws(() => openJournal(asDir), refusal("notify journal must be a regular file, mode 0600, owned by this user", { paths: [asDir] }));
|
||||
});
|
||||
|
||||
test("notify journal: a journal of the wrong mode and a malformed line are refused by label", (t) => {
|
||||
const file = journalPath(under(t), "demo");
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(file, "", { mode: 0o644 });
|
||||
chmodSync(file, 0o644);
|
||||
assert.throws(() => openJournal(file), refusal("notify journal must be a regular file, mode 0600, owned by this user", { paths: [file] }));
|
||||
chmodSync(file, 0o600);
|
||||
writeFileSync(file, "not json\n");
|
||||
assert.throws(() => openJournal(file), refusal("notify journal line 1 is malformed (json)", { paths: [file] }));
|
||||
});
|
||||
|
||||
// --- notifier config ---
|
||||
|
||||
function notifyRoot(t, text, mode = 0o600) {
|
||||
const dataRoot = join(under(t), "data");
|
||||
const dir = join(dataRoot, "notify", "acme");
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
if (text !== undefined) {
|
||||
writeFileSync(join(dir, "notify.json"), text, { mode });
|
||||
chmodSync(join(dir, "notify.json"), mode);
|
||||
}
|
||||
return dataRoot;
|
||||
}
|
||||
|
||||
test("notifier config: a missing file names the business and its place under the data root", (t) => {
|
||||
const dataRoot = notifyRoot(t);
|
||||
assert.throws(() => readNotifyConfig(dataRoot, "acme"),
|
||||
refusal('no notifier config for acme: write {"notifyVersion": 1, "binding": "<discord binding>"} to notify/acme/notify.json under the data root, mode 0600, or "binding": null to run without DMs', { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("notifier config: a file of the wrong mode names the business only", (t) => {
|
||||
const dataRoot = notifyRoot(t, JSON.stringify({ notifyVersion: 1, binding: null }), 0o644);
|
||||
assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal("notifier config for acme must be a regular file, mode 0600, owned by this user", { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("notifier config: a file that is not JSON names the business only", (t) => {
|
||||
const dataRoot = notifyRoot(t, "{not json");
|
||||
assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal("notifier config for acme is not JSON", { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("notifier config: a file of the wrong shape names the business only", (t) => {
|
||||
const dataRoot = notifyRoot(t, JSON.stringify({ notifyVersion: 1, binding: null, extra: 1 }));
|
||||
assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal('notifier config for acme must be exactly {"notifyVersion": 1, "binding": <binding name> | null}', { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
// --- dev fixture ---
|
||||
|
||||
const RECORD = { fixtureVersion: 1, model: "deterministic", endpoint: "http://127.0.0.1:45678", port: 45678, pid: 999999, instance: "fxtest", entry: FIXTURE_ENTRY };
|
||||
|
||||
function fixtureRoot(t, { record = RECORD, text = JSON.stringify(record), dirMode = 0o700, fileMode = 0o600 } = {}) {
|
||||
const dataRoot = join(under(t), "data");
|
||||
const file = fixtureFile(dataRoot);
|
||||
mkdirSync(dirname(file), { recursive: true, mode: dirMode });
|
||||
chmodSync(dirname(file), dirMode);
|
||||
if (text !== null) {
|
||||
writeFileSync(file, text, { mode: fileMode });
|
||||
chmodSync(file, fileMode);
|
||||
}
|
||||
return dataRoot;
|
||||
}
|
||||
|
||||
test("dev fixture: a shared fixture directory is refused by label", (t) => {
|
||||
const dataRoot = fixtureRoot(t, { dirMode: 0o755 });
|
||||
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: dev-fixture directory is not a private directory of this user", { code: "fixture-state-unsafe", paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("dev fixture: a linked fixture file is refused by label and error code", (t) => {
|
||||
const dataRoot = fixtureRoot(t, { text: null });
|
||||
writeFileSync(join(dataRoot, "elsewhere.json"), JSON.stringify(RECORD), { mode: 0o600 });
|
||||
symlinkSync(join(dataRoot, "elsewhere.json"), fixtureFile(dataRoot));
|
||||
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: fixture.json can't be opened (ELOOP)", { code: "fixture-state-unsafe", paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("dev fixture: a shared fixture file is refused by label", (t) => {
|
||||
const dataRoot = fixtureRoot(t, { fileMode: 0o644 });
|
||||
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: fixture.json is not a private file of this user", { code: "fixture-state-unsafe", paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("dev fixture: a fixture file that is not JSON is refused by label", (t) => {
|
||||
const dataRoot = fixtureRoot(t, { text: "not json" });
|
||||
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-invalid: fixture.json is not JSON", { code: "fixture-state-invalid", paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("dev fixture: a non-loopback endpoint and a record of the wrong shape are refused by label", (t) => {
|
||||
let dataRoot = fixtureRoot(t, { record: { ...RECORD, endpoint: "http://0.0.0.0:45678" } });
|
||||
assert.throws(() => readFixture(dataRoot), refusal("fixture-endpoint-not-loopback: fixture.json names an endpoint that is not http://127.0.0.1:<port>", { code: "fixture-endpoint-not-loopback", paths: [dataRoot] }));
|
||||
dataRoot = fixtureRoot(t, { record: { ...RECORD, port: 1 } });
|
||||
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-invalid: fixture.json is not a fixture record", { code: "fixture-state-invalid", paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
// --- git socket ---
|
||||
|
||||
test("git socket: a path that exists and is not a socket is refused by label", async (t) => {
|
||||
const dataRoot = join(under(t), "data");
|
||||
const path = gitSocketPath(dataRoot);
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(path, "", { mode: 0o600 });
|
||||
const sock = createGitSocket({ host: {}, business: { id: "acme", projects: {} }, dataRoot, env: {}, log: () => {} });
|
||||
await assert.rejects(sock.listen(), refusal("git socket path exists and isn't a socket", { paths: [dataRoot, path] }));
|
||||
});
|
||||
|
||||
// --- console launch ---
|
||||
|
||||
// The Console is given a port this test holds, so its listen fails and it exits at
|
||||
// startup. Should it start anyway, it is stopped before the tree goes.
|
||||
test("console: a Console that exits at startup is named by its log under the data root, never by host path", { timeout: 60000 }, async (t) => {
|
||||
const root = under(t);
|
||||
const ctx = { configDir: join(root, "config"), dataRoot: join(root, "data"), instance: "redact", env: { PATH: process.env.PATH, HOME: root } };
|
||||
for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 });
|
||||
const held = createServer();
|
||||
await new Promise((resolve, reject) => held.once("error", reject).listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
await assert.rejects(consoleStart(ctx, { detach: true, port: String(held.address().port), warn: () => {} }),
|
||||
refusal(/^console failed to start; see console\/console-[0-9]+-[0-9]+\.log under the data root$/, { paths: [ctx.dataRoot] }));
|
||||
} finally {
|
||||
await consoleStop(ctx, { warn: () => {} }).catch(() => {});
|
||||
await new Promise((resolve) => held.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
// --- review round (org-redact5 Sol P2 #3): documents, --vars and raw fs errors ---
|
||||
|
||||
const quietIo = () => ({ stdout: { write: () => true }, stderr: { write: () => true } });
|
||||
|
||||
test("org validate: a missing or invalid document is refused by kind, never by host path or bytes", async (t) => {
|
||||
const root = under(t), missing = join(root, "missing.json"), bad = join(root, "bad.json");
|
||||
writeFileSync(bad, `{"${SENTINEL}": `, { mode: 0o600 });
|
||||
await assert.rejects(orgCommand(["validate", missing], quietIo()), refusal("cannot read organization document: ENOENT", { exitCode: 2, paths: [missing, root] }));
|
||||
await assert.rejects(orgCommand(["validate", bad], quietIo()), refusal("cannot read organization document: not valid JSON", { exitCode: 2, paths: [bad, root] }));
|
||||
});
|
||||
|
||||
test("org template render: a raw filesystem error is named by operation and code, never by host path", async (t) => {
|
||||
const root = under(t), output = join(root, "alpha.json");
|
||||
writeFileSync(output, "{}\n", { mode: 0o600 });
|
||||
await assert.rejects(orgCommand(["template", "render", "alpha", "--output", output], quietIo()),
|
||||
refusal("EEXIST: cannot open (EEXIST)", { exitCode: 2, paths: [output, root] }));
|
||||
});
|
||||
|
||||
test("instance set: a missing or invalid --vars file is refused by flag and kind, never by host path or bytes", async (t) => {
|
||||
const root = under(t), missing = join(root, "vars.json"), bad = join(root, "bad.json");
|
||||
writeFileSync(bad, `{"${SENTINEL}": `, { mode: 0o600 });
|
||||
const rev = "a".repeat(64);
|
||||
const callFor = () => assert.fail("must refuse before any call");
|
||||
await assert.rejects(instancesCommand(["instance", "set", "acme", "coder", "--vars", missing, "--revision", rev], quietIo(), callFor),
|
||||
refusal("--vars file: ENOENT", { exitCode: 4, paths: [missing, root] }));
|
||||
await assert.rejects(instancesCommand(["instance", "set", "acme", "coder", "--vars", bad, "--revision", rev], quietIo(), callFor),
|
||||
refusal("--vars file: not valid JSON", { exitCode: 4, paths: [bad, root] }));
|
||||
});
|
||||
@@ -169,7 +169,7 @@ test("rename r2 B2: a draft file named like an installed ID: org validate reads
|
||||
rmSync(join(cwd, "acme"));
|
||||
const noDraft = spawn(MOSAIC, ["org", "validate", "acme"], env, cwd);
|
||||
assert.equal(noDraft.status, 2);
|
||||
assert.match(noDraft.stderr, /^mosaic: cannot read document acme: /);
|
||||
assert.equal(noDraft.stderr, "mosaic: cannot read organization document: ENOENT\n");
|
||||
writeJson(join(cwd, "acme"), await templateRender("acme", { fixture: "local" }));
|
||||
rmSync(join(root, "config", "businesses", "acme.json"));
|
||||
const noInstalled = spawn(MOSAIC, ["org", "config", "validate", "acme"], env, cwd);
|
||||
|
||||
@@ -21,11 +21,11 @@ const OTHER = '11111111-2222-4333-8444-555555555555';
|
||||
let n = 0;
|
||||
|
||||
// An instance with org alpha and one finished run for THREAD.
|
||||
async function world({ run = 'r0123456789ab' } = {}) {
|
||||
async function world({ run = 'r0123456789ab', under = HOME } = {}) {
|
||||
const name = `ev${++n}`;
|
||||
const env = { PATH: process.env.PATH, HOME, MOSAIC_CONFIG: join(HOME, '.config', `mosaic-${name}`, 'config.json') };
|
||||
mkdirSync(dirname(env.MOSAIC_CONFIG), { recursive: true, mode: 0o700 });
|
||||
const dataRoot = join(HOME, `data-${name}`); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const dataRoot = join(under, `data-${name}`); mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
|
||||
const org = await reserveOrg({ configDir: dirname(env.MOSAIC_CONFIG), dataRoot, instance: name, principal: DEV_OWNER }, 'alpha');
|
||||
const runDir = join(runsDir(org.root, 'alpha'), run);
|
||||
mkdirSync(join(runDir, 'bundle'), { recursive: true, mode: 0o700 });
|
||||
@@ -132,6 +132,27 @@ test('no launch for the thread, an unknown org and a usage error', async () => {
|
||||
await assert.rejects(runCommand(['evidence', 'alpha', '--thread', THREAD], io({ PATH: process.env.PATH, HOME }), { system: { dataRoot: w.dataRoot } }), e => e.exitCode === 3 && e.code === 'run-evidence-instance-refused');
|
||||
});
|
||||
|
||||
// PR #1916 (org-redact5): a refusal names the launch log by label, never by host path.
|
||||
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
||||
const noPath = (e, w) => !e.message.includes(SENTINEL) && !e.message.includes(w.dataRoot) && !e.message.includes(w.log);
|
||||
|
||||
test('a launch log of the wrong mode refuses by label, never by host path', async () => {
|
||||
const w = await world({ under: join(HOME, SENTINEL) });
|
||||
chmodSync(w.log, 0o644);
|
||||
const r = await evidence(w, ['--thread', THREAD, '--json']);
|
||||
assert.equal(r.error?.exitCode, 3);
|
||||
assert.equal(r.error.message, 'run-evidence-refused: launch log: mode 644, expected 600');
|
||||
assert.ok(noPath(r.error, w), r.error.message);
|
||||
});
|
||||
|
||||
test('no launch for the thread names the launch log by label, never by host path', async () => {
|
||||
const w = await world({ under: join(HOME, SENTINEL) });
|
||||
const r = await evidence(w, ['--thread', '99999999-2222-4333-8444-555555555555', '--json']);
|
||||
assert.equal(r.error?.exitCode, 2);
|
||||
assert.equal(r.error.message, 'no launch for thread 99999999-2222-4333-8444-555555555555 in the launch log');
|
||||
assert.ok(noPath(r.error, w), r.error.message);
|
||||
});
|
||||
|
||||
test('a thread name resolves through the bus with the org as business', async () => {
|
||||
const w = await world();
|
||||
const seen = [];
|
||||
|
||||
@@ -130,9 +130,13 @@ export function writeTurn(dir, id, record) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// A parse refusal names the turn by its id, never by host path (PR #1916); a
|
||||
// listed file's id is named only when it passes TURN_ID.
|
||||
const turnLabel = (id) => (TURN_ID.test(id) ? `turn record ${id}` : "turn record");
|
||||
|
||||
export function readTurn(dir, id) {
|
||||
const path = turnPath(dir, id);
|
||||
return parseJsonFileText(readFileSync(path, "utf8"), path, "turn record");
|
||||
return parseJsonFileText(readFileSync(path, "utf8"), path, turnLabel(String(id)));
|
||||
}
|
||||
|
||||
export function listTurns(dir) {
|
||||
@@ -140,7 +144,7 @@ export function listTurns(dir) {
|
||||
if (!existsSync(turns)) return [];
|
||||
return readdirSync(turns)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), "turn record"));
|
||||
.map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), turnLabel(f.slice(0, -".json".length))));
|
||||
}
|
||||
|
||||
// The daily ceiling counts admissions on the current UTC date. An admission
|
||||
|
||||
@@ -28,9 +28,10 @@ for (const [field, edit] of [
|
||||
});
|
||||
test('gateway url refusal omits its value', () => assert.throws(() => createGateway({ url: secret, token: 'fake' }), safe('url', DiscordError, { exitCode: 1 })));
|
||||
for (const mode of ['read', 'list']) test(`turn ${mode} parser omits file bytes and keeps SyntaxError`, t => {
|
||||
const root = scratch(t); mkdirSync(join(root, 'turns'));
|
||||
const root = join(scratch(t), secret); mkdirSync(join(root, 'turns'), { recursive: true });
|
||||
const file = join(root, 'turns', 'turn-1.json'); writeFileSync(file, secret);
|
||||
assert.throws(() => mode === 'read' ? readTurn(root, 'turn-1') : listTurns(root), safe(file, SyntaxError));
|
||||
// The refusal names the turn by its id, never by host path (PR #1916).
|
||||
assert.throws(() => mode === 'read' ? readTurn(root, 'turn-1') : listTurns(root), safe('turn record turn-1 is not valid JSON', SyntaxError, {}, file));
|
||||
});
|
||||
|
||||
for (const duplicate of [true, false]) test(`binding approver ${duplicate ? 'duplicate' : 'invalid'} name omits user values`, t => {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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) } : {}) });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -167,8 +167,8 @@ export function checkAccess(document, context) {
|
||||
// other, as the business file is.
|
||||
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
|
||||
const document = readJsonFile(file, "access file", (stat) => {
|
||||
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
|
||||
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
|
||||
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}`);
|
||||
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)})`);
|
||||
});
|
||||
return checkAccess(document, context);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -86,9 +86,9 @@ export function loadRootAgents(configDir, { rolesDir } = {}) {
|
||||
} catch (error) {
|
||||
if (error?.name !== "BusinessError") throw error;
|
||||
if (error.exitCode === 4 && /not found/.test(error.message)) return deepFreeze({ state: "absent", agents: {}, assistant: null });
|
||||
// The read's messages carry the host path; the Console shows this one, so it names the file only.
|
||||
// Keep parse refusals file-only here too, without the host path.
|
||||
if (/ is not valid JSON /.test(error.message)) refuse(`root agent file is not valid JSON (${ROOT_FILE})`, error.exitCode);
|
||||
// The Console shows this one, so it names the file only. The shared reader no longer quotes the
|
||||
// host path; the replaceAll below stays as a second guard.
|
||||
if (/ is not valid JSON$/.test(error.message)) refuse(`root agent file is not valid JSON (${ROOT_FILE})`, error.exitCode);
|
||||
refuse(error.message.replaceAll(file, ROOT_FILE), error.exitCode);
|
||||
}
|
||||
try { return deepFreeze({ state: "ok", ...checkRootAgents(document, { rolesDir }) }); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -79,7 +79,8 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
|
||||
? "configuration is not valid JSON; expected readable JSON: "
|
||||
: field ? `${field}: expected ${expected[field] ?? "a value satisfying the system configuration schema"}: `
|
||||
: "expected valid system configuration: ";
|
||||
fail(3, `system config problem (mosaic-config exit ${proc.status}): ${detail}${env.MOSAIC_CONFIG || join(env.HOME || "~", ".config", "mosaic-dev", "config.json")}${proc.error?.code ? ` (${proc.error.code})` : ""}`);
|
||||
// Name the config by where it came from, never by host path (PR #1916).
|
||||
fail(3, `system config problem (mosaic-config exit ${proc.status}): ${detail}${env.MOSAIC_CONFIG ? "the system config MOSAIC_CONFIG names" : "the default system config"}${proc.error?.code ? ` (${proc.error.code})` : ""}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(proc.stdout);
|
||||
@@ -119,7 +120,7 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
|
||||
continue;
|
||||
}
|
||||
const project = loadProject(entry.root);
|
||||
if (project.id !== id) fail(2, `project file ${file} has id that does not match the declared project key`);
|
||||
if (project.id !== id) fail(2, `project file of project ${id} has id that does not match the declared project key`);
|
||||
for (const instance of Object.keys(organization.roles)) resolveInstance({ system, business: organization, project, instance });
|
||||
projects[id] = "valid";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Roles round 2, slice T1 (#1873): the human verbs on an organization's definitions, and the reads.
|
||||
// Design: docs/plans/2026-11-10_roles-and-rails-design.md section 4.
|
||||
//
|
||||
// Each write needs the caller's org.hire right (VERB_RIGHTS) and refuses every agent caller, then
|
||||
// goes through the writer's setDefinition: only that definition changes, rev counts up, the entry
|
||||
// passes checkComposition, and a definition an instance names refuses definition-in-use.
|
||||
//
|
||||
// caller: { actor, rights, agent } — actor is the authenticated account the writer logs; rights is
|
||||
// the caller's resolved right list (an owner holds ROLE_RIGHTS.owner); agent is true for any agent
|
||||
// session, which never writes a definition. The receipt names the verb, organization, definition,
|
||||
// rev, revision and transaction, never role text.
|
||||
|
||||
import { VERB_RIGHTS } from "./org-rights.mjs";
|
||||
import { OrganizationError } from "./organization.mjs";
|
||||
import {
|
||||
diffDefinition, entryFromTemplate, instancesNaming, readTemplate, templateHashes, checkDefinitionName, ORG_PREFIX,
|
||||
} from "./definitions.mjs";
|
||||
|
||||
// The verb names the policy table maps, following R1's hire.resolve.console.
|
||||
export const DEFINITION_VERBS = Object.freeze({
|
||||
import: "definition.import.console",
|
||||
edit: "definition.edit.console",
|
||||
invent: "definition.invent.console",
|
||||
remove: "definition.remove.console",
|
||||
});
|
||||
|
||||
// The bus verbs for the writes (the broker's DEFINITION_BUS_KEYS checks their arguments). The bus
|
||||
// runs them for the business's human at the cli, with its credential store's secret check; the CLI's
|
||||
// writes go through them, as no other process holds the tokens that check looks for.
|
||||
export const DEFINITION_BUS_VERBS = Object.freeze({
|
||||
"definition.import": "import", "definition.invent": "invent", "definition.edit": "edit", "definition.remove": "remove",
|
||||
});
|
||||
|
||||
const fail = (code, message = code) => {
|
||||
throw new OrganizationError(code, message);
|
||||
};
|
||||
|
||||
function authorize(caller, verb) {
|
||||
if (caller === null || typeof caller !== "object") fail("caller-required");
|
||||
if (caller.agent !== false) fail("human-required", "an agent never writes a definition");
|
||||
const right = VERB_RIGHTS[DEFINITION_VERBS[verb]];
|
||||
if (typeof right !== "string") fail("verb-unmapped");
|
||||
if (!Array.isArray(caller.rights) || !caller.rights.includes(right)) fail("right-required", `${verb} needs ${right}`);
|
||||
}
|
||||
|
||||
// The parts of an entry a caller may supply for invent and edit: role, instructions, personality.
|
||||
const SUPPLIED = ["role", "instructions", "personality"];
|
||||
function supplied(input) {
|
||||
if (input === null || typeof input !== "object" || Array.isArray(input)) fail("definition-invalid", "the definition must be a JSON object");
|
||||
const bad = Object.keys(input).findIndex((key) => !SUPPLIED.includes(key));
|
||||
if (bad !== -1) fail("definition-invalid", `the definition has an unknown key (key ${bad + 1}; allowed: ${SUPPLIED.join(", ")})`);
|
||||
const out = { role: input.role, instructions: input.instructions };
|
||||
if (input.personality !== undefined) out.personality = input.personality;
|
||||
return out;
|
||||
}
|
||||
|
||||
function receipt(verb, id, name, result) {
|
||||
return { verb, organization: id, definition: name, rev: result.rev, revision: result.revision, transaction: result.transaction };
|
||||
}
|
||||
|
||||
export function definitionVerbs({ writer, rolesDir, assertClean }) {
|
||||
const options = (verb, caller, extra) => ({ ...extra, actor: caller.actor, assertClean, detail: { verb: DEFINITION_VERBS[verb] } });
|
||||
|
||||
// Copies a template into definitions with from set to its name and current hash.
|
||||
function importTemplate(id, { revision, template, as = template }, caller) {
|
||||
authorize(caller, "import");
|
||||
checkDefinitionName(as, "definition name");
|
||||
const source = readTemplate(rolesDir, template);
|
||||
const result = writer.setDefinition(id, as, (current) => {
|
||||
if (current) fail("definition-exists", `definitions already declares ${as}`);
|
||||
const { rev: _, ...entry } = entryFromTemplate(source);
|
||||
return entry;
|
||||
}, options("import", caller, { revision }));
|
||||
return { ...receipt("import", id, as, result), from: { template, sha256: source.sha256 } };
|
||||
}
|
||||
|
||||
// Changes an existing definition: the caller supplies the whole role, instructions and
|
||||
// personality; from stays as it was.
|
||||
function edit(id, { revision, rev, name, definition }, caller) {
|
||||
authorize(caller, "edit");
|
||||
if (!Number.isSafeInteger(rev)) fail("definition-rev-required", "edit needs the definition's current rev");
|
||||
const entry = supplied(definition);
|
||||
const result = writer.setDefinition(id, name, (current) => {
|
||||
if (!current) fail("definition-missing", `definitions declares no ${name}`);
|
||||
return current.from ? { from: current.from, ...entry } : entry;
|
||||
}, options("edit", caller, { revision, rev }));
|
||||
return receipt("edit", id, name, result);
|
||||
}
|
||||
|
||||
// Writes a new definition with no from.
|
||||
function invent(id, { revision, name, definition }, caller) {
|
||||
authorize(caller, "invent");
|
||||
const entry = supplied(definition);
|
||||
const result = writer.setDefinition(id, name, (current) => {
|
||||
if (current) fail("definition-exists", `definitions already declares ${name}`);
|
||||
return entry;
|
||||
}, options("invent", caller, { revision }));
|
||||
return receipt("invent", id, name, result);
|
||||
}
|
||||
|
||||
function remove(id, { revision, rev, name }, caller) {
|
||||
authorize(caller, "remove");
|
||||
if (!Number.isSafeInteger(rev)) fail("definition-rev-required", "remove needs the definition's current rev");
|
||||
const result = writer.setDefinition(id, name, () => null, options("remove", caller, { revision, rev }));
|
||||
return { ...receipt("remove", id, name, result), rev: null, removedRev: rev };
|
||||
}
|
||||
|
||||
// Reads. Each reads the validated document.
|
||||
function list(id) {
|
||||
const { document, revision } = writer.read(id);
|
||||
const definitions = Object.entries(document.definitions ?? {}).map(([name, entry]) => ({
|
||||
name, reference: ORG_PREFIX + name, rev: entry.rev, from: entry.from ?? null, usedBy: instancesNaming(document, name),
|
||||
}));
|
||||
return { organization: id, revision, definitions };
|
||||
}
|
||||
|
||||
function show(id, name) {
|
||||
const { document, revision } = writer.read(id);
|
||||
if (!Object.hasOwn(document.definitions ?? {}, name)) fail("definition-missing", `definitions declares no ${name}`);
|
||||
return { organization: id, revision, name, reference: ORG_PREFIX + name, usedBy: instancesNaming(document, name), ...structuredClone(document.definitions[name]) };
|
||||
}
|
||||
|
||||
function diff(id, name) {
|
||||
const { document, revision } = writer.read(id);
|
||||
return { organization: id, revision, ...diffDefinition(document, name, rolesDir) };
|
||||
}
|
||||
|
||||
return { import: importTemplate, edit, invent, remove, list, show, diff, templates: () => templateHashes(rolesDir) };
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Roles round 2, slice T1 (#1873): an organization's own role definitions and the templates they
|
||||
// copy. Design: docs/plans/2026-11-10_roles-and-rails-design.md sections 4 and 5, and the T1
|
||||
// paragraph of section 13.
|
||||
//
|
||||
// The organization file's `definitions` key holds one entry per name:
|
||||
// { rev, from?: { template, sha256 }, role: { tools, network, authority, credentials,
|
||||
// messaging?, skills? }, instructions, personality? }
|
||||
// An instance names one as `definition: "org:<name>"`. Every entry passes checkComposition (P1)
|
||||
// whenever the file is validated, so no path writes or loads a definition that skips it. A refusal
|
||||
// names the definition and the key at fault, never a value: composition.mjs quotes only registered
|
||||
// names, and this module quotes only definition and template names, which match ID_PATTERN.
|
||||
//
|
||||
// A template is a shipped roles/<name>.json (role version 2) with its contract. Its identity is the
|
||||
// name plus templateHash: sha256 over the JSON bytes and the contract bytes together, each preceded
|
||||
// by its byte length and a newline, so no field goes into roles/.
|
||||
//
|
||||
// Pure reads only. The writer verbs live in definition-verbs.mjs, which organization.mjs's writer
|
||||
// backs; this module is imported by organization-config.mjs and must not import the writer.
|
||||
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { buildRegistry, checkComposition } from "./composition.mjs";
|
||||
import { validateRoleDocument } from "./role.mjs";
|
||||
import { ID_PATTERN } from "./vocabulary.mjs";
|
||||
import { canonicalJson, deepFreeze } from "./util.mjs";
|
||||
|
||||
export const ORG_PREFIX = "org:";
|
||||
export const ENTRY_KEYS = Object.freeze(["rev", "from", "role", "instructions", "personality"]);
|
||||
// The primitives an entry keeps under `role`; instructions and personality sit beside it.
|
||||
export const ROLE_KEYS = Object.freeze(["tools", "network", "authority", "credentials", "messaging", "skills"]);
|
||||
// What the diff read compares, in section 4's order.
|
||||
export const PRIMITIVES = Object.freeze([...ROLE_KEYS, "instructions", "personality"]);
|
||||
const FROM_KEYS = Object.freeze(["template", "sha256"]);
|
||||
const HEX64 = /^[0-9a-f]{64}$/;
|
||||
|
||||
// The core registry. Plugin-declared primitives arrive with PL1; until then an org definition uses
|
||||
// core names only.
|
||||
const CORE = buildRegistry();
|
||||
// Validation keeps no flagged lines: A4 computes them at the decision, against the chief's live
|
||||
// role. Step 7 only flags, so a chief holding nothing refuses nothing here.
|
||||
const NO_CHIEF = Object.freeze({
|
||||
tools: Object.freeze([]), network: "none",
|
||||
authority: Object.freeze({ withinRole: Object.freeze([]), crossRole: Object.freeze([]) }),
|
||||
credentials: Object.freeze([]), messaging: null,
|
||||
});
|
||||
// The file-load check has no credential store: a load runs before the bus builds one from the file.
|
||||
// The held-secret scan runs where definitions are written (setDefinition requires it, and the bus
|
||||
// passes credentials.assertClean). A load still runs every other composition check.
|
||||
const NO_STORE = () => {};
|
||||
|
||||
const plain = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
||||
|
||||
// The definition name an instance's `definition` value names, or null for a template name.
|
||||
export function orgDefinitionName(value) {
|
||||
return typeof value === "string" && value.startsWith(ORG_PREFIX) ? value.slice(ORG_PREFIX.length) : null;
|
||||
}
|
||||
|
||||
export function checkDefinitionName(name, where) {
|
||||
if (typeof name !== "string" || !ID_PATTERN.test(name)) refuse(`${where} must be a definition name matching ${ID_PATTERN}`);
|
||||
return name;
|
||||
}
|
||||
|
||||
function keysOnly(object, allowed, where) {
|
||||
Object.keys(object).forEach((key, i) => {
|
||||
if (!allowed.includes(key)) refuse(`${where} has an unknown key (key ${i + 1}; allowed: ${allowed.join(", ")})`);
|
||||
});
|
||||
}
|
||||
|
||||
// The composition an entry describes, for checkComposition. Absent optional keys stay absent.
|
||||
export function compositionOf(entry) {
|
||||
const out = { ...entry.role, instructions: entry.instructions };
|
||||
if (entry.personality !== undefined) out.personality = entry.personality;
|
||||
return out;
|
||||
}
|
||||
|
||||
// One entry: its own keys, then checkComposition. Returns { composition, flagged }. options.assertClean
|
||||
// is the credential store's check; a write always passes it (setDefinition), a file load can't.
|
||||
export function checkDefinition(name, entry, { assertClean = NO_STORE, where = "definitions" } = {}) {
|
||||
const at = `${where}.${checkDefinitionName(name, `${where} key`)}`;
|
||||
if (!plain(entry)) refuse(`${at} must be a JSON object`);
|
||||
keysOnly(entry, ENTRY_KEYS, at);
|
||||
if (!Number.isSafeInteger(entry.rev) || entry.rev < 1) refuse(`${at}.rev must be a positive integer`);
|
||||
if (entry.from !== undefined) {
|
||||
if (!plain(entry.from)) refuse(`${at}.from must be a JSON object`);
|
||||
keysOnly(entry.from, FROM_KEYS, `${at}.from`);
|
||||
if (typeof entry.from.template !== "string" || !ID_PATTERN.test(entry.from.template)) refuse(`${at}.from.template must be a template name matching ${ID_PATTERN}`);
|
||||
if (typeof entry.from.sha256 !== "string" || !HEX64.test(entry.from.sha256)) refuse(`${at}.from.sha256 must be 64 lowercase hex characters`);
|
||||
}
|
||||
if (!plain(entry.role)) refuse(`${at}.role must be a JSON object`);
|
||||
keysOnly(entry.role, ROLE_KEYS, `${at}.role`);
|
||||
try {
|
||||
return checkComposition(compositionOf(entry), CORE, NO_CHIEF, { assertClean });
|
||||
} catch (error) {
|
||||
// composition.mjs's refusals quote registered names only; keep its text, add where.
|
||||
if (error?.name === "BusinessError") refuse(`${at}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// The resolved role an `org:` instance launches with: loadRole's shape, so the business checks,
|
||||
// the resolver and the broker read it as they read a template. Instructions stand in for the
|
||||
// contract file (contractPath null). A definition hires nobody: hireable stays empty.
|
||||
function resolved(name, entry, composition) {
|
||||
return {
|
||||
roleVersion: 2, name: ORG_PREFIX + name, title: name, contract: null, contractPath: null,
|
||||
tools: composition.tools, network: composition.network, authority: composition.authority,
|
||||
credentials: composition.credentials, messaging: composition.messaging, skills: composition.skills,
|
||||
hireable: [], instructions: composition.instructions, personality: composition.personality,
|
||||
rev: entry.rev, from: entry.from === undefined ? null : { ...entry.from },
|
||||
};
|
||||
}
|
||||
|
||||
// The whole `definitions` key. Returns a frozen map "org:<name>" -> resolved role. A bad entry
|
||||
// refuses the whole file.
|
||||
export function checkDefinitions(definitions, file, options = {}) {
|
||||
if (definitions === undefined) return deepFreeze({});
|
||||
const where = `${file} definitions`;
|
||||
if (!plain(definitions)) refuse(`${where} must be a JSON object`);
|
||||
const out = {};
|
||||
Object.keys(definitions).forEach((name, i) => {
|
||||
checkDefinitionName(name, `${where} key ${i + 1}`);
|
||||
const { composition } = checkDefinition(name, definitions[name], { ...options, where });
|
||||
out[ORG_PREFIX + name] = resolved(name, definitions[name], composition);
|
||||
});
|
||||
return deepFreeze(out);
|
||||
}
|
||||
|
||||
// The instances that name `org:<name>`, sorted.
|
||||
export function instancesNaming(document, name) {
|
||||
return Object.entries(document?.roles ?? {})
|
||||
.filter(([, entry]) => entry?.definition === ORG_PREFIX + name).map(([instance]) => instance).sort();
|
||||
}
|
||||
|
||||
// ---- Templates ----
|
||||
|
||||
function frame(...parts) {
|
||||
const hash = createHash("sha256");
|
||||
for (const part of parts) {
|
||||
hash.update(`${part.length}\n`);
|
||||
hash.update(part);
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
// One template, read once: its validated role, its contract text and its hash. Refuses a missing,
|
||||
// invalid or version 1 template.
|
||||
export function readTemplate(rolesDir, name) {
|
||||
if (typeof name !== "string" || !ID_PATTERN.test(name)) refuse(`template name must match ${ID_PATTERN}`);
|
||||
const file = join(rolesDir, `${name}.json`);
|
||||
let json;
|
||||
try {
|
||||
json = readFileSync(file);
|
||||
} catch {
|
||||
refuse(`template ${name} not found`);
|
||||
}
|
||||
let document;
|
||||
try {
|
||||
document = JSON.parse(json.toString("utf8"));
|
||||
} catch {
|
||||
refuse(`template ${name} is not valid JSON`);
|
||||
}
|
||||
const role = validateRoleDocument(document, file, { hireable: false });
|
||||
if (role.roleVersion !== 2) refuse(`template ${name} is role version 1 and has no contract`);
|
||||
const contract = readFileSync(role.contractPath);
|
||||
return { name, role, contract: contract.toString("utf8"), sha256: frame(json, contract) };
|
||||
}
|
||||
|
||||
export function templateHash(rolesDir, name) {
|
||||
return readTemplate(rolesDir, name).sha256;
|
||||
}
|
||||
|
||||
// The read: every version 2 template in rolesDir with its current hash, sorted by name. A file that
|
||||
// is not a usable version 2 role (conductor-policy.json, a version 1 role) is not a template.
|
||||
export function templateHashes(rolesDir) {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(rolesDir).filter((f) => f.endsWith(".json")).sort()) {
|
||||
const name = entry.slice(0, -".json".length);
|
||||
if (!ID_PATTERN.test(name)) continue;
|
||||
let template;
|
||||
try {
|
||||
template = readTemplate(rolesDir, name);
|
||||
} catch (error) {
|
||||
if (error?.name === "BusinessError") continue;
|
||||
throw error;
|
||||
}
|
||||
out.push({ template: name, sha256: template.sha256 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The entry an import writes: the template's primitives and its contract as instructions.
|
||||
export function entryFromTemplate(template) {
|
||||
const { role } = template;
|
||||
const out = {
|
||||
tools: [...role.tools], network: role.network,
|
||||
authority: { withinRole: [...role.authority.withinRole], crossRole: [...role.authority.crossRole] },
|
||||
credentials: role.credentials.map((c) => (c.scopes === undefined ? { service: c.service } : { service: c.service, scopes: structuredClone(c.scopes) })),
|
||||
};
|
||||
if (role.messaging) out.messaging = { sendTo: [...role.messaging.sendTo], receiveFrom: [...role.messaging.receiveFrom] };
|
||||
if (role.skills.length) out.skills = [...role.skills];
|
||||
return { rev: 1, from: { template: template.name, sha256: template.sha256 }, role: out, instructions: template.contract };
|
||||
}
|
||||
|
||||
// ---- The diff read ----
|
||||
|
||||
// A line diff of two texts: [{ op: " " | "-" | "+", line }]. Longest common subsequence over lines;
|
||||
// past MAX_CELLS it reports the old text removed and the new one added, which is still exact.
|
||||
const MAX_CELLS = 4_000_000;
|
||||
export function lineDiff(before, after) {
|
||||
const a = before.split("\n");
|
||||
const b = after.split("\n");
|
||||
if (a.length * b.length > MAX_CELLS) {
|
||||
return [...a.map((line) => ({ op: "-", line })), ...b.map((line) => ({ op: "+", line }))];
|
||||
}
|
||||
const width = b.length + 1;
|
||||
const table = new Uint32Array((a.length + 1) * width);
|
||||
for (let i = a.length - 1; i >= 0; i--) {
|
||||
for (let j = b.length - 1; j >= 0; j--) {
|
||||
table[i * width + j] = a[i] === b[j] ? table[(i + 1) * width + j + 1] + 1
|
||||
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < a.length && j < b.length) {
|
||||
if (a[i] === b[j]) {
|
||||
out.push({ op: " ", line: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
|
||||
out.push({ op: "-", line: a[i++] });
|
||||
} else {
|
||||
out.push({ op: "+", line: b[j++] });
|
||||
}
|
||||
}
|
||||
while (i < a.length) out.push({ op: "-", line: a[i++] });
|
||||
while (j < b.length) out.push({ op: "+", line: b[j++] });
|
||||
return out;
|
||||
}
|
||||
|
||||
// For a copy whose from.sha256 differs from its template's current hash: which primitives differ
|
||||
// between the copy as it stands and the template as it stands, and the instructions' line diff.
|
||||
// The file keeps only the hash of what was imported, so a customized copy's own edits show here
|
||||
// too. Nothing is applied: apply or keep is a change decision (A4).
|
||||
export function diffDefinition(document, name, rolesDir) {
|
||||
const definitions = document?.definitions ?? {};
|
||||
if (!Object.hasOwn(definitions, name)) refuse(`definitions declares no ${name}`);
|
||||
const entry = definitions[name];
|
||||
const base = { definition: name, rev: entry.rev, from: entry.from ?? null };
|
||||
if (!entry.from) return { ...base, current: null, changed: false, primitives: [], instructions: [] };
|
||||
let template;
|
||||
try {
|
||||
template = readTemplate(rolesDir, entry.from.template);
|
||||
} catch (error) {
|
||||
if (error?.name !== "BusinessError") throw error;
|
||||
return { ...base, current: null, changed: true, templateMissing: true, primitives: [], instructions: [] };
|
||||
}
|
||||
if (template.sha256 === entry.from.sha256) return { ...base, current: template.sha256, changed: false, primitives: [], instructions: [] };
|
||||
const now = compositionOf(entryFromTemplate(template));
|
||||
const copy = compositionOf(entry);
|
||||
const primitives = PRIMITIVES.filter((key) => canonicalJson(copy[key] ?? null) !== canonicalJson(now[key] ?? null));
|
||||
return {
|
||||
...base, current: template.sha256, changed: true, primitives,
|
||||
instructions: primitives.includes("instructions") ? lineDiff(copy.instructions, now.instructions) : [],
|
||||
};
|
||||
}
|
||||
@@ -77,7 +77,9 @@ export function checkCommands(commands, where) {
|
||||
if (/[*?[]/.test(element)) refuse(`${at} must not hold *, ? or [; the match is literal`);
|
||||
}
|
||||
if (prefix[0].includes("/") && !prefix[0].startsWith("/")) refuse(`${at}[0] must be a bare name or an absolute path`);
|
||||
if (refusedCommand(prefix[0])) refuse(`${at} starts with ${prefix[0]}, a shell, runner or interpreter, which would grant any command`);
|
||||
// Name the command by its basename, which refusedCommand matched against a closed list or
|
||||
// pattern, never by an absolute host path (PR #1916).
|
||||
if (refusedCommand(prefix[0])) refuse(`${at} starts with ${prefix[0].split("/").pop()}, a shell, runner or interpreter, which would grant any command`);
|
||||
const key = JSON.stringify(prefix);
|
||||
if (seen.has(key)) refuse(`${at} repeats a prefix`);
|
||||
seen.add(key);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -24,18 +24,20 @@ export function defaultRolesDir(env = process.env) {
|
||||
export function loadSeatSets({ dir = configDir(), rolesDir = defaultRolesDir() } = {}) {
|
||||
// lstat first: only nothing at the path is an absent directory. Anything else
|
||||
// there (a file, a dangling symlink, a directory) must list, or it throws.
|
||||
// The refusal names the directory by label and the error by its code only:
|
||||
// err.message quotes the host path (PR #1916).
|
||||
const businesses = join(dir, "businesses");
|
||||
try {
|
||||
lstatSync(businesses);
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") return { librarySeats: new Set(), creativeSeats: new Set() };
|
||||
throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`);
|
||||
throw new BusinessError(`cannot list businesses in the config directory: ${err.code ?? "unreadable"}`);
|
||||
}
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(businesses).filter((name) => name.endsWith(".json"));
|
||||
} catch (err) {
|
||||
throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`);
|
||||
throw new BusinessError(`cannot list businesses in the config directory: ${err.code ?? "unreadable"}`);
|
||||
}
|
||||
const librarySeats = new Set();
|
||||
const creativeSeats = new Set();
|
||||
|
||||
@@ -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";
|
||||
@@ -67,6 +67,11 @@ export const VERB_RIGHTS = record({
|
||||
'decision.preview.console': 'decide',
|
||||
'decision.resolve.console': 'decide',
|
||||
'hire.resolve.console': 'org.hire',
|
||||
// #1873 T1: the human verbs on an organization's own definitions.
|
||||
'definition.import.console': 'org.hire',
|
||||
'definition.edit.console': 'org.hire',
|
||||
'definition.invent.console': 'org.hire',
|
||||
'definition.remove.console': 'org.hire',
|
||||
'request.answer.console': 'request.write',
|
||||
'request.attach.console': 'request.write',
|
||||
'request.cancel.console': 'request.write',
|
||||
|
||||
@@ -19,15 +19,22 @@ const encode = doc => JSON.stringify(doc, null, 2) + '\n';
|
||||
const projectPath = (root, project) => join(root, 'projects', project);
|
||||
function authorize(ctx) { if (!ctx || typeof ctx.instance !== 'string' || !/^[a-z][a-z0-9]{1,15}$/.test(ctx.instance) || ctx.instance === 'dev' || !same(ctx.principal, owner)) fail('org-forbidden', 'authenticated local development owner required'); }
|
||||
function checkId(id) { if (typeof id !== 'string' || !ID_PATTERN.test(id)) fail('org-invalid', 'invalid id'); }
|
||||
async function privateRead(file) { const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe file', file); return await fd.readFile('utf8'); } finally { await fd.close(); } }
|
||||
// A filesystem error names the operation, the file by label and the error code, never the
|
||||
// host path Node puts in its message (PR #1916). code, syscall and the other fields stay.
|
||||
const quieted = new WeakSet();
|
||||
function quiet(e, what) {
|
||||
if (e && typeof e === 'object' && typeof e.syscall === 'string' && typeof e.code === 'string' && !quieted.has(e)) { e.message = `cannot ${e.syscall} ${what} (${e.code})`; quieted.add(e); }
|
||||
return e;
|
||||
}
|
||||
async function privateRead(file, what = 'organization file') { let fd; try { fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch (e) { throw quiet(e, what); } try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe file', file); return await fd.readFile('utf8'); } catch (e) { throw quiet(e, what); } finally { await fd.close(); } }
|
||||
async function atomic(file, content, exclusive = false) {
|
||||
const tmp = join(dirname(file), `.org-write-${randomBytes(12).toString('hex')}`);
|
||||
try { await fs.writeFile(tmp, content, { mode: 0o600, flag: 'wx' }); if (exclusive) await fs.link(tmp, file); else await fs.rename(tmp, file); } finally { await fs.rm(tmp, { force: true }); }
|
||||
try { await fs.writeFile(tmp, content, { mode: 0o600, flag: 'wx' }); if (exclusive) await fs.link(tmp, file); else await fs.rename(tmp, file); } catch (e) { throw quiet(e, 'organization file'); } finally { await fs.rm(tmp, { force: true }); }
|
||||
}
|
||||
async function ensurePrivateDir(path) { try { await fs.mkdir(path, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw e; } const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe directory', path); }
|
||||
async function ensurePrivateDir(path) { try { await fs.mkdir(path, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw quiet(e, 'organization directory'); } const st = await fs.lstat(path).catch(e => { throw quiet(e, 'organization directory'); }); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe directory', path); }
|
||||
async function receipt(root, op, org, principal, fields = {}) {
|
||||
const result = { op, org, at: new Date().toISOString(), subject: { ...principal }, ...fields };
|
||||
const file = join(root, 'receipts.jsonl'); const fd = await fs.open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
||||
const file = join(root, 'receipts.jsonl'); const fd = await fs.open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600).catch(e => { throw quiet(e, 'organization receipts'); });
|
||||
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe receipts', file); await fd.writeFile(JSON.stringify(result) + '\n'); } finally { await fd.close(); } return result;
|
||||
}
|
||||
async function receipts(root) {
|
||||
@@ -129,11 +136,11 @@ async function readCurrentUnlocked(ctx, id, { includeArchived = false } = {}) {
|
||||
if (found.state === 'archived' && !includeArchived) fail('org-archived', 'organization is archived');
|
||||
if (found.state !== 'active' && !(includeArchived && found.state === 'archived')) fail('org-unavailable', 'organization is not active');
|
||||
const ownerFile = join(found.root, 'owner.json');
|
||||
const principal = parseJsonFileText(await privateRead(ownerFile), ownerFile, 'organization owner file'); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required');
|
||||
const principal = parseJsonFileText(await privateRead(ownerFile, 'organization owner file'), ownerFile, 'organization owner file'); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required');
|
||||
// From here the caller is the verified owner. A failure carries that proof so a list may
|
||||
// show this org as broken to its owner; earlier failures prove nothing and stay hidden.
|
||||
try {
|
||||
const entry = (await readRegistry(ctx)).orgs[id]; const file = join(ctx.configDir, 'businesses', `${id}.json`); const raw = await privateRead(file);
|
||||
const entry = (await readRegistry(ctx)).orgs[id]; const file = join(ctx.configDir, 'businesses', `${id}.json`); const raw = await privateRead(file, 'organization file');
|
||||
if (!entry.digest || entry.digest !== digest(raw)) fail('org-mismatch', 'organization document and registry disagree');
|
||||
const doc = parseJsonFileText(raw, file, 'organization file'); await checked(doc); return { ...found, revision: entry.revision, doc, principal };
|
||||
} catch (e) { if (e && typeof e === 'object') e.ownerVerified = true; throw e; }
|
||||
@@ -171,13 +178,13 @@ export async function showOrg(ctx, id, opts = {}) { authorize(ctx); const { root
|
||||
export async function notificationShow(ctx, id) {
|
||||
authorize(ctx); const current = await readCurrent(ctx, id);
|
||||
for (const path of [join(current.root, 'notify'), join(current.root, 'notify', id)]) {
|
||||
const st = await fs.lstat(path);
|
||||
const st = await fs.lstat(path).catch(e => { throw quiet(e, 'notifier directory'); });
|
||||
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe notifier directory');
|
||||
}
|
||||
const file = join(current.root, 'notify', id, 'notify.json');
|
||||
const st = await fs.lstat(file);
|
||||
const st = await fs.lstat(file).catch(e => { throw quiet(e, 'notifier configuration file'); });
|
||||
if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe notifier configuration');
|
||||
const binding = parseJsonFileText(await privateRead(file), file, 'notifier configuration file');
|
||||
const binding = parseJsonFileText(await privateRead(file, 'notifier configuration file'), file, 'notifier configuration file');
|
||||
if (!binding || Object.keys(binding).sort().join(',') !== 'binding,notifyVersion' || binding.notifyVersion !== 1 || !(binding.binding === null || typeof binding.binding === 'string' && BINDING_NAME.test(binding.binding))) fail('org-mismatch', 'invalid notifier configuration');
|
||||
const record = (await receipts(current.root)).find(r => r.op === 'org.create' && r.org === id && same(r.subject, current.principal));
|
||||
if (!record || record.notifications !== 'none') fail('org-mismatch', 'notification choice receipt unavailable');
|
||||
@@ -198,7 +205,7 @@ function options({ mode, remote, acceptLocalLossRisk }) {
|
||||
}
|
||||
async function git(root, args) {
|
||||
const env = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' };
|
||||
return new Promise((resolve, reject) => { const p = spawn('git', ['-c', 'user.name=Mosaic Local', '-c', '[email protected]', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; p.stdout.on('data', d => out += d); p.stderr.on('data', d => err += d); p.on('error', reject); p.on('close', code => code === 0 ? resolve(out.trim()) : reject(new OrgError('org-git', `git failed: ${err.trim()}`))); });
|
||||
return new Promise((resolve, reject) => { const p = spawn('git', ['-c', 'user.name=Mosaic Local', '-c', '[email protected]', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; p.stdout.on('data', d => out += d); p.stderr.on('data', d => err += d); p.on('error', reject); p.on('close', code => code === 0 ? resolve(out.trim()) : reject(new OrgError('org-git', `git ${args[0]} failed (exit ${code})`))); });
|
||||
}
|
||||
async function initGit(root) { await fs.mkdir(root, { mode: 0o700 }); await git(root, ['init', '--quiet', '--template=']); await git(root, ['commit', '--quiet', '--allow-empty', '-m', 'Genesis']); return git(root, ['rev-parse', 'HEAD']); }
|
||||
async function setup(ctx, id, project, opts) {
|
||||
@@ -273,6 +280,8 @@ export async function restoreOrg(ctx, id, opts = {}) { return lifecycleChange(ct
|
||||
|
||||
// Deletion keeps a durable marker in the registry and a receipt beside the
|
||||
// moved data. It never opens the contents of organization secret trees.
|
||||
// The deletion folder deleteOrg generates: `<id>-<ISO time with : and . as ->-<8 hex>`.
|
||||
const deletionFolderPattern = (id) => new RegExp(`^${id.replace(/[.]/g, '\\.')}-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z-[0-9a-f]{8}$`);
|
||||
async function deleteInspect(path, { secret = false } = {}) {
|
||||
let count = 0;
|
||||
async function visit(p) {
|
||||
@@ -328,7 +337,7 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {})
|
||||
try {
|
||||
await deleteSafeRoot(ctx.dataRoot);
|
||||
await ensurePrivateDir(deleted); await ensurePrivateDir(destination);
|
||||
try { result = parseJsonFileText(await privateRead(receiptFile), receiptFile, 'deletion receipt file'); }
|
||||
try { result = parseJsonFileText(await privateRead(receiptFile, 'deletion receipt file'), receiptFile, 'deletion receipt file'); }
|
||||
catch (e) { if (e.code !== 'ENOENT') throw e; result = { op: 'org.delete', org: id, actor: deletion.actor, time: deletion.time, revision: deletion.revision, destination, bus: null, moves: [], secrets: [], note: 'Purging deleted data and remote-side revocation are out of scope.' }; await atomic(receiptFile, encode(result), true); }
|
||||
if (result.org !== id || result.destination !== destination || result.revision !== deletion.revision || !Array.isArray(result.moves) || !Array.isArray(result.secrets)) fail('org-mismatch', 'invalid deletion receipt');
|
||||
if (!result.bus) {
|
||||
@@ -380,9 +389,12 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {})
|
||||
});
|
||||
registryRemoved = true; result.registry = 'removed'; await atomic(receiptFile, encode(result)); return result;
|
||||
} catch (e) {
|
||||
e.deleteStep = phase; if (result) e.deleteReceipt = result;
|
||||
if (registryRemoved) { e.message += `; registry removal completed but the receipt could not be finalized at ${receiptFile}`; throw e; }
|
||||
e.message += `; deletion remains in progress. Inspect ${destination}/receipt.json and rerun mosaic org delete ${id} --confirm ${id}`;
|
||||
e.deleteStep = phase; if (result) e.deleteReceipt = result; quiet(e, `a file in the deletion ${phase} step`);
|
||||
// Name the receipt under the data root, never by host path (PR #1916). The folder comes
|
||||
// from the registry, so it is named only when it has the shape this function generates.
|
||||
const at = deletionFolderPattern(id).test(deletion.folder) ? `deleted/${deletion.folder}/receipt.json under the data root` : 'the deletion receipt under the data root';
|
||||
if (registryRemoved) { e.message += `; registry removal completed but the receipt could not be finalized at ${at}`; throw e; }
|
||||
e.message += `; deletion remains in progress. Inspect ${at} and rerun mosaic org delete ${id} --confirm ${id}`;
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,9 +10,11 @@ import { checkModels, checkLimits, checkAccount } from "./launch.mjs";
|
||||
import { checkVars } from "./vars.mjs";
|
||||
import { parseCredentialRef } from "./credentials.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { checkDefinitions, checkDefinitionName, orgDefinitionName, ORG_PREFIX } from "./definitions.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";
|
||||
@@ -25,7 +27,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
// How long a thread's session stays up with no turn before the launcher stops
|
||||
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
|
||||
export const THREAD_IDLE_SECONDS = 900;
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance"];
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions"];
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
@@ -121,7 +123,7 @@ function checkPersonaVars(vars, definition, at) {
|
||||
// so the business file can't name them. The key stays known so the refusal says where they went.
|
||||
const HOSTS_MOVED = "lives in the bus store now: enroll a host with a host-enroll grant (mosaic hosts status)";
|
||||
|
||||
function checkRoles(roles, file, rolesDir, business, hasLibrary) {
|
||||
function checkRoles(roles, file, rolesDir, business, hasLibrary, orgDefinitions) {
|
||||
const where = `${file} roles`;
|
||||
requireObject(roles, where);
|
||||
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
|
||||
@@ -134,8 +136,14 @@ function checkRoles(roles, file, rolesDir, business, hasLibrary) {
|
||||
const at = `${where}.${instance}`;
|
||||
requireObject(entry, at);
|
||||
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts"], at);
|
||||
requireId(entry.definition, `${at}.definition`);
|
||||
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
|
||||
// #1873 T1: "org:<name>" names one of this file's definitions; anything else is a template.
|
||||
const orgName = orgDefinitionName(entry.definition);
|
||||
if (orgName === null) requireId(entry.definition, `${at}.definition`);
|
||||
else if (!Object.hasOwn(orgDefinitions, ORG_PREFIX + checkDefinitionName(orgName, `${at}.definition after org:`))) {
|
||||
refuse(`definition-missing: ${at}.definition names ${ORG_PREFIX}${orgName}, which definitions doesn't declare`);
|
||||
}
|
||||
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition]
|
||||
: orgName === null ? loadRole(rolesDir, entry.definition) : orgDefinitions[entry.definition];
|
||||
definitions[entry.definition] = definition;
|
||||
if (definition.roleVersion !== 2) refuse(`${at}: role definition ${entry.definition} is version 1 and can't back a business role instance`);
|
||||
const services = definition.credentials.map((c) => c.service);
|
||||
@@ -238,7 +246,9 @@ 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 }) {
|
||||
// options.assertClean is the credential store's secret check for the definitions' text, when the
|
||||
// caller holds one (#1873 T1); without it the check is the empty store's.
|
||||
export function validateOrganizationDocument(document, file, { rolesDir, dataRoot = null, assertClean } = {}) {
|
||||
requireObject(document, "business file");
|
||||
rejectUnknownKeys(document, TOP_KEYS, "business file");
|
||||
if (document.businessVersion !== 1) refuse(`business file "businessVersion" must be 1 (${file})`);
|
||||
@@ -252,7 +262,8 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
|
||||
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
|
||||
if (document.hosts !== undefined) refuse(`${file} hosts ${HOSTS_MOVED}`);
|
||||
const hasLibrary = Object.values(document.projects ?? {}).some((project) => project?.kind === "library");
|
||||
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id, hasLibrary);
|
||||
const orgDefinitions = checkDefinitions(document.definitions, file, assertClean ? { assertClean } : {});
|
||||
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id, hasLibrary, orgDefinitions);
|
||||
|
||||
requireObject(document.arbiters, `${file} arbiters`);
|
||||
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
|
||||
@@ -333,8 +344,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 +365,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) => {
|
||||
const document = readJsonFile(file, `business file ${id}.json`, (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.
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
|
||||
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
|
||||
import { withRegistryWriter } from "./org-registry.mjs";
|
||||
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
|
||||
import { checkDefinition, checkDefinitionName, instancesNaming } from "./definitions.mjs";
|
||||
|
||||
export class OrganizationError extends Error {
|
||||
constructor(code, message = code) {
|
||||
@@ -140,7 +141,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 +156,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 +298,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 +308,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;
|
||||
@@ -352,8 +353,62 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
const projectsProtected = (old, document) => {
|
||||
if (canonicalJson(document?.projects) !== canonicalJson(old.projects)) fail("organization-projects-protected");
|
||||
};
|
||||
// #1873 T1: definitions change only through setDefinition, which bumps rev and checks the entry.
|
||||
const definitionsProtected = (old, document) => {
|
||||
if (canonicalJson(document?.definitions ?? {}) !== canonicalJson(old.definitions ?? {})) fail("organization-definitions-protected");
|
||||
};
|
||||
function write(id, document, options) {
|
||||
return commit(id, document, { ...options, guard: projectsProtected });
|
||||
return commit(id, document, { ...options, guard: (old, next) => { projectsProtected(old, next); definitionsProtected(old, next); } });
|
||||
}
|
||||
|
||||
// #1873 T1: the only writer of `definitions`. `next(current)` returns the new entry without its
|
||||
// rev, or null to remove it; current is the entry or null. Nothing else in the document may change.
|
||||
// An agent or the tester never writes one. A write bumps rev (1 for a new name); `rev`, when given,
|
||||
// must be the current one (definition-rev-conflict). Editing or removing a definition an instance
|
||||
// names refuses definition-in-use: changing a hired agent's role is a change decision (A4).
|
||||
// assertClean is the credential store's held-secret check (the bus's credentials.assertClean). A
|
||||
// write that adds or changes an entry needs it: with no check to run, the write refuses (R1).
|
||||
function setDefinition(id, name, next, { revision, rev, actor, detail, assertClean }) {
|
||||
checkDefinitionName(name, "definition name");
|
||||
const logged = { ...detail, definition: name, rev: null };
|
||||
const guard = (old, document) => {
|
||||
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
|
||||
if (actor === "tester" || agents.includes(actor) || (old.retired ?? []).includes(actor)) fail("human-required");
|
||||
const { definitions: a = {}, ...restOld } = old;
|
||||
const { definitions: b = {}, ...restNew } = document ?? {};
|
||||
if (canonicalJson(restNew) !== canonicalJson(restOld)) fail("organization-definitions-only");
|
||||
const others = (d) => canonicalJson(Object.fromEntries(Object.entries(d).filter(([k]) => k !== name)));
|
||||
if (others(a) !== others(b)) fail("organization-definitions-only");
|
||||
const current = Object.hasOwn(a, name) ? a[name] : null;
|
||||
const sansRev = (e) => canonicalJson({ ...e, rev: 0 });
|
||||
if (current && Object.hasOwn(b, name) && sansRev(current) === sansRev(b[name])) fail("definition-unchanged", `definition ${name} is unchanged`);
|
||||
if (current) {
|
||||
const users = instancesNaming(old, name);
|
||||
if (users.length) fail("definition-in-use", `definition ${name} is named by ${users.join(", ")}; a change to a hired agent's role is a change decision`);
|
||||
}
|
||||
if (Object.hasOwn(b, name)) {
|
||||
const want = current ? current.rev + 1 : 1;
|
||||
if (b[name]?.rev !== want) fail("definition-rev-conflict", `definition ${name} must be written at rev ${want}`);
|
||||
if (typeof assertClean !== "function") fail("definition-secret-check-required", "a definition write needs the credential store's secret check; write it through the bus");
|
||||
checkDefinition(name, b[name], { assertClean });
|
||||
}
|
||||
};
|
||||
const out = commit(id, (old) => {
|
||||
const definitions = { ...(old.definitions ?? {}) };
|
||||
const current = Object.hasOwn(definitions, name) ? definitions[name] : null;
|
||||
if (rev !== undefined && (current?.rev ?? null) !== rev) fail("definition-rev-conflict", `definition ${name} is not at rev ${rev}; read again`);
|
||||
const entry = next(current);
|
||||
if (entry === null) {
|
||||
if (!current) fail("definition-missing", `definitions declares no ${name}`);
|
||||
delete definitions[name];
|
||||
} else {
|
||||
definitions[name] = { ...entry, rev: current ? current.rev + 1 : 1 };
|
||||
logged.rev = definitions[name].rev;
|
||||
}
|
||||
const { definitions: _, ...rest } = old;
|
||||
return Object.keys(definitions).length ? { ...rest, definitions } : rest;
|
||||
}, { revision, actor, guard: (old, document) => { projectsProtected(old, document); guard(old, document); }, detail: logged });
|
||||
return { ...out, rev: logged.rev };
|
||||
}
|
||||
|
||||
// Lifecycle Task 2: the only writer that adds a project mapping, and only the exact bootstrap
|
||||
@@ -483,5 +538,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,20 +10,23 @@ export function projectFilePath(root) {
|
||||
return join(root, ".mosaic", "project.json");
|
||||
}
|
||||
|
||||
// Refusals name the project file by label: its path is the project root from
|
||||
// the business file, a config value, so no refusal quotes it (PR #1916). An
|
||||
// instance name is quoted only after requireId passed it.
|
||||
export function validateProjectDocument(document, file) {
|
||||
requireObject(document, "project file");
|
||||
rejectUnknownKeys(document, ["projectVersion", "id", "vars", "roles"], "project file");
|
||||
if (document.projectVersion !== 1) refuse(`project file "projectVersion" must be 1 (${file})`);
|
||||
if (document.projectVersion !== 1) refuse(`project file "projectVersion" must be 1`);
|
||||
requireId(document.id, "project id");
|
||||
const vars = checkVars(document.vars ?? {}, "project", `${file} vars`);
|
||||
const vars = checkVars(document.vars ?? {}, "project", "project file vars");
|
||||
const roles = {};
|
||||
if (document.roles !== undefined) {
|
||||
requireObject(document.roles, `${file} roles`);
|
||||
requireObject(document.roles, "project file roles");
|
||||
for (const [instance, entry] of Object.entries(document.roles)) {
|
||||
requireId(instance, `${file} role instance`);
|
||||
requireObject(entry, `${file} roles.${instance}`);
|
||||
rejectUnknownKeys(entry, ["vars"], `${file} roles.${instance}`);
|
||||
roles[instance] = { vars: checkVars(entry.vars ?? {}, "project", `${file} roles.${instance}.vars`) };
|
||||
requireId(instance, "project file role instance");
|
||||
requireObject(entry, `project file roles.${instance}`);
|
||||
rejectUnknownKeys(entry, ["vars"], `project file roles.${instance}`);
|
||||
roles[instance] = { vars: checkVars(entry.vars ?? {}, "project", `project file roles.${instance}.vars`) };
|
||||
}
|
||||
}
|
||||
return deepFreeze({ projectVersion: 1, id: document.id, file, vars, roles });
|
||||
|
||||
@@ -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,
|
||||
@@ -105,6 +116,9 @@ export function resolveInstance({ system, business, project = null, instance })
|
||||
webSource: business.plugins?.["search-source"] ?? null,
|
||||
// The role file's skill names. The bundle resolves each against <dataRoot>/skills-enabled.
|
||||
skills: [...definition.skills],
|
||||
// #1873 T1: an org definition has no contract file; its instructions are the contract. Added
|
||||
// only then, so a template instance resolves, and digests, exactly as before.
|
||||
...(definition.contractPath === null && typeof definition.instructions === "string" ? { instructions: definition.instructions } : {}),
|
||||
};
|
||||
return deepFreeze({ ...resolved, digest: sha256(canonicalJson(resolved)) });
|
||||
}
|
||||
@@ -112,7 +126,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";
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
}
|
||||
@@ -90,7 +92,7 @@ function checkHireable(hireable, file) {
|
||||
if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names ${name}, a creative definition only the human adds`);
|
||||
const path = join(dir, `${name}.json`);
|
||||
if (!existsSync(path)) refuse(`hireable names a definition which has no role file`);
|
||||
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
|
||||
const named = validateRoleDocument(readJsonFile(path, `role file ${name}.json`), path, { hireable: false });
|
||||
if (named.roleVersion !== 2) refuse(`hireable names a definition which is role version 1`);
|
||||
const outside = unhireableAction(named);
|
||||
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside} outside the hireable ceiling`);
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -226,7 +230,7 @@ export function validateRoleDocument(document, file, options = {}) {
|
||||
}
|
||||
|
||||
export function loadRoleFile(file) {
|
||||
return validateRoleDocument(readJsonFile(file, "role file"), file);
|
||||
return validateRoleDocument(readJsonFile(file, /^[a-z0-9][a-z0-9._-]{0,63}\.json$/.test(basename(file)) ? `role file ${basename(file)}` : "role file"), file);
|
||||
}
|
||||
|
||||
export function loadRole(rolesDir, name) {
|
||||
|
||||
@@ -60,11 +60,14 @@ export function requireDate(value, where) {
|
||||
|
||||
// Parser diagnostics can quote file bytes. Keep the existing SyntaxError refusal,
|
||||
// but never attach the parser's message or cause to an error that callers can log.
|
||||
// The message names the file by its `what` label only: a refusal never quotes a
|
||||
// host path (PR #1916). `file` stays in the signature so callers don't change;
|
||||
// it is not used.
|
||||
export function parseJsonFileText(text, file, what = "file") {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new SyntaxError(`${what} is not valid JSON (${file})`);
|
||||
throw new SyntaxError(`${what} is not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,19 +75,21 @@ export function parseJsonFileText(text, file, what = "file") {
|
||||
// Missing or not a regular file is 4; unparseable is 2. One descriptor,
|
||||
// opened without following a link, serves every check and the read.
|
||||
// `checkStat` sees that descriptor's stat before the read, so the file
|
||||
// can't be swapped between the check and the read.
|
||||
// can't be swapped between the check and the read. Every refusal names the
|
||||
// file by `what` and never by path; a caller whose label alone can't tell
|
||||
// two files apart adds a name that already passed a strict pattern.
|
||||
export function readJsonFile(file, what, checkStat) {
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") refuse(`${what} not found: ${file}`, 4);
|
||||
refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
|
||||
if (error.code === "ENOENT") refuse(`${what} not found`, 4);
|
||||
refuse(`${what} must be a regular, non-symbolic-link file`, 4);
|
||||
}
|
||||
let text;
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
|
||||
if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file`, 4);
|
||||
checkStat?.(stat);
|
||||
text = readFileSync(fd, "utf8");
|
||||
} finally {
|
||||
@@ -93,7 +98,7 @@ export function readJsonFile(file, what, checkStat) {
|
||||
try {
|
||||
return parseJsonFileText(text, file, what);
|
||||
} catch {
|
||||
refuse(`${what} is not valid JSON (${file})`);
|
||||
refuse(`${what} is not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -59,7 +59,8 @@ test("row 325: the old mosaic-stack project key refuses against those same proje
|
||||
const r = x.validate();
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.equal(r.stdout, "");
|
||||
assert.match(r.stderr, /has id that does not match the declared project key/);
|
||||
assert.ok(r.stderr.includes(x.projectFile));
|
||||
assert.match(r.stderr, /project file of project mosaic-stack has id that does not match the declared project key/);
|
||||
// The refusal names the declared key, never the project root (PR #1916).
|
||||
assert.ok(!r.stderr.includes(x.projectFile), r.stderr);
|
||||
assert.doesNotMatch(r.stderr, /no project file/);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { projectFilePath } from "../src/index.mjs";
|
||||
import { businessDoc, REPO, rolesCopy, systemConfig, tmp, tokenFile, writeJson } from "./helpers.mjs";
|
||||
@@ -87,11 +87,24 @@ test("validate: project files", () => {
|
||||
assert.equal(run(s, "validate", "acme").code, 2);
|
||||
});
|
||||
|
||||
// PR #1916 (org-redact5): the project root is a config value, so a mismatched
|
||||
// project file is named by its checked project key, never by path.
|
||||
test("validate: a project file with another id names the project key, not the project root", () => {
|
||||
const sentinel = "sk-test-NOT-A-SECRET-1234";
|
||||
const s = setup((doc, root) => { doc.projects.stack.root = join(root, sentinel, "project"); mkdirSync(doc.projects.stack.root, { recursive: true }); });
|
||||
writeJson(projectFilePath(s.doc.projects.stack.root), { projectVersion: 1, id: "other" });
|
||||
const r = run(s, "validate", "acme");
|
||||
assert.equal(r.code, 2);
|
||||
assert.match(r.err, /project file of project stack has id that does not match the declared project key/);
|
||||
assert.ok(!r.err.includes(sentinel), r.err);
|
||||
assert.ok(!r.err.includes(s.doc.projects.stack.root), r.err);
|
||||
});
|
||||
|
||||
test("validate: missing files and a broken system config", () => {
|
||||
const s = setup();
|
||||
const missing = run(s, "validate", "nobody");
|
||||
assert.equal(missing.code, 4);
|
||||
assert.match(missing.err, /business file not found/);
|
||||
assert.match(missing.err, /business file nobody\.json not found/);
|
||||
assert.equal(run(s, "validate", "Bad!").code, 2);
|
||||
|
||||
const noConfig = { ...s, env: { ...s.env, MOSAIC_CONFIG: join(s.root, "absent", "config.json") } };
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
// #1873 T1: organization definitions and templates (roles-and-rails design sections 4, 5 and 13).
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { appendFileSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { organizationWriter } from "../src/organization.mjs";
|
||||
import { validateBusinessDocument } from "../src/organization-config.mjs";
|
||||
import { definitionVerbs, DEFINITION_VERBS } from "../src/definition-verbs.mjs";
|
||||
import { templateHash, templateHashes, diffDefinition, lineDiff } from "../src/definitions.mjs";
|
||||
import { resolveInstance } from "../src/resolve.mjs";
|
||||
import { ROLE_RIGHTS, VERB_RIGHTS, normalizeRights } from "../src/org-rights.mjs";
|
||||
import { businessDoc, rolesCopy, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
// The credential store's check, as if it held SECRET.
|
||||
const assertClean = (value) => {
|
||||
if (String(value).includes(SECRET)) throw new Error(`credential-leak ${value}`);
|
||||
};
|
||||
const OWNER = { actor: "jason", rights: [...ROLE_RIGHTS.owner], agent: false };
|
||||
const MEMBER = { actor: "alice", rights: [...ROLE_RIGHTS.member], agent: false };
|
||||
const HIRER = { actor: "alice", rights: normalizeRights(ROLE_RIGHTS.member, { add: ["org.hire"] }), agent: false };
|
||||
const PLAIN = { role: { tools: ["read"], network: "none", authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: "Read and report.\n" };
|
||||
|
||||
function fixture(t, edit = (doc) => doc) {
|
||||
const root = tmp();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dir = join(root, "config");
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = writeJson(join(dir, "businesses/acme.json"), edit(businessDoc(root)));
|
||||
const writer = organizationWriter({ dir, rolesDir });
|
||||
return { root, dir, file, rolesDir, writer, verbs: definitionVerbs({ writer, rolesDir, assertClean }) };
|
||||
}
|
||||
const rev = (f) => f.writer.read("acme").revision;
|
||||
const doc = (f) => JSON.parse(readFileSync(f.file, "utf8"));
|
||||
|
||||
test("T1 import pins the template hash and copies its primitives and contract", (t) => {
|
||||
const f = fixture(t);
|
||||
const hash = templateHash(f.rolesDir, "coder");
|
||||
assert.match(hash, /^[0-9a-f]{64}$/);
|
||||
const receipt = f.verbs.import("acme", { revision: rev(f), template: "coder" }, OWNER);
|
||||
assert.deepEqual(Object.keys(receipt).sort(), ["definition", "from", "organization", "rev", "revision", "transaction", "verb"]);
|
||||
assert.equal(receipt.rev, 1);
|
||||
assert.deepEqual(receipt.from, { template: "coder", sha256: hash });
|
||||
const copy = doc(f).definitions.coder;
|
||||
assert.deepEqual(copy.from, { template: "coder", sha256: hash });
|
||||
assert.equal(copy.rev, 1);
|
||||
assert.equal(copy.instructions, readFileSync(join(f.rolesDir, "coder.md"), "utf8"));
|
||||
const template = JSON.parse(readFileSync(join(f.rolesDir, "coder.json"), "utf8"));
|
||||
assert.deepEqual(copy.role.tools, template.tools);
|
||||
assert.equal(copy.role.network, template.network);
|
||||
assert.equal(receipt.revision, rev(f));
|
||||
// The hash read lists every version 2 template, and only those.
|
||||
const hashes = templateHashes(f.rolesDir);
|
||||
assert.ok(hashes.some((h) => h.template === "coder" && h.sha256 === hash));
|
||||
assert.ok(!hashes.some((h) => h.template === "conductor-policy"));
|
||||
// --as names the copy.
|
||||
const second = f.verbs.import("acme", { revision: rev(f), template: "coder", as: "coder-two" }, OWNER);
|
||||
assert.equal(second.definition, "coder-two");
|
||||
assert.throws(() => f.verbs.import("acme", { revision: rev(f), template: "coder" }, OWNER), { code: "definition-exists" });
|
||||
});
|
||||
|
||||
test("T1 the hash covers the template JSON and its contract together", (t) => {
|
||||
const f = fixture(t);
|
||||
const before = templateHash(f.rolesDir, "coder");
|
||||
appendFileSync(join(f.rolesDir, "coder.md"), "\nOne more line.\n");
|
||||
const contractChanged = templateHash(f.rolesDir, "coder");
|
||||
assert.notEqual(contractChanged, before);
|
||||
const json = JSON.parse(readFileSync(join(f.rolesDir, "coder.json"), "utf8"));
|
||||
writeJson(join(f.rolesDir, "coder.json"), { ...json, title: `${json.title} (renamed)` }, 0o644);
|
||||
assert.notEqual(templateHash(f.rolesDir, "coder"), contractChanged);
|
||||
});
|
||||
|
||||
test("T1 editing a template file changes no copy, and the diff read shows the change", (t) => {
|
||||
const f = fixture(t);
|
||||
f.verbs.import("acme", { revision: rev(f), template: "coder" }, OWNER);
|
||||
const bytes = readFileSync(f.file);
|
||||
const pinned = doc(f).definitions.coder.from.sha256;
|
||||
assert.deepEqual(f.verbs.diff("acme", "coder"), { organization: "acme", revision: rev(f), definition: "coder", rev: 1, from: { template: "coder", sha256: pinned }, current: pinned, changed: false, primitives: [], instructions: [] });
|
||||
|
||||
appendFileSync(join(f.rolesDir, "coder.md"), "Always run the suites twice.\n");
|
||||
const json = JSON.parse(readFileSync(join(f.rolesDir, "coder.json"), "utf8"));
|
||||
writeJson(join(f.rolesDir, "coder.json"), { ...json, network: "none" }, 0o644);
|
||||
assert.notEqual(json.network, "none", "the fixture must change network");
|
||||
|
||||
// No copy changed, and the file still loads.
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
assert.equal(f.writer.read("acme").document.definitions.coder.from.sha256, pinned);
|
||||
|
||||
const diff = f.verbs.diff("acme", "coder");
|
||||
assert.equal(diff.changed, true);
|
||||
assert.equal(diff.current, templateHash(f.rolesDir, "coder"));
|
||||
assert.notEqual(diff.current, pinned);
|
||||
assert.deepEqual(diff.primitives, ["network", "instructions"]);
|
||||
assert.deepEqual(diff.instructions.filter((l) => l.op !== " "), [{ op: "+", line: "Always run the suites twice." }]);
|
||||
// Nothing applied.
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
});
|
||||
|
||||
test("T1 diff read: an invented definition has nothing to compare, a missing template is named", (t) => {
|
||||
const f = fixture(t);
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER);
|
||||
assert.equal(f.verbs.diff("acme", "scout").changed, false);
|
||||
assert.equal(f.verbs.diff("acme", "scout").current, null);
|
||||
f.verbs.import("acme", { revision: rev(f), template: "proofreader" }, OWNER);
|
||||
rmSync(join(f.rolesDir, "proofreader.json"));
|
||||
const gone = diffDefinition(f.writer.read("acme").document, "proofreader", f.rolesDir);
|
||||
assert.equal(gone.templateMissing, true);
|
||||
assert.throws(() => f.verbs.diff("acme", "nobody"), { name: "BusinessError" });
|
||||
assert.deepEqual(lineDiff("a\nb", "a\nc"), [{ op: " ", line: "a" }, { op: "-", line: "b" }, { op: "+", line: "c" }]);
|
||||
});
|
||||
|
||||
test("T1 rev counts up on every write, and a stale write refuses", (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER).rev, 1);
|
||||
const edited = { ...PLAIN, instructions: "Read, then report.\n" };
|
||||
assert.equal(f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition: edited }, OWNER).rev, 2);
|
||||
assert.equal(f.verbs.edit("acme", { revision: rev(f), rev: 2, name: "scout", definition: { ...edited, personality: { summary: "Terse." } } }, OWNER).rev, 3);
|
||||
assert.equal(doc(f).definitions.scout.rev, 3);
|
||||
const bytes = readFileSync(f.file);
|
||||
// A stale rev, a stale file revision, an unchanged edit.
|
||||
assert.throws(() => f.verbs.edit("acme", { revision: rev(f), rev: 2, name: "scout", definition: PLAIN }, OWNER), { code: "definition-rev-conflict" });
|
||||
const old = rev(f);
|
||||
assert.throws(() => f.verbs.edit("acme", { revision: "0".repeat(64), rev: 3, name: "scout", definition: PLAIN }, OWNER), { code: "revision-conflict" });
|
||||
assert.throws(() => f.verbs.edit("acme", { revision: old, rev: 3, name: "scout", definition: { ...edited, personality: { summary: "Terse." } } }, OWNER), { code: "definition-unchanged" });
|
||||
assert.throws(() => f.verbs.edit("acme", { revision: old, name: "scout", definition: PLAIN }, OWNER), { code: "definition-rev-required" });
|
||||
assert.throws(() => f.verbs.remove("acme", { revision: old, rev: 1, name: "scout" }, OWNER), { code: "definition-rev-conflict" });
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
// Edit keeps from; remove takes the entry out.
|
||||
f.verbs.import("acme", { revision: rev(f), template: "coder" }, OWNER);
|
||||
const from = doc(f).definitions.coder.from;
|
||||
f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "coder", definition: PLAIN }, OWNER);
|
||||
assert.deepEqual(doc(f).definitions.coder.from, from);
|
||||
const removed = f.verbs.remove("acme", { revision: rev(f), rev: 3, name: "scout" }, OWNER);
|
||||
assert.equal(removed.removedRev, 3);
|
||||
assert.ok(!Object.hasOwn(doc(f).definitions, "scout"));
|
||||
// Each write is logged with its verb, name and rev, never role text.
|
||||
const log = readFileSync(f.writer.logFile("acme"), "utf8").trim().split("\n").map(JSON.parse).filter((e) => e.state === "committed");
|
||||
assert.deepEqual(log.map((e) => [e.detail.verb, e.detail.definition, e.detail.rev]), [
|
||||
["definition.invent.console", "scout", 1], ["definition.edit.console", "scout", 2], ["definition.edit.console", "scout", 3],
|
||||
["definition.import.console", "coder", 1], ["definition.edit.console", "coder", 2], ["definition.remove.console", "scout", null],
|
||||
]);
|
||||
});
|
||||
|
||||
test("T1 invent and customize refuse what checkComposition refuses, with no value in the error", (t) => {
|
||||
const f = fixture(t);
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER);
|
||||
const bytes = readFileSync(f.file);
|
||||
const bad = [
|
||||
["held credential in instructions", { ...PLAIN, instructions: `Use ${SECRET} to log in.\n` }],
|
||||
["held credential in personality", { ...PLAIN, personality: { voice: `say ${SECRET}` } }],
|
||||
["unregistered tool", { ...PLAIN, role: { ...PLAIN.role, tools: [SECRET] } }],
|
||||
["unregistered service", { ...PLAIN, role: { ...PLAIN.role, credentials: [{ service: SECRET }] } }],
|
||||
["hire chain", { ...PLAIN, role: { ...PLAIN.role, authority: { withinRole: ["role.hire"], crossRole: [] } } }],
|
||||
["over bound", { ...PLAIN, instructions: "x".repeat(16385) }],
|
||||
["unknown entry key", { ...PLAIN, [SECRET]: 1 }],
|
||||
["unknown role key", { ...PLAIN, role: { ...PLAIN.role, [SECRET]: [] } }],
|
||||
];
|
||||
for (const [what, definition] of bad) {
|
||||
for (const call of [
|
||||
() => f.verbs.invent("acme", { revision: rev(f), name: "other", definition }, OWNER),
|
||||
() => f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition }, OWNER),
|
||||
]) {
|
||||
assert.throws(call, (e) => !e.message.includes(SECRET) && !e.message.includes("x".repeat(64)) && !String(e.stack).includes(SECRET), what);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
// Import refuses a template that holds a hire right (R9): the chief of staff.
|
||||
assert.throws(() => f.verbs.import("acme", { revision: rev(f), template: "chief-of-staff" }, OWNER), /cannot hire or fire/);
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
});
|
||||
|
||||
test("T1 a bad entry in the file refuses the whole file, naming the definition and key, never the value", (t) => {
|
||||
const root = tmp();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = join(root, "config/businesses/acme.json");
|
||||
const base = businessDoc(root);
|
||||
const entry = { rev: 1, ...PLAIN };
|
||||
const refusal = (definitions) => {
|
||||
try {
|
||||
validateBusinessDocument({ ...base, definitions }, file, { rolesDir, assertClean });
|
||||
} catch (e) {
|
||||
assert.equal(e.name, "BusinessError");
|
||||
return e.message;
|
||||
}
|
||||
assert.fail("expected a refusal");
|
||||
};
|
||||
assert.match(refusal({ scout: { ...entry, instructions: `see ${SECRET}` } }), /definitions\.scout: composition instructions holds a credential/);
|
||||
assert.match(refusal({ scout: { ...entry, role: { ...PLAIN.role, tools: [SECRET] } } }), /definitions\.scout: .*tools/);
|
||||
assert.match(refusal({ scout: { ...entry, rev: 0 } }), /definitions\.scout\.rev must be a positive integer/);
|
||||
assert.match(refusal({ scout: { ...entry, from: { template: "coder", sha256: SECRET } } }), /definitions\.scout\.from\.sha256/);
|
||||
assert.match(refusal({ scout: { ...entry, [SECRET]: true } }), /definitions\.scout has an unknown key \(key \d+;/);
|
||||
assert.match(refusal({ [SECRET]: entry }), /definitions key 1 must be a definition name/);
|
||||
for (const d of [{ scout: { ...entry, instructions: `see ${SECRET}` } }, { scout: { ...entry, role: { ...PLAIN.role, tools: [SECRET] } } }, { [SECRET]: entry }, { scout: { ...entry, [SECRET]: true } }]) {
|
||||
assert.ok(!refusal(d).includes(SECRET));
|
||||
}
|
||||
// A good definition validates; the resolved map holds only what instances name.
|
||||
const ok = validateBusinessDocument({ ...base, definitions: { scout: entry } }, file, { rolesDir });
|
||||
assert.ok(!Object.hasOwn(ok.definitions, "org:scout"));
|
||||
});
|
||||
|
||||
test("T1 an org: reference resolves to the definition, and a missing one refuses naming instance and definition", (t) => {
|
||||
const root = tmp();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = join(root, "config/businesses/acme.json");
|
||||
const base = businessDoc(root);
|
||||
const named = { ...base, definitions: { scout: { rev: 4, ...PLAIN, personality: { summary: "Terse." } } }, roles: { ...base.roles, helper: { definition: "org:scout" } } };
|
||||
const business = validateBusinessDocument(named, file, { rolesDir });
|
||||
const role = business.definitions["org:scout"];
|
||||
assert.equal(role.name, "org:scout");
|
||||
assert.equal(role.contractPath, null);
|
||||
assert.equal(role.instructions, PLAIN.instructions);
|
||||
assert.equal(role.rev, 4);
|
||||
const resolved = resolveInstance({ system: systemFor(root), business, instance: "helper" });
|
||||
assert.equal(resolved.definition, "org:scout");
|
||||
assert.equal(resolved.contract, null);
|
||||
assert.equal(resolved.instructions, PLAIN.instructions);
|
||||
assert.deepEqual(resolved.limits.tools, ["read"]);
|
||||
|
||||
const missing = { ...base, roles: { ...base.roles, helper: { definition: "org:ghost" } } };
|
||||
assert.throws(() => validateBusinessDocument(missing, file, { rolesDir }), (e) => /definition-missing: .*roles\.helper\.definition names org:ghost/.test(e.message));
|
||||
assert.throws(() => validateBusinessDocument({ ...base, roles: { ...base.roles, helper: { definition: `org:${SECRET.toLowerCase()}!` } } }, file, { rolesDir }), (e) => !e.message.includes(SECRET.toLowerCase()));
|
||||
// Template names work exactly as before, including one shaped like a definition name.
|
||||
assert.throws(() => validateBusinessDocument({ ...base, roles: { ...base.roles, helper: { definition: "scout" } } }, file, { rolesDir }), { name: "BusinessError" });
|
||||
});
|
||||
|
||||
test("T1 an org whose instances name only templates loads exactly as before", (t) => {
|
||||
const root = tmp();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = join(root, "config/businesses/acme.json");
|
||||
const base = businessDoc(root);
|
||||
const plain = validateBusinessDocument(base, file, { rolesDir });
|
||||
const withUnused = validateBusinessDocument({ ...base, definitions: { scout: { rev: 1, ...PLAIN } } }, file, { rolesDir });
|
||||
assert.deepEqual(withUnused, plain);
|
||||
assert.deepEqual(Object.keys(plain).sort(), ["arbiters", "businessVersion", "definitions", "file", "human", "id", "launch", "plugins", "projects", "roles", "tracker", "vars"]);
|
||||
assert.deepEqual(Object.keys(plain.definitions).sort(), ["chief-of-staff", "coder", "cto", "pm", "reviewer"]);
|
||||
const system = systemFor(root);
|
||||
for (const instance of Object.keys(base.roles)) {
|
||||
const a = resolveInstance({ system, business: plain, instance });
|
||||
const b = resolveInstance({ system, business: withUnused, instance });
|
||||
assert.equal(a.digest, b.digest);
|
||||
assert.ok(!Object.hasOwn(a, "instructions"));
|
||||
assert.equal(a.contract, plain.definitions[base.roles[instance].definition].contractPath);
|
||||
}
|
||||
});
|
||||
|
||||
test("T1 definition-in-use: an edit or removal of a definition an instance names refuses, naming it", (t) => {
|
||||
const f = fixture(t);
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER);
|
||||
// A human adds the instance through the ordinary writer; definitions stay as they are.
|
||||
const next = doc(f);
|
||||
next.roles.helper = { definition: "org:scout" };
|
||||
f.writer.write("acme", next, { revision: rev(f), actor: "jason" });
|
||||
const bytes = readFileSync(f.file);
|
||||
assert.throws(() => f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition: { ...PLAIN, instructions: "Changed.\n" } }, OWNER),
|
||||
(e) => e.code === "definition-in-use" && /named by helper/.test(e.message));
|
||||
assert.throws(() => f.verbs.remove("acme", { revision: rev(f), rev: 1, name: "scout" }, OWNER),
|
||||
(e) => e.code === "definition-in-use" && /named by helper/.test(e.message));
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
assert.deepEqual(f.verbs.list("acme").definitions, [{ name: "scout", reference: "org:scout", rev: 1, from: null, usedBy: ["helper"] }]);
|
||||
assert.deepEqual(f.verbs.show("acme", "scout").usedBy, ["helper"]);
|
||||
});
|
||||
|
||||
test("T1 the ordinary writer cannot change definitions, and setDefinition changes nothing else", (t) => {
|
||||
const f = fixture(t);
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER);
|
||||
const next = doc(f);
|
||||
next.definitions.scout.instructions = "Sneaked in.\n";
|
||||
next.definitions.scout.rev = 2;
|
||||
assert.throws(() => f.writer.write("acme", next, { revision: rev(f), actor: "jason" }), { code: "organization-definitions-protected" });
|
||||
const added = doc(f);
|
||||
added.definitions.other = { rev: 1, ...PLAIN };
|
||||
assert.throws(() => f.writer.write("acme", added, { revision: rev(f), actor: "jason" }), { code: "organization-definitions-protected" });
|
||||
// The writer itself checks rev and the composition, whatever builds the entry.
|
||||
assert.throws(() => f.writer.setDefinition("acme", "scout", () => ({ ...PLAIN, role: { ...PLAIN.role, tools: ["no-such-tool"] } }), { revision: rev(f), actor: "jason", assertClean }), { name: "BusinessError" });
|
||||
});
|
||||
|
||||
test("T1 an agent cannot call the edit verbs", (t) => {
|
||||
const f = fixture(t);
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER);
|
||||
const bytes = readFileSync(f.file);
|
||||
const calls = (caller) => [
|
||||
() => f.verbs.import("acme", { revision: rev(f), template: "coder" }, caller),
|
||||
() => f.verbs.invent("acme", { revision: rev(f), name: "other", definition: PLAIN }, caller),
|
||||
() => f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition: { ...PLAIN, instructions: "x\n" } }, caller),
|
||||
() => f.verbs.remove("acme", { revision: rev(f), rev: 1, name: "scout" }, caller),
|
||||
];
|
||||
// An agent session, even one holding every right; an agent's name or the tester as actor.
|
||||
for (const caller of [
|
||||
{ ...OWNER, agent: true }, { actor: "coder", rights: OWNER.rights }, { ...OWNER, actor: "coder" },
|
||||
{ ...OWNER, actor: "coder.coder" }, { ...OWNER, actor: "tester" }, { ...OWNER, actor: "chief" },
|
||||
]) {
|
||||
for (const call of calls(caller)) assert.throws(call, { code: "human-required" });
|
||||
}
|
||||
// The writer refuses an agent actor by itself too.
|
||||
assert.throws(() => f.writer.setDefinition("acme", "other", () => PLAIN, { revision: rev(f), actor: "pm" }), { code: "human-required" });
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
});
|
||||
|
||||
test("T1 a member without org.hire refuses on each verb; the owner and a member granted org.hire succeed", (t) => {
|
||||
const f = fixture(t);
|
||||
for (const verb of Object.values(DEFINITION_VERBS)) assert.equal(VERB_RIGHTS[verb], "org.hire");
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER);
|
||||
const bytes = readFileSync(f.file);
|
||||
const refused = [
|
||||
() => f.verbs.import("acme", { revision: rev(f), template: "coder" }, MEMBER),
|
||||
() => f.verbs.invent("acme", { revision: rev(f), name: "other", definition: PLAIN }, MEMBER),
|
||||
() => f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition: { ...PLAIN, instructions: "x\n" } }, MEMBER),
|
||||
() => f.verbs.remove("acme", { revision: rev(f), rev: 1, name: "scout" }, MEMBER),
|
||||
() => f.verbs.invent("acme", { revision: rev(f), name: "other", definition: PLAIN }, { actor: "alice", agent: false }),
|
||||
];
|
||||
for (const call of refused) assert.throws(call, (e) => e.code === "right-required" && /needs org\.hire/.test(e.message));
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
// Owner: every verb.
|
||||
f.verbs.import("acme", { revision: rev(f), template: "coder" }, OWNER);
|
||||
f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition: { ...PLAIN, instructions: "x\n" } }, OWNER);
|
||||
f.verbs.remove("acme", { revision: rev(f), rev: 2, name: "scout" }, OWNER);
|
||||
// A member granted org.hire: every verb, logged as that member.
|
||||
f.verbs.import("acme", { revision: rev(f), template: "reviewer" }, HIRER);
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, HIRER);
|
||||
f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition: { ...PLAIN, instructions: "y\n" } }, HIRER);
|
||||
f.verbs.remove("acme", { revision: rev(f), rev: 2, name: "scout" }, HIRER);
|
||||
const log = readFileSync(f.writer.logFile("acme"), "utf8").trim().split("\n").map(JSON.parse).filter((e) => e.state === "committed");
|
||||
assert.deepEqual(log.slice(-4).map((e) => e.actor), ["alice", "alice", "alice", "alice"]);
|
||||
});
|
||||
|
||||
test("T1 the template files on disk are never written", (t) => {
|
||||
const f = fixture(t);
|
||||
const before = readFileSync(join(f.rolesDir, "coder.json"));
|
||||
f.verbs.import("acme", { revision: rev(f), template: "coder" }, OWNER);
|
||||
f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "coder", definition: PLAIN }, OWNER);
|
||||
assert.deepEqual(readFileSync(join(f.rolesDir, "coder.json")), before);
|
||||
writeFileSync(join(f.root, "touched"), "");
|
||||
});
|
||||
|
||||
test("T1 a write that adds or changes a definition refuses without the credential store's secret check", (t) => {
|
||||
const f = fixture(t);
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER);
|
||||
const unchecked = definitionVerbs({ writer: f.writer, rolesDir: f.rolesDir });
|
||||
const before = readFileSync(f.file);
|
||||
const required = { code: "definition-secret-check-required" };
|
||||
assert.throws(() => unchecked.import("acme", { revision: rev(f), template: "coder" }, OWNER), required);
|
||||
assert.throws(() => unchecked.invent("acme", { revision: rev(f), name: "other", definition: PLAIN }, OWNER), required);
|
||||
assert.throws(() => unchecked.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition: { ...PLAIN, instructions: "Report.\n" } }, OWNER), required);
|
||||
assert.throws(() => f.writer.setDefinition("acme", "other", () => PLAIN, { revision: rev(f), actor: "jason" }), required);
|
||||
assert.deepEqual(readFileSync(f.file), before);
|
||||
// A removal writes no role text, so it needs no secret check.
|
||||
assert.equal(unchecked.remove("acme", { revision: rev(f), rev: 1, name: "scout" }, OWNER).removedRev, 1);
|
||||
});
|
||||
@@ -17,42 +17,46 @@ import { createOrg, deleteOrg, notificationShow, showOrg, templateRender } from
|
||||
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
const malformed = secret;
|
||||
// PR #1916 redaction (org-redact5): the sentinel is also a path component, so a
|
||||
// refusal that quoted the host path would carry it.
|
||||
function fixture(t) {
|
||||
const root = tmp();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const top = tmp();
|
||||
t.after(() => rmSync(top, { recursive: true, force: true }));
|
||||
const root = join(top, secret);
|
||||
mkdirSync(root, { mode: 0o700 });
|
||||
const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test',
|
||||
principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } };
|
||||
for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 });
|
||||
return ctx;
|
||||
}
|
||||
function safe(file, { name = 'SyntaxError', code, exitCode, prefix } = {}) {
|
||||
// The refusal names the file by its label and never by path.
|
||||
function safe(file, { name = 'SyntaxError', code, exitCode, prefix, label, suffix = '' } = {}) {
|
||||
return error => {
|
||||
assert.equal(error.name, name);
|
||||
assert.equal(error.code, code);
|
||||
assert.equal(error.exitCode, exitCode);
|
||||
assert.ok(error.message.includes(file), error.message);
|
||||
assert.match(error.message, /not valid JSON/);
|
||||
assert.ok(!error.message.includes(file), error.message);
|
||||
assert.equal(error.message, `${prefix ?? ''}${label} is not valid JSON${suffix}`);
|
||||
assert.doesNotMatch(error.message, /sk-test|private|broken|Unexpected|position|line \d/);
|
||||
assert.ok(!error.stack.includes(secret));
|
||||
assert.equal(error.cause, undefined);
|
||||
if (prefix) assert.ok(error.message.startsWith(prefix));
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
for (const [label, load, relative] of [
|
||||
['shared reader', file => readJsonFile(file, 'config file'), 'config.json'],
|
||||
['organization loader', (_file, ctx) => loadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json'],
|
||||
['legacy business loader', (_file, ctx) => legacyLoadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json'],
|
||||
['project loader', (_file, ctx) => loadProject(ctx.configDir), '.mosaic/project.json'],
|
||||
for (const [label, load, relative, labelText] of [
|
||||
['shared reader', file => readJsonFile(file, 'config file'), 'config.json', 'config file'],
|
||||
['organization loader', (_file, ctx) => loadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json', 'business file alpha.json'],
|
||||
['legacy business loader', (_file, ctx) => legacyLoadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json', 'business file alpha.json'],
|
||||
['project loader', (_file, ctx) => loadProject(ctx.configDir), '.mosaic/project.json', 'project file'],
|
||||
]) test(`${label} names malformed JSON's file without quoting bytes`, t => {
|
||||
const ctx = fixture(t), file = writeJson(join(ctx.configDir, relative), malformed);
|
||||
assert.throws(() => load(file, ctx), safe(file, { name: 'BusinessError', exitCode: 2 }));
|
||||
assert.throws(() => load(file, ctx), safe(file, { name: 'BusinessError', exitCode: 2, label: labelText }));
|
||||
});
|
||||
|
||||
test('root agents keep their existing file-only refusal', t => {
|
||||
const ctx = fixture(t); writeJson(join(ctx.configDir, 'agents.json'), malformed);
|
||||
assert.throws(() => loadRootAgents(ctx.configDir, { rolesDir: REPO_ROLES }), safe('agents.json', { name: 'BusinessError', exitCode: 2 }));
|
||||
assert.throws(() => loadRootAgents(ctx.configDir, { rolesDir: REPO_ROLES }), safe(join(ctx.configDir, 'agents.json'), { name: 'BusinessError', exitCode: 2, label: 'root agent file', suffix: ' (agents.json)' }));
|
||||
});
|
||||
|
||||
for (const operation of ['read', 'write', 'recover', 'findAddition']) test(`organization writer ${operation} does not quote malformed file bytes`, async t => {
|
||||
@@ -74,13 +78,13 @@ for (const operation of ['read', 'write', 'recover', 'findAddition']) test(`orga
|
||||
const invoke = () => operation === 'write'
|
||||
? writer.write('alpha', current.document, { revision: current.revision, actor: ctx.principal.sub })
|
||||
: writer[operation]('alpha', {});
|
||||
assert.throws(invoke, safe(file));
|
||||
assert.throws(invoke, safe(file, { label: receipt ? 'organization receipt file' : 'organization file' }));
|
||||
});
|
||||
|
||||
for (const operation of ['async', 'sync']) test(`${operation} registry read names the file without parser bytes`, async t => {
|
||||
const ctx = fixture(t), file = writeJson(join(ctx.configDir, 'org-runtime.json'), malformed);
|
||||
if (operation === 'async') await assert.rejects(readRegistry(ctx), safe(file, { name: 'OrgError', code: 'org-mismatch', prefix: 'invalid registry: ' }));
|
||||
else assert.throws(() => withRegistryWriter(ctx.configDir, () => assert.fail('must refuse')), safe(file));
|
||||
if (operation === 'async') await assert.rejects(readRegistry(ctx), safe(file, { name: 'OrgError', code: 'org-mismatch', prefix: 'invalid registry: ', label: 'metadata file' }));
|
||||
else assert.throws(() => withRegistryWriter(ctx.configDir, () => assert.fail('must refuse')), safe(file, { label: 'registry file' }));
|
||||
});
|
||||
|
||||
for (const kind of ['owner', 'organization', 'notifier', 'deletion receipt']) test(`service ${kind} refuses malformed JSON without file bytes`, async t => {
|
||||
@@ -107,9 +111,11 @@ for (const kind of ['owner', 'organization', 'notifier', 'deletion receipt']) te
|
||||
registry.orgs.alpha.digest = createHash('sha256').update(malformed).digest('hex');
|
||||
writeJson(registryFile, registry);
|
||||
}
|
||||
if (kind === 'notifier') await assert.rejects(notificationShow(ctx, 'alpha'), safe(file));
|
||||
else if (kind === 'deletion receipt') await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }), safe(file));
|
||||
else await assert.rejects(showOrg(ctx, 'alpha'), safe(file));
|
||||
const label = { owner: 'organization owner file', organization: 'organization file', notifier: 'notifier configuration file', 'deletion receipt': 'deletion receipt file' }[kind];
|
||||
if (kind === 'notifier') await assert.rejects(notificationShow(ctx, 'alpha'), safe(file, { label }));
|
||||
else if (kind === 'deletion receipt') await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }),
|
||||
safe(file, { label, suffix: '; deletion remains in progress. Inspect the deletion receipt under the data root and rerun mosaic org delete alpha --confirm alpha' }));
|
||||
else await assert.rejects(showOrg(ctx, 'alpha'), safe(file, { label }));
|
||||
});
|
||||
|
||||
// The packaged template is immutable at runtime; substitute only its read result
|
||||
@@ -122,7 +128,7 @@ test('template reader refuses malformed JSON without quoting template bytes', as
|
||||
});
|
||||
syncBuiltinESMExports();
|
||||
try {
|
||||
await assert.rejects(templateRender('alpha'), safe('org-template.json'));
|
||||
await assert.rejects(templateRender('alpha'), safe('/org-template.json', { label: 'organization template file' }));
|
||||
} finally {
|
||||
t.mock.restoreAll();
|
||||
syncBuiltinESMExports();
|
||||
|
||||
@@ -60,6 +60,10 @@ const VERBS = {
|
||||
'decision.preview.console': 'decide',
|
||||
'decision.resolve.console': 'decide',
|
||||
'hire.resolve.console': 'org.hire',
|
||||
'definition.import.console': 'org.hire',
|
||||
'definition.edit.console': 'org.hire',
|
||||
'definition.invent.console': 'org.hire',
|
||||
'definition.remove.console': 'org.hire',
|
||||
'request.answer.console': 'request.write',
|
||||
'request.attach.console': 'request.write',
|
||||
'request.cancel.console': 'request.write',
|
||||
@@ -186,6 +190,8 @@ test('M1 every actual console, user, board and person-task verb has a reviewed a
|
||||
assert.deepEqual(Object.keys(VERBS).sort(), [...new Set([
|
||||
...CONSOLE_VERBS, ...USER_VERBS, ...BOARD_VERBS, ...PERSON_TASK_VERBS, 'plugins', 'plugins.set', 'limits',
|
||||
'hire.resolve.console', // R1 reserves the policy mapping; A4 implements the verb.
|
||||
// T1: the definition verbs. The CLI calls them today; the Console route comes later.
|
||||
'definition.import.console', 'definition.edit.console', 'definition.invent.console', 'definition.remove.console',
|
||||
])].sort());
|
||||
});
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ test("REQ-THREAD-2 REQ-LAUNCH-1 launch.threadIdleSeconds: absent is the default,
|
||||
test("loadBusiness: file checks", () => {
|
||||
const root = tmp();
|
||||
const dir = join(root, "config");
|
||||
refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /business file not found/, 4);
|
||||
refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /business file acme\.json not found/, 4);
|
||||
|
||||
const file = writeJson(join(dir, "businesses", "acme.json"), businessDoc(root));
|
||||
assert.equal(loadBusiness("acme", { dir, rolesDir: REPO_ROLES }).file, file);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// PR #1916 redaction (org-redact5): a refusal names the file by its label, never
|
||||
// by host path. Every fixture here sits under a directory named for the sentinel,
|
||||
// so a refusal that quoted the path would carry it.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, copyFileSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { REPO_ROLES, tmp, writeJson } from './helpers.mjs';
|
||||
import { parseJsonFileText, readJsonFile } from '../src/util.mjs';
|
||||
import { loadAccess } from '../src/access.mjs';
|
||||
import { validateProjectDocument } from '../src/project.mjs';
|
||||
import { loadSeatSets } from '../src/library-seats.mjs';
|
||||
import { loadRole, loadRoleFile } from '../src/role.mjs';
|
||||
import { loadBusiness } from '../src/organization-config.mjs';
|
||||
import { createOrg, deleteOrg, notificationShow, templateRender } from '../src/org-service.mjs';
|
||||
import { checkCommands } from '../src/hosts.mjs';
|
||||
import { BUSINESS_CONFIG, runConfigCommand } from '../src/config-command.mjs';
|
||||
|
||||
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
||||
|
||||
function under(t) {
|
||||
const top = tmp();
|
||||
t.after(() => rmSync(top, { recursive: true, force: true }));
|
||||
const root = join(top, SENTINEL);
|
||||
mkdirSync(root, { mode: 0o700 });
|
||||
return root;
|
||||
}
|
||||
|
||||
// The refusal is exactly `message`, with its exit code, and carries neither the
|
||||
// sentinel nor any of `paths`.
|
||||
function refusal(message, { exitCode, name = 'BusinessError', paths = [] } = {}) {
|
||||
return (error) => {
|
||||
assert.equal(error.name, name);
|
||||
if (exitCode !== undefined) assert.equal(error.exitCode, exitCode);
|
||||
assert.equal(error.message, message);
|
||||
assert.ok(!error.message.includes(SENTINEL), error.message);
|
||||
for (const path of paths) assert.ok(!error.message.includes(path), error.message);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
test('readJsonFile: a missing file is refused by label, exit 4', (t) => {
|
||||
const file = join(under(t), 'config.json');
|
||||
assert.throws(() => readJsonFile(file, 'config file'), refusal('config file not found', { exitCode: 4, paths: [file] }));
|
||||
});
|
||||
|
||||
test('readJsonFile: a symbolic link is refused by label at open, exit 4', (t) => {
|
||||
const root = under(t), file = join(root, 'config.json');
|
||||
writeJson(join(root, 'real.json'), {});
|
||||
symlinkSync(join(root, 'real.json'), file);
|
||||
assert.throws(() => readJsonFile(file, 'config file'), refusal('config file must be a regular, non-symbolic-link file', { exitCode: 4, paths: [file] }));
|
||||
});
|
||||
|
||||
test('readJsonFile: a directory is refused by label after the stat, exit 4', (t) => {
|
||||
const file = join(under(t), 'config.json');
|
||||
mkdirSync(file);
|
||||
assert.throws(() => readJsonFile(file, 'config file'), refusal('config file must be a regular, non-symbolic-link file', { exitCode: 4, paths: [file] }));
|
||||
});
|
||||
|
||||
test('readJsonFile: invalid JSON is refused by label, exit 2', (t) => {
|
||||
const file = writeJson(join(under(t), 'config.json'), '{not json');
|
||||
assert.throws(() => readJsonFile(file, 'config file'), refusal('config file is not valid JSON', { exitCode: 2, paths: [file] }));
|
||||
});
|
||||
|
||||
test('parseJsonFileText: the SyntaxError names the label only', (t) => {
|
||||
const file = join(under(t), 'config.json');
|
||||
assert.throws(() => parseJsonFileText('{not json', file, 'config file'), refusal('config file is not valid JSON', { name: 'SyntaxError', paths: [file] }));
|
||||
});
|
||||
|
||||
test('loadAccess: an access file of another uid is refused without its path', (t) => {
|
||||
const file = writeJson(join(under(t), 'access.json'), {});
|
||||
const uid = process.getuid() + 1;
|
||||
assert.throws(() => loadAccess(file, {}, { uid }), refusal(`access file must belong to uid ${uid}`, { exitCode: 2, paths: [file] }));
|
||||
});
|
||||
|
||||
test('loadAccess: a group-writable access file is refused without its path', (t) => {
|
||||
const file = writeJson(join(under(t), 'access.json'), {}, 0o620);
|
||||
assert.throws(() => loadAccess(file, {}), refusal('access file must not be writable by group or other (mode 620)', { exitCode: 2, paths: [file] }));
|
||||
});
|
||||
|
||||
for (const [what, document, message] of [
|
||||
['projectVersion', { projectVersion: 2, id: 'stack' }, 'project file "projectVersion" must be 1'],
|
||||
['vars', { projectVersion: 1, id: 'stack', vars: [] }, 'project file vars must be a JSON object'],
|
||||
['roles', { projectVersion: 1, id: 'stack', roles: [] }, 'project file roles must be a JSON object'],
|
||||
['role instance', { projectVersion: 1, id: 'stack', roles: { 'Bad!': {} } }, 'project file role instance must match /^[a-z0-9][a-z0-9._-]{0,63}$/'],
|
||||
['roles entry', { projectVersion: 1, id: 'stack', roles: { coder: [] } }, 'project file roles.coder must be a JSON object'],
|
||||
['roles entry key', { projectVersion: 1, id: 'stack', roles: { coder: { extra: 1 } } }, 'unsupported project file roles.coder key: "extra"'],
|
||||
['roles entry vars', { projectVersion: 1, id: 'stack', roles: { coder: { vars: [] } } }, 'project file roles.coder.vars must be a JSON object'],
|
||||
]) test(`validateProjectDocument: ${what} is refused by label, never by the project root`, (t) => {
|
||||
const file = join(under(t), 'project', '.mosaic', 'project.json');
|
||||
assert.throws(() => validateProjectDocument(document, file), refusal(message, { exitCode: 2, paths: [file] }));
|
||||
});
|
||||
|
||||
test('loadSeatSets: a config directory that is a file is refused by its error code', (t) => {
|
||||
const dir = join(under(t), 'config');
|
||||
writeFileSync(dir, '');
|
||||
assert.throws(() => loadSeatSets({ dir, rolesDir: REPO_ROLES }), refusal('cannot list businesses in the config directory: ENOTDIR', { exitCode: 2, paths: [dir] }));
|
||||
});
|
||||
|
||||
test('loadSeatSets: a businesses entry that does not list is refused by its error code', (t) => {
|
||||
const dir = join(under(t), 'config');
|
||||
mkdirSync(dir);
|
||||
writeFileSync(join(dir, 'businesses'), '');
|
||||
assert.throws(() => loadSeatSets({ dir, rolesDir: REPO_ROLES }), refusal('cannot list businesses in the config directory: ENOTDIR', { exitCode: 2, paths: [dir] }));
|
||||
});
|
||||
|
||||
test('loadRole: a missing role file is named by its checked name', (t) => {
|
||||
const dir = under(t);
|
||||
assert.throws(() => loadRole(dir, 'coder'), refusal('role file coder.json not found', { exitCode: 4, paths: [dir] }));
|
||||
});
|
||||
|
||||
test('loadRoleFile: a file name outside the id pattern is not quoted', (t) => {
|
||||
const file = join(under(t), 'Not An Id.json');
|
||||
assert.throws(() => loadRoleFile(file), refusal('role file not found', { exitCode: 4, paths: [file] }));
|
||||
});
|
||||
|
||||
test('loadRoleFile: a hireable entry whose file is invalid JSON is named by its checked name', (t) => {
|
||||
const dir = join(under(t), 'roles');
|
||||
mkdirSync(dir);
|
||||
const chief = JSON.parse(readFileSync(join(REPO_ROLES, 'chief-of-staff.json'), 'utf8'));
|
||||
writeJson(join(dir, 'chief-of-staff.json'), { ...chief, hireable: ['coder'] });
|
||||
copyFileSync(join(REPO_ROLES, 'chief-of-staff.md'), join(dir, 'chief-of-staff.md'));
|
||||
writeJson(join(dir, 'coder.json'), '{not json');
|
||||
assert.throws(() => loadRole(dir, 'chief-of-staff'), refusal('role file coder.json is not valid JSON', { exitCode: 2, paths: [dir] }));
|
||||
});
|
||||
|
||||
test('loadBusiness: a missing business file is named by its checked id', (t) => {
|
||||
const dir = join(under(t), 'config');
|
||||
assert.throws(() => loadBusiness('acme', { dir, rolesDir: REPO_ROLES }), refusal('business file acme.json not found', { exitCode: 4, paths: [dir] }));
|
||||
});
|
||||
|
||||
test('loadBusiness: a symbolic link business file is named by its checked id', (t) => {
|
||||
const root = under(t), dir = join(root, 'config');
|
||||
writeJson(join(root, 'real.json'), {});
|
||||
mkdirSync(join(dir, 'businesses'), { recursive: true });
|
||||
symlinkSync(join(root, 'real.json'), join(dir, 'businesses', 'acme.json'));
|
||||
assert.throws(() => loadBusiness('acme', { dir, rolesDir: REPO_ROLES }), refusal('business file acme.json must be a regular, non-symbolic-link file', { exitCode: 4, paths: [dir] }));
|
||||
});
|
||||
|
||||
test('deleteOrg: a failed deletion names the generated receipt folder under the data root, never the host path', async (t) => {
|
||||
const root = under(t);
|
||||
const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test',
|
||||
principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } };
|
||||
for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 });
|
||||
await createOrg(ctx, 'alpha', await templateRender('alpha'));
|
||||
const folder = 'alpha-2026-11-10T00-00-00-000Z-0123abcd';
|
||||
const registryFile = join(ctx.configDir, 'org-runtime.json');
|
||||
const registry = JSON.parse(readFileSync(registryFile));
|
||||
registry.orgs.alpha.state = 'deleting';
|
||||
registry.orgs.alpha.deletion = { actor: ctx.principal, time: new Date().toISOString(), revision: 1, folder };
|
||||
writeJson(registryFile, registry);
|
||||
const destination = join(ctx.dataRoot, 'deleted', folder);
|
||||
mkdirSync(destination, { recursive: true, mode: 0o700 });
|
||||
writeJson(join(destination, 'receipt.json'), '{not json');
|
||||
chmodSync(join(destination, 'receipt.json'), 0o600);
|
||||
await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }),
|
||||
refusal(`deletion receipt file is not valid JSON; deletion remains in progress. Inspect deleted/${folder}/receipt.json under the data root and rerun mosaic org delete alpha --confirm alpha`,
|
||||
{ name: 'SyntaxError', paths: [ctx.dataRoot, destination] }));
|
||||
});
|
||||
|
||||
// Review round (org-redact5 Sol P2 #1): a raw filesystem error from the organization
|
||||
// service names the operation, the file by label and the error code, never the host path.
|
||||
async function deletingOrg(t) {
|
||||
const root = under(t);
|
||||
const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test',
|
||||
principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } };
|
||||
for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 });
|
||||
await createOrg(ctx, 'alpha', await templateRender('alpha'));
|
||||
return { root, ctx };
|
||||
}
|
||||
|
||||
test('deleteOrg: a receipt that is a symbolic link is refused by label and error code, never by host path', async (t) => {
|
||||
const { root, ctx } = await deletingOrg(t);
|
||||
const folder = 'alpha-2026-11-10T00-00-00-000Z-0123abcd';
|
||||
const registryFile = join(ctx.configDir, 'org-runtime.json');
|
||||
const registry = JSON.parse(readFileSync(registryFile));
|
||||
registry.orgs.alpha.state = 'deleting';
|
||||
registry.orgs.alpha.deletion = { actor: ctx.principal, time: new Date().toISOString(), revision: 1, folder };
|
||||
writeJson(registryFile, registry);
|
||||
const destination = join(ctx.dataRoot, 'deleted', folder);
|
||||
mkdirSync(destination, { recursive: true, mode: 0o700 });
|
||||
writeJson(join(root, 'elsewhere.json'), {});
|
||||
symlinkSync(join(root, 'elsewhere.json'), join(destination, 'receipt.json'));
|
||||
await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }), (error) => {
|
||||
assert.equal(error.code, 'ELOOP');
|
||||
assert.equal(error.syscall, 'open');
|
||||
assert.equal(error.deleteStep, 'prepare');
|
||||
return refusal(`cannot open deletion receipt file (ELOOP); deletion remains in progress. Inspect deleted/${folder}/receipt.json under the data root and rerun mosaic org delete alpha --confirm alpha`,
|
||||
{ name: 'Error', paths: [ctx.dataRoot, destination, root] })(error);
|
||||
});
|
||||
});
|
||||
|
||||
test('notificationShow: a missing notifier directory is refused by label and error code, never by host path', async (t) => {
|
||||
const { root, ctx } = await deletingOrg(t);
|
||||
const notify = join(ctx.dataRoot, 'org-runtime', 'alpha', 'notify');
|
||||
renameSync(notify, `${notify}.gone`);
|
||||
await assert.rejects(notificationShow(ctx, 'alpha'), (error) => {
|
||||
assert.equal(error.code, 'ENOENT');
|
||||
return refusal('cannot lstat notifier directory (ENOENT)', { name: 'Error', paths: [ctx.dataRoot, notify, root] })(error);
|
||||
});
|
||||
});
|
||||
|
||||
// Sol P3 #4: a refused command given by absolute path is named by the basename refusedCommand matched.
|
||||
test('checkCommands: a refused interpreter by absolute path is named by its basename, never by host path', (t) => {
|
||||
const bin = join(under(t), 'bin');
|
||||
assert.throws(() => checkCommands([[join(bin, 'bash'), '-c']], 'grant.commands'),
|
||||
refusal('grant.commands[0] starts with bash, a shell, runner or interpreter, which would grant any command', { exitCode: 2, paths: [bin] }));
|
||||
});
|
||||
|
||||
// Sol P2 #3: the system config refusal names where the config came from, never its path.
|
||||
test('config validate: a missing system config is named by where it came from, never by host path', (t) => {
|
||||
const root = under(t), config = join(root, 'config.json');
|
||||
const err = { text: '' }, out = { text: '' };
|
||||
const streams = { stdout: { write: (s) => { out.text += s; return true; } }, stderr: { write: (s) => { err.text += s; return true; } } };
|
||||
assert.equal(runConfigCommand(['validate', 'acme'], { ...streams, env: { PATH: process.env.PATH, HOME: root, MOSAIC_CONFIG: config } }), 3);
|
||||
assert.equal(out.text, '');
|
||||
assert.match(err.text, new RegExp(`^${BUSINESS_CONFIG.prefix}: system config problem \\(mosaic-config exit [1-9][0-9]*\\): .*the system config MOSAIC_CONFIG names\n$`));
|
||||
assert.ok(!err.text.includes(SENTINEL) && !err.text.includes(config), err.text);
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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};
|
||||
});
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -146,12 +146,13 @@ test('business: missing or invalid file refuses with the module\'s text, redacte
|
||||
const base = await serve(t, reads);
|
||||
let r = await get(base, '/api/business');
|
||||
assert.equal(r.status, 503);
|
||||
assert.deepEqual(JSON.parse(r.text), { error: 'not-configured', message: 'business file not found: <config>/businesses/acme.json' });
|
||||
assert.deepEqual(JSON.parse(r.text), { error: 'not-configured', message: 'business file acme.json not found' });
|
||||
clean(r.text, s.base);
|
||||
// Invalid JSON whose parse error would quote a secret-looking value.
|
||||
writeJson(join(s.configDir, 'businesses', 'acme.json'), '{"id": "acme", s3cret-value-xyz}');
|
||||
r = await get(base, '/api/business');
|
||||
assert.equal(r.status, 503);
|
||||
assert.match(JSON.parse(r.text).message, /^business file is not valid JSON \(<config>\/businesses\/acme\.json\)/);
|
||||
assert.match(JSON.parse(r.text).message, /^business file acme\.json is not valid JSON$/);
|
||||
assert.equal(r.text.includes('s3cret-value-xyz'), false);
|
||||
clean(r.text, s.base);
|
||||
// Valid JSON, invalid business: the validator's message names the file.
|
||||
@@ -194,7 +195,7 @@ test('settings: release, board, loopback address, notifier binding name only', a
|
||||
assert.equal(r.status, 200);
|
||||
const n = JSON.parse(r.text).notifier;
|
||||
assert.equal(n.refused, 'not-configured');
|
||||
assert.match(n.message, /^no notifier config at <dataRoot>\/notify\/acme\/notify\.json/);
|
||||
assert.match(n.message, /^no notifier config for acme: write .* to notify\/acme\/notify\.json under the data root/);
|
||||
clean(r.text, bare.base, repo.base);
|
||||
base = await serve(t, null);
|
||||
r = await get(base, '/api/settings');
|
||||
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user