Compare commits

..
4 Commits
Author SHA1 Message Date
jason.woltje 67d6c9ed84 fix(organization): composition review round, receiveFrom and one JSON read (#1873)
Review finding 1: step 7 compares messaging.receiveFrom with the chief's as
well as sendTo, since the broker checks the recipient's list too.
Review finding 2: checkComposition reads the role and the chief once, as a JSON
copy; a getter, proxy, toJSON, cycle or BigInt that throws refuses with a fixed
message, so the thrown text never reaches the caller.
2026-11-10 15:50:15 -06:00
jason.woltje c8dbd3f6ec feat(organization): composition validator and primitives registry (#1873)
Roles round 2, slice P1. checkComposition(role, registry, chief, { assertClean })
runs the design's seven steps in order against a registry from buildRegistry
(core vocabulary plus a caller-passed plugin set, empty by default) and returns
the flagged lines: every decision.resolve.* action and everything above the
chief's own resolved role. Refusals name the field and position, quote only
registered names, and never quote instructions or personality text.

COMPOSITION_BOUNDS holds the length bounds the design leaves open. Nothing
calls checkComposition yet; HIRE_ALLOWED and unhireableAction stay until A4.
2026-11-10 15:50:15 -06:00
jason.woltje 8f7de8c4d3 fix(cli): redact config and organization JSON parse refusals (#1873) 2026-11-10 15:39:53 -06:00
jason.woltje 6f0c72a02f fix(organization): redact model selections and JSON parser diagnostics (#1873) 2026-11-10 15:39:53 -06:00
14 changed files with 1077 additions and 23 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/composition.mjs (rename design r2, stage B).
export * from "../../organization/src/composition.mjs";
+1 -1
View File
@@ -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);
});
+1 -1
View File
@@ -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 } : {}) };
+14 -1
View File
@@ -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) => {
+1 -1
View File
@@ -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);
}
+518
View File
@@ -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 });
}
+3 -2
View File
@@ -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
View File
@@ -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) {
+5 -5
View File
@@ -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;
+13 -3
View File
@@ -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();
}
});
+3 -3
View File
@@ -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);
}