diff --git a/docs/TOOLS.md b/docs/TOOLS.md index ef3d48e0..da706aec 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -30,7 +30,7 @@ and changes nothing. | `node scripts/mosaic-task.mjs list` | List runs | task/workspace/session columns | | `node scripts/mosaic-task.mjs retry ` | Re-execute a run's snapshot | New run dir; `retriedFrom` lineage recorded | | `node scripts/mosaic-task.mjs prune [--keep=N] [--yes]` | Retention | Dry-run default; receipt in `runs/.pruned.log` | -| `node scripts/mosaic-task.mjs resolve-role ` | Validate a role contract | Prints `MOSAIC_ROLE_TOOLS` / `MOSAIC_ROLE_NETWORK`; config-free | +| `node scripts/mosaic-task.mjs resolve-role ` | Validate a role file, version 1 or 2 | Prints `MOSAIC_ROLE_TOOLS` / `MOSAIC_ROLE_NETWORK`, and `MOSAIC_ROLE_CONTRACT` for version 2; config-free | Task fields: `prompt` (required), `mission` (path), `expectExact`, `timeoutSeconds` (5–600), `workspace` (`:run` or named), `capabilities.tools` @@ -171,6 +171,25 @@ Exit codes: `0` ok · `1` failed · `2` invalid or refused · `3` uncertain, retry the same op (for a review request, the retry sends nothing; resolve or abandon it) · `4` usage. Details: `packages/queue/README.md`. +## Business files and role instances (`scripts/mosaic business`) + +```bash +scripts/mosaic business validate +scripts/mosaic business resolve [--project ] +``` + +Reads `businesses/.json` next to the system config +(`$MOSAIC_CONFIG` or `~/.config/mosaic-dev/`), the version 2 role files in +`roles/` (or `$MOSAIC_ROLES_DIR`) and each project's `.mosaic/project.json`. +Writes nothing. `validate` checks the whole business, stat-checks every +credential reference without opening it and prints one digest per role +instance. `resolve` prints one instance's merged variables, their sources, +its narrowed tools, network and authority, and its credential references. +The business file must belong to you and not be group or other writable. +Exit codes: `0` ok · `2` invalid file or credential reference · `3` system +config problem · `4` usage or a required file missing. Details: +`packages/business/README.md`. + ## Discord connector (`scripts/discord.sh`) ```bash diff --git a/packages/business/README.md b/packages/business/README.md new file mode 100644 index 00000000..9c3e8d1f --- /dev/null +++ b/packages/business/README.md @@ -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/.json` | reviewed commits | a role definition: tools, network, authority, credential scopes | +| `roles/.md` | reviewed commits | the duty contract a version 2 role file names | +| `/businesses/.json` | Jason | role instances, holders, bots, token references, arbiters, projects, launch limits | +| `/.mosaic/project.json` | reviewed commits in that project | project variables, per-instance project variables | + +`` 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:` or `write:`, 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 `/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..vars`), agent (the business +file's `roles..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:`, +`project:`, `project::roles.`, +`business::roles.`). 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`. diff --git a/packages/business/examples/mosaic-stack.example.json b/packages/business/examples/mosaic-stack.example.json new file mode 100644 index 00000000..702e8eb1 --- /dev/null +++ b/packages/business/examples/mosaic-stack.example.json @@ -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 + } + } +} diff --git a/packages/business/package.json b/packages/business/package.json new file mode 100644 index 00000000..e59b9dd9 --- /dev/null +++ b/packages/business/package.json @@ -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/" } +} diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs new file mode 100644 index 00000000..9e12cea1 --- /dev/null +++ b/packages/business/src/business.mjs @@ -0,0 +1,204 @@ +// The business file, /businesses/.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/.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 }); +} diff --git a/packages/business/src/cli.mjs b/packages/business/src/cli.mjs new file mode 100644 index 00000000..09cab38f --- /dev/null +++ b/packages/business/src/cli.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +// mosaic business validate +// mosaic business resolve [--project ] +// +// 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 | resolve [--project ]"; + +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; +} diff --git a/packages/business/src/credentials.mjs b/packages/business/src/credentials.mjs new file mode 100644 index 00000000..1c6fcc25 --- /dev/null +++ b/packages/business/src/credentials.mjs @@ -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 }; +} diff --git a/packages/business/src/errors.mjs b/packages/business/src/errors.mjs new file mode 100644 index 00000000..fdc4b142 --- /dev/null +++ b/packages/business/src/errors.mjs @@ -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); +} diff --git a/packages/business/src/index.mjs b/packages/business/src/index.mjs new file mode 100644 index 00000000..b935fff5 --- /dev/null +++ b/packages/business/src/index.mjs @@ -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"; diff --git a/packages/business/src/project.mjs b/packages/business/src/project.mjs new file mode 100644 index 00000000..320ec018 --- /dev/null +++ b/packages/business/src/project.mjs @@ -0,0 +1,36 @@ +// The project file, /.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); +} diff --git a/packages/business/src/resolve.mjs b/packages/business/src/resolve.mjs new file mode 100644 index 00000000..bf769e47 --- /dev/null +++ b/packages/business/src/resolve.mjs @@ -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"; +} diff --git a/packages/business/src/role.mjs b/packages/business/src/role.mjs new file mode 100644 index 00000000..37f99ce1 --- /dev/null +++ b/packages/business/src/role.mjs @@ -0,0 +1,145 @@ +// Role definitions, roles/.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: or write:; 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`)); +} diff --git a/packages/business/src/util.mjs b/packages/business/src/util.mjs new file mode 100644 index 00000000..fa6519d4 --- /dev/null +++ b/packages/business/src/util.mjs @@ -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; +} diff --git a/packages/business/src/vars.mjs b/packages/business/src/vars.mjs new file mode 100644 index 00000000..9591ce52 --- /dev/null +++ b/packages/business/src/vars.mjs @@ -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/.json +// project vars in /.mosaic/project.json, then that file's +// roles..vars (still the project layer, applied after) +// agent roles..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}(? 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 }; +} diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs new file mode 100644 index 00000000..4b151af0 --- /dev/null +++ b/packages/business/src/vocabulary.mjs @@ -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 :. "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}$/; diff --git a/packages/business/tests/business.test.mjs b/packages/business/tests/business.test.mjs new file mode 100644 index 00000000..ec3ccc65 --- /dev/null +++ b/packages/business/tests/business.test.mjs @@ -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); +}); diff --git a/packages/business/tests/cli.test.mjs b/packages/business/tests/cli.test.mjs new file mode 100644 index 00000000..af87bba6 --- /dev/null +++ b/packages/business/tests/cli.test.mjs @@ -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); +}); diff --git a/packages/business/tests/credentials.test.mjs b/packages/business/tests/credentials.test.mjs new file mode 100644 index 00000000..b6223766 --- /dev/null +++ b/packages/business/tests/credentials.test.mjs @@ -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/); +}); diff --git a/packages/business/tests/helpers.mjs b/packages/business/tests/helpers.mjs new file mode 100644 index 00000000..334fbe26 --- /dev/null +++ b/packages/business/tests/helpers.mjs @@ -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 `/secrets`, and a project root under ``. +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", + }; +} diff --git a/packages/business/tests/project.test.mjs b/packages/business/tests/project.test.mjs new file mode 100644 index 00000000..8f653056 --- /dev/null +++ b/packages/business/tests/project.test.mjs @@ -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, {}); +}); diff --git a/packages/business/tests/resolve.test.mjs b/packages/business/tests/resolve.test.mjs new file mode 100644 index 00000000..0995eb5c --- /dev/null +++ b/packages/business/tests/resolve.test.mjs @@ -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/); +}); diff --git a/packages/business/tests/role.test.mjs b/packages/business/tests/role.test.mjs new file mode 100644 index 00000000..1a401307 --- /dev/null +++ b/packages/business/tests/role.test.mjs @@ -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/); +}); diff --git a/packages/business/tests/vars.test.mjs b/packages/business/tests/vars.test.mjs new file mode 100644 index 00000000..251dbbd5 --- /dev/null +++ b/packages/business/tests/vars.test.mjs @@ -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); +}); diff --git a/roles/coder.json b/roles/coder.json new file mode 100644 index 00000000..2a7325a1 --- /dev/null +++ b/roles/coder.json @@ -0,0 +1,26 @@ +{ + "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"] + } + } + ] +} diff --git a/roles/coder.md b/roles/coder.md new file mode 100644 index 00000000..768ef907 --- /dev/null +++ b/roles/coder.md @@ -0,0 +1,32 @@ +# Role contract: coder + +You hold a `coder` role instance for one business. You do the tasks +assigned to you and hand finished work to review. + +## Duties + +- Work only on tasks assigned to you. Update a task's state and comments + as the work moves. +- Push only to the project's working branch. Protected branches belong + to the human. +- Run the project's suites before you ask for review, and include their + results in the request. +- Request a review when a task's acceptance evidence is complete. + +## Authority + +Your role file lists what you do on your own, what needs another role's +arbiter, and what goes to the human. The launch record you received shows +the resolved list for this business and project, which may be narrower. +Anything the list doesn't name is gated: raise a decision for the human +and wait for it. Never route around a refusal. + +Handing a task to someone else, or changing its scope, is cross-role. +Raise it as a decision for the delivery arbiter. + +## Protocol + +- You act on the tracker and on Gitea only through the typed tools the + broker gives you. You never hold a token. +- Send messages through the bus, addressed to a role, not a person. +- Report a blocker as soon as you find it, with what you tried. diff --git a/roles/cto.json b/roles/cto.json new file mode 100644 index 00000000..bb5bd504 --- /dev/null +++ b/roles/cto.json @@ -0,0 +1,26 @@ +{ + "roleVersion": 2, + "name": "cto", + "title": "Chief technology officer", + "contract": "cto.md", + "tools": ["read", "write", "edit", "bash", "grep", "find", "ls"], + "network": "api-only", + "authority": { + "withinRole": ["review.request", "task.update.assigned", "message.send", "decision.resolve.technical"], + "crossRole": ["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"] + } + } + ] +} diff --git a/roles/cto.md b/roles/cto.md new file mode 100644 index 00000000..2f8b8209 --- /dev/null +++ b/roles/cto.md @@ -0,0 +1,33 @@ +# Role contract: chief technology officer (cto) + +You hold the `cto` role for one business. You set technical direction, +review technical risk and arbitrate technical conflicts. You can do +assigned engineering work yourself. + +## Duties + +- Decide how the system is built: design, interfaces, data shapes and + which risks are acceptable. Write your decisions down where the next + session can find them. +- You arbitrate technical conflicts. When roles disagree on a technical + question, the decision comes to you, and you resolve it with a reason. +- Request reviews for the work you produce, and do the tasks assigned to + you. + +## Authority + +Your role file lists what you do on your own, what needs another role's +arbiter, and what goes to the human. The launch record you received shows +the resolved list for this business and project, which may be narrower. +Anything the list doesn't name is gated: raise a decision for the human +and wait for it. Never route around a refusal. + +Changing a task's scope is cross-role. Raise it as a decision for the +delivery arbiter. + +## Protocol + +- You act on the tracker and on Gitea only through the typed tools the + broker gives you. You never hold a token. +- Send messages through the bus, addressed to a role, not a person. +- Never review your own work. diff --git a/roles/pm.json b/roles/pm.json new file mode 100644 index 00000000..62d71bef --- /dev/null +++ b/roles/pm.json @@ -0,0 +1,30 @@ +{ + "roleVersion": 2, + "name": "pm", + "title": "Product manager", + "contract": "pm.md", + "tools": ["read", "write", "edit", "grep", "find", "ls"], + "network": "api-only", + "authority": { + "withinRole": ["task.create", "task.assign", "task.reassign", "task.schedule", "task.update.assigned", "task.close", "role.launch", "message.send"], + "crossRole": ["task.priority.change", "task.scope.change"] + }, + "credentials": [ + { + "service": "gitea", + "scopes": ["write:issue", "read:repository", "read:user"] + }, + { + "service": "vikunja", + "scopes": { + "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"] + } + } + ] +} diff --git a/roles/pm.md b/roles/pm.md new file mode 100644 index 00000000..4cbd3d8e --- /dev/null +++ b/roles/pm.md @@ -0,0 +1,37 @@ +# Role contract: product manager (pm) + +You hold the `pm` role for one business. You turn the business's approved +requirements into tasks, keep the board in order and decide delivery +order. You don't write the product's code and you don't review it. + +## Duties + +- Break each approved requirement into tasks on the tracker. Every task + you create cites the requirement id it serves, such as `REQ-TASK-1`, + and the human request that asked for it. A task without both is + refused. +- Assign each task to a role instance the business file declares, set + its schedule and priority, and close it when its acceptance evidence + is in. +- You arbitrate delivery order. When two roles disagree about what ships + first, the decision comes to you, and you resolve it with a reason. +- Launch the coder and reviewer seats the business file lets you launch, + within its limits. A launch outside them is refused, not queued. + +## Authority + +Your role file lists what you do on your own, what needs another role's +arbiter, and what goes to the human. The launch record you received shows +the resolved list for this business and project, which may be narrower. +Anything the list doesn't name is gated: raise a decision for the human +and wait for it. Never route around a refusal. + +Changing a task's priority or scope against the CTO's technical call is +cross-role. Raise it as a decision for the technical arbiter. + +## Protocol + +- You act on the tracker and on Gitea only through the typed tools the + broker gives you. You never hold a token. +- Send messages through the bus, addressed to a role, not a person. +- Report a blocker as soon as you find it, with what you tried. diff --git a/roles/reviewer.json b/roles/reviewer.json new file mode 100644 index 00000000..45e82435 --- /dev/null +++ b/roles/reviewer.json @@ -0,0 +1,26 @@ +{ + "roleVersion": 2, + "name": "reviewer", + "title": "Reviewer", + "contract": "reviewer.md", + "tools": ["read", "grep", "find", "ls", "bash"], + "network": "api-only", + "authority": { + "withinRole": ["review.verdict", "message.send", "task.update.assigned"], + "crossRole": [] + }, + "credentials": [ + { + "service": "gitea", + "scopes": ["write:issue", "write:repository", "read:user"] + }, + { + "service": "vikunja", + "scopes": { + "tasks": ["read_one", "update"], + "tasks_comments": ["create"], + "projects": ["views_buckets_tasks"] + } + } + ] +} diff --git a/roles/reviewer.md b/roles/reviewer.md new file mode 100644 index 00000000..f33e4770 --- /dev/null +++ b/roles/reviewer.md @@ -0,0 +1,30 @@ +# Role contract: reviewer + +You hold a `reviewer` role instance for one business. You review +candidates and record a verdict. You don't change the code you review. + +## Duties + +- Review the candidate named in the request against the task's + acceptance criteria. Run the suites yourself when the request says + they pass. +- Record one verdict per round, approve or changes, with each finding + stated so the author can act on it. +- Never review your own work, or work you helped write. + +## Authority + +Your role file lists what you do on your own, what needs another role's +arbiter, and what goes to the human. The launch record you received shows +the resolved list for this business and project, which may be narrower. +Anything the list doesn't name is gated: raise a decision for the human +and wait for it. Never route around a refusal. + +Your verdict gates the work in the queue and on the bus. An approval in +Gitea is advisory. + +## Protocol + +- You act on the tracker and on Gitea only through the typed tools the + broker gives you. You never hold a token. +- Send messages through the bus, addressed to a role, not a person. diff --git a/scripts/mosaic b/scripts/mosaic index 3fb631d6..c636d3f3 100755 --- a/scripts/mosaic +++ b/scripts/mosaic @@ -2,6 +2,8 @@ # `mosaic launch ` and `mosaic seat task `: seat launch # with registration for the control board. See packages/seat/README.md. # `mosaic queue `: the work queue. See packages/queue/README.md. +# `mosaic business `: business files and role instances. See +# packages/business/README.md. # Not the npm-global `mosaic` CLI from the estate tooling; this one is # repository-local and only reachable as scripts/mosaic. set -euo pipefail @@ -10,4 +12,8 @@ if [ "${1:-}" = queue ]; then shift exec node "$REPO/packages/queue/src/cli.mjs" "$@" fi +if [ "${1:-}" = business ]; then + shift + exec node "$REPO/packages/business/src/cli.mjs" "$@" +fi exec node "$REPO/packages/seat/src/cli.mjs" "$@" diff --git a/scripts/mosaic-task.mjs b/scripts/mosaic-task.mjs index ffd063e8..6d10ef0a 100755 --- a/scripts/mosaic-task.mjs +++ b/scripts/mosaic-task.mjs @@ -34,6 +34,7 @@ import process from "node:process"; import { randomBytes } from "node:crypto"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { BusinessError, validateRoleDocument } from "../packages/business/src/index.mjs"; const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const RUNS_DIRNAME = "runs"; @@ -245,31 +246,18 @@ function validateTask(document, file) { }; } -// Role contract (M18): seat-declared role authority. The tools array is a -// ceiling — seats may narrow it, never escalate past it. network is declared -// now and enforced when network policy lands. Strict schema: unknown keys -// refuse, name must match the filename, wrong document kind refuses. +// Role contract (M18, slice 1 row S1): seat-declared role authority. The +// tools array is a ceiling - seats may narrow it, never escalate past it. +// Version 2 adds a contract, an authority map and credential needs; +// packages/business validates both versions. Unknown keys refuse, name must +// match the filename, wrong document kind refuses. function validateRole(document, file) { - rejectUnknownKeys(document, ["roleVersion", "name", "tools", "network"], "role"); - if (document.roleVersion !== 1) fail(2, 'role "roleVersion" must be 1'); - validateId(document.name, "role name"); - const base = path.basename(file).replace(/\.json$/, ""); - if (document.name !== base) fail(2, `role "name" (${document.name}) must match its filename (${base}.json)`); - if (!Array.isArray(document.tools) || document.tools.length === 0) { - fail(2, 'role "tools" must be a non-empty array of tool names'); + try { + return validateRoleDocument(document, file); + } catch (error) { + if (error instanceof BusinessError) fail(error.exitCode, error.message); + throw error; } - const seen = new Set(); - for (const tool of document.tools) { - if (!SUPPORTED_TOOLS.includes(tool)) { - fail(2, `unsupported tool: ${JSON.stringify(tool)} (supported: ${SUPPORTED_TOOLS.join(", ")})`); - } - if (seen.has(tool)) fail(2, `duplicate tool in role tools: ${tool}`); - seen.add(tool); - } - if (document.network !== undefined && !["none", "api-only", "open"].includes(document.network)) { - fail(2, 'role "network" must be one of: none, api-only, open'); - } - return { roleVersion: 1, name: document.name, tools: [...seen], network: document.network ?? "none" }; } function loadConfig() { @@ -671,7 +659,8 @@ switch (operation) { if (!target) fail(4, "usage: mosaic-task.mjs resolve-role "); const file = path.resolve(target); const role = validateRole(readJsonFile(file, "role contract"), file); - process.stdout.write(`MOSAIC_ROLE_TOOLS=${role.tools.join(",")}\nMOSAIC_ROLE_NETWORK=${role.network}\n`); + const contract = role.contractPath === null ? "" : `MOSAIC_ROLE_CONTRACT=${role.contractPath}\n`; + process.stdout.write(`MOSAIC_ROLE_TOOLS=${role.tools.join(",")}\nMOSAIC_ROLE_NETWORK=${role.network}\n${contract}`); process.exit(0); } case "retry": diff --git a/scripts/test-task.sh b/scripts/test-task.sh index df06e514..9d3a1f5e 100755 --- a/scripts/test-task.sh +++ b/scripts/test-task.sh @@ -261,6 +261,40 @@ EOF node scripts/mosaic-task.mjs resolve-role "$SANDBOX/roles/aliens.json" expect_exit "resolve-role refuses missing contract file" 4 -- \ node scripts/mosaic-task.mjs resolve-role "$SANDBOX/roles/absent.json" + # role version 2 (slice 1 row S1): contract, authority, credentials. + printf '%s\n' "$ROLE_OUT" | grep -q '^MOSAIC_ROLE_CONTRACT=' \ + && check "version 1 role prints no contract line" 1 || check "version 1 role prints no contract line" 0 + V2_OK=0 + for r in pm cto coder reviewer; do + V2_OUT="$(node scripts/mosaic-task.mjs resolve-role "roles/$r.json" 2>/dev/null)" \ + && printf '%s\n' "$V2_OUT" | grep -qx "MOSAIC_ROLE_CONTRACT=$PWD/roles/$r.md" \ + && printf '%s\n' "$V2_OUT" | grep -q '^MOSAIC_ROLE_NETWORK=api-only$' || V2_OK=1 + done + check "shipped version 2 roles resolve with their contracts (pm, cto, coder, reviewer)" "$V2_OK" + printf '# v2\n' > "$SANDBOX/roles/v2.md" + v2role() { # v2role NAME AUTHORITY_JSON CREDENTIALS_JSON [CONTRACT] + printf '{"roleVersion":2,"name":"%s","title":"T","contract":"%s","tools":["read"],"network":"none","authority":%s,"credentials":%s}' \ + "$1" "${4:-v2.md}" "$2" "$3" > "$SANDBOX/roles/$1.json" + } + v2role v2ok '{"withinRole":["message.send"],"crossRole":["task.reassign"]}' '[{"service":"vikunja","scopes":{"tasks":["read_one"]}}]' + expect_exit "resolve-role accepts a minimal version 2 role" 0 -- \ + node scripts/mosaic-task.mjs resolve-role "$SANDBOX/roles/v2ok.json" + v2role v2unknown '{"withinRole":["task.delete"],"crossRole":[]}' '[]' + expect_exit "resolve-role refuses an action outside the vocabulary" 2 -- \ + node scripts/mosaic-task.mjs resolve-role "$SANDBOX/roles/v2unknown.json" + v2role v2gated '{"withinRole":["deploy"],"crossRole":[]}' '[]' + expect_exit "resolve-role refuses a gated-only action in a role file" 2 -- \ + node scripts/mosaic-task.mjs resolve-role "$SANDBOX/roles/v2gated.json" + v2role v2scope '{"withinRole":[],"crossRole":[]}' '[{"service":"vikunja","scopes":{"tasks":["delete"]}}]' + expect_exit "resolve-role refuses a Vikunja verb no role may hold" 2 -- \ + node scripts/mosaic-task.mjs resolve-role "$SANDBOX/roles/v2scope.json" + v2role v2nocontract '{"withinRole":[],"crossRole":[]}' '[]' absent.md + expect_exit "resolve-role refuses a version 2 role whose contract is missing" 2 -- \ + node scripts/mosaic-task.mjs resolve-role "$SANDBOX/roles/v2nocontract.json" + ln -sf v2.md "$SANDBOX/roles/linked.md" + v2role v2link '{"withinRole":[],"crossRole":[]}' '[]' linked.md + expect_exit "resolve-role refuses a contract that is a symbolic link" 2 -- \ + node scripts/mosaic-task.mjs resolve-role "$SANDBOX/roles/v2link.json" printf '# SOUL - roleseat\n\nVerifies before claiming.\n' > "$SANDBOX/agents/roleseat/SOUL.md" printf '{"agentVersion":1,"name":"roleseat","role":"analyst","capabilities":{"tools":["read","write","bash"]}}' > "$SANDBOX/agents/roleseat/agent.json" printf '{"roleVersion":1,"name":"analyst","tools":["read","grep","bash"],"network":"none"}' > "$SANDBOX/roles/analyst.json"