Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67d6c9ed84 | ||
|
|
c8dbd3f6ec | ||
|
|
8f7de8c4d3 | ||
|
|
6f0c72a02f |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/composition.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/composition.mjs";
|
||||
@@ -11,7 +11,7 @@ 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]';
|
||||
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.message}`, 2); } }
|
||||
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); } }
|
||||
export async function orgCommand(args, io, deps = {}) {
|
||||
try {
|
||||
const p = parse(args); const words = p.pos; let result; let accepted = [];
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { loadSystem } from '../src/config.mjs';
|
||||
import { tmp } from './helpers.mjs';
|
||||
|
||||
const repo = resolve(import.meta.dirname, '../../..');
|
||||
const configScript = join(repo, 'scripts/mosaic-config.mjs');
|
||||
const cli = join(repo, 'packages/cli/src/cli.mjs');
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function fixture(t) {
|
||||
const root = tmp(t, 'jr-'), home = join(root, 'home');
|
||||
const file = join(home, '.config/mosaic-redacttest/config.json');
|
||||
const env = { PATH: process.env.PATH, HOME: home, TMPDIR: process.env.TMPDIR, LANG: 'C.UTF-8', MOSAIC_CONFIG: file };
|
||||
const run = (script, ...args) => spawnSync(process.execPath, [script, ...args], { env, cwd: repo, encoding: 'utf8' });
|
||||
const boot = () => {
|
||||
const result = run(configScript, 'bootstrap', '--instance', 'redacttest');
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
};
|
||||
return { root, file, env, run, boot };
|
||||
}
|
||||
function redacted(result, file, status) {
|
||||
assert.equal(result.status, status, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.ok(result.stderr.includes(file), result.stderr);
|
||||
assert.match(result.stderr, /not valid JSON/);
|
||||
assert.doesNotMatch(result.stderr, /sk-test|Unexpected token/);
|
||||
}
|
||||
|
||||
test('system config parse refusal and CLI relay omit parser bytes', t => {
|
||||
const f = fixture(t);
|
||||
mkdirSync(dirname(f.file), { recursive: true }); writeFileSync(f.file, secret);
|
||||
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), f.file, 2);
|
||||
assert.throws(() => loadSystem({ env: f.env, instance: 'redacttest' }), error => {
|
||||
assert.equal(error.exitCode, 3);
|
||||
assert.ok(error.message.includes(f.file));
|
||||
assert.match(error.message, /not valid JSON/);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
return true;
|
||||
});
|
||||
redacted(f.run(cli, 'org', 'config', 'validate', 'alpha'), f.file, 3);
|
||||
});
|
||||
|
||||
test('named instance marker parse refusal omits parser bytes', t => {
|
||||
const f = fixture(t); f.boot();
|
||||
const { dataRoot } = JSON.parse(readFileSync(f.file, 'utf8'));
|
||||
const marker = join(dataRoot, 'instance.json'); writeFileSync(marker, secret);
|
||||
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), marker, 3);
|
||||
});
|
||||
|
||||
for (const operation of ['validate', 'create', 'update']) test(`org ${operation} document refusal omits parser bytes`, t => {
|
||||
const f = fixture(t); f.boot();
|
||||
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);
|
||||
});
|
||||
@@ -98,7 +98,7 @@ export function sessionModel(resolved, models = null, which = "primary", { proxy
|
||||
});
|
||||
if (models === null) return { harness, provider: null, model, thinking };
|
||||
const entry = Object.hasOwn(models, model) ? models[model] : null;
|
||||
if (!entry) throw new BundleError(`instance ${resolved.instance}: unknown model table selection ${model}`);
|
||||
if (!entry) throw new BundleError(`instance ${resolved.instance}: unknown model table selection for ${ROUTE_VARS[which][1]} var`);
|
||||
const transport = entry.provider === "proxy" || proxy ? checkedTransport(entry, null, resolver) : null;
|
||||
return { harness, provider: entry.provider, model: entry.model, modelKey: model, thinking,
|
||||
...(transport ? { proxyRoute: transport.routeId, proxyTransport: transport } : {}) };
|
||||
|
||||
@@ -125,7 +125,20 @@ test("sessionModel: the route is the instance's own vars, and a missing one refu
|
||||
const fb = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "claude-code", model: "claude-opus-5-5", thinking: "max", "fallback.harness": "pi", "fallback.model": "glm-5.3-flash", "fallback.thinking": "minimal" }));
|
||||
assert.deepEqual(sessionModel(fb, null, "fallback"), { harness: "pi", provider: null, model: "glm-5.3-flash", thinking: "minimal" });
|
||||
assert.throws(() => sessionModel(resolvedFor(t, "pm"), null, "fallback"), (e) => e.code === "no-route" && e.missing === "fallback.harness");
|
||||
assert.throws(() => sessionModel(resolvedFor(t, "coder"), models), /unknown model table selection glm-5.3-flash/);
|
||||
assert.throws(() => sessionModel(resolvedFor(t, "coder"), models), /unknown model table selection for model var/);
|
||||
});
|
||||
|
||||
for (const [route, name] of [["primary", "model"], ["fallback", "fallback.model"]]) test(`unknown ${route} model selection names the variable without its value`, (t) => {
|
||||
const secret = "sk-test-NOT-A-SECRET-1234";
|
||||
const resolved = resolvedFor(t, "coder");
|
||||
const vars = { ...resolved.vars, [name]: secret, "fallback.harness": "pi", "fallback.thinking": "high" };
|
||||
assert.throws(() => sessionModel({ ...resolved, vars }, {}, route), error => {
|
||||
assert.ok(error instanceof BundleError);
|
||||
assert.equal(error.code, "bundle-refused");
|
||||
assert.equal(error.message, `instance coder: unknown model table selection for ${name} var`);
|
||||
assert.ok(!error.message.includes(secret));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory", (t) => {
|
||||
|
||||
@@ -87,7 +87,7 @@ export function loadRootAgents(configDir, { rolesDir } = {}) {
|
||||
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.
|
||||
// A parse refusal also quotes the parser, which can quote the file's bytes: say only that it is not JSON.
|
||||
// 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);
|
||||
refuse(error.message.replaceAll(file, ROOT_FILE), error.exitCode);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
// Roles round 2, slice P1 (#1873): the primitives registry and the composition validator.
|
||||
// Design: docs/plans/2026-11-10_roles-and-rails-design.md sections 3, 4 and 7, and the P1
|
||||
// paragraph of section 13.
|
||||
//
|
||||
// A role is a composition of the primitives in section 4 and nothing else: tools, network,
|
||||
// authority, credentials, messaging, skills, instructions and personality. checkComposition
|
||||
// checks one against a registry (the core set from vocabulary.mjs plus a plugin-declared set) in
|
||||
// the seven steps section 4 lists, in order, and returns the lines a human must see flagged.
|
||||
//
|
||||
// Nothing calls checkComposition yet. HIRE_ALLOWED and unhireableAction (vocabulary.mjs) still
|
||||
// bound a hire, and role.mjs and the broker still use them. Slice A4 switches hire over to this
|
||||
// validator and removes them. In P1 the plugin set is whatever the caller passes, empty by
|
||||
// default; nothing here reads a plugin file (PL1 builds install and the manifest).
|
||||
//
|
||||
// A refusal names the field and the position at fault. It quotes a value only when that value is
|
||||
// a registered name (a core or plugin action, tool, network, service or scope), so no role text,
|
||||
// and no secret placed where a name belongs, reaches an error. Free text (instructions,
|
||||
// personality) is never quoted.
|
||||
|
||||
import { refuse } from "./errors.mjs";
|
||||
import {
|
||||
ACTIONS, ROUTED, GATED_ONLY, TOOLS, NETWORKS, SERVICES, HOST_SERVICE, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
ID_PATTERN,
|
||||
} from "./vocabulary.mjs";
|
||||
import { NAME as PLUGIN_NAME } from "../../plugins/src/kinds.mjs";
|
||||
import { deepFreeze } from "./util.mjs";
|
||||
|
||||
// The primitives (section 4). Every other key refuses (step 1).
|
||||
export const COMPOSITION_KEYS = Object.freeze([
|
||||
"tools", "network", "authority", "credentials", "messaging", "skills", "instructions", "personality",
|
||||
]);
|
||||
const REQUIRED = Object.freeze(["tools", "network", "authority", "credentials", "instructions"]);
|
||||
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_SERVICE_KEYS = Object.freeze(["name", "scopes"]);
|
||||
|
||||
// Step 6's length bounds, in characters or entries. The design gives none, so P1 sets them. The
|
||||
// largest shipped contract is about 8 KB; instructions get twice that. T1 stores personality
|
||||
// with these same bounds.
|
||||
export const COMPOSITION_BOUNDS = Object.freeze({
|
||||
instructions: 16384,
|
||||
summary: 280,
|
||||
traits: 12,
|
||||
trait: 40,
|
||||
voice: 280,
|
||||
messaging: 64,
|
||||
skills: 64,
|
||||
});
|
||||
|
||||
// Ruling 8 and R9: a hired agent can't hire or fire, whatever HIRE_ALLOWED says later.
|
||||
export const NEVER_HIRED = Object.freeze(["role.hire", "role.fire"]);
|
||||
// The role rails (section 3): an action that lets its holder decide for other agents.
|
||||
export const RESOLVE_PREFIX = "decision.resolve.";
|
||||
|
||||
export const FLAG_RESOLVE = "resolve-right";
|
||||
export const FLAG_ABOVE_CHIEF = "above-chief";
|
||||
|
||||
const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
|
||||
const GITEA_SCOPE = /^(read|write):([a-z]+)$/;
|
||||
// A plugin's declared bare name: lower-case segments joined by dots. With the prefix it is
|
||||
// <plugin>.<name>.
|
||||
const DECLARED_NAME = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
||||
const DECLARED_SCOPE = /^[a-z][a-z0-9_.:-]*$/;
|
||||
const NAME_MAX = 64;
|
||||
|
||||
const isPlain = (value) => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The registry.
|
||||
|
||||
const BUILT = new WeakSet();
|
||||
|
||||
// The first segment of every core action, tool and service. A plugin may not take one as its
|
||||
// name, so a prefixed plugin name can never read as a core name (git + push.protected).
|
||||
const RESERVED_PLUGIN_NAMES = Object.freeze([...new Set([
|
||||
...ACTIONS.map((a) => a.split(".")[0]), ...TOOLS, ...SERVICES, HOST_SERVICE, ...NETWORKS,
|
||||
])]);
|
||||
|
||||
function declaredList(value, where, pattern) {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) refuse(`${where} must be an array`);
|
||||
const seen = new Set();
|
||||
value.forEach((entry, i) => {
|
||||
if (typeof entry !== "string" || entry.length > NAME_MAX || !pattern.test(entry)) {
|
||||
refuse(`${where}[${i}] must be a name matching ${pattern}`);
|
||||
}
|
||||
if (seen.has(entry)) refuse(`${where}[${i}] repeats an earlier entry`);
|
||||
seen.add(entry);
|
||||
});
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
// One plugin's declared set: { name, tools?: [name], actions?: [name], services?: [{ name, scopes:
|
||||
// [scope] }] }. Names are declared bare and registered with the plugin's prefix. A plugin action is
|
||||
// always routed (section 4), so it may not name a hard wait, and no declared name may reuse a core
|
||||
// name, bare or prefixed.
|
||||
function checkPlugin(plugin, i, core) {
|
||||
const where = `registry plugins[${i}]`;
|
||||
if (!isPlain(plugin)) refuse(`${where} must be a JSON object`);
|
||||
for (const key of Object.keys(plugin)) {
|
||||
if (!PLUGIN_KEYS.includes(key)) refuse(`${where} has a key outside ${PLUGIN_KEYS.join(", ")}`);
|
||||
}
|
||||
const { name } = plugin;
|
||||
if (typeof name !== "string" || name.length > 32 || !PLUGIN_NAME.test(name)) refuse(`${where}.name must match ${PLUGIN_NAME}`);
|
||||
if (RESERVED_PLUGIN_NAMES.includes(name)) refuse(`${where}.name ${JSON.stringify(name)} is a core name`);
|
||||
const full = (bare) => `${name}.${bare}`;
|
||||
const coreName = (bare) => core.has(bare) || core.has(full(bare));
|
||||
const tools = declaredList(plugin.tools, `${where}.tools`, DECLARED_NAME);
|
||||
tools.forEach((bare, j) => {
|
||||
if (coreName(bare)) refuse(`${where}.tools[${j}] reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`);
|
||||
});
|
||||
const actions = declaredList(plugin.actions, `${where}.actions`, DECLARED_NAME);
|
||||
actions.forEach((bare, j) => {
|
||||
if (GATED_ONLY.includes(bare)) refuse(`${where}.actions[${j}] declares ${bare}, a hard wait; a plugin action is always routed`);
|
||||
if (coreName(bare)) refuse(`${where}.actions[${j}] reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`);
|
||||
});
|
||||
if (plugin.services !== undefined && !Array.isArray(plugin.services)) refuse(`${where}.services must be an array`);
|
||||
const services = (plugin.services ?? []).map((service, j) => {
|
||||
const at = `${where}.services[${j}]`;
|
||||
if (!isPlain(service)) refuse(`${at} must be a JSON object`);
|
||||
for (const key of Object.keys(service)) {
|
||||
if (!PLUGIN_SERVICE_KEYS.includes(key)) refuse(`${at} has a key outside ${PLUGIN_SERVICE_KEYS.join(", ")}`);
|
||||
}
|
||||
const [bare] = declaredList([service.name], `${at}.name`, DECLARED_NAME);
|
||||
if (coreName(bare)) refuse(`${at}.name reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`);
|
||||
const scopes = declaredList(service.scopes, `${at}.scopes`, DECLARED_SCOPE);
|
||||
if (scopes.length === 0) refuse(`${at}.scopes must not be empty`);
|
||||
return { name: full(bare), scopes };
|
||||
});
|
||||
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 };
|
||||
}
|
||||
|
||||
// The registry checkComposition reads: the core set plus each plugin's declared set. The plugin
|
||||
// set is the caller's input and empty by default (PL1 passes the plugins enabled in the
|
||||
// organization). checkComposition accepts only a registry built here.
|
||||
export function buildRegistry({ plugins = [] } = {}) {
|
||||
if (!Array.isArray(plugins)) refuse("registry plugins must be an array");
|
||||
const core = new Set([...ACTIONS, ...TOOLS, ...SERVICES, HOST_SERVICE]);
|
||||
const declared = plugins.map((plugin, i) => checkPlugin(plugin, i, core));
|
||||
const pluginNames = declared.map((p) => p.name);
|
||||
const twice = pluginNames.find((n, j) => pluginNames.indexOf(n) !== j);
|
||||
if (twice !== undefined) refuse(`registry names plugin ${JSON.stringify(twice)} twice`);
|
||||
const services = Object.create(null);
|
||||
for (const name of SERVICES) services[name] = { kind: name };
|
||||
services[HOST_SERVICE] = { kind: HOST_SERVICE };
|
||||
for (const p of declared) for (const s of p.services) services[s.name] = { kind: "plugin", scopes: s.scopes };
|
||||
const pluginActions = declared.flatMap((p) => p.actions);
|
||||
const registry = deepFreeze({
|
||||
tools: [...TOOLS, ...declared.flatMap((p) => p.tools)],
|
||||
networks: [...NETWORKS],
|
||||
actions: [...ACTIONS, ...pluginActions],
|
||||
routed: [...ROUTED, ...pluginActions],
|
||||
services,
|
||||
plugins: declared,
|
||||
});
|
||||
BUILT.add(registry);
|
||||
return registry;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The validator.
|
||||
|
||||
function keysWithin(object, allowed, where) {
|
||||
Object.keys(object).forEach((key, i) => {
|
||||
if (!allowed.includes(key)) refuse(`composition ${where} has an unknown key (key ${i + 1}; allowed: ${allowed.join(", ")})`);
|
||||
});
|
||||
}
|
||||
|
||||
// A list of distinct strings. Entries are named by position only: this runs before anything is
|
||||
// known to be a registered name.
|
||||
function stringList(value, where, { nonEmpty = false } = {}) {
|
||||
if (!Array.isArray(value)) refuse(`composition ${where} must be an array`);
|
||||
if (nonEmpty && value.length === 0) refuse(`composition ${where} must not be empty`);
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
if (typeof value[i] !== "string" || value[i].includes("\0")) refuse(`composition ${where}[${i}] must be a string`);
|
||||
if (seen.has(value[i])) refuse(`composition ${where}[${i}] repeats an earlier entry`);
|
||||
seen.add(value[i]);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
function text(value, where) {
|
||||
if (typeof value !== "string" || value.includes("\0")) refuse(`composition ${where} must be a string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
// Step 1: the shape, with every unknown key refused. Returns a fresh copy; the input is not read
|
||||
// again after this.
|
||||
function shape(role) {
|
||||
if (!isPlain(role)) refuse("composition must be a JSON object");
|
||||
keysWithin(role, COMPOSITION_KEYS, "role");
|
||||
for (const key of REQUIRED) {
|
||||
if (role[key] === undefined) refuse(`composition requires "${key}"`);
|
||||
}
|
||||
const out = {};
|
||||
out.tools = stringList(role.tools, "tools", { nonEmpty: true });
|
||||
if (typeof role.network !== "string") refuse("composition network must be a string");
|
||||
out.network = role.network;
|
||||
|
||||
if (!isPlain(role.authority)) refuse("composition authority must be a JSON object");
|
||||
keysWithin(role.authority, AUTHORITY_KEYS, "authority");
|
||||
out.authority = {};
|
||||
for (const key of AUTHORITY_KEYS) out.authority[key] = stringList(role.authority[key], `authority.${key}`);
|
||||
out.authority.crossRole.forEach((action, i) => {
|
||||
if (out.authority.withinRole.includes(action)) refuse(`composition authority.crossRole[${i}] is also in authority.withinRole`);
|
||||
});
|
||||
|
||||
if (!Array.isArray(role.credentials)) refuse("composition credentials must be an array");
|
||||
out.credentials = role.credentials.map((entry, i) => {
|
||||
const where = `credentials[${i}]`;
|
||||
if (!isPlain(entry)) refuse(`composition ${where} must be a JSON object`);
|
||||
keysWithin(entry, CREDENTIAL_KEYS, where);
|
||||
if (typeof entry.service !== "string") refuse(`composition ${where}.service must be a string`);
|
||||
const copy = { service: entry.service };
|
||||
if (entry.scopes !== undefined) {
|
||||
if (Array.isArray(entry.scopes)) {
|
||||
copy.scopes = stringList(entry.scopes, `${where}.scopes`);
|
||||
} else if (isPlain(entry.scopes)) {
|
||||
// No prototype, so a "__proto__" group stays a key and step 2 refuses it.
|
||||
copy.scopes = Object.create(null);
|
||||
Object.keys(entry.scopes).forEach((group, j) => {
|
||||
if (group.includes("\0")) refuse(`composition ${where}.scopes key ${j + 1} must be a string`);
|
||||
copy.scopes[group] = stringList(entry.scopes[group], `${where}.scopes (group ${j + 1})`);
|
||||
});
|
||||
} else {
|
||||
refuse(`composition ${where}.scopes must be an array or a JSON object`);
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
|
||||
if (role.messaging === undefined) {
|
||||
out.messaging = null;
|
||||
} else {
|
||||
if (!isPlain(role.messaging)) refuse("composition messaging must be a JSON object");
|
||||
keysWithin(role.messaging, MESSAGING_KEYS, "messaging");
|
||||
out.messaging = {};
|
||||
for (const key of MESSAGING_KEYS) {
|
||||
out.messaging[key] = stringList(role.messaging[key], `messaging.${key}`);
|
||||
out.messaging[key].forEach((entry, i) => {
|
||||
if (!ID_PATTERN.test(entry)) refuse(`composition messaging.${key}[${i}] must be a role name matching ${ID_PATTERN}`);
|
||||
if (entry === "tester") refuse(`composition messaging.${key}[${i}] names the tester, which is never a role`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
out.skills = role.skills === undefined ? [] : stringList(role.skills, "skills");
|
||||
out.skills.forEach((name, i) => {
|
||||
if (!SKILL_NAME.test(name) || name === "." || name === "..") refuse(`composition skills[${i}] must be a skill directory name matching ${SKILL_NAME}`);
|
||||
});
|
||||
|
||||
out.instructions = text(role.instructions, "instructions");
|
||||
|
||||
if (role.personality === undefined) {
|
||||
out.personality = null;
|
||||
} else {
|
||||
if (!isPlain(role.personality)) refuse("composition personality must be a JSON object");
|
||||
keysWithin(role.personality, PERSONALITY_KEYS, "personality");
|
||||
out.personality = {};
|
||||
if (role.personality.summary !== undefined) out.personality.summary = text(role.personality.summary, "personality.summary");
|
||||
if (role.personality.traits !== undefined) out.personality.traits = stringList(role.personality.traits, "personality.traits");
|
||||
if (role.personality.voice !== undefined) out.personality.voice = text(role.personality.voice, "personality.voice");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkGitea(scopes, where) {
|
||||
if (!Array.isArray(scopes) || scopes.length === 0) refuse(`composition ${where}.scopes must be a non-empty array of read:<category> or write:<category>`);
|
||||
const categories = new Set();
|
||||
scopes.forEach((scope, i) => {
|
||||
const m = GITEA_SCOPE.exec(scope);
|
||||
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) {
|
||||
refuse(`composition ${where}.scopes[${i}] is not a grantable gitea scope (categories: ${GITEA_SCOPE_CATEGORIES.join(", ")}; admin and all are never granted)`);
|
||||
}
|
||||
if (categories.has(m[2])) refuse(`composition ${where}.scopes[${i}] names the category ${m[2]} again; a token holds one level per category`);
|
||||
categories.add(m[2]);
|
||||
});
|
||||
}
|
||||
|
||||
function checkVikunja(scopes, where) {
|
||||
if (!isPlain(scopes) || Object.keys(scopes).length === 0) refuse(`composition ${where}.scopes must name at least one vikunja route group`);
|
||||
Object.keys(scopes).forEach((group, j) => {
|
||||
if (!Object.hasOwn(VIKUNJA_GRANTABLE, group)) refuse(`composition ${where}.scopes group ${j + 1} is not a grantable vikunja route group`);
|
||||
const verbs = scopes[group];
|
||||
if (verbs.length === 0) refuse(`composition ${where}.scopes.${group} must not be empty`);
|
||||
verbs.forEach((verb, i) => {
|
||||
if (!VIKUNJA_GRANTABLE[group].includes(verb)) refuse(`composition ${where}.scopes.${group}[${i}] is not a grantable vikunja verb`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function checkPluginScopes(scopes, declared, service, where) {
|
||||
if (!Array.isArray(scopes) || scopes.length === 0) refuse(`composition ${where}.scopes must be a non-empty array of ${service} scopes`);
|
||||
scopes.forEach((scope, i) => {
|
||||
if (!declared.includes(scope)) refuse(`composition ${where}.scopes[${i}] is not a scope ${service} declares`);
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: every action, tool, network, service and scope must be in the registry.
|
||||
function registered(c, registry) {
|
||||
c.tools.forEach((tool, i) => {
|
||||
if (!registry.tools.includes(tool)) refuse(`composition tools[${i}] names no registered tool`);
|
||||
});
|
||||
if (!registry.networks.includes(c.network)) refuse(`composition network must be one of: ${registry.networks.join(", ")}`);
|
||||
for (const key of AUTHORITY_KEYS) {
|
||||
c.authority[key].forEach((action, i) => {
|
||||
if (!registry.actions.includes(action)) refuse(`composition authority.${key}[${i}] names no registered action`);
|
||||
});
|
||||
}
|
||||
const seen = new Set();
|
||||
c.credentials.forEach((entry, i) => {
|
||||
const where = `credentials[${i}]`;
|
||||
if (!Object.hasOwn(registry.services, entry.service)) refuse(`composition ${where}.service names no registered credential service`);
|
||||
if (seen.has(entry.service)) refuse(`composition ${where} names ${entry.service} again`);
|
||||
seen.add(entry.service);
|
||||
const { kind, scopes } = registry.services[entry.service];
|
||||
if (kind === HOST_SERVICE) {
|
||||
if (entry.scopes !== undefined) refuse(`composition ${where} names ${HOST_SERVICE}, which takes no scopes`);
|
||||
} else if (kind === "gitea") {
|
||||
checkGitea(entry.scopes, where);
|
||||
} else if (kind === "vikunja") {
|
||||
checkVikunja(entry.scopes, where);
|
||||
} else {
|
||||
checkPluginScopes(entry.scopes, scopes, entry.service, where);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function eachAction(c, fn) {
|
||||
for (const key of AUTHORITY_KEYS) c.authority[key].forEach((action, i) => fn(action, key, i));
|
||||
}
|
||||
|
||||
// Every string in the composition with its path, for step 6.
|
||||
function strings(c) {
|
||||
const out = [];
|
||||
c.tools.forEach((v, i) => out.push([`tools[${i}]`, v]));
|
||||
out.push(["network", c.network]);
|
||||
eachAction(c, (v, key, i) => out.push([`authority.${key}[${i}]`, v]));
|
||||
c.credentials.forEach((entry, i) => {
|
||||
out.push([`credentials[${i}].service`, entry.service]);
|
||||
if (Array.isArray(entry.scopes)) entry.scopes.forEach((v, j) => out.push([`credentials[${i}].scopes[${j}]`, v]));
|
||||
else if (entry.scopes) {
|
||||
for (const [group, verbs] of Object.entries(entry.scopes)) {
|
||||
out.push([`credentials[${i}].scopes (group key)`, group]);
|
||||
verbs.forEach((v, j) => out.push([`credentials[${i}].scopes.${group}[${j}]`, v]));
|
||||
}
|
||||
}
|
||||
});
|
||||
if (c.messaging) for (const key of MESSAGING_KEYS) c.messaging[key].forEach((v, i) => out.push([`messaging.${key}[${i}]`, v]));
|
||||
c.skills.forEach((v, i) => out.push([`skills[${i}]`, v]));
|
||||
out.push(["instructions", c.instructions]);
|
||||
if (c.personality) {
|
||||
if (c.personality.summary !== undefined) out.push(["personality.summary", c.personality.summary]);
|
||||
(c.personality.traits ?? []).forEach((v, i) => out.push([`personality.traits[${i}]`, v]));
|
||||
if (c.personality.voice !== undefined) out.push(["personality.voice", c.personality.voice]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function bounded(value, max, where, { empty = false } = {}) {
|
||||
if ((!empty && value.trim().length === 0) || value.length > max) {
|
||||
refuse(`composition ${where} must be ${empty ? "" : "non-empty and "}at most ${max} characters`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6, the bounds half.
|
||||
function checkBounds(c) {
|
||||
const b = COMPOSITION_BOUNDS;
|
||||
bounded(c.instructions, b.instructions, "instructions");
|
||||
if (c.personality) {
|
||||
if (c.personality.summary !== undefined) bounded(c.personality.summary, b.summary, "personality.summary");
|
||||
if (c.personality.voice !== undefined) bounded(c.personality.voice, b.voice, "personality.voice");
|
||||
const traits = c.personality.traits ?? [];
|
||||
if (traits.length > b.traits) refuse(`composition personality.traits must hold at most ${b.traits} entries`);
|
||||
traits.forEach((trait, i) => bounded(trait, b.trait, `personality.traits[${i}]`));
|
||||
}
|
||||
if (c.messaging) {
|
||||
for (const key of MESSAGING_KEYS) {
|
||||
if (c.messaging[key].length > b.messaging) refuse(`composition messaging.${key} must hold at most ${b.messaging} entries`);
|
||||
}
|
||||
}
|
||||
if (c.skills.length > b.skills) refuse(`composition skills must hold at most ${b.skills} entries`);
|
||||
}
|
||||
|
||||
// A plain JSON copy of a caller's object, read once. Getters, proxies and toJSON run here and only
|
||||
// here, and whatever they throw becomes the fixed message: their error text could carry role text.
|
||||
function snapshot(value, message) {
|
||||
let copy;
|
||||
try {
|
||||
copy = isPlain(value) ? JSON.parse(JSON.stringify(value)) : undefined;
|
||||
} catch {
|
||||
copy = undefined;
|
||||
}
|
||||
if (!isPlain(copy)) refuse(message);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// The chief's resolved role, as loadRole returns it. Read only for step 7; a malformed one refuses.
|
||||
function checkChief(chief) {
|
||||
const bad = () => refuse("checkComposition needs the chief's resolved role");
|
||||
if (!isPlain(chief) || !Array.isArray(chief.tools) || !NETWORKS.includes(chief.network)) bad();
|
||||
if (!isPlain(chief.authority) || !AUTHORITY_KEYS.every((k) => Array.isArray(chief.authority[k]))) bad();
|
||||
if (!Array.isArray(chief.credentials) || !chief.credentials.every((c) => isPlain(c) && typeof c.service === "string")) bad();
|
||||
if (chief.messaging !== null && chief.messaging !== undefined &&
|
||||
!(isPlain(chief.messaging) && MESSAGING_KEYS.every((k) => Array.isArray(chief.messaging[k])))) bad();
|
||||
return chief;
|
||||
}
|
||||
|
||||
// One string per credential scope a role holds: service, then scope (a vikunja scope is
|
||||
// group.verb; host has the one line "host").
|
||||
function credentialLines(credentials) {
|
||||
const out = [];
|
||||
for (const { service, scopes } of credentials) {
|
||||
if (scopes === undefined) out.push([service, service]);
|
||||
else if (Array.isArray(scopes)) for (const scope of scopes) out.push([service, scope]);
|
||||
else for (const [group, verbs] of Object.entries(scopes)) for (const verb of verbs) out.push([service, `${group}.${verb}`]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Validate a composition (section 4's seven steps, in order) against a registry from
|
||||
// buildRegistry, with the chief's resolved role for step 7. options.assertClean is the credential
|
||||
// store's check (credentials.assertClean, as the broker's secretCheck); it throws on a held
|
||||
// secret. Returns a frozen { composition, flagged }, where each flagged line is
|
||||
// { field, value, reasons } and reasons holds "resolve-right" (step 5), "above-chief" (step 7) or
|
||||
// both. A composition that fails a step refuses with an OrganizationConfigError.
|
||||
export function checkComposition(role, registry, chief, { assertClean } = {}) {
|
||||
if (!BUILT.has(registry)) refuse("checkComposition needs a registry from buildRegistry");
|
||||
if (typeof assertClean !== "function") refuse("checkComposition needs the credential store's assertClean");
|
||||
const boss = checkChief(snapshot(chief, "checkComposition needs the chief's resolved role"));
|
||||
|
||||
// 1. Unknown keys, and the shape.
|
||||
const c = shape(snapshot(role, "composition must be a JSON object"));
|
||||
|
||||
// 2. Every name in the registry.
|
||||
registered(c, registry);
|
||||
|
||||
// 3. Hard waits stay with the human (R4). From here on an action is a registered name and may be
|
||||
// quoted.
|
||||
eachAction(c, (action, key, i) => {
|
||||
if (!registry.routed.includes(action)) refuse(`composition authority.${key}[${i}] is ${action}, a hard wait the human always decides`);
|
||||
});
|
||||
|
||||
// 4. No hire chains (R9).
|
||||
eachAction(c, (action, key, i) => {
|
||||
if (NEVER_HIRED.includes(action)) refuse(`composition authority.${key}[${i}] is ${action}; a hired agent cannot hire or fire`);
|
||||
});
|
||||
|
||||
// 5. Every resolve right is a flagged line.
|
||||
const lines = [];
|
||||
const flag = (field, value, reason) => {
|
||||
const line = lines.find((l) => l.field === field && l.value === value);
|
||||
if (line) {
|
||||
if (!line.reasons.includes(reason)) line.reasons.push(reason);
|
||||
} else {
|
||||
lines.push({ field, value, reasons: [reason] });
|
||||
}
|
||||
};
|
||||
eachAction(c, (action, key) => {
|
||||
if (action.startsWith(RESOLVE_PREFIX)) flag(`authority.${key}`, action, FLAG_RESOLVE);
|
||||
});
|
||||
|
||||
// 6. No secret in any string (R1), and every field within its bound.
|
||||
for (const [where, value] of strings(c)) {
|
||||
let clean = true;
|
||||
try {
|
||||
assertClean(value);
|
||||
} catch {
|
||||
clean = false;
|
||||
}
|
||||
if (!clean) refuse(`composition ${where} holds a credential`);
|
||||
}
|
||||
checkBounds(c);
|
||||
|
||||
// 7. Everything the chief's own role does not hold (R8). A within-role action is held only
|
||||
// within-role; a cross-role one either way.
|
||||
const chiefWithin = boss.authority.withinRole;
|
||||
const chiefAny = [...chiefWithin, ...boss.authority.crossRole];
|
||||
c.authority.withinRole.forEach((action) => {
|
||||
if (!chiefWithin.includes(action)) flag("authority.withinRole", action, FLAG_ABOVE_CHIEF);
|
||||
});
|
||||
c.authority.crossRole.forEach((action) => {
|
||||
if (!chiefAny.includes(action)) flag("authority.crossRole", action, FLAG_ABOVE_CHIEF);
|
||||
});
|
||||
c.tools.forEach((tool) => {
|
||||
if (!boss.tools.includes(tool)) flag("tools", tool, FLAG_ABOVE_CHIEF);
|
||||
});
|
||||
if (NETWORKS.indexOf(c.network) > NETWORKS.indexOf(boss.network)) flag("network", c.network, FLAG_ABOVE_CHIEF);
|
||||
const held = new Set(credentialLines(boss.credentials).map(([s, v]) => `${s}\n${v}`));
|
||||
for (const [service, value] of credentialLines(c.credentials)) {
|
||||
if (!held.has(`${service}\n${value}`)) flag(`credentials.${service}`, value, FLAG_ABOVE_CHIEF);
|
||||
}
|
||||
// Both directions: the broker checks the sender's sendTo and the recipient's receiveFrom. A role
|
||||
// with no messaging key may message and hear anyone; "*" stands for that.
|
||||
if (boss.messaging) {
|
||||
for (const key of MESSAGING_KEYS) {
|
||||
const chiefList = boss.messaging[key];
|
||||
if (c.messaging === null) flag(`messaging.${key}`, "*", FLAG_ABOVE_CHIEF);
|
||||
else c.messaging[key].forEach((target) => {
|
||||
if (!chiefList.includes(target)) flag(`messaging.${key}`, target, FLAG_ABOVE_CHIEF);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of lines) line.reasons.sort();
|
||||
return deepFreeze({ composition: c, flagged: lines });
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { dirname, isAbsolute, join, normalize, parse, resolve } from 'node:path'
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { processState } from '../../seat/src/process-state.mjs';
|
||||
import { ID_PATTERN } from './vocabulary.mjs';
|
||||
import { parseJsonFileText } from './util.mjs';
|
||||
|
||||
export class OrgError extends Error {
|
||||
constructor(code, message, path) { super(message); this.name = 'OrgError'; this.code = code; if (path) this.path = path; }
|
||||
@@ -22,7 +23,7 @@ async function safeDir(path, create = false) {
|
||||
}
|
||||
async function readPrivate(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 & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file); return JSON.parse(await fd.readFile('utf8')); } finally { await fd.close(); }
|
||||
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file); return parseJsonFileText(await fd.readFile('utf8'), file, 'metadata file'); } finally { await fd.close(); }
|
||||
}
|
||||
async function privateDir(path) { const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', path); }
|
||||
function validateRegistry(doc, file) {
|
||||
@@ -86,7 +87,7 @@ export function withRegistryWriter(configDir, fn) {
|
||||
if (!dir.isDirectory() || dir.isSymbolicLink() || dir.uid !== process.getuid() || (dir.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', configDir);
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file);
|
||||
registry = validateRegistry(JSON.parse(readFileSync(fd, 'utf8')), file);
|
||||
registry = validateRegistry(parseJsonFileText(readFileSync(fd, 'utf8'), file, 'registry file'), file);
|
||||
} finally { closeSync(fd); }
|
||||
}
|
||||
const publish = () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ID_PATTERN } from './vocabulary.mjs';
|
||||
import { OrgError, orgRoot, readRegistry, reserveOrg, resolveOrg, withRegistry, withOrgLifecycle } from './org-registry.mjs';
|
||||
import { loadInstalled } from '../../plugins/src/loader.mjs';
|
||||
import { BINDING_NAME } from '../../discord/src/binding.mjs';
|
||||
import { parseJsonFileText } from './util.mjs';
|
||||
const rolesDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../roles');
|
||||
const owner = Object.freeze({ provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' });
|
||||
const fail = (code, message, path) => { throw new OrgError(code, message, path); };
|
||||
@@ -87,7 +88,8 @@ export async function templateRender(id, { fixture, preset, thinking } = {}) {
|
||||
if (fixture !== undefined && preset !== undefined) fail('org-invalid', 'choose fixture or preset');
|
||||
if (fixture === 'local') preset = 'fixture';
|
||||
checkId(id);
|
||||
const doc = JSON.parse(await fs.readFile(new URL('./org-template.json', import.meta.url), 'utf8'));
|
||||
const template = new URL('./org-template.json', import.meta.url);
|
||||
const doc = parseJsonFileText(await fs.readFile(template, 'utf8'), fileURLToPath(template), 'organization template file');
|
||||
doc.id = id; doc.projects.work.root = `/__mosaic_org__/${id}/projects/work`;
|
||||
if (preset !== undefined) {
|
||||
if (typeof preset !== 'string') fail('org-invalid', 'unknown preset');
|
||||
@@ -126,13 +128,14 @@ async function readCurrentUnlocked(ctx, id, { includeArchived = false } = {}) {
|
||||
const found = await resolveOrg(ctx, id);
|
||||
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 principal = JSON.parse(await privateRead(join(found.root, 'owner.json'))); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required');
|
||||
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');
|
||||
// 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 raw = await privateRead(join(ctx.configDir, 'businesses', `${id}.json`));
|
||||
const entry = (await readRegistry(ctx)).orgs[id]; const file = join(ctx.configDir, 'businesses', `${id}.json`); const raw = await privateRead(file);
|
||||
if (!entry.digest || entry.digest !== digest(raw)) fail('org-mismatch', 'organization document and registry disagree');
|
||||
const doc = JSON.parse(raw); await checked(doc); return { ...found, revision: entry.revision, doc, principal };
|
||||
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; }
|
||||
}
|
||||
// Readers do not take the writer lock. Require unchanged registry snapshots and
|
||||
@@ -174,7 +177,7 @@ export async function notificationShow(ctx, id) {
|
||||
const file = join(current.root, 'notify', id, 'notify.json');
|
||||
const st = await fs.lstat(file);
|
||||
if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe notifier configuration');
|
||||
const binding = JSON.parse(await privateRead(file));
|
||||
const binding = parseJsonFileText(await privateRead(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');
|
||||
@@ -325,7 +328,7 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {})
|
||||
try {
|
||||
await deleteSafeRoot(ctx.dataRoot);
|
||||
await ensurePrivateDir(deleted); await ensurePrivateDir(destination);
|
||||
try { result = JSON.parse(await privateRead(receiptFile)); }
|
||||
try { result = parseJsonFileText(await privateRead(receiptFile), 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) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { join, dirname } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { businessFilePath, validateBusinessDocument } from "./organization-config.mjs";
|
||||
import { requireId, sha256, canonicalJson } from "./util.mjs";
|
||||
import { requireId, sha256, canonicalJson, parseJsonFileText } from "./util.mjs";
|
||||
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
|
||||
import { checkReplyLimits } from "../../library/src/limits.mjs";
|
||||
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
|
||||
@@ -153,7 +153,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
function read(id) {
|
||||
const file = businessFilePath(id, dir);
|
||||
const bytes = regular(file);
|
||||
const document = JSON.parse(bytes);
|
||||
const document = parseJsonFileText(bytes, file, "organization file");
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
return { document, revision: sha256(bytes) };
|
||||
}
|
||||
@@ -243,7 +243,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
throw e;
|
||||
}
|
||||
if (text && !text.endsWith("\n")) fail("organization-uncertain", "torn organization receipt");
|
||||
const entries = text.trim() ? text.trim().split("\n").map(JSON.parse) : [];
|
||||
const entries = text.trim() ? text.trim().split("\n").map(line => parseJsonFileText(line, logFile(id), "organization receipt file")) : [];
|
||||
const pending = new Map();
|
||||
for (const e of entries) {
|
||||
if (e.state === "prepared") pending.set(e.transaction, e);
|
||||
@@ -287,7 +287,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
fail('org-mismatch', 'organization document and registry disagree');
|
||||
}
|
||||
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
|
||||
const old = JSON.parse(oldBytes);
|
||||
const old = parseJsonFileText(oldBytes, file, "organization file");
|
||||
if (typeof document === "function") document = document(old);
|
||||
guard(old, document);
|
||||
replyLimitsFence(old, document, actor);
|
||||
@@ -421,7 +421,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
}
|
||||
if (text && !text.endsWith("\n")) fail("organization-uncertain", "torn organization receipt");
|
||||
const last = new Map();
|
||||
for (const e of text.trim() ? text.trim().split("\n").map(JSON.parse) : []) last.set(e.transaction, e);
|
||||
for (const e of text.trim() ? text.trim().split("\n").map(line => parseJsonFileText(line, logFile(id), "organization receipt file")) : []) last.set(e.transaction, e);
|
||||
const hash = sha256(regular(businessFilePath(id, dir)));
|
||||
for (const e of last.values()) {
|
||||
const d = e.detail;
|
||||
|
||||
@@ -58,6 +58,16 @@ export function requireDate(value, where) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// 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.
|
||||
export function parseJsonFileText(text, file, what = "file") {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new SyntaxError(`${what} is not valid JSON (${file})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Read a JSON file that must be a regular file, not a symbolic link.
|
||||
// Missing or not a regular file is 4; unparseable is 2. One descriptor,
|
||||
// opened without following a link, serves every check and the read.
|
||||
@@ -81,9 +91,9 @@ export function readJsonFile(file, what, checkStat) {
|
||||
closeSync(fd);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
refuse(`${what} is not valid JSON (${file}): ${error.message}`);
|
||||
return parseJsonFileText(text, file, what);
|
||||
} catch {
|
||||
refuse(`${what} is not valid JSON (${file})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// Roles round 2, slice P1 (#1873): the composition validator and its registry.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadRole, BusinessError } from "../src/index.mjs";
|
||||
import { GATED_ONLY, HIRE_ALLOWED, unhireableAction, ROUTED } from "../src/vocabulary.mjs";
|
||||
import {
|
||||
buildRegistry, checkComposition, COMPOSITION_BOUNDS, COMPOSITION_KEYS, FLAG_RESOLVE, FLAG_ABOVE_CHIEF,
|
||||
} from "../src/composition.mjs";
|
||||
import { REPO_ROLES } from "./helpers.mjs";
|
||||
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
// Stands in for the bus credential store's assertClean. Its own error quotes the secret, so a test
|
||||
// can tell if checkComposition ever passes that error on.
|
||||
const assertClean = (value) => {
|
||||
if (JSON.stringify(value).includes(SECRET)) throw new Error(`credential-leak: ${SECRET}`);
|
||||
};
|
||||
const OPTS = { assertClean };
|
||||
const CHIEF = loadRole(REPO_ROLES, "chief-of-staff");
|
||||
const CORE = buildRegistry();
|
||||
|
||||
function base(overrides = {}) {
|
||||
return {
|
||||
tools: ["read"],
|
||||
network: "none",
|
||||
authority: { withinRole: ["message.send"], crossRole: [] },
|
||||
credentials: [{ service: "gitea", scopes: ["read:repository"] }],
|
||||
instructions: "Do the work in the brief.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const run = (role, registry = CORE, chief = CHIEF) => checkComposition(role, registry, chief, OPTS);
|
||||
|
||||
function refuses(fn, pattern, ...absent) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.match(error.message, pattern);
|
||||
for (const text of [SECRET, ...absent]) {
|
||||
assert.ok(!error.message.includes(text), `the refusal quotes ${JSON.stringify(text)}: ${error.message}`);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
test("a minimal composition passes with nothing flagged", () => {
|
||||
const { composition, flagged } = run(base());
|
||||
assert.deepEqual(flagged, []);
|
||||
assert.equal(composition.instructions, "Do the work in the brief.");
|
||||
assert.equal(composition.messaging, null);
|
||||
assert.equal(composition.personality, null);
|
||||
assert.ok(Object.isFrozen(composition) && Object.isFrozen(composition.authority.withinRole));
|
||||
});
|
||||
|
||||
test("checkComposition needs a registry from buildRegistry and the store's assertClean", () => {
|
||||
refuses(() => checkComposition(base(), { ...CORE }, CHIEF, OPTS), /registry from buildRegistry/);
|
||||
refuses(() => checkComposition(base(), CORE, CHIEF), /assertClean/);
|
||||
refuses(() => checkComposition(base(), CORE, CHIEF, { assertClean: true }), /assertClean/);
|
||||
refuses(() => checkComposition(base(), CORE, { tools: [] }, OPTS), /chief's resolved role/);
|
||||
});
|
||||
|
||||
// Step 1.
|
||||
test("an unknown key refuses at every level, without quoting the key", () => {
|
||||
const key = `notes ${SECRET}`;
|
||||
refuses(() => run({ ...base(), [key]: 1 }), /role has an unknown key/, key);
|
||||
for (const field of ["name", "title", "contract", "roleVersion", "hireable"]) {
|
||||
refuses(() => run({ ...base(), [field]: "x" }), /role has an unknown key/);
|
||||
}
|
||||
refuses(() => run(base({ authority: { withinRole: [], crossRole: [], [key]: [] } })), /authority has an unknown key/, key);
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["read:issue"], [key]: 1 }] })), /credentials\[0\] has an unknown key/, key);
|
||||
refuses(() => run(base({ messaging: { sendTo: [], receiveFrom: [], [key]: [] } })), /messaging has an unknown key/, key);
|
||||
refuses(() => run(base({ personality: { summary: "Calm.", [key]: "x" } })), /personality has an unknown key/, key);
|
||||
refuses(() => run(JSON.parse('{"__proto__": {"tools": ["read"]}}')), /unknown key|requires/);
|
||||
});
|
||||
|
||||
test("the required primitives and their shapes", () => {
|
||||
for (const key of ["tools", "network", "authority", "credentials", "instructions"]) {
|
||||
const role = base();
|
||||
delete role[key];
|
||||
refuses(() => run(role), new RegExp(`requires "${key}"`));
|
||||
}
|
||||
refuses(() => run(base({ tools: [] })), /tools must not be empty/);
|
||||
refuses(() => run(base({ tools: ["read", "read"] })), /tools\[1\] repeats/);
|
||||
refuses(() => run(base({ authority: { withinRole: ["message.send"] } })), /authority.crossRole must be an array/);
|
||||
refuses(() => run(base({ authority: { withinRole: ["message.send"], crossRole: ["message.send"] } })), /crossRole\[0\] is also in authority.withinRole/);
|
||||
refuses(() => run(base({ instructions: ["x"] })), /instructions must be a string/);
|
||||
refuses(() => run(base({ instructions: "a\0b" })), /instructions must be a string/);
|
||||
refuses(() => run(base({ messaging: { sendTo: ["tester"], receiveFrom: [] } })), /names the tester/);
|
||||
refuses(() => run(base({ messaging: { sendTo: ["Bad Name"], receiveFrom: [] } })), /sendTo\[0\] must be a role name/, "Bad Name");
|
||||
refuses(() => run(base({ skills: [".."] })), /skills\[0\] must be a skill directory name/);
|
||||
assert.deepEqual(Object.keys(base()).every((k) => COMPOSITION_KEYS.includes(k)), true);
|
||||
});
|
||||
|
||||
// Step 2.
|
||||
test("an unknown action, tool, network, service or scope refuses without quoting it", () => {
|
||||
const odd = `zz-${SECRET}`;
|
||||
refuses(() => run(base({ authority: { withinRole: [odd], crossRole: [] } })), /withinRole\[0\] names no registered action/, odd);
|
||||
refuses(() => run(base({ authority: { withinRole: [], crossRole: ["task.nonesuch"] } })), /crossRole\[0\] names no registered action/, "task.nonesuch");
|
||||
refuses(() => run(base({ tools: ["read", "hammer"] })), /tools\[1\] names no registered tool/, "hammer");
|
||||
refuses(() => run(base({ network: odd })), /network must be one of: none, api-only, open/, odd);
|
||||
refuses(() => run(base({ credentials: [{ service: "jira", scopes: ["read"] }] })), /credentials\[0\].service names no registered/, "jira");
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["write:nonesuch"] }] })), /scopes\[0\] is not a grantable gitea scope/, "nonesuch");
|
||||
refuses(() => run(base({ credentials: [{ service: "vikunja", scopes: { nonesuch: ["read_one"] } }] })), /group 1 is not a grantable vikunja route group/, "nonesuch");
|
||||
refuses(() => run(base({ credentials: [{ service: "vikunja", scopes: { __proto__: null, ["__proto__"]: ["read_one"] } }] })), /not a grantable vikunja route group/);
|
||||
refuses(() => run(base({ credentials: [{ service: "vikunja", scopes: { tasks: ["obliterate"] } }] })), /scopes.tasks\[0\] is not a grantable vikunja verb/, "obliterate");
|
||||
refuses(() => run(base({ credentials: [{ service: "host", scopes: ["any"] }] })), /host, which takes no scopes/);
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["read:issue"] }, { service: "gitea", scopes: ["read:user"] }] })), /credentials\[1\] names gitea again/);
|
||||
});
|
||||
|
||||
test("gitea admin and all refuse, at either level", () => {
|
||||
for (const scope of ["admin", "all", "write:admin", "read:admin", "write:all", "read:all", "sudo"]) {
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: [scope] }] })), /not a grantable gitea scope/);
|
||||
}
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["read:issue", "write:issue"] }] })), /scopes\[1\] names the category issue again/);
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: [] }] })), /non-empty array/);
|
||||
});
|
||||
|
||||
// Step 3.
|
||||
test("each hard wait refuses, in either authority list", () => {
|
||||
assert.ok(GATED_ONLY.length > 0);
|
||||
for (const action of GATED_ONLY) {
|
||||
refuses(() => run(base({ authority: { withinRole: [action], crossRole: [] } })), /withinRole\[0\] is .* a hard wait/);
|
||||
refuses(() => run(base({ authority: { withinRole: [], crossRole: [action] } })), /crossRole\[0\] is .* a hard wait/);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 4.
|
||||
test("role.hire and role.fire refuse", () => {
|
||||
for (const action of ["role.hire", "role.fire"]) {
|
||||
assert.ok(ROUTED.includes(action), `${action} is routed, so step 4 is what refuses it`);
|
||||
refuses(() => run(base({ authority: { withinRole: [action], crossRole: [] } })), /cannot hire or fire/);
|
||||
refuses(() => run(base({ authority: { withinRole: [], crossRole: [action] } })), /cannot hire or fire/);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 5.
|
||||
test("a decision.resolve action comes back as a flagged line", () => {
|
||||
const { flagged } = run(base({ authority: { withinRole: ["message.send"], crossRole: ["decision.resolve.technical"] } }));
|
||||
assert.deepEqual(flagged, [
|
||||
{ field: "authority.crossRole", value: "decision.resolve.technical", reasons: [FLAG_ABOVE_CHIEF, FLAG_RESOLVE] },
|
||||
]);
|
||||
// Held by the chief: still flagged as a resolve right, and only that.
|
||||
const chief = { ...CHIEF, authority: { withinRole: [...CHIEF.authority.withinRole, "decision.resolve.technical"], crossRole: [] } };
|
||||
const held = run(base({ authority: { withinRole: ["decision.resolve.technical"], crossRole: [] } }), CORE, chief);
|
||||
assert.deepEqual(held.flagged, [{ field: "authority.withinRole", value: "decision.resolve.technical", reasons: [FLAG_RESOLVE] }]);
|
||||
});
|
||||
|
||||
// Step 6.
|
||||
test("a secret in instructions or personality refuses, and the refusal does not contain it", () => {
|
||||
const cases = [
|
||||
[{ instructions: `Use ${SECRET} for the API.` }, /instructions holds a credential/],
|
||||
[{ personality: { summary: `Knows ${SECRET}.` } }, /personality.summary holds a credential/],
|
||||
[{ personality: { traits: ["calm", `key ${SECRET}`] } }, /personality.traits\[1\] holds a credential/],
|
||||
[{ personality: { voice: `Says ${SECRET} often.` } }, /personality.voice holds a credential/],
|
||||
[{ skills: [SECRET] }, /skills\[0\] holds a credential/],
|
||||
];
|
||||
for (const [overrides, pattern] of cases) refuses(() => run(base(overrides)), pattern, "Use ", "Knows", "Says");
|
||||
});
|
||||
|
||||
test("the free text never reaches an error", () => {
|
||||
const prose = "PRIVATE-INSTRUCTION-TEXT";
|
||||
refuses(() => run(base({ instructions: prose.repeat(2000) })), /instructions must be non-empty and at most 16384 characters/, prose);
|
||||
refuses(() => run(base({ instructions: prose, personality: { summary: prose.repeat(20) } })), /personality.summary must be/, prose);
|
||||
refuses(() => run(base({ instructions: prose, authority: { withinRole: ["deploy"], crossRole: [] } })), /hard wait/, prose);
|
||||
});
|
||||
|
||||
test("the length bounds hold, from one exported constant", () => {
|
||||
assert.deepEqual({ ...COMPOSITION_BOUNDS }, { instructions: 16384, summary: 280, traits: 12, trait: 40, voice: 280, messaging: 64, skills: 64 });
|
||||
assert.ok(Object.isFrozen(COMPOSITION_BOUNDS));
|
||||
const b = COMPOSITION_BOUNDS;
|
||||
run(base({ instructions: "x".repeat(b.instructions), personality: { summary: "s".repeat(b.summary), voice: "v".repeat(b.voice), traits: Array.from({ length: b.traits }, (_, i) => `${i}`.padEnd(b.trait, "t")) } }));
|
||||
refuses(() => run(base({ instructions: "x".repeat(b.instructions + 1) })), /instructions must be/);
|
||||
refuses(() => run(base({ instructions: " " })), /instructions must be non-empty/);
|
||||
refuses(() => run(base({ personality: { summary: "s".repeat(b.summary + 1) } })), /personality.summary must be/);
|
||||
refuses(() => run(base({ personality: { voice: "v".repeat(b.voice + 1) } })), /personality.voice must be/);
|
||||
refuses(() => run(base({ personality: { traits: Array.from({ length: b.traits + 1 }, (_, i) => `t${i}`) } })), /at most 12 entries/);
|
||||
refuses(() => run(base({ personality: { traits: ["t".repeat(b.trait + 1)] } })), /traits\[0\] must be/);
|
||||
refuses(() => run(base({ messaging: { sendTo: Array.from({ length: b.messaging + 1 }, (_, i) => `r${i}`), receiveFrom: [] } })), /sendTo must hold at most 64/);
|
||||
refuses(() => run(base({ skills: Array.from({ length: b.skills + 1 }, (_, i) => `s${i}`) })), /skills must hold at most 64/);
|
||||
});
|
||||
|
||||
test("the steps run in order: a hard wait refuses before the secret check", () => {
|
||||
refuses(() => run(base({ instructions: SECRET, authority: { withinRole: ["spend"], crossRole: [] } })), /hard wait/);
|
||||
refuses(() => run(base({ instructions: SECRET, authority: { withinRole: ["role.fire"], crossRole: [] } })), /cannot hire or fire/);
|
||||
});
|
||||
|
||||
// Step 7.
|
||||
test("the shipped coder against the shipped chief flags exactly the hand-built set", () => {
|
||||
const coder = loadRole(REPO_ROLES, "coder");
|
||||
const role = {
|
||||
tools: coder.tools,
|
||||
network: coder.network,
|
||||
authority: coder.authority,
|
||||
credentials: coder.credentials,
|
||||
skills: coder.skills,
|
||||
instructions: readFileSync(join(REPO_ROLES, "coder.md"), "utf8"),
|
||||
};
|
||||
const { flagged } = run(role);
|
||||
const above = (field, value) => ({ field, value, reasons: [FLAG_ABOVE_CHIEF] });
|
||||
const expected = [
|
||||
above("authority.withinRole", "task.update.assigned"),
|
||||
above("authority.withinRole", "git.push.working"),
|
||||
above("authority.withinRole", "review.request"),
|
||||
above("authority.crossRole", "task.scope.change"),
|
||||
above("tools", "write"),
|
||||
above("tools", "edit"),
|
||||
above("tools", "bash"),
|
||||
above("credentials.gitea", "write:issue"),
|
||||
above("credentials.gitea", "write:repository"),
|
||||
above("credentials.vikunja", "tasks.read_one"),
|
||||
above("credentials.vikunja", "tasks.update"),
|
||||
above("credentials.vikunja", "tasks_comments.create"),
|
||||
above("credentials.vikunja", "projects.views_buckets_tasks"),
|
||||
];
|
||||
const key = (l) => `${l.field} ${l.value}`;
|
||||
assert.deepEqual([...flagged].map((l) => ({ ...l, reasons: [...l.reasons] })).sort((a, b) => key(a).localeCompare(key(b))),
|
||||
expected.sort((a, b) => key(a).localeCompare(key(b))));
|
||||
});
|
||||
|
||||
test("network, cross-role and messaging above the chief are flagged", () => {
|
||||
assert.deepEqual(run(base({ network: "open" })).flagged, [{ field: "network", value: "open", reasons: [FLAG_ABOVE_CHIEF] }]);
|
||||
assert.deepEqual(run(base({ network: "api-only" })).flagged, []);
|
||||
// A cross-role action the chief holds within its role is not above it.
|
||||
assert.deepEqual(run(base({ authority: { withinRole: [], crossRole: ["message.send"] } })).flagged, []);
|
||||
// A within-role action the chief holds only cross-role is.
|
||||
const crossChief = { ...CHIEF, authority: { withinRole: ["message.send"], crossRole: ["record.write"] } };
|
||||
assert.deepEqual(run(base({ authority: { withinRole: ["record.write"], crossRole: [] } }), CORE, crossChief).flagged,
|
||||
[{ field: "authority.withinRole", value: "record.write", reasons: [FLAG_ABOVE_CHIEF] }]);
|
||||
const host = run(base({ credentials: [{ service: "host" }] })).flagged;
|
||||
assert.deepEqual(host, [{ field: "credentials.host", value: "host", reasons: [FLAG_ABOVE_CHIEF] }]);
|
||||
const narrow = { ...CHIEF, messaging: { sendTo: ["pm"], receiveFrom: ["qa"] } };
|
||||
assert.deepEqual(run(base(), CORE, narrow).flagged, [
|
||||
{ field: "messaging.sendTo", value: "*", reasons: [FLAG_ABOVE_CHIEF] },
|
||||
{ field: "messaging.receiveFrom", value: "*", reasons: [FLAG_ABOVE_CHIEF] },
|
||||
]);
|
||||
assert.deepEqual(run(base({ messaging: { sendTo: ["pm", "cto"], receiveFrom: ["qa"] } }), CORE, narrow).flagged,
|
||||
[{ field: "messaging.sendTo", value: "cto", reasons: [FLAG_ABOVE_CHIEF] }]);
|
||||
// Review finding 1: a wider inbound list is above the chief too.
|
||||
assert.deepEqual(run(base({ messaging: { sendTo: ["pm"], receiveFrom: ["qa", "cto"] } }), CORE, narrow).flagged,
|
||||
[{ field: "messaging.receiveFrom", value: "cto", reasons: [FLAG_ABOVE_CHIEF] }]);
|
||||
// A chief with no messaging key holds both directions to everyone.
|
||||
assert.deepEqual(run(base({ messaging: { sendTo: ["cto"], receiveFrom: ["cto"] } })).flagged, []);
|
||||
});
|
||||
|
||||
// Review finding 2: the caller's object is read once, as JSON data, and nothing it throws escapes.
|
||||
test("a getter, proxy or toJSON that throws role text refuses with a fixed message", () => {
|
||||
const throwing = base();
|
||||
Object.defineProperty(throwing, "instructions", { enumerable: true, get() { throw new Error(SECRET); } });
|
||||
refuses(() => run(throwing), /^composition must be a JSON object$/);
|
||||
const proxy = new Proxy(base(), { ownKeys() { throw new Error(SECRET); } });
|
||||
refuses(() => run(proxy), /^composition must be a JSON object$/);
|
||||
const proto = new Proxy(base(), { getPrototypeOf() { throw new Error(SECRET); } });
|
||||
refuses(() => run(proto), /^composition must be a JSON object$/);
|
||||
refuses(() => run(base({ personality: { toJSON() { throw new Error(SECRET); } } })), /^composition must be a JSON object$/);
|
||||
const cyclic = base();
|
||||
cyclic.authority.self = cyclic;
|
||||
refuses(() => run(cyclic), /^composition must be a JSON object$/);
|
||||
refuses(() => run(base({ tools: ["read", 10n] })), /^composition must be a JSON object$/);
|
||||
const chief = { ...CHIEF };
|
||||
Object.defineProperty(chief, "tools", { enumerable: true, get() { throw new Error(SECRET); } });
|
||||
refuses(() => run(base(), CORE, chief), /chief's resolved role/);
|
||||
});
|
||||
|
||||
test("a getter that changes its answer is read once: what is checked is what comes back", () => {
|
||||
let reads = 0;
|
||||
const role = base();
|
||||
Object.defineProperty(role, "network", { enumerable: true, get() { reads += 1; return reads === 1 ? "none" : "open"; } });
|
||||
const { composition, flagged } = run(role);
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(composition.network, "none");
|
||||
assert.deepEqual(flagged, []);
|
||||
});
|
||||
|
||||
// The plugin-declared set (section 7, registry shape only).
|
||||
test("a plugin action is accepted only with its prefix", () => {
|
||||
const registry = buildRegistry({ plugins: [{ name: "forge", tools: ["lathe"], actions: ["build.run"], services: [{ name: "store", scopes: ["read", "write"] }] }] });
|
||||
const ok = run(base({
|
||||
tools: ["read", "forge.lathe"],
|
||||
authority: { withinRole: ["forge.build.run"], crossRole: [] },
|
||||
credentials: [{ service: "forge.store", scopes: ["read"] }],
|
||||
}), registry);
|
||||
assert.deepEqual(ok.flagged.map((l) => `${l.field} ${l.value}`).sort(),
|
||||
["authority.withinRole forge.build.run", "credentials.forge.store read", "tools forge.lathe"]);
|
||||
refuses(() => run(base({ authority: { withinRole: ["build.run"], crossRole: [] } }), registry), /names no registered action/, "build.run");
|
||||
refuses(() => run(base({ tools: ["lathe"] }), registry), /names no registered tool/, "lathe");
|
||||
refuses(() => run(base({ credentials: [{ service: "store", scopes: ["read"] }] }), registry), /names no registered credential service/);
|
||||
refuses(() => run(base({ credentials: [{ service: "forge.store", scopes: ["admin"] }] }), registry), /is not a scope forge.store declares/, "admin");
|
||||
// Without the plugin in the registry, its names are unknown.
|
||||
refuses(() => run(base({ authority: { withinRole: ["forge.build.run"], crossRole: [] } })), /names no registered action/);
|
||||
});
|
||||
|
||||
test("a plugin refuses if it reuses a core name or declares a hard wait", () => {
|
||||
const reg = (plugin) => () => buildRegistry({ plugins: [plugin] });
|
||||
for (const action of GATED_ONLY) refuses(reg({ name: "forge", actions: [action] }), /a hard wait/);
|
||||
refuses(reg({ name: "forge", actions: ["message.send"] }), /reuses the core name "message.send"/);
|
||||
refuses(reg({ name: "forge", tools: ["read"] }), /reuses the core name "read"/);
|
||||
refuses(reg({ name: "forge", services: [{ name: "gitea", scopes: ["x"] }] }), /reuses the core name "gitea"/);
|
||||
for (const name of ["git", "role", "decision", "read", "gitea", "host", "open"]) {
|
||||
refuses(reg({ name, actions: ["x"] }), /is a core name/);
|
||||
}
|
||||
refuses(reg({ name: "Forge" }), /name must match/);
|
||||
refuses(reg({ name: "forge", extra: 1 }), /has a key outside/);
|
||||
refuses(reg({ name: "forge", actions: ["a", "a"] }), /repeats an earlier entry/);
|
||||
refuses(reg({ name: "forge", tools: ["a"], actions: ["a"] }), /declares "forge.a" twice/);
|
||||
refuses(reg({ name: "forge", actions: [`x ${SECRET}`] }), /must be a name matching/);
|
||||
refuses(reg({ name: "forge", services: [{ name: "store", scopes: [] }] }), /scopes must not be empty/);
|
||||
refuses(() => buildRegistry({ plugins: [{ name: "forge" }, { name: "forge" }] }), /plugin "forge" twice/);
|
||||
refuses(() => buildRegistry({ plugins: {} }), /plugins must be an array/);
|
||||
// A plugin action is routed, so it never reads as a hard wait at step 3.
|
||||
assert.ok(buildRegistry({ plugins: [{ name: "forge", actions: ["build.run"] }] }).routed.includes("forge.build.run"));
|
||||
});
|
||||
|
||||
test("HIRE_ALLOWED and unhireableAction stay until A4", () => {
|
||||
assert.ok(Array.isArray(HIRE_ALLOWED) && HIRE_ALLOWED.length > 0);
|
||||
assert.equal(typeof unhireableAction, "function");
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { test } from 'node:test';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmp, writeJson, REPO_ROLES } from './helpers.mjs';
|
||||
import { loadBusiness } from '../src/organization-config.mjs';
|
||||
import { loadBusiness as legacyLoadBusiness } from '../../business/src/business.mjs';
|
||||
import { loadProject } from '../src/project.mjs';
|
||||
import { loadRootAgents } from '../src/agent-levels.mjs';
|
||||
import { readJsonFile } from '../src/util.mjs';
|
||||
import { organizationWriter } from '../src/organization.mjs';
|
||||
import { readRegistry, withRegistryWriter } from '../src/org-registry.mjs';
|
||||
import { createOrg, deleteOrg, notificationShow, showOrg, templateRender } from '../src/org-service.mjs';
|
||||
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
const malformed = secret;
|
||||
function fixture(t) {
|
||||
const root = tmp();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
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 } = {}) {
|
||||
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.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'],
|
||||
]) 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 }));
|
||||
});
|
||||
|
||||
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 }));
|
||||
});
|
||||
|
||||
for (const operation of ['read', 'write', 'recover', 'findAddition']) test(`organization writer ${operation} does not quote malformed file bytes`, async t => {
|
||||
const ctx = fixture(t);
|
||||
await createOrg(ctx, 'alpha', await templateRender('alpha'));
|
||||
const writer = organizationWriter({ dir: ctx.configDir, rolesDir: REPO_ROLES });
|
||||
const current = writer.read('alpha');
|
||||
const receipt = ['recover', 'findAddition'].includes(operation);
|
||||
const file = receipt ? writer.logFile('alpha') : join(ctx.configDir, 'businesses/alpha.json');
|
||||
if (receipt) mkdirSync(join(ctx.configDir, 'businesses/.history/alpha'), { recursive: true, mode: 0o700 });
|
||||
writeJson(file, malformed + '\n');
|
||||
if (operation === 'write') {
|
||||
// Exercise the parse after both the revision and registry digest fences.
|
||||
current.revision = createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
const registryFile = join(ctx.configDir, 'org-runtime.json');
|
||||
const registry = JSON.parse(readFileSync(registryFile)); registry.orgs.alpha.digest = current.revision;
|
||||
writeJson(registryFile, registry);
|
||||
}
|
||||
const invoke = () => operation === 'write'
|
||||
? writer.write('alpha', current.document, { revision: current.revision, actor: ctx.principal.sub })
|
||||
: writer[operation]('alpha', {});
|
||||
assert.throws(invoke, safe(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));
|
||||
});
|
||||
|
||||
for (const kind of ['owner', 'organization', 'notifier', 'deletion receipt']) test(`service ${kind} refuses malformed JSON without file bytes`, async t => {
|
||||
const ctx = fixture(t);
|
||||
const created = await createOrg(ctx, 'alpha', await templateRender('alpha'));
|
||||
let file;
|
||||
if (kind === 'owner') file = join(created.root, 'owner.json');
|
||||
if (kind === 'organization') file = join(ctx.configDir, 'businesses/alpha.json');
|
||||
if (kind === 'notifier') file = join(created.root, 'notify/alpha/notify.json');
|
||||
if (kind === 'deletion receipt') {
|
||||
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: 'alpha-redaction' };
|
||||
writeJson(registryFile, registry);
|
||||
const destination = join(ctx.dataRoot, 'deleted/alpha-redaction');
|
||||
mkdirSync(destination, { recursive: true, mode: 0o700 });
|
||||
file = join(destination, 'receipt.json');
|
||||
}
|
||||
writeJson(file, malformed);
|
||||
if (kind === 'organization') {
|
||||
const registryFile = join(ctx.configDir, 'org-runtime.json');
|
||||
const registry = JSON.parse(readFileSync(registryFile));
|
||||
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));
|
||||
});
|
||||
|
||||
// The packaged template is immutable at runtime; substitute only its read result
|
||||
// to exercise a corrupt template without editing repository data.
|
||||
test('template reader refuses malformed JSON without quoting template bytes', async t => {
|
||||
const original = fsPromises.readFile;
|
||||
t.mock.method(fsPromises, 'readFile', function(file, ...args) {
|
||||
if (file instanceof URL && file.pathname.endsWith('/org-template.json')) return Promise.resolve(malformed);
|
||||
return original.call(this, file, ...args);
|
||||
});
|
||||
syncBuiltinESMExports();
|
||||
try {
|
||||
await assert.rejects(templateRender('alpha'), safe('org-template.json'));
|
||||
} finally {
|
||||
t.mock.restoreAll();
|
||||
syncBuiltinESMExports();
|
||||
}
|
||||
});
|
||||
@@ -133,7 +133,7 @@ function readMarker(dataRoot) {
|
||||
try {
|
||||
marker = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch (error) {
|
||||
fail(3, `instance-root-unclaimed: ${file} is not a valid marker (${error.message})`);
|
||||
fail(3, `instance-root-unclaimed: ${file} is not a valid marker (${error instanceof SyntaxError ? 'not valid JSON' : error.message})`);
|
||||
}
|
||||
if (!isPlainObject(marker) || Object.keys(marker).sort().join(",") !== "instanceVersion,name"
|
||||
|| marker.instanceVersion !== 1 || typeof marker.name !== "string" || !INSTANCE_PATTERN.test(marker.name)) {
|
||||
@@ -382,8 +382,8 @@ async function load(file, instance = null) {
|
||||
let document;
|
||||
try {
|
||||
document = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
fail(2, `configuration is not valid JSON (${file}): ${error.message}`);
|
||||
} catch {
|
||||
fail(2, `configuration is not valid JSON (${file})`);
|
||||
}
|
||||
return await validate(document, file);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user