Files
stack/packages/business/src/vars.mjs
T
jason.woltjeandClaude Opus 5.5 179697a8a3 bus: tester identity for PM tests (row 110, #1604)
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, packet 9f1563ec1), BASE badfa52b9. Approved by Filbert
(comment 28360) and Sage (comment 28361). Sage gate rerun passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:43:40 -05:00

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 };
}