A tester binds with a process proof from the bus's own tester-cli.mjs when the business var bus.tester is true. It may send [mos test] messages to a role, receive replies on the tester address and use the read verbs; every other verb refuses. Messages store from_role tester and write no human.input event. turnRequest() marks them as never Jason's instruction. human.mjs is unchanged. Candidate manifest bdaf7f476abd0b2b5095ab2f7f43e38540e6ba48ee14a02a4f4995a1469b79df (Darkwing round 2, packet9f1563ec1), BASEbadfa52b9. Approved by Filbert (comment 28360) and Sage (comment 28361). Sage gate rerun passed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
178 lines
7.5 KiB
JavaScript
178 lines
7.5 KiB
JavaScript
// The variable key registry (REQ-VAR-1, note section 2, addendum A section
|
|
// 7). Every key is declared here once, with its type, the layers allowed to
|
|
// set it and its merge rule. A key that isn't here refuses, and so does a
|
|
// key set at a layer it isn't allowed in.
|
|
//
|
|
// Layers, least to most specific:
|
|
// system ~/.config/mosaic-dev/config.json, resolved by mosaic-config.mjs
|
|
// business vars in businesses/<id>.json
|
|
// project vars in <root>/.mosaic/project.json, then that file's
|
|
// roles.<instance>.vars (still the project layer, applied after)
|
|
// agent roles.<instance>.vars in the business file
|
|
//
|
|
// Merge rules:
|
|
// replace the most specific layer that sets the key wins
|
|
// intersect every layer that sets the key narrows it; nothing widens
|
|
|
|
import { refuse } from "./errors.mjs";
|
|
import { ACTIONS, NETWORKS, TOOLS } from "./vocabulary.mjs";
|
|
import { isPlainObject, requireDistinctList, requirePositiveInt, requireString } from "./util.mjs";
|
|
|
|
export const LAYERS = Object.freeze(["system", "business", "project", "agent"]);
|
|
|
|
const BRANCH = /^(?!-)(?!.*\.\.)(?!.*\/\/)(?!.*\.lock$)[A-Za-z0-9._/-]{1,100}(?<![./])$/;
|
|
|
|
function string(max = 200) {
|
|
return (v, key) => requireString(v, `variable ${key}`, { max });
|
|
}
|
|
|
|
function oneOf(values) {
|
|
return (v, key) => {
|
|
if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")} (got ${JSON.stringify(v)})`);
|
|
return v;
|
|
};
|
|
}
|
|
|
|
function integer(min) {
|
|
return (v, key) => {
|
|
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min} (got ${JSON.stringify(v)})`);
|
|
return v;
|
|
};
|
|
}
|
|
|
|
// http or https, no user info, query or fragment, no trailing slash.
|
|
function baseUrl(v, key) {
|
|
requireString(v, `variable ${key}`, { max: 300 });
|
|
let url;
|
|
try {
|
|
url = new URL(v);
|
|
} catch {
|
|
refuse(`variable ${key} must be an http or https URL (got ${JSON.stringify(v)})`);
|
|
}
|
|
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || v.endsWith("/")) {
|
|
refuse(`variable ${key} must be an http or https base URL with no credentials, query, fragment or trailing slash`);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
function branch(v, key) {
|
|
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name (got ${JSON.stringify(v)})`);
|
|
return v;
|
|
}
|
|
|
|
function list(check, { nonEmpty = true } = {}) {
|
|
return (v, key) => requireDistinctList(v, `variable ${key}`, (item) => check(item, `${key} entry`), { nonEmpty });
|
|
}
|
|
|
|
function boolean(v, key) {
|
|
if (typeof v !== "boolean") refuse(`variable ${key} must be true or false (got ${JSON.stringify(v)})`);
|
|
return v;
|
|
}
|
|
|
|
function absolutePath(v, key) {
|
|
if (typeof v !== "string" || !v.startsWith("/") || v.includes("\0")) refuse(`variable ${key} must be an absolute path`);
|
|
return v;
|
|
}
|
|
|
|
const def = (layers, check, extra = {}) => Object.freeze({ layers: Object.freeze(layers), merge: "replace", check, default: undefined, ...extra });
|
|
|
|
export const REGISTRY = Object.freeze({
|
|
// System. mosaic-config.mjs owns their validation; these checks only
|
|
// keep the resolver honest about types.
|
|
environment: def(["system"], string(64)),
|
|
dataRoot: def(["system"], absolutePath),
|
|
"execution.backend": def(["system"], string(64)),
|
|
"execution.provider": def(["system"], string(64)),
|
|
"execution.model": def(["system"], string(200)),
|
|
"execution.adapter": def(["system"], string(64)),
|
|
|
|
// Business.
|
|
"tracker.kind": def(["business"], oneOf(["vikunja"]), { default: "vikunja" }),
|
|
"tracker.baseUrl": def(["business"], baseUrl),
|
|
"tracker.reconcileMinutes": def(["business"], integer(1), { default: 60 }),
|
|
"tracker.pollSeconds": def(["business", "project"], integer(10), { default: 30 }),
|
|
"human.discordUserId": def(["business"], (v, key) => {
|
|
if (typeof v !== "string" || !/^[1-9][0-9]{16,19}$/.test(v)) refuse(`variable ${key} must be a Discord user id, 17 to 20 digits as a string`);
|
|
return v;
|
|
}),
|
|
"gitea.baseUrl": def(["business"], baseUrl),
|
|
// On: the bus binds a tester from tester-cli.mjs for PM tests (row 110). Read at bus start.
|
|
"bus.tester": def(["business"], boolean, { default: false }),
|
|
|
|
// Project.
|
|
"tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)),
|
|
// On: the tracker project is the root of a tree, and sync reads its descendants too.
|
|
"tracker.descendants": def(["project"], boolean, { default: false }),
|
|
"git.workingBranch": def(["project"], branch),
|
|
"git.protectedBranches": def(["project"], list(branch)),
|
|
suites: def(["project"], list(string(300))),
|
|
"issues.repo": def(["project"], (v, key) => {
|
|
if (typeof v !== "string" || !/^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(v)) refuse(`variable ${key} must be owner/name`);
|
|
return v;
|
|
}),
|
|
|
|
// Agent: one role instance.
|
|
harness: def(["agent"], oneOf(["pi", "claude-code"])),
|
|
model: def(["agent"], string(200)),
|
|
thinking: def(["agent"], oneOf(["off", "minimal", "low", "medium", "high", "xhigh"])),
|
|
|
|
// Limits narrow the role definition's ceilings. limits.authority is an
|
|
// allowlist: a role action it doesn't name becomes gated.
|
|
"limits.tools": def(["business", "project", "agent"], list((v, key) => {
|
|
if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool: ${JSON.stringify(v)}`);
|
|
}, { nonEmpty: false }), { merge: "intersect" }),
|
|
"limits.network": def(["business", "project", "agent"], oneOf(NETWORKS), { merge: "intersect" }),
|
|
"limits.authority": def(["business", "project", "agent"], list((v, key) => {
|
|
if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action: ${JSON.stringify(v)}`);
|
|
}, { nonEmpty: false }), { merge: "intersect" }),
|
|
});
|
|
|
|
// Check one layer's vars object. `where` names the file and path for the
|
|
// message. Returns a frozen copy with checked values.
|
|
export function checkVars(vars, layer, where) {
|
|
if (!LAYERS.includes(layer)) throw new Error(`unknown layer ${layer}`);
|
|
if (!isPlainObject(vars)) refuse(`${where} must be a JSON object`);
|
|
const out = {};
|
|
for (const [key, value] of Object.entries(vars)) {
|
|
const entry = Object.hasOwn(REGISTRY, key) ? REGISTRY[key] : null;
|
|
if (!entry) refuse(`${where}: unknown variable ${JSON.stringify(key)}`);
|
|
if (!entry.layers.includes(layer)) refuse(`${where}: variable ${key} can't be set at the ${layer} layer (allowed: ${entry.layers.join(", ")})`);
|
|
out[key] = entry.check(value, key);
|
|
}
|
|
return Object.freeze(out);
|
|
}
|
|
|
|
function narrower(a, b) {
|
|
return NETWORKS.indexOf(a) <= NETWORKS.indexOf(b) ? a : b;
|
|
}
|
|
|
|
// Merge checked layers, given least specific first as [{ layer, source,
|
|
// vars }]. Returns { vars, provenance }. provenance[key] is the source
|
|
// that set a replace key, or the list of sources that narrowed a limit.
|
|
export function mergeVars(layers) {
|
|
const vars = {};
|
|
const provenance = {};
|
|
for (const [key, entry] of Object.entries(REGISTRY)) {
|
|
if (entry.default !== undefined) {
|
|
vars[key] = entry.default;
|
|
provenance[key] = "default";
|
|
}
|
|
}
|
|
for (const { source, vars: layerVars } of layers) {
|
|
for (const [key, value] of Object.entries(layerVars)) {
|
|
const entry = REGISTRY[key];
|
|
if (entry.merge === "replace" || vars[key] === undefined) {
|
|
vars[key] = value;
|
|
provenance[key] = entry.merge === "replace" ? source : [source];
|
|
} else if (key === "limits.network") {
|
|
vars[key] = narrower(vars[key], value);
|
|
provenance[key].push(source);
|
|
} else {
|
|
vars[key] = vars[key].filter((item) => value.includes(item));
|
|
provenance[key].push(source);
|
|
}
|
|
}
|
|
}
|
|
return { vars, provenance };
|
|
}
|