feat(business): roles v2, business and project files, variable layers (row 36, S1, darkwing)
Darkwing's round 2 candidate, approved by Filbert (#1518 comment 26730). build-r2.patch a27890d5, manifest 869168c7, 34 files, applied on HEAD and checked 34/34. Integration gate on an export of HEAD plus the patch: business 60/60 on Node 24 and 26, every package test and every scripts/test-*.sh green, test-task 98/98 with the live-provider cases. Conductor, queue, conversation and discord confirmed in git worktrees of HEAD with and without the patch, identical results. Lead decision 63 accepts the vocabulary location, the example path and the business branch. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
# Business
|
||||
|
||||
Role definitions, the business and project files, the variable registry and
|
||||
the resolver (slice 1 row S1, #1518). Brief: `docs/plans/2026-10-04_slice-1.md`,
|
||||
row S1. Design: `agents/darkwing/work/slice1-data-model-2026-10-04.md` and its
|
||||
addenda A and B. The package reads files and refuses on any problem. It
|
||||
never writes, and it never opens a token file.
|
||||
|
||||
```sh
|
||||
scripts/mosaic business validate mosaic-stack
|
||||
scripts/mosaic business resolve mosaic-stack coder --project stack
|
||||
node --test packages/business/tests/
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Who writes it | Holds |
|
||||
|---|---|---|
|
||||
| `roles/<name>.json` | reviewed commits | a role definition: tools, network, authority, credential scopes |
|
||||
| `roles/<name>.md` | reviewed commits | the duty contract a version 2 role file names |
|
||||
| `<configDir>/businesses/<id>.json` | Jason | role instances, holders, bots, token references, arbiters, projects, launch limits |
|
||||
| `<root>/.mosaic/project.json` | reviewed commits in that project | project variables, per-instance project variables |
|
||||
|
||||
`<configDir>` is the directory holding the system config: the directory of
|
||||
`$MOSAIC_CONFIG`, or `~/.config/mosaic-dev`. Role files come from `roles/`
|
||||
in this repository, or `$MOSAIC_ROLES_DIR` if set.
|
||||
|
||||
### Role files, version 2
|
||||
|
||||
```json
|
||||
{
|
||||
"roleVersion": 2, "name": "coder", "title": "Coder", "contract": "coder.md",
|
||||
"tools": ["read", "write", "edit", "bash", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": ["task.update.assigned", "git.push.working", "review.request", "message.send"],
|
||||
"crossRole": ["task.reassign", "task.scope.change"]
|
||||
},
|
||||
"credentials": [
|
||||
{ "service": "gitea", "scopes": ["write:issue", "write:repository", "read:user"] },
|
||||
{ "service": "vikunja", "scopes": { "tasks": ["read_one", "update"], "tasks_comments": ["create"], "projects": ["views_buckets_tasks"] } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `name` matches the file name. `contract` is a Markdown file name in the
|
||||
same directory, not a path. It must exist, be a regular file and not be
|
||||
empty.
|
||||
- `authority` names actions from the closed list in `src/vocabulary.mjs`.
|
||||
`withinRole` actions run without asking. `crossRole` actions need the
|
||||
arbiter. Any action not listed is gated: it goes to Jason. The nine
|
||||
always-gated actions (`deploy`, `spend`, `git.push.protected`,
|
||||
`git.merge.protected`, `credential.mint`, `message.external`,
|
||||
`policy.change`, `prd.approve`, `role.revoke`) can't appear in either list.
|
||||
- Gitea scopes are `read:<category>` or `write:<category>`, one level per
|
||||
category. `all` and `admin` are refused. Vikunja scopes map a route group
|
||||
to verbs, and only the pairs in `VIKUNJA_GRANTABLE` pass.
|
||||
- Version 1 files (`roleVersion`, `name`, `tools`, `network`) still load.
|
||||
They have no authority and no credentials, and a business can't use them
|
||||
for a role instance.
|
||||
|
||||
`scripts/mosaic-task.mjs resolve-role` checks both versions through this
|
||||
package and prints `MOSAIC_ROLE_CONTRACT` for version 2.
|
||||
|
||||
### Business file
|
||||
|
||||
`examples/mosaic-stack.example.json` is a complete example. As shipped it
|
||||
refuses on purpose: every `botId` is 0 and every date is `YYYY-MM-DD`. Copy
|
||||
it to `<configDir>/businesses/mosaic-stack.json`, put in the bot ids and
|
||||
token dates from `docs/guides/slice-1-identities.md`, fix the token paths
|
||||
and run `validate`. The file must belong to you and must not be writable
|
||||
by group or other. The loader checks the owner and mode on the descriptor
|
||||
it reads from, so the file can't be swapped between the check and the
|
||||
read.
|
||||
|
||||
Each role instance names a `definition` (a version 2 role file), an
|
||||
optional `holder`, its Vikunja bot (`tracker`, required when the definition
|
||||
uses Vikunja), a credential reference for each service the definition
|
||||
lists, and optional agent-layer `vars`. Several instances may share one
|
||||
definition. Bot names and bot ids are unique across the instances and the
|
||||
sync bot.
|
||||
|
||||
A credential reference names a token file by absolute path, or an
|
||||
environment variable by name, plus `rotateBy` for Gitea or `expires` for
|
||||
Vikunja. The checks use `lstat` and `realpath` only. A token file must be a
|
||||
regular file you own, mode 600 or tighter, not empty, and outside the
|
||||
repository, `dataRoot` and every project root. A Vikunja token past
|
||||
`expires` refuses. One within seven days of it, or a Gitea token past
|
||||
`rotateBy`, prints a warning.
|
||||
|
||||
`launch` lets one instance (`by`) start the listed instances, up to `max`
|
||||
sessions per model family (Opus 4, Sonnet 4 at most). `by` must name an
|
||||
instance whose definition holds `role.launch` within-role; otherwise the
|
||||
file refuses. Only that instance keeps `role.launch`. Every other instance
|
||||
loses it, so for them it's gated.
|
||||
|
||||
### Project file
|
||||
|
||||
```json
|
||||
{ "projectVersion": 1, "id": "stack",
|
||||
"vars": { "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"] },
|
||||
"roles": { "coder": { "vars": { "limits.tools": ["read", "edit", "bash"] } } } }
|
||||
```
|
||||
|
||||
`id` must match the business file's name for the project. A missing
|
||||
project file is a warning in `validate` and an error in
|
||||
`resolve --project`.
|
||||
|
||||
## Variables
|
||||
|
||||
Every key is declared once in `src/vars.mjs` with its type, the layers
|
||||
that may set it and its merge rule. An unknown key refuses, and so does a
|
||||
key set at a layer it doesn't belong to.
|
||||
|
||||
Layers, least specific first: defaults, system (the system config), business
|
||||
(`vars`), project (`vars`, then `roles.<instance>.vars`), agent (the business
|
||||
file's `roles.<instance>.vars`). For a plain key the most specific layer
|
||||
wins. `limits.tools`, `limits.network` and `limits.authority` intersect:
|
||||
each layer that sets one narrows it, and the result also narrows the role
|
||||
definition. `limits.authority` is an allowlist, so a role action it leaves
|
||||
out becomes gated.
|
||||
|
||||
`resolve` prints `provenance` beside `vars`. For a plain key it's the
|
||||
layer that set the value (`default`, `system`, `business:<id>`,
|
||||
`project:<id>`, `project:<id>:roles.<instance>`,
|
||||
`business:<id>:roles.<instance>`). For a limit it's the list of layers
|
||||
that narrowed it.
|
||||
|
||||
## API
|
||||
|
||||
```js
|
||||
import {
|
||||
loadBusiness, loadProject, systemVars, resolveInstance, classify,
|
||||
loadRole, validateRoleDocument, checkCredentialRef,
|
||||
} from "../packages/business/src/index.mjs";
|
||||
|
||||
const business = loadBusiness("mosaic-stack", { rolesDir }); // frozen
|
||||
const project = loadProject(business.projects.stack.root); // frozen
|
||||
const coder = resolveInstance({ system: systemVars(config), business, project, instance: "coder" });
|
||||
classify(coder, "git.push.working"); // "within" | "cross" | "gated"
|
||||
```
|
||||
|
||||
`resolveInstance` returns a frozen record: `business`, `project`,
|
||||
`instance`, `definition`, `holder`, `contract` (absolute path), `vars`,
|
||||
`provenance`, `limits` (`tools`, `network`, `authority.withinRole`,
|
||||
`authority.crossRole`), `credentials` (references only), `tracker` (`bot`,
|
||||
`botId` or null), `launch` (the launch block, or null) and `digest`, the
|
||||
SHA-256 of the record's canonical JSON. `classify` refuses an action
|
||||
outside the vocabulary. `launch` is set exactly when `classify(record,
|
||||
"role.launch")` is `within`. If `limits.authority` leaves out
|
||||
`role.launch` for the launcher, `launch` is null. A launcher should
|
||||
still ask `classify`, not read `launch` alone.
|
||||
|
||||
Errors are `BusinessError` with `exitCode` 2 (invalid) or 4 (a required
|
||||
file missing). The CLI adds 3 for a system config problem and 4 for usage.
|
||||
|
||||
## Limits
|
||||
|
||||
- The action vocabulary lives here, on the host. Neither workers nor
|
||||
container images read it, so it isn't under `contracts/`.
|
||||
- The checks run as you, against files you can change. They catch
|
||||
mistakes; they don't stop someone who already runs as your user.
|
||||
- A token file can change between `validate` and the moment it's opened.
|
||||
The code that opens it at run time repeats these checks (rows S2 and S3).
|
||||
- Slice 1 knows two services and one tracker kind. A new one is a
|
||||
reviewed change to `src/vocabulary.mjs` and `src/vars.mjs`.
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"businessVersion": 1,
|
||||
"id": "mosaic-stack",
|
||||
"human": "jason",
|
||||
"arbiters": {
|
||||
"delivery": "pm",
|
||||
"technical": "cto"
|
||||
},
|
||||
"projects": {
|
||||
"stack": {
|
||||
"root": "/mnt/storage/src/mosaic-stack"
|
||||
}
|
||||
},
|
||||
"vars": {
|
||||
"tracker.baseUrl": "http://127.0.0.1:3456",
|
||||
"gitea.baseUrl": "https://git.mosaicstack.dev"
|
||||
},
|
||||
"tracker": {
|
||||
"sync": {
|
||||
"bot": "bot-mosaic-stack-sync",
|
||||
"botId": 0,
|
||||
"credentials": {
|
||||
"vikunja": {
|
||||
"file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/sync-vikunja.token",
|
||||
"expires": "YYYY-MM-DD"
|
||||
}
|
||||
}
|
||||
},
|
||||
"labels": {}
|
||||
},
|
||||
"roles": {
|
||||
"pm": {
|
||||
"definition": "pm",
|
||||
"holder": "sage",
|
||||
"tracker": {
|
||||
"bot": "bot-mosaic-stack-pm",
|
||||
"botId": 0
|
||||
},
|
||||
"credentials": {
|
||||
"gitea": {
|
||||
"file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/pm-gitea.token",
|
||||
"rotateBy": "YYYY-MM-DD"
|
||||
},
|
||||
"vikunja": {
|
||||
"file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/pm-vikunja.token",
|
||||
"expires": "YYYY-MM-DD"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cto": {
|
||||
"definition": "cto",
|
||||
"holder": "darkwing",
|
||||
"tracker": {
|
||||
"bot": "bot-mosaic-stack-cto",
|
||||
"botId": 0
|
||||
},
|
||||
"credentials": {
|
||||
"gitea": {
|
||||
"file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/cto-gitea.token",
|
||||
"rotateBy": "YYYY-MM-DD"
|
||||
},
|
||||
"vikunja": {
|
||||
"file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/cto-vikunja.token",
|
||||
"expires": "YYYY-MM-DD"
|
||||
}
|
||||
}
|
||||
},
|
||||
"coder": {
|
||||
"definition": "coder",
|
||||
"tracker": {
|
||||
"bot": "bot-mosaic-stack-coder",
|
||||
"botId": 0
|
||||
},
|
||||
"credentials": {
|
||||
"gitea": {
|
||||
"file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/coder-gitea.token",
|
||||
"rotateBy": "YYYY-MM-DD"
|
||||
},
|
||||
"vikunja": {
|
||||
"file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/coder-vikunja.token",
|
||||
"expires": "YYYY-MM-DD"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewer": {
|
||||
"definition": "reviewer",
|
||||
"tracker": {
|
||||
"bot": "bot-mosaic-stack-reviewer",
|
||||
"botId": 0
|
||||
},
|
||||
"credentials": {
|
||||
"gitea": {
|
||||
"file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/reviewer-gitea.token",
|
||||
"rotateBy": "YYYY-MM-DD"
|
||||
},
|
||||
"vikunja": {
|
||||
"file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/reviewer-vikunja.token",
|
||||
"expires": "YYYY-MM-DD"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"launch": {
|
||||
"by": "pm",
|
||||
"instances": [
|
||||
"coder",
|
||||
"reviewer"
|
||||
],
|
||||
"max": {
|
||||
"opus": 4,
|
||||
"sonnet": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@mosaic/business",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Role definitions, business and project files, variable layers and the resolver (slice 1 row S1).",
|
||||
"license": "UNLICENSED",
|
||||
"type": "module",
|
||||
"engines": { "node": ">=24" },
|
||||
"exports": { ".": "./src/index.mjs" },
|
||||
"scripts": { "test": "node --test tests/" }
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// The business file, <configDir>/businesses/<id>.json (REQ-ROLE-3, note
|
||||
// section 2, addendum A sections 7 and 8). Jason writes it; the stack reads
|
||||
// it and never writes it. A missing or invalid file refuses (lead decision
|
||||
// 46, 6.1).
|
||||
|
||||
import { dirname, isAbsolute, join, normalize } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { LAUNCH_CEILING } from "./vocabulary.mjs";
|
||||
import { checkVars } from "./vars.mjs";
|
||||
import { parseCredentialRef } from "./credentials.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
} from "./util.mjs";
|
||||
|
||||
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch"];
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
export function configDir(env = process.env) {
|
||||
const file = env.MOSAIC_CONFIG || join(homedir(), ".config", "mosaic-dev", "config.json");
|
||||
return dirname(file);
|
||||
}
|
||||
|
||||
export function businessFilePath(id, dir = configDir()) {
|
||||
requireId(id, "business id");
|
||||
return join(dir, "businesses", `${id}.json`);
|
||||
}
|
||||
|
||||
function checkBot(tracker, where) {
|
||||
requireObject(tracker, where);
|
||||
rejectUnknownKeys(tracker, ["bot", "botId"], where);
|
||||
if (typeof tracker.bot !== "string" || !BOT_NAME.test(tracker.bot)) refuse(`${where}.bot must be a Vikunja bot username starting with "bot-"`);
|
||||
requirePositiveInt(tracker.botId, `${where}.botId`);
|
||||
return { bot: tracker.bot, botId: tracker.botId };
|
||||
}
|
||||
|
||||
function checkCredentialMap(map, services, where) {
|
||||
requireObject(map, where);
|
||||
const names = Object.keys(map).sort();
|
||||
const wanted = [...services].sort();
|
||||
if (names.join(",") !== wanted.join(",")) {
|
||||
refuse(`${where} must reference exactly the services its role definition needs (${wanted.join(", ") || "none"}; got ${names.join(", ") || "none"})`);
|
||||
}
|
||||
const out = {};
|
||||
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkTracker(tracker, file) {
|
||||
const where = `${file} tracker`;
|
||||
requireObject(tracker, where);
|
||||
rejectUnknownKeys(tracker, ["sync", "labels"], where);
|
||||
requireObject(tracker.sync, `${where}.sync`);
|
||||
rejectUnknownKeys(tracker.sync, ["bot", "botId", "credentials"], `${where}.sync`);
|
||||
const sync = {
|
||||
...checkBot({ bot: tracker.sync.bot, botId: tracker.sync.botId }, `${where}.sync`),
|
||||
credentials: checkCredentialMap(tracker.sync.credentials, ["vikunja"], `${where}.sync.credentials`),
|
||||
};
|
||||
let labels = {};
|
||||
if (tracker.labels !== undefined) {
|
||||
requireObject(tracker.labels, `${where}.labels`);
|
||||
// fromEntries defines own properties, so a "__proto__" title stays data.
|
||||
labels = Object.fromEntries(Object.entries(tracker.labels).map(([title, id]) => [
|
||||
requireString(title, `${where}.labels title`, { max: 100 }),
|
||||
requirePositiveInt(id, `${where}.labels.${title}`),
|
||||
]));
|
||||
}
|
||||
return { sync, labels };
|
||||
}
|
||||
|
||||
function checkRoles(roles, file, rolesDir) {
|
||||
const where = `${file} roles`;
|
||||
requireObject(roles, where);
|
||||
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
|
||||
const out = {};
|
||||
const definitions = {};
|
||||
const bots = new Set();
|
||||
const botIds = new Set();
|
||||
for (const [instance, entry] of Object.entries(roles)) {
|
||||
requireId(instance, `${where} instance name`);
|
||||
const at = `${where}.${instance}`;
|
||||
requireObject(entry, at);
|
||||
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials"], at);
|
||||
requireId(entry.definition, `${at}.definition`);
|
||||
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
|
||||
definitions[entry.definition] = definition;
|
||||
if (definition.roleVersion !== 2) refuse(`${at}: role definition ${entry.definition} is version 1 and can't back a business role instance`);
|
||||
const services = definition.credentials.map((c) => c.service);
|
||||
let tracker = null;
|
||||
if (services.includes("vikunja")) {
|
||||
if (entry.tracker === undefined) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`);
|
||||
tracker = checkBot(entry.tracker, `${at}.tracker`);
|
||||
if (bots.has(tracker.bot)) refuse(`${at}.tracker.bot ${tracker.bot} is used by another instance`);
|
||||
if (botIds.has(tracker.botId)) refuse(`${at}.tracker.botId ${tracker.botId} is used by another instance`);
|
||||
bots.add(tracker.bot);
|
||||
botIds.add(tracker.botId);
|
||||
} else if (entry.tracker !== undefined) {
|
||||
refuse(`${at} has "tracker" but ${entry.definition} uses no vikunja credential`);
|
||||
}
|
||||
out[instance] = {
|
||||
definition: entry.definition,
|
||||
holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`),
|
||||
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
|
||||
tracker,
|
||||
credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`),
|
||||
};
|
||||
}
|
||||
return { roles: out, definitions };
|
||||
}
|
||||
|
||||
function checkLaunch(launch, roles, definitions, file) {
|
||||
const where = `${file} launch`;
|
||||
requireObject(launch, where);
|
||||
rejectUnknownKeys(launch, ["by", "instances", "max"], where);
|
||||
requireId(launch.by, `${where}.by`);
|
||||
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
|
||||
const launcher = definitions[roles[launch.by].definition];
|
||||
if (!launcher.authority.withinRole.includes("role.launch")) {
|
||||
refuse(`${where}.by names ${launch.by}, whose role ${launcher.name} doesn't hold role.launch within-role`);
|
||||
}
|
||||
const instances = requireDistinctList(launch.instances, `${where}.instances`, (name) => {
|
||||
requireId(name, `${where}.instances entry`);
|
||||
if (!Object.hasOwn(roles, name)) refuse(`${where}.instances names ${name}, which the business file doesn't declare`);
|
||||
if (name === launch.by) refuse(`${where}.instances can't include the launcher itself (${name})`);
|
||||
}, { nonEmpty: true });
|
||||
requireObject(launch.max, `${where}.max`);
|
||||
if (Object.keys(launch.max).length === 0) refuse(`${where}.max must name at least one model family`);
|
||||
const max = {};
|
||||
for (const [family, count] of Object.entries(launch.max)) {
|
||||
const ceiling = Object.hasOwn(LAUNCH_CEILING, family) ? LAUNCH_CEILING[family] : undefined;
|
||||
if (ceiling === undefined) refuse(`${where}.max names unknown model family ${JSON.stringify(family)} (known: ${Object.keys(LAUNCH_CEILING).join(", ")})`);
|
||||
if (!Number.isSafeInteger(count) || count < 0 || count > ceiling) refuse(`${where}.max.${family} must be an integer from 0 to ${ceiling}`);
|
||||
max[family] = count;
|
||||
}
|
||||
return { by: launch.by, instances, max };
|
||||
}
|
||||
|
||||
// Validate a parsed business document read from `file`. `rolesDir` is where
|
||||
// role definitions live. Returns a frozen business with its definitions.
|
||||
export function validateBusinessDocument(document, file, { rolesDir }) {
|
||||
requireObject(document, "business file");
|
||||
rejectUnknownKeys(document, TOP_KEYS, "business file");
|
||||
if (document.businessVersion !== 1) refuse(`business file "businessVersion" must be 1 (${file})`);
|
||||
requireId(document.id, "business id");
|
||||
const base = file.split("/").pop().replace(/\.json$/, "");
|
||||
if (document.id !== base) refuse(`business "id" (${document.id}) must match its filename (${base}.json)`);
|
||||
for (const key of ["human", "arbiters", "projects", "tracker", "roles"]) {
|
||||
if (document[key] === undefined) refuse(`business file requires "${key}" (${file})`);
|
||||
}
|
||||
const human = requireId(document.human, "business human");
|
||||
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
|
||||
const { roles, definitions } = checkRoles(document.roles, file, rolesDir);
|
||||
|
||||
requireObject(document.arbiters, `${file} arbiters`);
|
||||
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
|
||||
const arbiters = {};
|
||||
for (const kind of ["delivery", "technical"]) {
|
||||
const name = document.arbiters[kind];
|
||||
requireId(name, `${file} arbiters.${kind}`);
|
||||
if (!Object.hasOwn(roles, name)) refuse(`${file} arbiters.${kind} names ${name}, which the business file doesn't declare`);
|
||||
arbiters[kind] = name;
|
||||
}
|
||||
|
||||
requireObject(document.projects, `${file} projects`);
|
||||
if (Object.keys(document.projects).length === 0) refuse(`${file} projects must name at least one project`);
|
||||
const projects = {};
|
||||
for (const [id, entry] of Object.entries(document.projects)) {
|
||||
requireId(id, `${file} project id`);
|
||||
requireObject(entry, `${file} projects.${id}`);
|
||||
rejectUnknownKeys(entry, ["root"], `${file} projects.${id}`);
|
||||
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
|
||||
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
|
||||
}
|
||||
projects[id] = { root: entry.root };
|
||||
}
|
||||
|
||||
const tracker = checkTracker(document.tracker, file);
|
||||
for (const [instance, role] of Object.entries(roles)) {
|
||||
if (role.tracker && (role.tracker.bot === tracker.sync.bot || role.tracker.botId === tracker.sync.botId)) {
|
||||
refuse(`${file} tracker.sync must use its own bot, not the one roles.${instance} uses`);
|
||||
}
|
||||
}
|
||||
const launch = document.launch === undefined ? null : checkLaunch(document.launch, roles, definitions, file);
|
||||
|
||||
return deepFreeze({
|
||||
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, definitions,
|
||||
});
|
||||
}
|
||||
|
||||
// Load businesses/<id>.json from the config directory. The file holds
|
||||
// authority, so it must belong to this user and not be writable by group
|
||||
// or other.
|
||||
export function loadBusiness(id, { dir = configDir(), rolesDir }) {
|
||||
if (!rolesDir) throw new Error("loadBusiness needs rolesDir");
|
||||
const file = businessFilePath(id, dir);
|
||||
const document = readJsonFile(file, "business file", (stat) => {
|
||||
if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`);
|
||||
if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
|
||||
});
|
||||
return validateBusinessDocument(document, file, { rolesDir });
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
// mosaic business validate <business>
|
||||
// mosaic business resolve <business> <instance> [--project <id>]
|
||||
//
|
||||
// validate loads the business file, every role definition it uses, the
|
||||
// project files that exist under its declared roots, and stat-checks every
|
||||
// credential reference. resolve prints one role instance's resolved record.
|
||||
// Both print JSON on stdout and problems on stderr.
|
||||
//
|
||||
// Exit codes: 0 ok; 2 invalid (business, role, project file or a credential
|
||||
// reference); 3 system config problem; 4 usage, or a required file missing.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BusinessError } from "./errors.mjs";
|
||||
import { loadBusiness } from "./business.mjs";
|
||||
import { loadProject, projectFilePath } from "./project.mjs";
|
||||
import { checkCredentialRef } from "./credentials.mjs";
|
||||
import { systemVars, resolveInstance } from "./resolve.mjs";
|
||||
|
||||
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
const USAGE = "usage: mosaic business validate <business> | resolve <business> <instance> [--project <id>]";
|
||||
|
||||
function fail(code, message) {
|
||||
process.stderr.write(`mosaic business: ${message}\n`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function loadSystem() {
|
||||
const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate"], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
if (proc.status !== 0) fail(3, `system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`);
|
||||
try {
|
||||
return JSON.parse(proc.stdout);
|
||||
} catch {
|
||||
fail(3, "system config: mosaic-config printed something that isn't JSON");
|
||||
}
|
||||
}
|
||||
|
||||
function rolesDir() {
|
||||
return resolve(process.env.MOSAIC_ROLES_DIR || join(REPO, "roles"));
|
||||
}
|
||||
|
||||
// Check credential references; print warnings; return the problem list.
|
||||
function checkRefs(refs, forbiddenRoots) {
|
||||
const problems = [];
|
||||
const warnings = [];
|
||||
for (const ref of refs) {
|
||||
const result = checkCredentialRef(ref, { forbiddenRoots });
|
||||
problems.push(...result.problems);
|
||||
warnings.push(...result.warnings);
|
||||
}
|
||||
return { problems, warnings };
|
||||
}
|
||||
|
||||
function forbiddenRoots(config, business) {
|
||||
return [REPO, config.dataRoot, ...Object.values(business.projects).map((p) => p.root)];
|
||||
}
|
||||
|
||||
function validate(args) {
|
||||
if (args.length !== 1) fail(4, USAGE);
|
||||
const config = loadSystem();
|
||||
const system = systemVars(config);
|
||||
const business = loadBusiness(args[0], { rolesDir: rolesDir() });
|
||||
const projects = {};
|
||||
const warnings = [];
|
||||
for (const [id, entry] of Object.entries(business.projects)) {
|
||||
const file = projectFilePath(entry.root);
|
||||
if (!existsSync(file)) {
|
||||
projects[id] = "absent";
|
||||
warnings.push(`project ${id}: no project file at ${file}; it has no project variables`);
|
||||
continue;
|
||||
}
|
||||
const project = loadProject(entry.root);
|
||||
if (project.id !== id) fail(2, `project file ${file} has id ${project.id}, but business ${business.id} declares it as ${id}`);
|
||||
for (const instance of Object.keys(business.roles)) resolveInstance({ system, business, project, instance });
|
||||
projects[id] = "valid";
|
||||
}
|
||||
const refs = [
|
||||
...Object.values(business.tracker.sync.credentials),
|
||||
...Object.values(business.roles).flatMap((r) => Object.values(r.credentials)),
|
||||
];
|
||||
const checked = checkRefs(refs, forbiddenRoots(config, business));
|
||||
warnings.push(...checked.warnings);
|
||||
const instances = {};
|
||||
for (const instance of Object.keys(business.roles)) {
|
||||
instances[instance] = resolveInstance({ system, business, instance }).digest;
|
||||
}
|
||||
for (const w of warnings) process.stderr.write(`mosaic business: warning: ${w}\n`);
|
||||
if (checked.problems.length > 0) {
|
||||
for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`);
|
||||
fail(2, `business ${business.id}: ${checked.problems.length} credential reference problem(s)`);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify({ business: business.id, file: business.file, projects, instances }, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function resolveCommand(args) {
|
||||
let project = null;
|
||||
const rest = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--project") {
|
||||
if (!args[i + 1] || project !== null) fail(4, USAGE);
|
||||
project = args[++i];
|
||||
} else rest.push(args[i]);
|
||||
}
|
||||
if (rest.length !== 2) fail(4, USAGE);
|
||||
const [businessId, instance] = rest;
|
||||
const config = loadSystem();
|
||||
const business = loadBusiness(businessId, { rolesDir: rolesDir() });
|
||||
let loaded = null;
|
||||
if (project !== null) {
|
||||
const entry = Object.hasOwn(business.projects, project) ? business.projects[project] : null;
|
||||
if (!entry) fail(2, `business ${business.id} declares no project ${JSON.stringify(project)}`);
|
||||
loaded = loadProject(entry.root);
|
||||
}
|
||||
const resolved = resolveInstance({ system: systemVars(config), business, project: loaded, instance });
|
||||
const checked = checkRefs(Object.values(resolved.credentials), forbiddenRoots(config, business));
|
||||
for (const w of checked.warnings) process.stderr.write(`mosaic business: warning: ${w}\n`);
|
||||
if (checked.problems.length > 0) {
|
||||
for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`);
|
||||
fail(2, `${business.id} ${instance}: ${checked.problems.length} credential reference problem(s)`);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
|
||||
}
|
||||
|
||||
const [verb, ...args] = process.argv.slice(2);
|
||||
try {
|
||||
if (verb === "validate") validate(args);
|
||||
else if (verb === "resolve") resolveCommand(args);
|
||||
else fail(4, USAGE);
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessError) fail(error.exitCode, error.message);
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Credential references (note section 2, addendum A section 7). A
|
||||
// reference names where a token lives, never the token. The checks here use
|
||||
// lstat and realpath only; nothing in this package opens a token file.
|
||||
|
||||
import { lstatSync, realpathSync } from "node:fs";
|
||||
import { isAbsolute, normalize, relative, sep } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { SERVICES } from "./vocabulary.mjs";
|
||||
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
|
||||
|
||||
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" });
|
||||
const WARN_DAYS = 7;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Shape check for one reference. Returns a frozen { service, file | env,
|
||||
// rotateBy | expires }.
|
||||
export function parseCredentialRef(ref, service, where) {
|
||||
if (!SERVICES.includes(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
|
||||
requireObject(ref, where);
|
||||
const dateKey = DATE_KEY[service];
|
||||
rejectUnknownKeys(ref, ["file", "env", dateKey], where);
|
||||
const hasFile = ref.file !== undefined;
|
||||
const hasEnv = ref.env !== undefined;
|
||||
if (hasFile === hasEnv) refuse(`${where} must name exactly one of "file" or "env"`);
|
||||
const out = { service };
|
||||
if (hasFile) {
|
||||
if (typeof ref.file !== "string" || !isAbsolute(ref.file) || normalize(ref.file) !== ref.file || ref.file.includes("\0")) {
|
||||
refuse(`${where}.file must be a normalized absolute path`);
|
||||
}
|
||||
out.file = ref.file;
|
||||
} else {
|
||||
if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`);
|
||||
out.env = ref.env;
|
||||
}
|
||||
if (ref[dateKey] === undefined) refuse(`${where} needs "${dateKey}" (YYYY-MM-DD)`);
|
||||
out[dateKey] = requireDate(ref[dateKey], `${where}.${dateKey}`);
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
function inside(root, path) {
|
||||
const rel = relative(root, path);
|
||||
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
// Check a parsed reference against the filesystem and the calendar.
|
||||
// Returns { problems: [...], warnings: [...] }; a caller that finds any
|
||||
// problem refuses. `forbiddenRoots` are directories a token file must not
|
||||
// sit in (the repository, dataRoot). `now` is a Date.
|
||||
export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(), env = process.env, uid = process.getuid() } = {}) {
|
||||
const problems = [];
|
||||
const warnings = [];
|
||||
const label = `${ref.service} ${ref.file ? `file ${ref.file}` : `env ${ref.env}`}`;
|
||||
if (ref.file) {
|
||||
let stat = null;
|
||||
try {
|
||||
stat = lstatSync(ref.file);
|
||||
} catch {
|
||||
problems.push(`${label}: not found`);
|
||||
}
|
||||
if (stat) {
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`);
|
||||
else {
|
||||
if (stat.uid !== uid) problems.push(`${label}: owned by uid ${stat.uid}, not ${uid}`);
|
||||
if ((stat.mode & 0o077) !== 0) problems.push(`${label}: mode ${(stat.mode & 0o777).toString(8)} gives group or other access; use 600`);
|
||||
if (stat.size === 0) problems.push(`${label}: empty`);
|
||||
let real = ref.file;
|
||||
try {
|
||||
real = realpathSync(ref.file);
|
||||
} catch {
|
||||
problems.push(`${label}: path can't be resolved`);
|
||||
}
|
||||
for (const root of forbiddenRoots) {
|
||||
let realRoot = root;
|
||||
try {
|
||||
realRoot = realpathSync(root);
|
||||
} catch {
|
||||
// A root that doesn't exist yet can't contain the file.
|
||||
}
|
||||
if (inside(realRoot, real)) problems.push(`${label}: inside ${root}; token files live outside the repository and dataRoot`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (env[ref.env] === undefined || env[ref.env] === "") {
|
||||
warnings.push(`${label}: not set in this environment; the launcher must provide it`);
|
||||
}
|
||||
const days = (dateText) => Math.floor((Date.parse(`${dateText}T00:00:00Z`) - now.getTime()) / DAY_MS);
|
||||
if (ref.expires) {
|
||||
const left = days(ref.expires);
|
||||
if (Date.parse(`${ref.expires}T00:00:00Z`) <= now.getTime()) problems.push(`${label}: expired on ${ref.expires}`);
|
||||
else if (left < WARN_DAYS) warnings.push(`${label}: expires on ${ref.expires}`);
|
||||
}
|
||||
if (ref.rotateBy && Date.parse(`${ref.rotateBy}T00:00:00Z`) <= now.getTime()) {
|
||||
warnings.push(`${label}: rotation was due on ${ref.rotateBy}`);
|
||||
}
|
||||
return { problems, warnings };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Exit codes follow docs/TOOLS.md: 2 invalid data or refused, 4 usage or a
|
||||
// missing file. Every refusal in this package is a BusinessError.
|
||||
export class BusinessError extends Error {
|
||||
constructor(message, exitCode = 2) {
|
||||
super(message);
|
||||
this.name = "BusinessError";
|
||||
this.exitCode = exitCode;
|
||||
}
|
||||
}
|
||||
|
||||
export function refuse(message, exitCode = 2) {
|
||||
throw new BusinessError(message, exitCode);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// @mosaic/business: role definitions, the business and project files, the
|
||||
// variable registry and the resolver (slice 1 row S1). Everything here reads
|
||||
// files and refuses on a problem; nothing writes.
|
||||
|
||||
export { BusinessError } from "./errors.mjs";
|
||||
export {
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE, LAUNCH_CEILING,
|
||||
} from "./vocabulary.mjs";
|
||||
export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
|
||||
export { LAYERS, REGISTRY, checkVars, mergeVars } from "./vars.mjs";
|
||||
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
|
||||
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
|
||||
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
|
||||
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
|
||||
@@ -0,0 +1,36 @@
|
||||
// The project file, <root>/.mosaic/project.json. It lives in the project's
|
||||
// repository and changes by reviewed commits there. The stack only reads it.
|
||||
|
||||
import { isAbsolute, join, normalize } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { checkVars } from "./vars.mjs";
|
||||
import { requireObject, rejectUnknownKeys, requireId, readJsonFile, deepFreeze } from "./util.mjs";
|
||||
|
||||
export function projectFilePath(root) {
|
||||
return join(root, ".mosaic", "project.json");
|
||||
}
|
||||
|
||||
export function validateProjectDocument(document, file) {
|
||||
requireObject(document, "project file");
|
||||
rejectUnknownKeys(document, ["projectVersion", "id", "vars", "roles"], "project file");
|
||||
if (document.projectVersion !== 1) refuse(`project file "projectVersion" must be 1 (${file})`);
|
||||
requireId(document.id, "project id");
|
||||
const vars = checkVars(document.vars ?? {}, "project", `${file} vars`);
|
||||
const roles = {};
|
||||
if (document.roles !== undefined) {
|
||||
requireObject(document.roles, `${file} roles`);
|
||||
for (const [instance, entry] of Object.entries(document.roles)) {
|
||||
requireId(instance, `${file} role instance`);
|
||||
requireObject(entry, `${file} roles.${instance}`);
|
||||
rejectUnknownKeys(entry, ["vars"], `${file} roles.${instance}`);
|
||||
roles[instance] = { vars: checkVars(entry.vars ?? {}, "project", `${file} roles.${instance}.vars`) };
|
||||
}
|
||||
}
|
||||
return deepFreeze({ projectVersion: 1, id: document.id, file, vars, roles });
|
||||
}
|
||||
|
||||
export function loadProject(root) {
|
||||
if (typeof root !== "string" || !isAbsolute(root) || normalize(root) !== root) refuse(`project root must be a normalized absolute path (got ${JSON.stringify(root)})`);
|
||||
const file = projectFilePath(root);
|
||||
return validateProjectDocument(readJsonFile(file, "project file"), file);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// The resolver (REQ-VAR-1 and 2). It joins one role instance's definition,
|
||||
// the business file, the optional project file and the system config into
|
||||
// the record a launcher or the broker uses. Plain values: the most specific
|
||||
// layer wins. Limits: every layer narrows, nothing widens.
|
||||
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ACTIONS, NETWORKS } from "./vocabulary.mjs";
|
||||
import { checkVars, mergeVars } from "./vars.mjs";
|
||||
import { canonicalJson, sha256, deepFreeze } from "./util.mjs";
|
||||
import { projectFilePath } from "./project.mjs";
|
||||
|
||||
// The system layer from `mosaic-config.mjs validate` output.
|
||||
export function systemVars(config) {
|
||||
return checkVars({
|
||||
environment: config.environment,
|
||||
dataRoot: config.dataRoot,
|
||||
"execution.backend": config.execution.backend,
|
||||
"execution.provider": config.execution.provider,
|
||||
"execution.model": config.execution.model,
|
||||
"execution.adapter": config.execution.adapter,
|
||||
}, "system", "system config");
|
||||
}
|
||||
|
||||
function narrowerNetwork(a, b) {
|
||||
return NETWORKS.indexOf(a) <= NETWORKS.indexOf(b) ? a : b;
|
||||
}
|
||||
|
||||
// resolveInstance({ system, business, project, instance })
|
||||
// system systemVars(...) output
|
||||
// business loadBusiness(...) output
|
||||
// project loadProject(...) output, or null
|
||||
// instance a role instance the business file declares
|
||||
export function resolveInstance({ system, business, project = null, instance }) {
|
||||
const entry = Object.hasOwn(business.roles, instance) ? business.roles[instance] : null;
|
||||
if (!entry) refuse(`business ${business.id} declares no role instance ${JSON.stringify(instance)}`);
|
||||
const definition = business.definitions[entry.definition];
|
||||
|
||||
const layers = [
|
||||
{ layer: "system", source: "system", vars: system },
|
||||
{ layer: "business", source: `business:${business.id}`, vars: business.vars },
|
||||
];
|
||||
let projectId = null;
|
||||
if (project) {
|
||||
const declared = Object.entries(business.projects).find(([, p]) => projectFilePath(p.root) === project.file);
|
||||
if (!declared || declared[0] !== project.id) {
|
||||
refuse(`project ${project.id} (${project.file}) isn't declared under that id in business ${business.id}`);
|
||||
}
|
||||
projectId = project.id;
|
||||
for (const name of Object.keys(project.roles)) {
|
||||
if (!Object.hasOwn(business.roles, name)) refuse(`project ${project.id} sets vars for role instance ${name}, which business ${business.id} doesn't declare`);
|
||||
}
|
||||
layers.push({ layer: "project", source: `project:${project.id}`, vars: project.vars });
|
||||
if (Object.hasOwn(project.roles, instance)) {
|
||||
layers.push({ layer: "project", source: `project:${project.id}:roles.${instance}`, vars: project.roles[instance].vars });
|
||||
}
|
||||
}
|
||||
layers.push({ layer: "agent", source: `business:${business.id}:roles.${instance}`, vars: entry.vars });
|
||||
const { vars, provenance } = mergeVars(layers);
|
||||
|
||||
let tools = [...definition.tools];
|
||||
let network = definition.network;
|
||||
let withinRole = [...definition.authority.withinRole];
|
||||
let crossRole = [...definition.authority.crossRole];
|
||||
if (vars["limits.tools"]) tools = tools.filter((t) => vars["limits.tools"].includes(t));
|
||||
if (vars["limits.network"]) network = narrowerNetwork(network, vars["limits.network"]);
|
||||
if (vars["limits.authority"]) {
|
||||
withinRole = withinRole.filter((a) => vars["limits.authority"].includes(a));
|
||||
crossRole = crossRole.filter((a) => vars["limits.authority"].includes(a));
|
||||
}
|
||||
// role.launch needs the business file's launch block naming this
|
||||
// instance (addendum A section 8), and the verb must survive
|
||||
// limits.authority. Otherwise the verb is gated and `launch` is null,
|
||||
// so the two never disagree.
|
||||
const launch = business.launch && business.launch.by === instance && withinRole.includes("role.launch") ? business.launch : null;
|
||||
if (!launch) {
|
||||
withinRole = withinRole.filter((a) => a !== "role.launch");
|
||||
crossRole = crossRole.filter((a) => a !== "role.launch");
|
||||
}
|
||||
|
||||
const resolved = {
|
||||
business: business.id,
|
||||
project: projectId,
|
||||
instance,
|
||||
definition: definition.name,
|
||||
holder: entry.holder,
|
||||
contract: definition.contractPath,
|
||||
vars,
|
||||
provenance,
|
||||
limits: { tools, network, authority: { withinRole, crossRole } },
|
||||
credentials: entry.credentials,
|
||||
tracker: entry.tracker,
|
||||
launch,
|
||||
};
|
||||
return deepFreeze({ ...resolved, digest: sha256(canonicalJson(resolved)) });
|
||||
}
|
||||
|
||||
// Classify one action for a resolved instance: "within", "cross" or
|
||||
// "gated". An action outside the vocabulary refuses.
|
||||
export function classify(resolved, action) {
|
||||
if (!ACTIONS.includes(action)) refuse(`unknown action: ${JSON.stringify(action)}`);
|
||||
if (resolved.limits.authority.withinRole.includes(action)) return "within";
|
||||
if (resolved.limits.authority.crossRole.includes(action)) return "cross";
|
||||
return "gated";
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Role definitions, roles/<name>.json. Version 2 (REQ-ROLE-1) adds a
|
||||
// contract, an authority map over the closed vocabulary and the
|
||||
// credentials the role needs, and keeps version 1's tool and network
|
||||
// ceilings. Version 1 files still load: they carry no authority, so every
|
||||
// vocabulary action is gated for them.
|
||||
|
||||
import { lstatSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import {
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
} from "./vocabulary.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
} from "./util.mjs";
|
||||
|
||||
const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
|
||||
const V1_KEYS = ["roleVersion", "name", "tools", "network"];
|
||||
const V2_KEYS = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
|
||||
|
||||
function checkTools(tools) {
|
||||
if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names');
|
||||
return requireDistinctList(tools, "role tools", (tool) => {
|
||||
if (!TOOLS.includes(tool)) refuse(`unsupported tool: ${JSON.stringify(tool)} (supported: ${TOOLS.join(", ")})`);
|
||||
});
|
||||
}
|
||||
|
||||
function checkNetwork(network) {
|
||||
if (!NETWORKS.includes(network)) refuse(`role "network" must be one of: ${NETWORKS.join(", ")}`);
|
||||
return network;
|
||||
}
|
||||
|
||||
function checkAuthority(authority) {
|
||||
requireObject(authority, 'role "authority"');
|
||||
rejectUnknownKeys(authority, ["withinRole", "crossRole"], 'role "authority"');
|
||||
const lists = {};
|
||||
for (const key of ["withinRole", "crossRole"]) {
|
||||
lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => {
|
||||
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}: ${JSON.stringify(action)}`);
|
||||
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`);
|
||||
});
|
||||
}
|
||||
const both = lists.withinRole.filter((a) => lists.crossRole.includes(a));
|
||||
if (both.length > 0) refuse(`action listed as both withinRole and crossRole: ${both.join(", ")}`);
|
||||
return lists;
|
||||
}
|
||||
|
||||
function checkGiteaScopes(scopes) {
|
||||
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
|
||||
const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
|
||||
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) {
|
||||
refuse(`unsupported gitea scope: ${JSON.stringify(scope)} (expected read:<category> or write:<category>; categories: ${GITEA_SCOPE_CATEGORIES.join(", ")})`);
|
||||
}
|
||||
}, { nonEmpty: true });
|
||||
const categories = list.map((s) => s.split(":")[1]);
|
||||
const twice = categories.filter((c, i) => categories.indexOf(c) !== i);
|
||||
if (twice.length > 0) refuse(`gitea scopes name ${twice[0]} twice; a token holds one level per category`);
|
||||
return list;
|
||||
}
|
||||
|
||||
function checkVikunjaScopes(scopes) {
|
||||
requireObject(scopes, "vikunja scopes");
|
||||
if (Object.keys(scopes).length === 0) refuse("vikunja scopes must name at least one route group");
|
||||
const out = {};
|
||||
for (const [group, verbs] of Object.entries(scopes)) {
|
||||
const allowed = VIKUNJA_GRANTABLE[group];
|
||||
if (!allowed) refuse(`vikunja route group not grantable to a role: ${JSON.stringify(group)}`);
|
||||
out[group] = requireDistinctList(verbs, `vikunja scopes.${group}`, (verb) => {
|
||||
if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role: ${group}.${JSON.stringify(verb)}`);
|
||||
}, { nonEmpty: true });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkCredentials(credentials) {
|
||||
if (!Array.isArray(credentials)) refuse('role "credentials" must be an array');
|
||||
const seen = new Set();
|
||||
return credentials.map((entry, i) => {
|
||||
requireObject(entry, `role credentials[${i}]`);
|
||||
rejectUnknownKeys(entry, ["service", "scopes"], `role credentials[${i}]`);
|
||||
if (!SERVICES.includes(entry.service)) refuse(`role credentials[${i}].service must be one of: ${SERVICES.join(", ")}`);
|
||||
if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`);
|
||||
seen.add(entry.service);
|
||||
const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
|
||||
return { service: entry.service, scopes };
|
||||
});
|
||||
}
|
||||
|
||||
function checkContract(contract, file) {
|
||||
if (typeof contract !== "string" || !CONTRACT_NAME.test(contract)) {
|
||||
refuse(`role "contract" must be a Markdown file name in the role's directory, matching ${CONTRACT_NAME} (got ${JSON.stringify(contract)})`);
|
||||
}
|
||||
const path = join(dirname(file), contract);
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(path);
|
||||
} catch {
|
||||
refuse(`role contract not found: ${path}`);
|
||||
}
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) refuse(`role contract must be a non-empty regular file: ${path}`);
|
||||
return path;
|
||||
}
|
||||
|
||||
// Validate a parsed role document read from `file`. Returns a frozen role:
|
||||
// { roleVersion, name, title, contract, contractPath, tools, network,
|
||||
// authority: { withinRole, crossRole }, credentials: [{ service, scopes }] }.
|
||||
export function validateRoleDocument(document, file) {
|
||||
requireObject(document, "role");
|
||||
if (document.roleVersion !== 1 && document.roleVersion !== 2) refuse('role "roleVersion" must be 1 or 2');
|
||||
rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role");
|
||||
requireId(document.name, "role name");
|
||||
const base = basename(file).replace(/\.json$/, "");
|
||||
if (document.name !== base) refuse(`role "name" (${document.name}) must match its filename (${base}.json)`);
|
||||
const tools = checkTools(document.tools);
|
||||
if (document.roleVersion === 1) {
|
||||
const network = document.network === undefined ? "none" : checkNetwork(document.network);
|
||||
return deepFreeze({
|
||||
roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network,
|
||||
authority: { withinRole: [], crossRole: [] }, credentials: [],
|
||||
});
|
||||
}
|
||||
for (const key of V2_KEYS) {
|
||||
if (document[key] === undefined) refuse(`role version 2 requires "${key}"`);
|
||||
}
|
||||
return deepFreeze({
|
||||
roleVersion: 2,
|
||||
name: document.name,
|
||||
title: requireString(document.title, 'role "title"', { max: 80 }),
|
||||
contract: document.contract,
|
||||
contractPath: checkContract(document.contract, file),
|
||||
tools,
|
||||
network: checkNetwork(document.network),
|
||||
authority: checkAuthority(document.authority),
|
||||
credentials: checkCredentials(document.credentials),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadRoleFile(file) {
|
||||
return validateRoleDocument(readJsonFile(file, "role file"), file);
|
||||
}
|
||||
|
||||
export function loadRole(rolesDir, name) {
|
||||
requireId(name, "role name");
|
||||
return loadRoleFile(join(rolesDir, `${name}.json`));
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ID_PATTERN } from "./vocabulary.mjs";
|
||||
|
||||
export function isPlainObject(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function requireObject(value, where) {
|
||||
if (!isPlainObject(value)) refuse(`${where} must be a JSON object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function rejectUnknownKeys(object, allowed, where) {
|
||||
for (const key of Object.keys(object)) {
|
||||
if (!allowed.includes(key)) refuse(`unsupported ${where} key: ${JSON.stringify(key)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function requireId(value, where) {
|
||||
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
|
||||
refuse(`${where} must match ${ID_PATTERN} (got ${JSON.stringify(value)})`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requireString(value, where, { max = 200 } = {}) {
|
||||
if (typeof value !== "string" || value.trim().length === 0 || value.length > max || value.includes("\0")) {
|
||||
refuse(`${where} must be a non-empty string of at most ${max} characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requirePositiveInt(value, where) {
|
||||
if (!Number.isSafeInteger(value) || value < 1) refuse(`${where} must be a positive integer (got ${JSON.stringify(value)})`);
|
||||
return value;
|
||||
}
|
||||
|
||||
// A list of distinct strings, each checked by `check`.
|
||||
export function requireDistinctList(value, where, check, { nonEmpty = false } = {}) {
|
||||
if (!Array.isArray(value)) refuse(`${where} must be an array`);
|
||||
if (nonEmpty && value.length === 0) refuse(`${where} must not be empty`);
|
||||
const seen = new Set();
|
||||
for (const item of value) {
|
||||
check(item);
|
||||
if (seen.has(item)) refuse(`duplicate entry in ${where}: ${JSON.stringify(item)}`);
|
||||
seen.add(item);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
// Calendar date YYYY-MM-DD that exists (2026-02-30 refuses).
|
||||
export function requireDate(value, where) {
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${where} must be a date YYYY-MM-DD (got ${JSON.stringify(value)})`);
|
||||
const d = new Date(`${value}T00:00:00Z`);
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) refuse(`${where} is not a real date: ${value}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// `checkStat` sees that descriptor's stat before the read, so the file
|
||||
// can't be swapped between the check and the read.
|
||||
export function readJsonFile(file, what, checkStat) {
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") refuse(`${what} not found: ${file}`, 4);
|
||||
refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
|
||||
}
|
||||
let text;
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
|
||||
checkStat?.(stat);
|
||||
text = readFileSync(fd, "utf8");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
refuse(`${what} is not valid JSON (${file}): ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// JSON with object keys sorted at every level, for digests.
|
||||
export function canonicalJson(value) {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (isPlainObject(value)) {
|
||||
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function sha256(text) {
|
||||
return createHash("sha256").update(text).digest("hex");
|
||||
}
|
||||
|
||||
export function deepFreeze(value) {
|
||||
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
|
||||
Object.freeze(value);
|
||||
for (const v of Object.values(value)) deepFreeze(v);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// 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 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),
|
||||
|
||||
// Project.
|
||||
"tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)),
|
||||
"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 };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// The closed action vocabulary and the other frozen lists a role file is
|
||||
// checked against. Design: agents/darkwing/work/slice1-data-model-2026-10-04.md
|
||||
// section 1.4, addendum A sections 2 and 8, addendum B section 2.
|
||||
//
|
||||
// A change to any list here is a reviewed commit to this file. Role files
|
||||
// that name something outside these lists are refused.
|
||||
|
||||
// Every action with an outside effect that slice 1 touches. Routine work
|
||||
// (editing files, running tests, writing docs, retrying) has no outside
|
||||
// effect and isn't listed.
|
||||
export const ACTIONS = Object.freeze([
|
||||
"task.create",
|
||||
"task.assign",
|
||||
"task.schedule",
|
||||
"task.update.assigned",
|
||||
"task.close",
|
||||
"task.reassign",
|
||||
"task.scope.change",
|
||||
"task.priority.change",
|
||||
"git.push.working",
|
||||
"git.push.protected",
|
||||
"git.merge.protected",
|
||||
"review.request",
|
||||
"review.verdict",
|
||||
"message.send",
|
||||
"message.external",
|
||||
"role.launch",
|
||||
"role.revoke",
|
||||
"credential.mint",
|
||||
"spend",
|
||||
"deploy",
|
||||
"policy.change",
|
||||
"prd.approve",
|
||||
"decision.resolve.technical",
|
||||
]);
|
||||
|
||||
// Always gated: a role file may not list these under withinRole or
|
||||
// crossRole. role.revoke is here because lead decision 46 (6.7) keeps every
|
||||
// revoke gated in slice 1.
|
||||
export const GATED_ONLY = Object.freeze([
|
||||
"credential.mint",
|
||||
"git.merge.protected",
|
||||
"git.push.protected",
|
||||
"deploy",
|
||||
"spend",
|
||||
"message.external",
|
||||
"policy.change",
|
||||
"prd.approve",
|
||||
"role.revoke",
|
||||
]);
|
||||
|
||||
// pi's documented built-in tools; the same list scripts/mosaic-task.mjs
|
||||
// accepts for tasks.
|
||||
export const TOOLS = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls"]);
|
||||
|
||||
// Ordered from narrowest to widest. Intersecting two values keeps the
|
||||
// narrower one.
|
||||
export const NETWORKS = Object.freeze(["none", "api-only", "open"]);
|
||||
|
||||
export const SERVICES = Object.freeze(["gitea", "vikunja"]);
|
||||
|
||||
// Gitea token scopes are <read|write>:<category>. "all" and the admin
|
||||
// category are never granted to a role.
|
||||
export const GITEA_SCOPE_CATEGORIES = Object.freeze([
|
||||
"activitypub", "issue", "misc", "notification", "organization", "package", "repository", "user",
|
||||
]);
|
||||
|
||||
// Vikunja 2.7.0 route groups and verbs a role token may hold. Each pair was
|
||||
// minted or used on a scratch 2.7.0 (Researcher's probes, addendum B
|
||||
// section 2). Anything not listed, including every verb addendum B lists
|
||||
// as never granted, is refused. A pair missing here that a role really
|
||||
// needs gets added after a probe shows Vikunja accepts it.
|
||||
export const VIKUNJA_GRANTABLE = Object.freeze({
|
||||
projects: Object.freeze(["read_one", "views_buckets", "views_buckets_tasks", "views_buckets_tasks_get"]),
|
||||
projects_views: Object.freeze(["read_all"]),
|
||||
tasks: Object.freeze(["read_all", "read_one", "create", "update"]),
|
||||
tasks_comments: Object.freeze(["read_all", "create"]),
|
||||
tasks_assignees: Object.freeze(["create", "delete"]),
|
||||
tasks_relations: Object.freeze(["create", "delete"]),
|
||||
tasks_labels: Object.freeze(["create", "delete"]),
|
||||
labels: Object.freeze(["read_all"]),
|
||||
});
|
||||
|
||||
// Model families the launch block may cap, and the PRD's ceiling for each
|
||||
// (REQ-LAUNCH-1: at most 4 Opus and 4 Sonnet sessions at once).
|
||||
export const LAUNCH_CEILING = Object.freeze({ opus: 4, sonnet: 4 });
|
||||
|
||||
export const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
@@ -0,0 +1,220 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { BusinessError, businessFilePath, configDir, loadBusiness, validateBusinessDocument } from "../src/index.mjs";
|
||||
import { businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
function refuses(fn, pattern, exitCode = 2) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
|
||||
assert.equal(error.exitCode, exitCode);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Validate a mutated copy of the fixture document.
|
||||
function check(mutate, { rolesDir = REPO_ROLES } = {}) {
|
||||
const root = tmp();
|
||||
const doc = businessDoc(root);
|
||||
mutate?.(doc, root);
|
||||
return validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir });
|
||||
}
|
||||
|
||||
test("config directory and file path follow MOSAIC_CONFIG", () => {
|
||||
assert.equal(configDir({ MOSAIC_CONFIG: "/x/cfg/config.json" }), "/x/cfg");
|
||||
assert.match(configDir({}), /\.config\/mosaic-dev$/);
|
||||
assert.equal(businessFilePath("acme", "/x/cfg"), "/x/cfg/businesses/acme.json");
|
||||
refuses(() => businessFilePath("../acme", "/x"), /business id/);
|
||||
});
|
||||
|
||||
test("the fixture business validates and comes back frozen", () => {
|
||||
const business = check();
|
||||
assert.equal(business.id, "acme");
|
||||
assert.deepEqual(Object.keys(business.roles), ["pm", "cto", "coder", "reviewer"]);
|
||||
assert.equal(business.roles.pm.holder, "sage");
|
||||
assert.equal(business.roles.reviewer.holder, null);
|
||||
assert.equal(business.roles.coder.vars["limits.network"], "none");
|
||||
assert.equal(business.definitions.pm.roleVersion, 2);
|
||||
assert.deepEqual(business.launch, { by: "pm", instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } });
|
||||
assert.ok(Object.isFrozen(business.roles.pm.credentials.gitea));
|
||||
assert.throws(() => { business.roles.pm.holder = "x"; }, TypeError);
|
||||
});
|
||||
|
||||
test("two instances may share a definition", () => {
|
||||
const business = check((doc) => {
|
||||
doc.roles.coder2 = { ...structuredClone(doc.roles.coder), tracker: { bot: "bot-acme-coder2", botId: 7 } };
|
||||
});
|
||||
assert.equal(business.roles.coder2.definition, "coder");
|
||||
});
|
||||
|
||||
test("top-level refusals", () => {
|
||||
refuses(() => check((d) => { d.businessVersion = 2; }), /businessVersion/);
|
||||
refuses(() => check((d) => { d.owner = "x"; }), /unsupported business file key: "owner"/);
|
||||
refuses(() => check((d) => { d.id = "other"; }), /must match its filename/);
|
||||
refuses(() => check((d) => { d.id = "Acme"; }), /business id/);
|
||||
for (const key of ["human", "arbiters", "projects", "tracker", "roles"]) {
|
||||
refuses(() => check((d) => { delete d[key]; }), new RegExp(`requires "${key}"`));
|
||||
}
|
||||
refuses(() => check((d) => { d.vars.harness = "pi"; }), /harness can't be set at the business layer/);
|
||||
refuses(() => check((d) => { d.vars.secret = "x"; }), /unknown variable "secret"/);
|
||||
});
|
||||
|
||||
test("arbiters and projects", () => {
|
||||
refuses(() => check((d) => { d.arbiters.technical = "ghost"; }), /arbiters\.technical names ghost/);
|
||||
refuses(() => check((d) => { delete d.arbiters.delivery; }), /arbiters\.delivery/);
|
||||
refuses(() => check((d) => { d.arbiters.final = "pm"; }), /unsupported .* arbiters key/);
|
||||
refuses(() => check((d) => { d.projects = {}; }), /at least one project/);
|
||||
refuses(() => check((d) => { d.projects.stack.root = "relative/path"; }), /normalized absolute path/);
|
||||
refuses(() => check((d) => { d.projects.stack.root += "/../x"; }), /normalized absolute path/);
|
||||
refuses(() => check((d) => { d.projects.stack.branch = "main"; }), /unsupported .* key: "branch"/);
|
||||
});
|
||||
|
||||
test("role instances", () => {
|
||||
refuses(() => check((d) => { d.roles = {}; }), /at least one role instance/);
|
||||
refuses(() => check((d) => { d.roles.pm.definition = "ghost"; }), /not found/, 4);
|
||||
refuses(() => check((d) => { d.roles.pm.definition = "researcher"; }), /version 1 and can't back/);
|
||||
refuses(() => check((d) => { d.roles.pm.token = "x"; }), /unsupported .*roles\.pm key: "token"/);
|
||||
refuses(() => check((d) => { d.roles.pm.holder = "Sage!"; }), /holder/);
|
||||
refuses(() => check((d) => { d.roles.pm.vars["tracker.baseUrl"] = "http://x"; }), /can't be set at the agent layer/);
|
||||
refuses(() => check((d) => { d.roles.pm.vars["limits.authority"] = ["deploy.prod"]; }), /unknown action/);
|
||||
});
|
||||
|
||||
test("Vikunja bots", () => {
|
||||
refuses(() => check((d) => { delete d.roles.pm.tracker; }), /needs "tracker"/);
|
||||
refuses(() => check((d) => { d.roles.pm.tracker.botId = 0; }), /botId must be a positive integer/);
|
||||
refuses(() => check((d) => { d.roles.pm.tracker.bot = "pm-bot"; }), /starting with "bot-"/);
|
||||
refuses(() => check((d) => { d.roles.cto.tracker.bot = d.roles.pm.tracker.bot; }), /used by another instance/);
|
||||
refuses(() => check((d) => { d.roles.cto.tracker.botId = d.roles.pm.tracker.botId; }), /botId 3 is used by another instance/);
|
||||
refuses(() => check((d) => { d.tracker.sync.bot = d.roles.pm.tracker.bot; }), /sync must use its own bot/);
|
||||
refuses(() => check((d) => { d.tracker.sync.botId = d.roles.cto.tracker.botId; }), /sync must use its own bot/);
|
||||
refuses(() => check((d) => { d.tracker.sync.botId = 0; }), /sync\.botId/);
|
||||
refuses(() => check((d) => { d.tracker.labels = { "needs-jason": 0 }; }), /labels\.needs-jason/);
|
||||
refuses(() => check((d) => { d.tracker.webhook = "x"; }), /unsupported .* tracker key/);
|
||||
assert.deepEqual(check((d) => { d.tracker.labels = { "needs-jason": 12 }; }).tracker.labels, { "needs-jason": 12 });
|
||||
});
|
||||
|
||||
test("a role without Vikunja takes no tracker block", () => {
|
||||
const root = tmp();
|
||||
const rolesDir = rolesCopy(root);
|
||||
const pm = JSON.parse(readFileSync(join(rolesDir, "pm.json"), "utf8"));
|
||||
pm.credentials = pm.credentials.filter((c) => c.service === "gitea");
|
||||
writeJson(join(rolesDir, "pm.json"), pm);
|
||||
refuses(() => check(undefined, { rolesDir }), /has "tracker" but pm uses no vikunja credential/);
|
||||
refuses(() => check((d) => { delete d.roles.pm.tracker; }, { rolesDir }), /exactly the services its role definition needs \(gitea; got gitea, vikunja\)/);
|
||||
const business = check((d) => { delete d.roles.pm.tracker; delete d.roles.pm.credentials.vikunja; }, { rolesDir });
|
||||
assert.equal(business.roles.pm.tracker, null);
|
||||
});
|
||||
|
||||
test("credential references match the definition's services", () => {
|
||||
refuses(() => check((d) => { delete d.roles.cto.credentials.gitea; }), /exactly the services .* \(gitea, vikunja; got vikunja\)/);
|
||||
refuses(() => check((d) => { d.roles.cto.credentials.github = { env: "X", rotateBy: "2099-01-01" }; }), /exactly the services/);
|
||||
refuses(() => check((d) => { d.roles.cto.credentials.gitea = { env: "X" }; }), /needs "rotateBy"/);
|
||||
refuses(() => check((d) => { d.roles.cto.credentials.vikunja.token = "tk_x"; }), /unsupported .* key: "token"/);
|
||||
refuses(() => check((d) => { d.tracker.sync.credentials = {}; }), /exactly the services .* \(vikunja; got none\)/);
|
||||
});
|
||||
|
||||
test("launch", () => {
|
||||
assert.equal(check((d) => { delete d.launch; }).launch, null);
|
||||
refuses(() => check((d) => { d.launch.by = "ghost"; }), /launch\.by names ghost/);
|
||||
refuses(() => check((d) => { d.launch.by = "cto"; d.launch.instances = ["coder"]; }), /launch\.by names cto, whose role cto doesn't hold role\.launch within-role/);
|
||||
refuses(() => check((d) => { d.launch.instances = []; }), /must not be empty/);
|
||||
refuses(() => check((d) => { d.launch.instances = ["coder", "coder"]; }), /duplicate/);
|
||||
refuses(() => check((d) => { d.launch.instances = ["ghost"]; }), /instances names ghost/);
|
||||
refuses(() => check((d) => { d.launch.instances = ["pm"]; }), /can't include the launcher itself/);
|
||||
refuses(() => check((d) => { d.launch.max = {}; }), /at least one model family/);
|
||||
refuses(() => check((d) => { d.launch.max = { glm: 1 }; }), /unknown model family "glm"/);
|
||||
refuses(() => check((d) => { d.launch.max = { opus: 5 }; }), /from 0 to 4/);
|
||||
refuses(() => check((d) => { d.launch.max = { sonnet: -1 }; }), /from 0 to 4/);
|
||||
refuses(() => check((d) => { d.launch.max = { opus: 1.5 }; }), /from 0 to 4/);
|
||||
refuses(() => check((d) => { d.launch.cap = 1; }), /unsupported .* launch key/);
|
||||
assert.deepEqual(check((d) => { d.launch.max = { opus: 0 }; }).launch.max, { opus: 0 });
|
||||
});
|
||||
|
||||
test("loadBusiness: file checks", () => {
|
||||
const root = tmp();
|
||||
const dir = join(root, "config");
|
||||
refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /business file not found/, 4);
|
||||
|
||||
const file = writeJson(join(dir, "businesses", "acme.json"), businessDoc(root));
|
||||
assert.equal(loadBusiness("acme", { dir, rolesDir: REPO_ROLES }).file, file);
|
||||
|
||||
chmodSync(file, 0o620);
|
||||
refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /writable by group or other \(mode 620\)/);
|
||||
chmodSync(file, 0o602);
|
||||
refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /writable by group or other/);
|
||||
chmodSync(file, 0o644);
|
||||
assert.equal(loadBusiness("acme", { dir, rolesDir: REPO_ROLES }).id, "acme");
|
||||
|
||||
writeJson(join(dir, "businesses", "real.json"), businessDoc(root, "linked"));
|
||||
symlinkSync("real.json", join(dir, "businesses", "linked.json"));
|
||||
refuses(() => loadBusiness("linked", { dir, rolesDir: REPO_ROLES }), /non-symbolic-link/, 4);
|
||||
|
||||
writeFileSync(join(dir, "businesses", "broken.json"), "{ not json");
|
||||
refuses(() => loadBusiness("broken", { dir, rolesDir: REPO_ROLES }), /not valid JSON/);
|
||||
// The owner and mode checks run on the opened file before the read, so
|
||||
// a group-writable file refuses for its mode even when it won't parse.
|
||||
chmodSync(join(dir, "businesses", "broken.json"), 0o660);
|
||||
refuses(() => loadBusiness("broken", { dir, rolesDir: REPO_ROLES }), /writable by group or other \(mode 660\)/);
|
||||
assert.throws(() => loadBusiness("acme", { dir }), /needs rolesDir/);
|
||||
});
|
||||
|
||||
// A directory or a FIFO under the business name refuses without a read.
|
||||
// Opening a FIFO without O_NONBLOCK would block the whole process, which
|
||||
// node:test can't time out, so that case runs in a child with a timeout.
|
||||
test("loadBusiness: not a regular file", () => {
|
||||
const dir = join(tmp(), "config");
|
||||
mkdirSync(join(dir, "businesses", "folder.json"), { recursive: true });
|
||||
refuses(() => loadBusiness("folder", { dir, rolesDir: REPO_ROLES }), /must be a regular, non-symbolic-link file/, 4);
|
||||
execFileSync("mkfifo", [join(dir, "businesses", "pipe.json")]);
|
||||
const index = pathToFileURL(join(REPO, "packages", "business", "src", "index.mjs")).href;
|
||||
const child = spawnSync(process.execPath, ["--input-type=module", "-e", `
|
||||
import { loadBusiness } from ${JSON.stringify(index)};
|
||||
try { loadBusiness("pipe", { dir: ${JSON.stringify(dir)}, rolesDir: ${JSON.stringify(REPO_ROLES)} }); }
|
||||
catch (error) { console.log(error.exitCode, error.message); }
|
||||
`], { encoding: "utf8", timeout: 5000 });
|
||||
assert.equal(child.signal, null, "opening the FIFO waited for a writer");
|
||||
assert.match(child.stdout, /^4 .*must be a regular, non-symbolic-link file/);
|
||||
});
|
||||
|
||||
test("loading writes nothing", () => {
|
||||
const root = tmp();
|
||||
const dir = join(root, "config");
|
||||
const file = writeJson(join(dir, "businesses", "acme.json"), businessDoc(root));
|
||||
const listing = (d) => readdirSync(d, { recursive: true }).sort().join("\n");
|
||||
const before = { tree: listing(root), mtime: lstatSync(file).mtimeMs, text: readFileSync(file, "utf8") };
|
||||
loadBusiness("acme", { dir, rolesDir: REPO_ROLES });
|
||||
assert.equal(listing(root), before.tree);
|
||||
assert.equal(lstatSync(file).mtimeMs, before.mtime);
|
||||
assert.equal(readFileSync(file, "utf8"), before.text);
|
||||
});
|
||||
|
||||
test("names that are Object.prototype properties don't count as declared", () => {
|
||||
refuses(() => check((d) => { d.arbiters.delivery = "constructor"; }), /arbiters\.delivery names constructor/);
|
||||
refuses(() => check((d) => { d.launch.by = "constructor"; }), /launch\.by names constructor/);
|
||||
refuses(() => check((d) => { d.launch.instances = ["constructor"]; }), /instances names constructor/);
|
||||
refuses(() => check((d) => { d.launch.max = { constructor: 1 }; }), /unknown model family "constructor"/);
|
||||
refuses(() => check((d) => { d.roles.pm.definition = "constructor"; }), /not found/, 4);
|
||||
const labels = check((d) => { d.tracker.labels = JSON.parse('{"__proto__": 5}'); }).tracker.labels;
|
||||
assert.equal(Object.getPrototypeOf(labels), Object.prototype);
|
||||
assert.equal(Object.getOwnPropertyDescriptor(labels, "__proto__").value, 5);
|
||||
});
|
||||
|
||||
test("the shipped example refuses as written and validates once filled in", () => {
|
||||
const example = readFileSync(join(REPO, "packages", "business", "examples", "mosaic-stack.example.json"), "utf8");
|
||||
const root = tmp();
|
||||
const file = join(root, "businesses", "mosaic-stack.json");
|
||||
refuses(() => validateBusinessDocument(JSON.parse(example), file, { rolesDir: REPO_ROLES }), /botId must be a positive integer/);
|
||||
let id = 10;
|
||||
const filled = JSON.parse(example
|
||||
.replaceAll('"botId": 0', () => `"botId": ${id++}`)
|
||||
.replaceAll("YYYY-MM-DD", "2099-01-01")
|
||||
.replaceAll("/home/you", root));
|
||||
const business = validateBusinessDocument(filled, file, { rolesDir: REPO_ROLES });
|
||||
assert.deepEqual(Object.keys(business.roles), ["pm", "cto", "coder", "reviewer"]);
|
||||
assert.equal(business.tracker.sync.credentials.vikunja.file, join(root, ".config/mosaic-dev/secrets/mosaic-stack/sync-vikunja.token"));
|
||||
assert.equal(id, 15);
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { projectFilePath } from "../src/index.mjs";
|
||||
import { businessDoc, REPO, rolesCopy, systemConfig, tmp, tokenFile, writeJson } from "./helpers.mjs";
|
||||
|
||||
const CLI = join(REPO, "packages", "business", "src", "cli.mjs");
|
||||
|
||||
// A complete scratch setup: system config, business file, roles copy.
|
||||
// HOME points into the scratch tree too, so nothing reads the real one.
|
||||
function setup(mutate) {
|
||||
const root = tmp();
|
||||
const config = systemConfig(root);
|
||||
const doc = businessDoc(root);
|
||||
mutate?.(doc, root);
|
||||
writeJson(join(root, "config", "businesses", `${doc.id}.json`), doc);
|
||||
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesCopy(root) };
|
||||
return { root, doc, env };
|
||||
}
|
||||
|
||||
function run(s, ...args) {
|
||||
const proc = spawnSync(process.execPath, [CLI, ...args], { env: s.env, encoding: "utf8" });
|
||||
return { code: proc.status, out: proc.stdout, err: proc.stderr };
|
||||
}
|
||||
|
||||
test("usage errors exit 4", () => {
|
||||
const s = setup();
|
||||
assert.equal(run(s).code, 4);
|
||||
assert.equal(run(s, "show", "acme").code, 4);
|
||||
assert.equal(run(s, "validate").code, 4);
|
||||
assert.equal(run(s, "validate", "acme", "extra").code, 4);
|
||||
assert.equal(run(s, "resolve", "acme").code, 4);
|
||||
assert.equal(run(s, "resolve", "acme", "pm", "--project").code, 4);
|
||||
assert.equal(run(s, "resolve", "acme", "pm", "--project", "a", "--project", "b").code, 4);
|
||||
assert.match(run(s).err, /usage: mosaic business validate/);
|
||||
});
|
||||
|
||||
test("validate: a good business exits 0 and prints instance digests", () => {
|
||||
const s = setup();
|
||||
const r = run(s, "validate", "acme");
|
||||
assert.equal(r.code, 0, r.err);
|
||||
const out = JSON.parse(r.out);
|
||||
assert.equal(out.business, "acme");
|
||||
assert.deepEqual(out.projects, { stack: "absent" });
|
||||
assert.deepEqual(Object.keys(out.instances), ["pm", "cto", "coder", "reviewer"]);
|
||||
for (const digest of Object.values(out.instances)) assert.match(digest, /^[0-9a-f]{64}$/);
|
||||
assert.match(r.err, /warning: project stack: no project file/);
|
||||
assert.doesNotMatch(r.out + r.err, /placeholder-not-a-token/);
|
||||
});
|
||||
|
||||
test("validate: project files", () => {
|
||||
const s = setup();
|
||||
const file = projectFilePath(s.doc.projects.stack.root);
|
||||
writeJson(file, { projectVersion: 1, id: "stack", vars: { "tracker.project": 3 } });
|
||||
const r = run(s, "validate", "acme");
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.deepEqual(JSON.parse(r.out).projects, { stack: "valid" });
|
||||
|
||||
writeJson(file, { projectVersion: 1, id: "other" });
|
||||
assert.match(run(s, "validate", "acme").err, /has id other, but business acme declares it as stack/);
|
||||
assert.equal(run(s, "validate", "acme").code, 2);
|
||||
writeJson(file, { projectVersion: 1, id: "stack", roles: { ghost: {} } });
|
||||
const ghost = run(s, "validate", "acme");
|
||||
assert.equal(ghost.code, 2);
|
||||
assert.match(ghost.err, /role instance ghost/);
|
||||
writeJson(file, { projectVersion: 1, id: "stack", vars: { harness: "pi" } });
|
||||
assert.equal(run(s, "validate", "acme").code, 2);
|
||||
});
|
||||
|
||||
test("validate: missing files and a broken system config", () => {
|
||||
const s = setup();
|
||||
const missing = run(s, "validate", "nobody");
|
||||
assert.equal(missing.code, 4);
|
||||
assert.match(missing.err, /business file not found/);
|
||||
assert.equal(run(s, "validate", "Bad!").code, 2);
|
||||
|
||||
const noConfig = { ...s, env: { ...s.env, MOSAIC_CONFIG: join(s.root, "absent", "config.json") } };
|
||||
const r = run(noConfig, "validate", "acme");
|
||||
assert.equal(r.code, 3);
|
||||
assert.match(r.err, /system config problem/);
|
||||
writeFileSync(s.env.MOSAIC_CONFIG, "{");
|
||||
assert.equal(run(s, "validate", "acme").code, 3);
|
||||
});
|
||||
|
||||
test("validate: credential reference problems exit 2 and name each one", () => {
|
||||
const s = setup((doc, root) => {
|
||||
chmodSync(doc.roles.cto.credentials.gitea.file, 0o644);
|
||||
doc.roles.coder.credentials.vikunja.file = tokenFile(join(root, "data", "tokens"), "coder.token");
|
||||
doc.roles.reviewer.credentials.vikunja.expires = "2026-01-01";
|
||||
});
|
||||
const r = run(s, "validate", "acme");
|
||||
assert.equal(r.code, 2);
|
||||
assert.equal(r.out, "");
|
||||
assert.match(r.err, /cto-gitea\.token: mode 644/);
|
||||
assert.match(r.err, /coder\.token: inside .*data/);
|
||||
assert.match(r.err, /expired on 2026-01-01/);
|
||||
assert.match(r.err, /3 credential reference problem/);
|
||||
});
|
||||
|
||||
test("validate: a token file inside the repository is refused", () => {
|
||||
const s = setup((doc) => { doc.roles.pm.credentials.gitea.file = join(REPO, "roles", "pm.md"); });
|
||||
const r = run(s, "validate", "acme");
|
||||
assert.equal(r.code, 2);
|
||||
assert.match(r.err, /inside .*; token files live outside the repository and dataRoot/);
|
||||
});
|
||||
|
||||
test("validate: role definitions come from MOSAIC_ROLES_DIR", () => {
|
||||
const s = setup();
|
||||
const before = JSON.parse(run(s, "validate", "acme").out).instances;
|
||||
const pmFile = join(s.env.MOSAIC_ROLES_DIR, "pm.json");
|
||||
const pm = JSON.parse(readFileSync(pmFile, "utf8"));
|
||||
pm.tools = pm.tools.filter((t) => t !== "write");
|
||||
writeJson(pmFile, pm);
|
||||
const after = JSON.parse(run(s, "validate", "acme").out).instances;
|
||||
assert.notEqual(after.pm, before.pm);
|
||||
assert.equal(after.cto, before.cto);
|
||||
pm.authority.withinRole.push("deploy");
|
||||
writeJson(pmFile, pm);
|
||||
const gated = run(s, "validate", "acme");
|
||||
assert.equal(gated.code, 2);
|
||||
assert.match(gated.err, /always gated/);
|
||||
});
|
||||
|
||||
test("resolve: prints one instance's record", () => {
|
||||
const s = setup();
|
||||
writeJson(projectFilePath(s.doc.projects.stack.root), {
|
||||
projectVersion: 1, id: "stack", vars: { "tracker.project": 3 }, roles: { coder: { vars: { "limits.tools": ["read", "bash"] } } },
|
||||
});
|
||||
const r = run(s, "resolve", "acme", "coder", "--project", "stack");
|
||||
assert.equal(r.code, 0, r.err);
|
||||
const coder = JSON.parse(r.out);
|
||||
assert.equal(coder.project, "stack");
|
||||
assert.equal(coder.vars["tracker.project"], 3);
|
||||
assert.deepEqual(coder.limits.tools, ["read", "bash"]);
|
||||
assert.equal(coder.limits.network, "none");
|
||||
assert.equal(coder.contract, join(s.env.MOSAIC_ROLES_DIR, "coder.md"));
|
||||
assert.doesNotMatch(r.out, /placeholder-not-a-token/);
|
||||
|
||||
const pm = JSON.parse(run(s, "resolve", "acme", "pm").out);
|
||||
assert.equal(pm.project, null);
|
||||
assert.ok(pm.limits.authority.withinRole.includes("role.launch"));
|
||||
const validated = JSON.parse(run(s, "validate", "acme").out).instances;
|
||||
assert.equal(pm.digest, validated.pm);
|
||||
});
|
||||
|
||||
test("resolve: refusals", () => {
|
||||
const s = setup((doc) => { chmodSync(doc.roles.reviewer.credentials.gitea.file, 0o604); });
|
||||
assert.equal(run(s, "resolve", "acme", "pm").code, 0);
|
||||
const reviewer = run(s, "resolve", "acme", "reviewer");
|
||||
assert.equal(reviewer.code, 2);
|
||||
assert.equal(reviewer.out, "");
|
||||
assert.match(reviewer.err, /mode 604/);
|
||||
assert.equal(run(s, "resolve", "acme", "ghost").code, 2);
|
||||
assert.equal(run(s, "resolve", "acme", "constructor").code, 2);
|
||||
assert.match(run(s, "resolve", "acme", "pm", "--project", "ghost").err, /declares no project "ghost"/);
|
||||
assert.equal(run(s, "resolve", "acme", "pm", "--project", "stack").code, 4);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { BusinessError, checkCredentialRef, parseCredentialRef } from "../src/index.mjs";
|
||||
import { FAR_DATE, tmp, tokenFile } from "./helpers.mjs";
|
||||
|
||||
function refuses(fn, pattern) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const NOW = new Date("2026-10-04T12:00:00Z");
|
||||
|
||||
test("parse: exactly one of file or env, plus the service's date", () => {
|
||||
assert.deepEqual({ ...parseCredentialRef({ file: "/s/pm-gitea.token", rotateBy: FAR_DATE }, "gitea", "r") },
|
||||
{ service: "gitea", file: "/s/pm-gitea.token", rotateBy: FAR_DATE });
|
||||
assert.deepEqual({ ...parseCredentialRef({ env: "PM_VIKUNJA_TOKEN", expires: FAR_DATE }, "vikunja", "r") },
|
||||
{ service: "vikunja", env: "PM_VIKUNJA_TOKEN", expires: FAR_DATE });
|
||||
refuses(() => parseCredentialRef({ file: "/a", env: "A", rotateBy: FAR_DATE }, "gitea", "r"), /exactly one/);
|
||||
refuses(() => parseCredentialRef({ rotateBy: FAR_DATE }, "gitea", "r"), /exactly one/);
|
||||
refuses(() => parseCredentialRef({ file: "/a" }, "gitea", "r"), /needs "rotateBy"/);
|
||||
refuses(() => parseCredentialRef({ file: "/a", expires: FAR_DATE }, "gitea", "r"), /unsupported r key: "expires"/);
|
||||
refuses(() => parseCredentialRef({ file: "/a", rotateBy: FAR_DATE }, "vikunja", "r"), /unsupported r key: "rotateBy"/);
|
||||
refuses(() => parseCredentialRef({ file: "/a", expires: FAR_DATE, token: "x" }, "vikunja", "r"), /unsupported r key: "token"/);
|
||||
refuses(() => parseCredentialRef({ file: "a/b", rotateBy: FAR_DATE }, "gitea", "r"), /normalized absolute path/);
|
||||
refuses(() => parseCredentialRef({ file: "/a/../b", rotateBy: FAR_DATE }, "gitea", "r"), /normalized absolute path/);
|
||||
refuses(() => parseCredentialRef({ file: "/a//b", rotateBy: FAR_DATE }, "gitea", "r"), /normalized absolute path/);
|
||||
refuses(() => parseCredentialRef({ env: "pm_token", rotateBy: FAR_DATE }, "gitea", "r"), /\.env must match/);
|
||||
refuses(() => parseCredentialRef({ env: "A", rotateBy: "2026-02-30" }, "gitea", "r"), /not a real date/);
|
||||
refuses(() => parseCredentialRef({ env: "A", expires: "2026-10-04T00:00:00Z" }, "vikunja", "r"), /YYYY-MM-DD/);
|
||||
refuses(() => parseCredentialRef({ env: "A" }, "github", "r"), /unknown credential service/);
|
||||
});
|
||||
|
||||
test("check: a good file has no problems", () => {
|
||||
const dir = tmp();
|
||||
const ref = parseCredentialRef({ file: tokenFile(join(dir, "s"), "t"), expires: FAR_DATE }, "vikunja", "r");
|
||||
assert.deepEqual(checkCredentialRef(ref, { now: NOW, forbiddenRoots: [join(dir, "repo")] }), { problems: [], warnings: [] });
|
||||
});
|
||||
|
||||
test("check never opens the file: a write-only token passes", () => {
|
||||
const dir = tmp();
|
||||
const file = tokenFile(join(dir, "s"), "t");
|
||||
chmodSync(file, 0o200);
|
||||
const ref = parseCredentialRef({ file, rotateBy: FAR_DATE }, "gitea", "r");
|
||||
assert.deepEqual(checkCredentialRef(ref, { now: NOW }).problems, []);
|
||||
});
|
||||
|
||||
test("check: file problems", () => {
|
||||
const dir = tmp();
|
||||
const s = join(dir, "s");
|
||||
const problems = (file, opts = {}) => checkCredentialRef(parseCredentialRef({ file, rotateBy: FAR_DATE }, "gitea", "r"), { now: NOW, ...opts }).problems;
|
||||
assert.match(problems(join(s, "absent")).join(), /not found/);
|
||||
|
||||
const loose = tokenFile(s, "loose");
|
||||
chmodSync(loose, 0o640);
|
||||
assert.match(problems(loose).join(), /mode 640 gives group or other access/);
|
||||
|
||||
const empty = join(s, "empty");
|
||||
writeFileSync(empty, "");
|
||||
chmodSync(empty, 0o600);
|
||||
assert.match(problems(empty).join(), /empty/);
|
||||
|
||||
const link = join(s, "link");
|
||||
symlinkSync(tokenFile(s, "real"), link);
|
||||
assert.match(problems(link).join(), /not a symbolic link/);
|
||||
|
||||
mkdirSync(join(s, "dir"));
|
||||
assert.match(problems(join(s, "dir")).join(), /regular file/);
|
||||
|
||||
assert.deepEqual(problems(tokenFile(s, "other")), []);
|
||||
assert.match(problems(tokenFile(s, "uid"), { uid: process.getuid() + 1 }).join(), /owned by uid/);
|
||||
});
|
||||
|
||||
test("check: token files can't live in the repository or dataRoot, even through a linked directory", () => {
|
||||
const dir = tmp();
|
||||
const repo = join(dir, "repo");
|
||||
const inRepo = tokenFile(join(repo, "secrets"), "t");
|
||||
const ref = (file) => parseCredentialRef({ file, rotateBy: FAR_DATE }, "gitea", "r");
|
||||
assert.match(checkCredentialRef(ref(inRepo), { now: NOW, forbiddenRoots: [repo] }).problems.join(), /inside .*repo/);
|
||||
symlinkSync(join(repo, "secrets"), join(dir, "elsewhere"));
|
||||
const viaLink = join(dir, "elsewhere", "t");
|
||||
assert.match(checkCredentialRef(ref(viaLink), { now: NOW, forbiddenRoots: [repo] }).problems.join(), /inside/);
|
||||
const repoLink = join(dir, "repo-link");
|
||||
symlinkSync(repo, repoLink);
|
||||
assert.match(checkCredentialRef(ref(inRepo), { now: NOW, forbiddenRoots: [repoLink] }).problems.join(), /inside/);
|
||||
const sibling = tokenFile(join(dir, "repo-secrets"), "t");
|
||||
assert.deepEqual(checkCredentialRef(ref(sibling), { now: NOW, forbiddenRoots: [repo, join(dir, "absent-root")] }).problems, []);
|
||||
});
|
||||
|
||||
test("check: dates and environment references", () => {
|
||||
const vikunja = (expires) => checkCredentialRef(parseCredentialRef({ env: "TOK", expires }, "vikunja", "r"), { now: NOW, env: { TOK: "set" } });
|
||||
assert.match(vikunja("2026-10-04").problems.join(), /expired on 2026-10-04/);
|
||||
assert.match(vikunja("2026-10-01").problems.join(), /expired/);
|
||||
assert.deepEqual(vikunja("2026-10-05").problems, []);
|
||||
assert.match(vikunja("2026-10-05").warnings.join(), /expires on 2026-10-05/);
|
||||
assert.match(vikunja("2026-10-10").warnings.join(), /expires on/);
|
||||
assert.deepEqual(vikunja("2026-10-12"), { problems: [], warnings: [] });
|
||||
|
||||
const gitea = (rotateBy) => checkCredentialRef(parseCredentialRef({ env: "TOK", rotateBy }, "gitea", "r"), { now: NOW, env: { TOK: "set" } });
|
||||
assert.deepEqual(gitea("2026-10-01").problems, []);
|
||||
assert.match(gitea("2026-10-01").warnings.join(), /rotation was due on 2026-10-01/);
|
||||
assert.deepEqual(gitea(FAR_DATE), { problems: [], warnings: [] });
|
||||
|
||||
const unset = checkCredentialRef(parseCredentialRef({ env: "TOK", rotateBy: FAR_DATE }, "gitea", "r"), { now: NOW, env: {} });
|
||||
assert.deepEqual(unset.problems, []);
|
||||
assert.match(unset.warnings.join(), /not set in this environment/);
|
||||
const blank = checkCredentialRef(parseCredentialRef({ env: "TOK", rotateBy: FAR_DATE }, "gitea", "r"), { now: NOW, env: { TOK: "" } });
|
||||
assert.match(blank.warnings.join(), /not set/);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// Shared fixtures. Every test works in its own temporary directory and
|
||||
// points MOSAIC_CONFIG there; nothing reads the real ~/.config.
|
||||
|
||||
import { chmodSync, cpSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
export const REPO_ROLES = join(REPO, "roles");
|
||||
export const FAR_DATE = "2099-01-01";
|
||||
|
||||
export function tmp() {
|
||||
return mkdtempSync(join(tmpdir(), "mosaic-business-"));
|
||||
}
|
||||
|
||||
export function writeJson(file, value, mode = 0o600) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, typeof value === "string" ? value : `${JSON.stringify(value, null, 2)}\n`);
|
||||
chmodSync(file, mode);
|
||||
return file;
|
||||
}
|
||||
|
||||
// A token file with a placeholder value; the package never reads it.
|
||||
export function tokenFile(dir, name) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const file = join(dir, name);
|
||||
writeFileSync(file, "placeholder-not-a-token");
|
||||
chmodSync(file, 0o600);
|
||||
return file;
|
||||
}
|
||||
|
||||
// A copy of the shipped role files in a scratch roles directory.
|
||||
export function rolesCopy(root) {
|
||||
const dir = join(root, "roles");
|
||||
cpSync(REPO_ROLES, dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
// A complete valid business document for the shipped four roles, with
|
||||
// token files under `<root>/secrets`, and a project root under `<root>`.
|
||||
export function businessDoc(root, id = "acme") {
|
||||
const secrets = join(root, "secrets");
|
||||
const ref = (role, service) => service === "gitea"
|
||||
? { file: tokenFile(secrets, `${role}-gitea.token`), rotateBy: FAR_DATE }
|
||||
: { file: tokenFile(secrets, `${role}-vikunja.token`), expires: FAR_DATE };
|
||||
const role = (definition, botId, extra = {}) => ({
|
||||
definition,
|
||||
tracker: { bot: `bot-${id}-${definition}`, botId },
|
||||
credentials: { gitea: ref(definition, "gitea"), vikunja: ref(definition, "vikunja") },
|
||||
...extra,
|
||||
});
|
||||
const projectRoot = join(root, "project");
|
||||
mkdirSync(projectRoot, { recursive: true });
|
||||
return {
|
||||
businessVersion: 1,
|
||||
id,
|
||||
human: "jason",
|
||||
arbiters: { delivery: "pm", technical: "cto" },
|
||||
projects: { stack: { root: projectRoot } },
|
||||
vars: { "tracker.baseUrl": "http://127.0.0.1:3456", "gitea.baseUrl": "https://git.example", "tracker.pollSeconds": 60 },
|
||||
tracker: {
|
||||
sync: { bot: `bot-${id}-sync`, botId: 9, credentials: { vikunja: ref("sync", "vikunja") } },
|
||||
labels: {},
|
||||
},
|
||||
roles: {
|
||||
pm: role("pm", 3, { holder: "sage", vars: { harness: "pi", model: "claude-opus-5-5" } }),
|
||||
cto: role("cto", 4, { holder: "darkwing", vars: { harness: "claude-code" } }),
|
||||
coder: role("coder", 5, { vars: { harness: "pi", "limits.network": "none" } }),
|
||||
reviewer: role("reviewer", 6),
|
||||
},
|
||||
launch: { by: "pm", instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } },
|
||||
};
|
||||
}
|
||||
|
||||
// The system config file mosaic-config.mjs validates, under `root`.
|
||||
export function systemConfig(root) {
|
||||
const file = join(root, "config", "config.json");
|
||||
writeJson(file, {
|
||||
configVersion: 1,
|
||||
environment: "development",
|
||||
dataRoot: join(root, "data"),
|
||||
execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash", adapter: "mock" },
|
||||
});
|
||||
return file;
|
||||
}
|
||||
|
||||
// The checked system layer, as systemVars() returns it, without spawning.
|
||||
export function systemFor(root) {
|
||||
return {
|
||||
environment: "development",
|
||||
dataRoot: join(root, "data"),
|
||||
"execution.backend": "docker",
|
||||
"execution.provider": "zai",
|
||||
"execution.model": "glm-5.3-flash",
|
||||
"execution.adapter": "mock",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import { BusinessError, loadProject, projectFilePath, validateProjectDocument } from "../src/index.mjs";
|
||||
import { tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
function refuses(fn, pattern, exitCode = 2) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
|
||||
assert.equal(error.exitCode, exitCode);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const doc = (extra = {}) => ({
|
||||
projectVersion: 1, id: "stack",
|
||||
vars: { "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"] },
|
||||
roles: { coder: { vars: { "limits.tools": ["read", "bash"] } } },
|
||||
...extra,
|
||||
});
|
||||
|
||||
test("path and load", () => {
|
||||
assert.equal(projectFilePath("/w/stack"), "/w/stack/.mosaic/project.json");
|
||||
const root = tmp();
|
||||
writeJson(projectFilePath(root), doc());
|
||||
const project = loadProject(root);
|
||||
assert.equal(project.id, "stack");
|
||||
assert.equal(project.file, projectFilePath(root));
|
||||
assert.deepEqual(project.roles.coder.vars["limits.tools"], ["read", "bash"]);
|
||||
assert.ok(Object.isFrozen(project.vars));
|
||||
});
|
||||
|
||||
test("refusals", () => {
|
||||
refuses(() => loadProject("relative"), /normalized absolute path/);
|
||||
refuses(() => loadProject("/a/../b"), /normalized absolute path/);
|
||||
refuses(() => loadProject(tmp()), /project file not found/, 4);
|
||||
const v = (d) => validateProjectDocument(d, "/p/.mosaic/project.json");
|
||||
refuses(() => v(doc({ projectVersion: 2 })), /projectVersion/);
|
||||
refuses(() => v(doc({ id: "Stack" })), /project id/);
|
||||
refuses(() => v(doc({ owner: "x" })), /unsupported project file key: "owner"/);
|
||||
refuses(() => v(doc({ vars: { harness: "pi" } })), /harness can't be set at the project layer/);
|
||||
refuses(() => v(doc({ vars: { "tracker.baseUrl": "http://x" } })), /project layer/);
|
||||
refuses(() => v(doc({ roles: { coder: { vars: { model: "x" } } } })), /roles\.coder\.vars: variable model can't be set at the project layer/);
|
||||
refuses(() => v(doc({ roles: { coder: { holder: "x" } } })), /unsupported .*roles\.coder key: "holder"/);
|
||||
refuses(() => v(doc({ roles: { "Coder!": {} } })), /role instance/);
|
||||
assert.deepEqual(v(doc({ vars: undefined, roles: undefined })).roles, {});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
BusinessError, classify, projectFilePath, resolveInstance, systemVars, validateBusinessDocument, validateProjectDocument,
|
||||
} from "../src/index.mjs";
|
||||
import { businessDoc, REPO_ROLES, systemFor, tmp } from "./helpers.mjs";
|
||||
|
||||
function refuses(fn, pattern) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function setup(mutate, projectDoc) {
|
||||
const root = tmp();
|
||||
const doc = businessDoc(root);
|
||||
mutate?.(doc);
|
||||
const business = validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
|
||||
const project = projectDoc
|
||||
? validateProjectDocument({ projectVersion: 1, id: "stack", ...projectDoc }, projectFilePath(doc.projects.stack.root))
|
||||
: null;
|
||||
return { root, business, project, system: systemFor(root) };
|
||||
}
|
||||
|
||||
const resolve = (s, instance) => resolveInstance({ system: s.system, business: s.business, project: s.project, instance });
|
||||
|
||||
test("systemVars flattens the validated config", () => {
|
||||
const vars = systemVars({ environment: "development", dataRoot: "/d", execution: { backend: "docker", provider: "zai", model: "m", adapter: "pi" } });
|
||||
assert.deepEqual({ ...vars }, {
|
||||
environment: "development", dataRoot: "/d", "execution.backend": "docker",
|
||||
"execution.provider": "zai", "execution.model": "m", "execution.adapter": "pi",
|
||||
});
|
||||
refuses(() => systemVars({ environment: "development", dataRoot: "relative", execution: { backend: "b", provider: "p", model: "m", adapter: "a" } }), /dataRoot must be an absolute path/);
|
||||
});
|
||||
|
||||
test("precedence: system, business, project, project role, agent", () => {
|
||||
const s = setup((d) => {
|
||||
d.roles.coder.vars.model = "agent-model";
|
||||
}, {
|
||||
vars: { "tracker.pollSeconds": 20, "tracker.project": 3 },
|
||||
roles: { coder: { vars: { "tracker.pollSeconds": 15 } } },
|
||||
});
|
||||
const coder = resolve(s, "coder");
|
||||
assert.equal(coder.project, "stack");
|
||||
assert.equal(coder.vars["tracker.pollSeconds"], 15);
|
||||
assert.equal(coder.provenance["tracker.pollSeconds"], "project:stack:roles.coder");
|
||||
assert.equal(coder.vars["tracker.project"], 3);
|
||||
assert.equal(coder.provenance["tracker.project"], "project:stack");
|
||||
assert.equal(coder.vars["tracker.baseUrl"], "http://127.0.0.1:3456");
|
||||
assert.equal(coder.provenance["tracker.baseUrl"], "business:acme");
|
||||
assert.equal(coder.vars.model, "agent-model");
|
||||
assert.equal(coder.provenance.model, "business:acme:roles.coder");
|
||||
assert.equal(coder.vars["execution.adapter"], "mock");
|
||||
assert.equal(coder.provenance["execution.adapter"], "system");
|
||||
assert.equal(coder.provenance["tracker.reconcileMinutes"], "default");
|
||||
|
||||
const pm = resolve(s, "pm");
|
||||
assert.equal(pm.vars["tracker.pollSeconds"], 20);
|
||||
assert.equal(pm.provenance["tracker.pollSeconds"], "project:stack");
|
||||
const noProject = resolveInstance({ system: s.system, business: s.business, instance: "pm" });
|
||||
assert.equal(noProject.project, null);
|
||||
assert.equal(noProject.vars["tracker.pollSeconds"], 60);
|
||||
});
|
||||
|
||||
test("limits narrow the definition and never widen it", () => {
|
||||
const s = setup((d) => {
|
||||
d.vars["limits.network"] = "open";
|
||||
d.roles.cto.vars["limits.tools"] = ["read", "grep", "bash", "write"];
|
||||
d.roles.reviewer.vars = { "limits.tools": ["read", "write"], "limits.authority": ["review.verdict", "task.close"] };
|
||||
}, { roles: { cto: { vars: { "limits.tools": ["read", "bash", "write", "edit"] } } } });
|
||||
const coder = resolve(s, "coder");
|
||||
assert.equal(coder.limits.network, "none");
|
||||
assert.deepEqual(coder.provenance["limits.network"], ["business:acme", "business:acme:roles.coder"]);
|
||||
assert.equal(resolve(s, "pm").limits.network, "api-only");
|
||||
|
||||
const cto = resolve(s, "cto");
|
||||
assert.deepEqual(cto.limits.tools, ["read", "write", "bash"]);
|
||||
assert.deepEqual(cto.provenance["limits.tools"], ["project:stack:roles.cto", "business:acme:roles.cto"]);
|
||||
|
||||
const reviewer = resolve(s, "reviewer");
|
||||
assert.deepEqual(reviewer.limits.tools, ["read"]);
|
||||
assert.deepEqual(reviewer.limits.authority, { withinRole: ["review.verdict"], crossRole: [] });
|
||||
assert.equal(classify(reviewer, "message.send"), "gated");
|
||||
assert.equal(classify(reviewer, "task.close"), "gated");
|
||||
});
|
||||
|
||||
test("role.launch stays within-role only for the instance the launch block names", () => {
|
||||
const pm = resolve(setup(), "pm");
|
||||
assert.equal(classify(pm, "role.launch"), "within");
|
||||
assert.deepEqual(pm.launch.instances, ["coder", "reviewer"]);
|
||||
assert.equal(resolve(setup(), "cto").launch, null);
|
||||
|
||||
const noLaunch = resolve(setup((d) => { delete d.launch; }), "pm");
|
||||
assert.equal(classify(noLaunch, "role.launch"), "gated");
|
||||
assert.equal(noLaunch.launch, null);
|
||||
|
||||
// A second pm instance is the launcher, so the first one may not launch.
|
||||
const two = setup((d) => {
|
||||
d.roles.pm2 = { ...d.roles.pm, holder: undefined, tracker: { bot: "bot-acme-pm2", botId: 7 } };
|
||||
d.launch.by = "pm2";
|
||||
});
|
||||
assert.equal(classify(resolve(two, "pm"), "role.launch"), "gated");
|
||||
assert.equal(resolve(two, "pm").launch, null);
|
||||
assert.equal(classify(resolve(two, "pm2"), "role.launch"), "within");
|
||||
assert.equal(resolve(two, "pm2").launch.by, "pm2");
|
||||
});
|
||||
|
||||
test("limits.authority without role.launch leaves the launcher with no launch block", () => {
|
||||
for (const layer of ["agent", "project"]) {
|
||||
const authority = ["task.create", "task.assign", "message.send"];
|
||||
const s = layer === "agent"
|
||||
? setup((d) => { d.roles.pm.vars["limits.authority"] = authority; })
|
||||
: setup(undefined, { roles: { pm: { vars: { "limits.authority": authority } } } });
|
||||
const pm = resolve(s, "pm");
|
||||
assert.equal(classify(pm, "role.launch"), "gated", layer);
|
||||
assert.equal(pm.launch, null, layer);
|
||||
}
|
||||
});
|
||||
|
||||
test("limits.authority narrows cross-role actions too", () => {
|
||||
const s = setup((d) => {
|
||||
d.roles.coder.vars["limits.authority"] = ["task.update.assigned", "git.push.working", "task.scope.change"];
|
||||
});
|
||||
const coder = resolve(s, "coder");
|
||||
assert.deepEqual(coder.limits.authority, { withinRole: ["task.update.assigned", "git.push.working"], crossRole: ["task.scope.change"] });
|
||||
assert.equal(classify(coder, "task.reassign"), "gated");
|
||||
assert.equal(classify(coder, "task.scope.change"), "cross");
|
||||
assert.equal(classify(coder, "review.request"), "gated");
|
||||
});
|
||||
|
||||
test("classify", () => {
|
||||
const s = setup();
|
||||
const coder = resolve(s, "coder");
|
||||
assert.equal(classify(coder, "git.push.working"), "within");
|
||||
assert.equal(classify(coder, "task.reassign"), "cross");
|
||||
assert.equal(classify(coder, "task.close"), "gated");
|
||||
assert.equal(classify(coder, "deploy"), "gated");
|
||||
assert.equal(classify(resolve(s, "pm"), "task.priority.change"), "cross");
|
||||
assert.equal(classify(resolve(s, "cto"), "decision.resolve.technical"), "within");
|
||||
refuses(() => classify(coder, "task.delete"), /unknown action/);
|
||||
});
|
||||
|
||||
test("the record carries what the broker and launcher need", () => {
|
||||
const s = setup();
|
||||
const pm = resolve(s, "pm");
|
||||
assert.deepEqual(Object.keys(pm).sort(), [
|
||||
"business", "contract", "credentials", "definition", "digest", "holder", "instance", "launch", "limits", "project", "provenance", "tracker", "vars",
|
||||
]);
|
||||
assert.equal(pm.contract, join(REPO_ROLES, "pm.md"));
|
||||
assert.equal(pm.holder, "sage");
|
||||
assert.deepEqual(pm.tracker, { bot: "bot-acme-pm", botId: 3 });
|
||||
assert.deepEqual(Object.keys(pm.credentials).sort(), ["gitea", "vikunja"]);
|
||||
assert.match(pm.credentials.gitea.file, /pm-gitea\.token$/);
|
||||
assert.ok(Object.isFrozen(pm.limits.authority.withinRole));
|
||||
});
|
||||
|
||||
test("digest: key order doesn't matter, any value change does", () => {
|
||||
const root = tmp();
|
||||
const file = join(root, "businesses", "acme.json");
|
||||
const load = (mutate) => {
|
||||
const doc = businessDoc(root);
|
||||
mutate?.(doc);
|
||||
return validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES });
|
||||
};
|
||||
const system = systemFor(root);
|
||||
const digest = (business, instance = "coder", sys = system) => resolveInstance({ system: sys, business, instance }).digest;
|
||||
const base = digest(load());
|
||||
assert.match(base, /^[0-9a-f]{64}$/);
|
||||
assert.equal(digest(load()), base);
|
||||
assert.equal(digest(load((d) => { d.roles.coder.vars = { "limits.network": "none", harness: "pi" }; })), base);
|
||||
assert.equal(digest(load((d) => { d.vars = Object.fromEntries(Object.entries(d.vars).reverse()); })), base);
|
||||
assert.notEqual(digest(load((d) => { d.roles.coder.vars.harness = "claude-code"; })), base);
|
||||
assert.notEqual(digest(load((d) => { d.roles.coder.holder = "rocko"; })), base);
|
||||
assert.notEqual(digest(load((d) => { d.roles.coder.credentials.gitea.rotateBy = "2098-01-01"; })), base);
|
||||
assert.notEqual(digest(load(), "coder", { ...system, "execution.model": "other" }), base);
|
||||
assert.notEqual(digest(load(), "reviewer"), base);
|
||||
});
|
||||
|
||||
test("refusals", () => {
|
||||
const s = setup();
|
||||
refuses(() => resolveInstance({ system: s.system, business: s.business, instance: "ghost" }), /declares no role instance "ghost"/);
|
||||
refuses(() => resolveInstance({ system: s.system, business: s.business, instance: "constructor" }), /declares no role instance/);
|
||||
const misnamed = setup(undefined, {});
|
||||
const wrongId = validateProjectDocument({ projectVersion: 1, id: "other" }, misnamed.project.file);
|
||||
refuses(() => resolveInstance({ system: misnamed.system, business: misnamed.business, project: wrongId, instance: "pm" }), /isn't declared under that id/);
|
||||
const elsewhere = validateProjectDocument({ projectVersion: 1, id: "stack" }, "/elsewhere/.mosaic/project.json");
|
||||
refuses(() => resolveInstance({ system: s.system, business: s.business, project: elsewhere, instance: "pm" }), /isn't declared under that id/);
|
||||
const extraRole = setup(undefined, { roles: { ghost: { vars: {} } } });
|
||||
refuses(() => resolve(extraRole, "pm"), /sets vars for role instance ghost/);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadRole, loadRoleFile, validateRoleDocument, GATED_ONLY, BusinessError } from "../src/index.mjs";
|
||||
import { REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
function refuses(fn, pattern, exitCode = 2) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
|
||||
assert.equal(error.exitCode, exitCode);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function scratchRole(overrides = {}, contract = "# contract\n") {
|
||||
const dir = tmp();
|
||||
if (contract !== null) writeFileSync(join(dir, "r.md"), contract);
|
||||
const doc = {
|
||||
roleVersion: 2, name: "r", title: "R", contract: "r.md", tools: ["read"], network: "none",
|
||||
authority: { withinRole: ["message.send"], crossRole: [] },
|
||||
credentials: [{ service: "gitea", scopes: ["read:issue"] }],
|
||||
...overrides,
|
||||
};
|
||||
return { dir, file: join(dir, "r.json"), doc };
|
||||
}
|
||||
|
||||
const check = (overrides, contract) => {
|
||||
const { file, doc } = scratchRole(overrides, contract);
|
||||
return validateRoleDocument(doc, file);
|
||||
};
|
||||
|
||||
test("the four shipped version 2 roles load", () => {
|
||||
for (const name of ["pm", "cto", "coder", "reviewer"]) {
|
||||
const role = loadRole(REPO_ROLES, name);
|
||||
assert.equal(role.roleVersion, 2);
|
||||
assert.equal(role.contractPath, join(REPO_ROLES, `${name}.md`));
|
||||
assert.deepEqual(role.credentials.map((c) => c.service), ["gitea", "vikunja"]);
|
||||
for (const action of [...role.authority.withinRole, ...role.authority.crossRole]) {
|
||||
assert.ok(!GATED_ONLY.includes(action), `${name} lists gated ${action}`);
|
||||
}
|
||||
assert.ok(Object.isFrozen(role.authority.withinRole));
|
||||
}
|
||||
});
|
||||
|
||||
test("shipped role scopes match addendum B section 2 and the SR runbook", () => {
|
||||
const scopes = (name, service) => loadRole(REPO_ROLES, name).credentials.find((c) => c.service === service).scopes;
|
||||
assert.deepEqual(scopes("pm", "vikunja"), {
|
||||
tasks: ["read_one", "create", "update"], tasks_assignees: ["create", "delete"],
|
||||
tasks_relations: ["create", "delete"], tasks_labels: ["create", "delete"],
|
||||
tasks_comments: ["create"], labels: ["read_all"], projects: ["views_buckets_tasks"],
|
||||
});
|
||||
for (const worker of ["cto", "coder", "reviewer"]) {
|
||||
assert.deepEqual(scopes(worker, "vikunja"), { tasks: ["read_one", "update"], tasks_comments: ["create"], projects: ["views_buckets_tasks"] });
|
||||
assert.deepEqual(scopes(worker, "gitea"), ["write:issue", "write:repository", "read:user"]);
|
||||
}
|
||||
assert.deepEqual(scopes("pm", "gitea"), ["write:issue", "read:repository", "read:user"]);
|
||||
});
|
||||
|
||||
test("shipped authority follows the note's table", () => {
|
||||
const auth = (name) => loadRole(REPO_ROLES, name).authority;
|
||||
assert.ok(auth("coder").withinRole.includes("git.push.working"));
|
||||
assert.ok(!auth("reviewer").withinRole.includes("git.push.working"));
|
||||
assert.deepEqual(auth("reviewer").crossRole, []);
|
||||
assert.ok(auth("pm").withinRole.includes("role.launch"));
|
||||
assert.ok(auth("cto").withinRole.includes("decision.resolve.technical"));
|
||||
assert.ok(!auth("pm").withinRole.includes("decision.resolve.technical"));
|
||||
});
|
||||
|
||||
test("version 1 files keep loading with no authority", () => {
|
||||
const role = loadRole(REPO_ROLES, "researcher");
|
||||
assert.equal(role.roleVersion, 1);
|
||||
assert.equal(role.contractPath, null);
|
||||
assert.deepEqual(role.authority, { withinRole: [], crossRole: [] });
|
||||
assert.deepEqual(role.tools, ["read", "grep", "find", "ls", "bash"]);
|
||||
const dir = tmp();
|
||||
const file = writeJson(join(dir, "plain.json"), { roleVersion: 1, name: "plain", tools: ["read"] });
|
||||
assert.equal(loadRoleFile(file).network, "none");
|
||||
refuses(() => loadRoleFile(writeJson(join(dir, "v1x.json"), { roleVersion: 1, name: "v1x", tools: ["read"], authority: {} })), /unsupported role key: "authority"/);
|
||||
});
|
||||
|
||||
test("the conductor policy isn't a role", () => {
|
||||
refuses(() => loadRole(REPO_ROLES, "conductor-policy"), /roleVersion/);
|
||||
});
|
||||
|
||||
test("a missing role file is exit 4, a symbolic link too", () => {
|
||||
const dir = tmp();
|
||||
refuses(() => loadRole(dir, "absent"), /not found/, 4);
|
||||
writeJson(join(dir, "real.json"), { roleVersion: 1, name: "link", tools: ["read"] });
|
||||
symlinkSync("real.json", join(dir, "link.json"));
|
||||
refuses(() => loadRole(dir, "link"), /non-symbolic-link/, 4);
|
||||
refuses(() => loadRole(dir, "../etc"), /role name/);
|
||||
});
|
||||
|
||||
test("version 2 refusals", () => {
|
||||
assert.equal(check({}).name, "r");
|
||||
refuses(() => check({ roleVersion: 3 }), /roleVersion/);
|
||||
refuses(() => check({ extra: 1 }), /unsupported role key: "extra"/);
|
||||
refuses(() => check({ name: "other" }), /must match its filename/);
|
||||
refuses(() => check({ title: undefined }), /requires "title"/);
|
||||
refuses(() => check({ title: "x".repeat(81) }), /title/);
|
||||
refuses(() => check({ network: "everywhere" }), /network/);
|
||||
refuses(() => check({ tools: [] }), /tools/);
|
||||
refuses(() => check({ tools: ["read", "read"] }), /duplicate/);
|
||||
refuses(() => check({ tools: ["render3d"] }), /unsupported tool/);
|
||||
});
|
||||
|
||||
test("authority: closed vocabulary, no gated-only action, no overlap", () => {
|
||||
refuses(() => check({ authority: { withinRole: ["task.delete"], crossRole: [] } }), /unknown action/);
|
||||
for (const gated of GATED_ONLY) {
|
||||
refuses(() => check({ authority: { withinRole: [gated], crossRole: [] } }), /always gated/);
|
||||
refuses(() => check({ authority: { withinRole: [], crossRole: [gated] } }), /always gated/);
|
||||
}
|
||||
refuses(() => check({ authority: { withinRole: ["task.reassign"], crossRole: ["task.reassign"] } }), /both withinRole and crossRole/);
|
||||
refuses(() => check({ authority: { withinRole: [] } }), /crossRole must be an array/);
|
||||
refuses(() => check({ authority: { withinRole: [], crossRole: [], gated: [] } }), /unsupported role "authority" key/);
|
||||
});
|
||||
|
||||
test("credentials: Gitea scopes", () => {
|
||||
const gitea = (scopes) => check({ credentials: [{ service: "gitea", scopes }] });
|
||||
assert.deepEqual(gitea(["write:repository", "read:user"]).credentials[0].scopes, ["write:repository", "read:user"]);
|
||||
refuses(() => gitea(["write:admin"]), /unsupported gitea scope/);
|
||||
refuses(() => gitea(["all"]), /unsupported gitea scope/);
|
||||
refuses(() => gitea(["sudo:repository"]), /unsupported gitea scope/);
|
||||
refuses(() => gitea(["read:issue", "write:issue"]), /name issue twice/);
|
||||
refuses(() => gitea([]), /must not be empty/);
|
||||
});
|
||||
|
||||
test("credentials: Vikunja scopes are a group-to-verbs map from the grantable list", () => {
|
||||
const vikunja = (scopes) => check({ credentials: [{ service: "vikunja", scopes }] });
|
||||
assert.deepEqual(vikunja({ tasks: ["read_one"] }).credentials[0].scopes, { tasks: ["read_one"] });
|
||||
refuses(() => vikunja({ tasks: ["delete"] }), /not grantable/);
|
||||
refuses(() => vikunja({ projects: ["create"] }), /not grantable/);
|
||||
refuses(() => vikunja({ tokens: ["read_all"] }), /route group not grantable/);
|
||||
refuses(() => vikunja({ projects_webhooks: ["create"] }), /route group not grantable/);
|
||||
refuses(() => vikunja({ tasks: [] }), /must not be empty/);
|
||||
refuses(() => vikunja({}), /at least one route group/);
|
||||
refuses(() => vikunja(["tasks.read"]), /JSON object/);
|
||||
});
|
||||
|
||||
test("credentials: services", () => {
|
||||
refuses(() => check({ credentials: [{ service: "github", scopes: [] }] }), /service must be one of/);
|
||||
refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"] }, { service: "gitea", scopes: ["read:user"] }] }), /twice/);
|
||||
refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"], token: "x" }] }), /unsupported role credentials\[0\] key/);
|
||||
assert.deepEqual(check({ credentials: [] }).credentials, []);
|
||||
});
|
||||
|
||||
test("contract: a non-empty regular Markdown file beside the role file", () => {
|
||||
refuses(() => check({}, null), /contract not found/);
|
||||
refuses(() => check({}, ""), /non-empty regular file/);
|
||||
refuses(() => check({ contract: "../r.md" }), /Markdown file name/);
|
||||
refuses(() => check({ contract: "roles/r.md" }), /Markdown file name/);
|
||||
refuses(() => check({ contract: "r.txt" }), /Markdown file name/);
|
||||
const { dir, file, doc } = scratchRole({ contract: "link.md" });
|
||||
symlinkSync("r.md", join(dir, "link.md"));
|
||||
refuses(() => validateRoleDocument(doc, file), /non-empty regular file/);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { BusinessError, LAYERS, REGISTRY, checkVars, mergeVars } from "../src/index.mjs";
|
||||
|
||||
function refuses(fn, pattern) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
test("every key names known layers and a merge rule", () => {
|
||||
for (const [key, entry] of Object.entries(REGISTRY)) {
|
||||
assert.ok(entry.layers.length > 0, key);
|
||||
for (const layer of entry.layers) assert.ok(LAYERS.includes(layer), `${key}: ${layer}`);
|
||||
assert.ok(["replace", "intersect"].includes(entry.merge), key);
|
||||
if (entry.merge === "intersect") assert.ok(key.startsWith("limits."), key);
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown keys and wrong layers refuse", () => {
|
||||
refuses(() => checkVars({ colour: "blue" }, "business", "b.vars"), /b\.vars: unknown variable "colour"/);
|
||||
refuses(() => checkVars({ harness: "pi" }, "business", "b.vars"), /can't be set at the business layer \(allowed: agent\)/);
|
||||
refuses(() => checkVars({ "tracker.baseUrl": "http://x" }, "project", "p.vars"), /project layer/);
|
||||
refuses(() => checkVars({ "tracker.project": 3 }, "agent", "a.vars"), /agent layer/);
|
||||
refuses(() => checkVars({ dataRoot: "/x" }, "business", "b.vars"), /business layer/);
|
||||
refuses(() => checkVars(["harness"], "agent", "a.vars"), /JSON object/);
|
||||
refuses(() => checkVars({ constructor: 1 }, "agent", "a.vars"), /unknown variable "constructor"/);
|
||||
assert.throws(() => checkVars({}, "fleet", "x"), /unknown layer/);
|
||||
});
|
||||
|
||||
test("types", () => {
|
||||
const ok = (vars, layer) => assert.deepEqual({ ...checkVars(vars, layer, "v") }, vars);
|
||||
ok({ "tracker.baseUrl": "https://tasks.example:3456", "tracker.reconcileMinutes": 1, "tracker.pollSeconds": 10, "human.discordUserId": "123456789012345678" }, "business");
|
||||
ok({ "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"], suites: ["scripts/test-task.sh"], "issues.repo": "mosaicstack/stack" }, "project");
|
||||
ok({ harness: "claude-code", model: "claude-opus-5-5", thinking: "high", "limits.tools": [], "limits.network": "none", "limits.authority": ["message.send"] }, "agent");
|
||||
|
||||
const bad = [
|
||||
[{ "tracker.baseUrl": "ftp://x" }, "business", /http or https/],
|
||||
[{ "tracker.baseUrl": "https://x/" }, "business", /trailing slash/],
|
||||
[{ "tracker.baseUrl": "https://u:p@x" }, "business", /no credentials/],
|
||||
[{ "tracker.baseUrl": "https://x?a=1" }, "business", /query/],
|
||||
[{ "tracker.baseUrl": "not a url" }, "business", /http or https URL/],
|
||||
[{ "tracker.kind": "jira" }, "business", /one of: vikunja/],
|
||||
[{ "tracker.pollSeconds": 9 }, "business", /at least 10/],
|
||||
[{ "tracker.pollSeconds": "30" }, "project", /at least 10/],
|
||||
[{ "tracker.reconcileMinutes": 0 }, "business", /at least 1/],
|
||||
[{ "human.discordUserId": 123456789012345678 }, "business", /as a string/],
|
||||
[{ "human.discordUserId": "0123456789012345678" }, "business", /Discord user id/],
|
||||
[{ "tracker.project": 0 }, "project", /positive integer/],
|
||||
[{ "git.workingBranch": "-x" }, "project", /git branch name/],
|
||||
[{ "git.workingBranch": "a..b" }, "project", /git branch name/],
|
||||
[{ "git.workingBranch": "x.lock" }, "project", /git branch name/],
|
||||
[{ "git.workingBranch": "feat/" }, "project", /git branch name/],
|
||||
[{ "git.protectedBranches": [] }, "project", /must not be empty/],
|
||||
[{ "git.protectedBranches": ["main", "main"] }, "project", /duplicate/],
|
||||
[{ suites: "scripts/test-task.sh" }, "project", /must be an array/],
|
||||
[{ "issues.repo": "stack" }, "project", /owner\/name/],
|
||||
[{ harness: "codex" }, "agent", /one of: pi, claude-code/],
|
||||
[{ thinking: "max" }, "agent", /one of/],
|
||||
[{ model: "" }, "agent", /non-empty string/],
|
||||
[{ "limits.tools": ["render3d"] }, "agent", /unsupported tool/],
|
||||
[{ "limits.network": "lan" }, "agent", /one of: none, api-only, open/],
|
||||
[{ "limits.authority": ["deploy.prod"] }, "agent", /unknown action/],
|
||||
];
|
||||
for (const [vars, layer, pattern] of bad) refuses(() => checkVars(vars, layer, "v"), pattern);
|
||||
});
|
||||
|
||||
test("merge: defaults, then the most specific layer wins", () => {
|
||||
const { vars, provenance } = mergeVars([
|
||||
{ layer: "business", source: "business", vars: checkVars({ "tracker.pollSeconds": 60 }, "business", "b") },
|
||||
{ layer: "project", source: "project", vars: checkVars({ "tracker.pollSeconds": 20 }, "project", "p") },
|
||||
]);
|
||||
assert.equal(vars["tracker.pollSeconds"], 20);
|
||||
assert.equal(provenance["tracker.pollSeconds"], "project");
|
||||
assert.equal(vars["tracker.reconcileMinutes"], 60);
|
||||
assert.equal(provenance["tracker.reconcileMinutes"], "default");
|
||||
assert.equal(vars["tracker.kind"], "vikunja");
|
||||
assert.equal(vars.harness, undefined);
|
||||
assert.equal(provenance.harness, undefined);
|
||||
});
|
||||
|
||||
test("merge: limits only narrow, and provenance lists each source", () => {
|
||||
const layer = (source, layerName, v) => ({ layer: layerName, source, vars: checkVars(v, layerName, source) });
|
||||
const { vars, provenance } = mergeVars([
|
||||
layer("business", "business", { "limits.network": "none", "limits.tools": ["read", "grep", "bash"] }),
|
||||
layer("project", "project", { "limits.network": "open", "limits.tools": ["read", "bash", "write"] }),
|
||||
layer("agent", "agent", { "limits.tools": ["bash", "read", "edit"] }),
|
||||
]);
|
||||
assert.equal(vars["limits.network"], "none");
|
||||
assert.deepEqual(provenance["limits.network"], ["business", "project"]);
|
||||
assert.deepEqual(vars["limits.tools"], ["read", "bash"]);
|
||||
assert.deepEqual(provenance["limits.tools"], ["business", "project", "agent"]);
|
||||
|
||||
const widen = mergeVars([
|
||||
layer("business", "business", { "limits.authority": ["message.send"] }),
|
||||
layer("agent", "agent", { "limits.authority": ["message.send", "task.close"] }),
|
||||
]);
|
||||
assert.deepEqual(widen.vars["limits.authority"], ["message.send"]);
|
||||
const empty = mergeVars([layer("agent", "agent", { "limits.tools": [] })]);
|
||||
assert.deepEqual(empty.vars["limits.tools"], []);
|
||||
});
|
||||
|
||||
test("merge doesn't change its inputs", () => {
|
||||
const business = checkVars({ "limits.tools": ["read", "bash"] }, "business", "b");
|
||||
const snapshot = JSON.stringify(business);
|
||||
mergeVars([
|
||||
{ layer: "business", source: "business", vars: business },
|
||||
{ layer: "agent", source: "agent", vars: checkVars({ "limits.tools": ["read"] }, "agent", "a") },
|
||||
]);
|
||||
assert.equal(JSON.stringify(business), snapshot);
|
||||
});
|
||||
Reference in New Issue
Block a user