S1 candidate for Filbert's review on #1518: build.md, build.patch and
build-manifest.sha256 (34 files at base fef4b362). The candidate itself
is not committed.
proto-v3a-notes-correction: the schema has 36 triggers; the printed 35 is
counted after the tamper check's DROP TRIGGER. Found by Rocko.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
3024 lines
138 KiB
Diff
3024 lines
138 KiB
Diff
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 <runId>` | 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 <roleFile>` | Validate a role contract | Prints `MOSAIC_ROLE_TOOLS` / `MOSAIC_ROLE_NETWORK`; config-free |
|
||
+| `node scripts/mosaic-task.mjs resolve-role <roleFile>` | 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 <business>
|
||
+scripts/mosaic business resolve <business> <instance> [--project <id>]
|
||
+```
|
||
+
|
||
+Reads `businesses/<business>.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..35b17cc7
|
||
--- /dev/null
|
||
+++ b/packages/business/README.md
|
||
@@ -0,0 +1,160 @@
|
||
+# Business
|
||
+
|
||
+Role definitions, the business and project files, the variable registry and
|
||
+the resolver (slice 1 row S1, #1518). Brief: `docs/plans/2026-10-04_slice-1.md`,
|
||
+row S1. Design: `agents/darkwing/work/slice1-data-model-2026-10-04.md` and its
|
||
+addenda A and B. The package reads files and refuses on any problem. It
|
||
+never writes, and it never opens a token file.
|
||
+
|
||
+```sh
|
||
+scripts/mosaic business validate mosaic-stack
|
||
+scripts/mosaic business resolve mosaic-stack coder --project stack
|
||
+node --test packages/business/tests/
|
||
+```
|
||
+
|
||
+## Files
|
||
+
|
||
+| File | Who writes it | Holds |
|
||
+|---|---|---|
|
||
+| `roles/<name>.json` | reviewed commits | a role definition: tools, network, authority, credential scopes |
|
||
+| `roles/<name>.md` | reviewed commits | the duty contract a version 2 role file names |
|
||
+| `<configDir>/businesses/<id>.json` | Jason | role instances, holders, bots, token references, arbiters, projects, launch limits |
|
||
+| `<root>/.mosaic/project.json` | reviewed commits in that project | project variables, per-instance project variables |
|
||
+
|
||
+`<configDir>` is the directory holding the system config: the directory of
|
||
+`$MOSAIC_CONFIG`, or `~/.config/mosaic-dev`. Role files come from `roles/`
|
||
+in this repository, or `$MOSAIC_ROLES_DIR` if set.
|
||
+
|
||
+### Role files, version 2
|
||
+
|
||
+```json
|
||
+{
|
||
+ "roleVersion": 2, "name": "coder", "title": "Coder", "contract": "coder.md",
|
||
+ "tools": ["read", "write", "edit", "bash", "grep", "find", "ls"],
|
||
+ "network": "api-only",
|
||
+ "authority": {
|
||
+ "withinRole": ["task.update.assigned", "git.push.working", "review.request", "message.send"],
|
||
+ "crossRole": ["task.reassign", "task.scope.change"]
|
||
+ },
|
||
+ "credentials": [
|
||
+ { "service": "gitea", "scopes": ["write:issue", "write:repository", "read:user"] },
|
||
+ { "service": "vikunja", "scopes": { "tasks": ["read_one", "update"], "tasks_comments": ["create"], "projects": ["views_buckets_tasks"] } }
|
||
+ ]
|
||
+}
|
||
+```
|
||
+
|
||
+- `name` matches the file name. `contract` is a Markdown file name in the
|
||
+ same directory, not a path. It must exist, be a regular file and not be
|
||
+ empty.
|
||
+- `authority` names actions from the closed list in `src/vocabulary.mjs`.
|
||
+ `withinRole` actions run without asking. `crossRole` actions need the
|
||
+ arbiter. Any action not listed is gated: it goes to Jason. The nine
|
||
+ always-gated actions (`deploy`, `spend`, `git.push.protected`,
|
||
+ `git.merge.protected`, `credential.mint`, `message.external`,
|
||
+ `policy.change`, `prd.approve`, `role.revoke`) can't appear in either list.
|
||
+- Gitea scopes are `read:<category>` or `write:<category>`, one level per
|
||
+ category. `all` and `admin` are refused. Vikunja scopes map a route group
|
||
+ to verbs, and only the pairs in `VIKUNJA_GRANTABLE` pass.
|
||
+- Version 1 files (`roleVersion`, `name`, `tools`, `network`) still load.
|
||
+ They have no authority and no credentials, and a business can't use them
|
||
+ for a role instance.
|
||
+
|
||
+`scripts/mosaic-task.mjs resolve-role` checks both versions through this
|
||
+package and prints `MOSAIC_ROLE_CONTRACT` for version 2.
|
||
+
|
||
+### Business file
|
||
+
|
||
+`examples/mosaic-stack.example.json` is a complete example. As shipped it
|
||
+refuses on purpose: every `botId` is 0 and every date is `YYYY-MM-DD`. Copy
|
||
+it to `<configDir>/businesses/mosaic-stack.json`, put in the bot ids and
|
||
+token dates from `docs/guides/slice-1-identities.md`, fix the token paths
|
||
+and run `validate`. The file must belong to you and must not be writable
|
||
+by group or other.
|
||
+
|
||
+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). Only that instance
|
||
+keeps `role.launch` as within-role. Every other instance loses it, so for
|
||
+them it's gated.
|
||
+
|
||
+### Project file
|
||
+
|
||
+```json
|
||
+{ "projectVersion": 1, "id": "stack",
|
||
+ "vars": { "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"] },
|
||
+ "roles": { "coder": { "vars": { "limits.tools": ["read", "edit", "bash"] } } } }
|
||
+```
|
||
+
|
||
+`id` must match the business file's name for the project. A missing
|
||
+project file is a warning in `validate` and an error in
|
||
+`resolve --project`.
|
||
+
|
||
+## Variables
|
||
+
|
||
+Every key is declared once in `src/vars.mjs` with its type, the layers
|
||
+that may set it and its merge rule. An unknown key refuses, and so does a
|
||
+key set at a layer it doesn't belong to.
|
||
+
|
||
+Layers, least specific first: defaults, system (the system config), business
|
||
+(`vars`), project (`vars`, then `roles.<instance>.vars`), agent (the business
|
||
+file's `roles.<instance>.vars`). For a plain key the most specific layer
|
||
+wins. `limits.tools`, `limits.network` and `limits.authority` intersect:
|
||
+each layer that sets one narrows it, and the result also narrows the role
|
||
+definition. `limits.authority` is an allowlist, so a role action it leaves
|
||
+out becomes gated.
|
||
+
|
||
+`resolve` prints `provenance` beside `vars`. For a plain key it's the
|
||
+layer that set the value (`default`, `system`, `business:<id>`,
|
||
+`project:<id>`, `project:<id>:roles.<instance>`,
|
||
+`business:<id>:roles.<instance>`). For a limit it's the list of layers
|
||
+that narrowed it.
|
||
+
|
||
+## API
|
||
+
|
||
+```js
|
||
+import {
|
||
+ loadBusiness, loadProject, systemVars, resolveInstance, classify,
|
||
+ loadRole, validateRoleDocument, checkCredentialRef,
|
||
+} from "../packages/business/src/index.mjs";
|
||
+
|
||
+const business = loadBusiness("mosaic-stack", { rolesDir }); // frozen
|
||
+const project = loadProject(business.projects.stack.root); // frozen
|
||
+const coder = resolveInstance({ system: systemVars(config), business, project, instance: "coder" });
|
||
+classify(coder, "git.push.working"); // "within" | "cross" | "gated"
|
||
+```
|
||
+
|
||
+`resolveInstance` returns a frozen record: `business`, `project`,
|
||
+`instance`, `definition`, `holder`, `contract` (absolute path), `vars`,
|
||
+`provenance`, `limits` (`tools`, `network`, `authority.withinRole`,
|
||
+`authority.crossRole`), `credentials` (references only), `tracker` (`bot`,
|
||
+`botId` or null), `launch` (the launch block, or null) and `digest`, the
|
||
+SHA-256 of the record's canonical JSON. `classify` refuses an action
|
||
+outside the vocabulary.
|
||
+
|
||
+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..6fc4e636
|
||
--- /dev/null
|
||
+++ b/packages/business/src/business.mjs
|
||
@@ -0,0 +1,201 @@
|
||
+// The business file, <configDir>/businesses/<id>.json (REQ-ROLE-3, note
|
||
+// section 2, addendum A sections 7 and 8). Jason writes it; the stack reads
|
||
+// it and never writes it. A missing or invalid file refuses (lead decision
|
||
+// 46, 6.1).
|
||
+
|
||
+import { lstatSync } from "node:fs";
|
||
+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, 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 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, file);
|
||
+
|
||
+ return deepFreeze({
|
||
+ businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, definitions,
|
||
+ });
|
||
+}
|
||
+
|
||
+// Load businesses/<id>.json from the config directory. The file holds
|
||
+// authority, so it must belong to this user and not be writable by group
|
||
+// or other.
|
||
+export function loadBusiness(id, { dir = configDir(), rolesDir }) {
|
||
+ if (!rolesDir) throw new Error("loadBusiness needs rolesDir");
|
||
+ const file = businessFilePath(id, dir);
|
||
+ const document = readJsonFile(file, "business file");
|
||
+ const stat = lstatSync(file);
|
||
+ 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 <business>
|
||
+// mosaic business resolve <business> <instance> [--project <id>]
|
||
+//
|
||
+// validate loads the business file, every role definition it uses, the
|
||
+// project files that exist under its declared roots, and stat-checks every
|
||
+// credential reference. resolve prints one role instance's resolved record.
|
||
+// Both print JSON on stdout and problems on stderr.
|
||
+//
|
||
+// Exit codes: 0 ok; 2 invalid (business, role, project file or a credential
|
||
+// reference); 3 system config problem; 4 usage, or a required file missing.
|
||
+
|
||
+import { spawnSync } from "node:child_process";
|
||
+import { existsSync } from "node:fs";
|
||
+import { dirname, join, resolve } from "node:path";
|
||
+import { fileURLToPath } from "node:url";
|
||
+import { BusinessError } from "./errors.mjs";
|
||
+import { loadBusiness } from "./business.mjs";
|
||
+import { loadProject, projectFilePath } from "./project.mjs";
|
||
+import { checkCredentialRef } from "./credentials.mjs";
|
||
+import { systemVars, resolveInstance } from "./resolve.mjs";
|
||
+
|
||
+const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||
+const USAGE = "usage: mosaic business validate <business> | resolve <business> <instance> [--project <id>]";
|
||
+
|
||
+function fail(code, message) {
|
||
+ process.stderr.write(`mosaic business: ${message}\n`);
|
||
+ process.exit(code);
|
||
+}
|
||
+
|
||
+function loadSystem() {
|
||
+ const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate"], {
|
||
+ encoding: "utf8",
|
||
+ maxBuffer: 1024 * 1024,
|
||
+ });
|
||
+ if (proc.status !== 0) fail(3, `system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`);
|
||
+ try {
|
||
+ return JSON.parse(proc.stdout);
|
||
+ } catch {
|
||
+ fail(3, "system config: mosaic-config printed something that isn't JSON");
|
||
+ }
|
||
+}
|
||
+
|
||
+function rolesDir() {
|
||
+ return resolve(process.env.MOSAIC_ROLES_DIR || join(REPO, "roles"));
|
||
+}
|
||
+
|
||
+// Check credential references; print warnings; return the problem list.
|
||
+function checkRefs(refs, forbiddenRoots) {
|
||
+ const problems = [];
|
||
+ const warnings = [];
|
||
+ for (const ref of refs) {
|
||
+ const result = checkCredentialRef(ref, { forbiddenRoots });
|
||
+ problems.push(...result.problems);
|
||
+ warnings.push(...result.warnings);
|
||
+ }
|
||
+ return { problems, warnings };
|
||
+}
|
||
+
|
||
+function forbiddenRoots(config, business) {
|
||
+ return [REPO, config.dataRoot, ...Object.values(business.projects).map((p) => p.root)];
|
||
+}
|
||
+
|
||
+function validate(args) {
|
||
+ if (args.length !== 1) fail(4, USAGE);
|
||
+ const config = loadSystem();
|
||
+ const system = systemVars(config);
|
||
+ const business = loadBusiness(args[0], { rolesDir: rolesDir() });
|
||
+ const projects = {};
|
||
+ const warnings = [];
|
||
+ for (const [id, entry] of Object.entries(business.projects)) {
|
||
+ const file = projectFilePath(entry.root);
|
||
+ if (!existsSync(file)) {
|
||
+ projects[id] = "absent";
|
||
+ warnings.push(`project ${id}: no project file at ${file}; it has no project variables`);
|
||
+ continue;
|
||
+ }
|
||
+ const project = loadProject(entry.root);
|
||
+ if (project.id !== id) fail(2, `project file ${file} has id ${project.id}, but business ${business.id} declares it as ${id}`);
|
||
+ for (const instance of Object.keys(business.roles)) resolveInstance({ system, business, project, instance });
|
||
+ projects[id] = "valid";
|
||
+ }
|
||
+ const refs = [
|
||
+ ...Object.values(business.tracker.sync.credentials),
|
||
+ ...Object.values(business.roles).flatMap((r) => Object.values(r.credentials)),
|
||
+ ];
|
||
+ const checked = checkRefs(refs, forbiddenRoots(config, business));
|
||
+ warnings.push(...checked.warnings);
|
||
+ const instances = {};
|
||
+ for (const instance of Object.keys(business.roles)) {
|
||
+ instances[instance] = resolveInstance({ system, business, instance }).digest;
|
||
+ }
|
||
+ for (const w of warnings) process.stderr.write(`mosaic business: warning: ${w}\n`);
|
||
+ if (checked.problems.length > 0) {
|
||
+ for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`);
|
||
+ fail(2, `business ${business.id}: ${checked.problems.length} credential reference problem(s)`);
|
||
+ }
|
||
+ process.stdout.write(`${JSON.stringify({ business: business.id, file: business.file, projects, instances }, null, 2)}\n`);
|
||
+}
|
||
+
|
||
+function resolveCommand(args) {
|
||
+ let project = null;
|
||
+ const rest = [];
|
||
+ for (let i = 0; i < args.length; i++) {
|
||
+ if (args[i] === "--project") {
|
||
+ if (!args[i + 1] || project !== null) fail(4, USAGE);
|
||
+ project = args[++i];
|
||
+ } else rest.push(args[i]);
|
||
+ }
|
||
+ if (rest.length !== 2) fail(4, USAGE);
|
||
+ const [businessId, instance] = rest;
|
||
+ const config = loadSystem();
|
||
+ const business = loadBusiness(businessId, { rolesDir: rolesDir() });
|
||
+ let loaded = null;
|
||
+ if (project !== null) {
|
||
+ const entry = Object.hasOwn(business.projects, project) ? business.projects[project] : null;
|
||
+ if (!entry) fail(2, `business ${business.id} declares no project ${JSON.stringify(project)}`);
|
||
+ loaded = loadProject(entry.root);
|
||
+ }
|
||
+ const resolved = resolveInstance({ system: systemVars(config), business, project: loaded, instance });
|
||
+ const checked = checkRefs(Object.values(resolved.credentials), forbiddenRoots(config, business));
|
||
+ for (const w of checked.warnings) process.stderr.write(`mosaic business: warning: ${w}\n`);
|
||
+ if (checked.problems.length > 0) {
|
||
+ for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`);
|
||
+ fail(2, `${business.id} ${instance}: ${checked.problems.length} credential reference problem(s)`);
|
||
+ }
|
||
+ process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
|
||
+}
|
||
+
|
||
+const [verb, ...args] = process.argv.slice(2);
|
||
+try {
|
||
+ if (verb === "validate") validate(args);
|
||
+ else if (verb === "resolve") resolveCommand(args);
|
||
+ else fail(4, USAGE);
|
||
+} catch (error) {
|
||
+ if (error instanceof BusinessError) fail(error.exitCode, error.message);
|
||
+ throw error;
|
||
+}
|
||
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, <root>/.mosaic/project.json. It lives in the project's
|
||
+// repository and changes by reviewed commits there. The stack only reads it.
|
||
+
|
||
+import { isAbsolute, join, normalize } from "node:path";
|
||
+import { refuse } from "./errors.mjs";
|
||
+import { checkVars } from "./vars.mjs";
|
||
+import { requireObject, rejectUnknownKeys, requireId, readJsonFile, deepFreeze } from "./util.mjs";
|
||
+
|
||
+export function projectFilePath(root) {
|
||
+ return join(root, ".mosaic", "project.json");
|
||
+}
|
||
+
|
||
+export function validateProjectDocument(document, file) {
|
||
+ requireObject(document, "project file");
|
||
+ rejectUnknownKeys(document, ["projectVersion", "id", "vars", "roles"], "project file");
|
||
+ if (document.projectVersion !== 1) refuse(`project file "projectVersion" must be 1 (${file})`);
|
||
+ requireId(document.id, "project id");
|
||
+ const vars = checkVars(document.vars ?? {}, "project", `${file} vars`);
|
||
+ const roles = {};
|
||
+ if (document.roles !== undefined) {
|
||
+ requireObject(document.roles, `${file} roles`);
|
||
+ for (const [instance, entry] of Object.entries(document.roles)) {
|
||
+ requireId(instance, `${file} role instance`);
|
||
+ requireObject(entry, `${file} roles.${instance}`);
|
||
+ rejectUnknownKeys(entry, ["vars"], `${file} roles.${instance}`);
|
||
+ roles[instance] = { vars: checkVars(entry.vars ?? {}, "project", `${file} roles.${instance}.vars`) };
|
||
+ }
|
||
+ }
|
||
+ return deepFreeze({ projectVersion: 1, id: document.id, file, vars, roles });
|
||
+}
|
||
+
|
||
+export function loadProject(root) {
|
||
+ if (typeof root !== "string" || !isAbsolute(root) || normalize(root) !== root) refuse(`project root must be a normalized absolute path (got ${JSON.stringify(root)})`);
|
||
+ const file = projectFilePath(root);
|
||
+ return validateProjectDocument(readJsonFile(file, "project file"), file);
|
||
+}
|
||
diff --git a/packages/business/src/resolve.mjs b/packages/business/src/resolve.mjs
|
||
new file mode 100644
|
||
index 00000000..7b628d57
|
||
--- /dev/null
|
||
+++ b/packages/business/src/resolve.mjs
|
||
@@ -0,0 +1,102 @@
|
||
+// 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). Without it the verb is gated.
|
||
+ const launch = business.launch && business.launch.by === instance ? 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/<name>.json. Version 2 (REQ-ROLE-1) adds a
|
||
+// contract, an authority map over the closed vocabulary and the
|
||
+// credentials the role needs, and keeps version 1's tool and network
|
||
+// ceilings. Version 1 files still load: they carry no authority, so every
|
||
+// vocabulary action is gated for them.
|
||
+
|
||
+import { lstatSync } from "node:fs";
|
||
+import { basename, dirname, join } from "node:path";
|
||
+import { refuse } from "./errors.mjs";
|
||
+import {
|
||
+ ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||
+} from "./vocabulary.mjs";
|
||
+import {
|
||
+ requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||
+} from "./util.mjs";
|
||
+
|
||
+const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
|
||
+const V1_KEYS = ["roleVersion", "name", "tools", "network"];
|
||
+const V2_KEYS = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
|
||
+
|
||
+function checkTools(tools) {
|
||
+ if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names');
|
||
+ return requireDistinctList(tools, "role tools", (tool) => {
|
||
+ if (!TOOLS.includes(tool)) refuse(`unsupported tool: ${JSON.stringify(tool)} (supported: ${TOOLS.join(", ")})`);
|
||
+ });
|
||
+}
|
||
+
|
||
+function checkNetwork(network) {
|
||
+ if (!NETWORKS.includes(network)) refuse(`role "network" must be one of: ${NETWORKS.join(", ")}`);
|
||
+ return network;
|
||
+}
|
||
+
|
||
+function checkAuthority(authority) {
|
||
+ requireObject(authority, 'role "authority"');
|
||
+ rejectUnknownKeys(authority, ["withinRole", "crossRole"], 'role "authority"');
|
||
+ const lists = {};
|
||
+ for (const key of ["withinRole", "crossRole"]) {
|
||
+ lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => {
|
||
+ if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}: ${JSON.stringify(action)}`);
|
||
+ if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`);
|
||
+ });
|
||
+ }
|
||
+ const both = lists.withinRole.filter((a) => lists.crossRole.includes(a));
|
||
+ if (both.length > 0) refuse(`action listed as both withinRole and crossRole: ${both.join(", ")}`);
|
||
+ return lists;
|
||
+}
|
||
+
|
||
+function checkGiteaScopes(scopes) {
|
||
+ const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
|
||
+ const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
|
||
+ if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) {
|
||
+ refuse(`unsupported gitea scope: ${JSON.stringify(scope)} (expected read:<category> or write:<category>; categories: ${GITEA_SCOPE_CATEGORIES.join(", ")})`);
|
||
+ }
|
||
+ }, { nonEmpty: true });
|
||
+ const categories = list.map((s) => s.split(":")[1]);
|
||
+ const twice = categories.filter((c, i) => categories.indexOf(c) !== i);
|
||
+ if (twice.length > 0) refuse(`gitea scopes name ${twice[0]} twice; a token holds one level per category`);
|
||
+ return list;
|
||
+}
|
||
+
|
||
+function checkVikunjaScopes(scopes) {
|
||
+ requireObject(scopes, "vikunja scopes");
|
||
+ if (Object.keys(scopes).length === 0) refuse("vikunja scopes must name at least one route group");
|
||
+ const out = {};
|
||
+ for (const [group, verbs] of Object.entries(scopes)) {
|
||
+ const allowed = VIKUNJA_GRANTABLE[group];
|
||
+ if (!allowed) refuse(`vikunja route group not grantable to a role: ${JSON.stringify(group)}`);
|
||
+ out[group] = requireDistinctList(verbs, `vikunja scopes.${group}`, (verb) => {
|
||
+ if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role: ${group}.${JSON.stringify(verb)}`);
|
||
+ }, { nonEmpty: true });
|
||
+ }
|
||
+ return out;
|
||
+}
|
||
+
|
||
+function checkCredentials(credentials) {
|
||
+ if (!Array.isArray(credentials)) refuse('role "credentials" must be an array');
|
||
+ const seen = new Set();
|
||
+ return credentials.map((entry, i) => {
|
||
+ requireObject(entry, `role credentials[${i}]`);
|
||
+ rejectUnknownKeys(entry, ["service", "scopes"], `role credentials[${i}]`);
|
||
+ if (!SERVICES.includes(entry.service)) refuse(`role credentials[${i}].service must be one of: ${SERVICES.join(", ")}`);
|
||
+ if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`);
|
||
+ seen.add(entry.service);
|
||
+ const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
|
||
+ return { service: entry.service, scopes };
|
||
+ });
|
||
+}
|
||
+
|
||
+function checkContract(contract, file) {
|
||
+ if (typeof contract !== "string" || !CONTRACT_NAME.test(contract)) {
|
||
+ refuse(`role "contract" must be a Markdown file name in the role's directory, matching ${CONTRACT_NAME} (got ${JSON.stringify(contract)})`);
|
||
+ }
|
||
+ const path = join(dirname(file), contract);
|
||
+ let stat;
|
||
+ try {
|
||
+ stat = lstatSync(path);
|
||
+ } catch {
|
||
+ refuse(`role contract not found: ${path}`);
|
||
+ }
|
||
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) refuse(`role contract must be a non-empty regular file: ${path}`);
|
||
+ return path;
|
||
+}
|
||
+
|
||
+// Validate a parsed role document read from `file`. Returns a frozen role:
|
||
+// { roleVersion, name, title, contract, contractPath, tools, network,
|
||
+// authority: { withinRole, crossRole }, credentials: [{ service, scopes }] }.
|
||
+export function validateRoleDocument(document, file) {
|
||
+ requireObject(document, "role");
|
||
+ if (document.roleVersion !== 1 && document.roleVersion !== 2) refuse('role "roleVersion" must be 1 or 2');
|
||
+ rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role");
|
||
+ requireId(document.name, "role name");
|
||
+ const base = basename(file).replace(/\.json$/, "");
|
||
+ if (document.name !== base) refuse(`role "name" (${document.name}) must match its filename (${base}.json)`);
|
||
+ const tools = checkTools(document.tools);
|
||
+ if (document.roleVersion === 1) {
|
||
+ const network = document.network === undefined ? "none" : checkNetwork(document.network);
|
||
+ return deepFreeze({
|
||
+ roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network,
|
||
+ authority: { withinRole: [], crossRole: [] }, credentials: [],
|
||
+ });
|
||
+ }
|
||
+ for (const key of V2_KEYS) {
|
||
+ if (document[key] === undefined) refuse(`role version 2 requires "${key}"`);
|
||
+ }
|
||
+ return deepFreeze({
|
||
+ roleVersion: 2,
|
||
+ name: document.name,
|
||
+ title: requireString(document.title, 'role "title"', { max: 80 }),
|
||
+ contract: document.contract,
|
||
+ contractPath: checkContract(document.contract, file),
|
||
+ tools,
|
||
+ network: checkNetwork(document.network),
|
||
+ authority: checkAuthority(document.authority),
|
||
+ credentials: checkCredentials(document.credentials),
|
||
+ });
|
||
+}
|
||
+
|
||
+export function loadRoleFile(file) {
|
||
+ return validateRoleDocument(readJsonFile(file, "role file"), file);
|
||
+}
|
||
+
|
||
+export function loadRole(rolesDir, name) {
|
||
+ requireId(name, "role name");
|
||
+ return loadRoleFile(join(rolesDir, `${name}.json`));
|
||
+}
|
||
diff --git a/packages/business/src/util.mjs b/packages/business/src/util.mjs
|
||
new file mode 100644
|
||
index 00000000..2f8dcdd1
|
||
--- /dev/null
|
||
+++ b/packages/business/src/util.mjs
|
||
@@ -0,0 +1,97 @@
|
||
+import { lstatSync, 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.
|
||
+export function readJsonFile(file, what) {
|
||
+ let stat;
|
||
+ try {
|
||
+ stat = lstatSync(file);
|
||
+ } catch {
|
||
+ refuse(`${what} not found: ${file}`, 4);
|
||
+ }
|
||
+ if (!stat.isFile() || stat.isSymbolicLink()) refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
|
||
+ try {
|
||
+ return JSON.parse(readFileSync(file, "utf8"));
|
||
+ } 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/<id>.json
|
||
+// project vars in <root>/.mosaic/project.json, then that file's
|
||
+// roles.<instance>.vars (still the project layer, applied after)
|
||
+// agent roles.<instance>.vars in the business file
|
||
+//
|
||
+// Merge rules:
|
||
+// replace the most specific layer that sets the key wins
|
||
+// intersect every layer that sets the key narrows it; nothing widens
|
||
+
|
||
+import { refuse } from "./errors.mjs";
|
||
+import { ACTIONS, NETWORKS, TOOLS } from "./vocabulary.mjs";
|
||
+import { isPlainObject, requireDistinctList, requirePositiveInt, requireString } from "./util.mjs";
|
||
+
|
||
+export const LAYERS = Object.freeze(["system", "business", "project", "agent"]);
|
||
+
|
||
+const BRANCH = /^(?!-)(?!.*\.\.)(?!.*\/\/)(?!.*\.lock$)[A-Za-z0-9._/-]{1,100}(?<![./])$/;
|
||
+
|
||
+function string(max = 200) {
|
||
+ return (v, key) => requireString(v, `variable ${key}`, { max });
|
||
+}
|
||
+
|
||
+function oneOf(values) {
|
||
+ return (v, key) => {
|
||
+ if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")} (got ${JSON.stringify(v)})`);
|
||
+ return v;
|
||
+ };
|
||
+}
|
||
+
|
||
+function integer(min) {
|
||
+ return (v, key) => {
|
||
+ if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min} (got ${JSON.stringify(v)})`);
|
||
+ return v;
|
||
+ };
|
||
+}
|
||
+
|
||
+// http or https, no user info, query or fragment, no trailing slash.
|
||
+function baseUrl(v, key) {
|
||
+ requireString(v, `variable ${key}`, { max: 300 });
|
||
+ let url;
|
||
+ try {
|
||
+ url = new URL(v);
|
||
+ } catch {
|
||
+ refuse(`variable ${key} must be an http or https URL (got ${JSON.stringify(v)})`);
|
||
+ }
|
||
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || v.endsWith("/")) {
|
||
+ refuse(`variable ${key} must be an http or https base URL with no credentials, query, fragment or trailing slash`);
|
||
+ }
|
||
+ return v;
|
||
+}
|
||
+
|
||
+function branch(v, key) {
|
||
+ if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name (got ${JSON.stringify(v)})`);
|
||
+ return v;
|
||
+}
|
||
+
|
||
+function list(check, { nonEmpty = true } = {}) {
|
||
+ return (v, key) => requireDistinctList(v, `variable ${key}`, (item) => check(item, `${key} entry`), { nonEmpty });
|
||
+}
|
||
+
|
||
+function absolutePath(v, key) {
|
||
+ if (typeof v !== "string" || !v.startsWith("/") || v.includes("\0")) refuse(`variable ${key} must be an absolute path`);
|
||
+ return v;
|
||
+}
|
||
+
|
||
+const def = (layers, check, extra = {}) => Object.freeze({ layers: Object.freeze(layers), merge: "replace", check, default: undefined, ...extra });
|
||
+
|
||
+export const REGISTRY = Object.freeze({
|
||
+ // System. mosaic-config.mjs owns their validation; these checks only
|
||
+ // keep the resolver honest about types.
|
||
+ environment: def(["system"], string(64)),
|
||
+ dataRoot: def(["system"], absolutePath),
|
||
+ "execution.backend": def(["system"], string(64)),
|
||
+ "execution.provider": def(["system"], string(64)),
|
||
+ "execution.model": def(["system"], string(200)),
|
||
+ "execution.adapter": def(["system"], string(64)),
|
||
+
|
||
+ // Business.
|
||
+ "tracker.kind": def(["business"], oneOf(["vikunja"]), { default: "vikunja" }),
|
||
+ "tracker.baseUrl": def(["business"], baseUrl),
|
||
+ "tracker.reconcileMinutes": def(["business"], integer(1), { default: 60 }),
|
||
+ "tracker.pollSeconds": def(["business", "project"], integer(10), { default: 30 }),
|
||
+ "human.discordUserId": def(["business"], (v, key) => {
|
||
+ if (typeof v !== "string" || !/^[1-9][0-9]{16,19}$/.test(v)) refuse(`variable ${key} must be a Discord user id, 17 to 20 digits as a string`);
|
||
+ return v;
|
||
+ }),
|
||
+ "gitea.baseUrl": def(["business"], baseUrl),
|
||
+
|
||
+ // Project.
|
||
+ "tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)),
|
||
+ "git.workingBranch": def(["project"], branch),
|
||
+ "git.protectedBranches": def(["project"], list(branch)),
|
||
+ suites: def(["project"], list(string(300))),
|
||
+ "issues.repo": def(["project"], (v, key) => {
|
||
+ if (typeof v !== "string" || !/^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(v)) refuse(`variable ${key} must be owner/name`);
|
||
+ return v;
|
||
+ }),
|
||
+
|
||
+ // Agent: one role instance.
|
||
+ harness: def(["agent"], oneOf(["pi", "claude-code"])),
|
||
+ model: def(["agent"], string(200)),
|
||
+ thinking: def(["agent"], oneOf(["off", "minimal", "low", "medium", "high", "xhigh"])),
|
||
+
|
||
+ // Limits narrow the role definition's ceilings. limits.authority is an
|
||
+ // allowlist: a role action it doesn't name becomes gated.
|
||
+ "limits.tools": def(["business", "project", "agent"], list((v, key) => {
|
||
+ if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool: ${JSON.stringify(v)}`);
|
||
+ }, { nonEmpty: false }), { merge: "intersect" }),
|
||
+ "limits.network": def(["business", "project", "agent"], oneOf(NETWORKS), { merge: "intersect" }),
|
||
+ "limits.authority": def(["business", "project", "agent"], list((v, key) => {
|
||
+ if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action: ${JSON.stringify(v)}`);
|
||
+ }, { nonEmpty: false }), { merge: "intersect" }),
|
||
+});
|
||
+
|
||
+// Check one layer's vars object. `where` names the file and path for the
|
||
+// message. Returns a frozen copy with checked values.
|
||
+export function checkVars(vars, layer, where) {
|
||
+ if (!LAYERS.includes(layer)) throw new Error(`unknown layer ${layer}`);
|
||
+ if (!isPlainObject(vars)) refuse(`${where} must be a JSON object`);
|
||
+ const out = {};
|
||
+ for (const [key, value] of Object.entries(vars)) {
|
||
+ const entry = Object.hasOwn(REGISTRY, key) ? REGISTRY[key] : null;
|
||
+ if (!entry) refuse(`${where}: unknown variable ${JSON.stringify(key)}`);
|
||
+ if (!entry.layers.includes(layer)) refuse(`${where}: variable ${key} can't be set at the ${layer} layer (allowed: ${entry.layers.join(", ")})`);
|
||
+ out[key] = entry.check(value, key);
|
||
+ }
|
||
+ return Object.freeze(out);
|
||
+}
|
||
+
|
||
+function narrower(a, b) {
|
||
+ return NETWORKS.indexOf(a) <= NETWORKS.indexOf(b) ? a : b;
|
||
+}
|
||
+
|
||
+// Merge checked layers, given least specific first as [{ layer, source,
|
||
+// vars }]. Returns { vars, provenance }. provenance[key] is the source
|
||
+// that set a replace key, or the list of sources that narrowed a limit.
|
||
+export function mergeVars(layers) {
|
||
+ const vars = {};
|
||
+ const provenance = {};
|
||
+ for (const [key, entry] of Object.entries(REGISTRY)) {
|
||
+ if (entry.default !== undefined) {
|
||
+ vars[key] = entry.default;
|
||
+ provenance[key] = "default";
|
||
+ }
|
||
+ }
|
||
+ for (const { source, vars: layerVars } of layers) {
|
||
+ for (const [key, value] of Object.entries(layerVars)) {
|
||
+ const entry = REGISTRY[key];
|
||
+ if (entry.merge === "replace" || vars[key] === undefined) {
|
||
+ vars[key] = value;
|
||
+ provenance[key] = entry.merge === "replace" ? source : [source];
|
||
+ } else if (key === "limits.network") {
|
||
+ vars[key] = narrower(vars[key], value);
|
||
+ provenance[key].push(source);
|
||
+ } else {
|
||
+ vars[key] = vars[key].filter((item) => value.includes(item));
|
||
+ provenance[key].push(source);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ return { vars, provenance };
|
||
+}
|
||
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 <read|write>:<category>. "all" and the admin
|
||
+// category are never granted to a role.
|
||
+export const GITEA_SCOPE_CATEGORIES = Object.freeze([
|
||
+ "activitypub", "issue", "misc", "notification", "organization", "package", "repository", "user",
|
||
+]);
|
||
+
|
||
+// Vikunja 2.7.0 route groups and verbs a role token may hold. Each pair was
|
||
+// minted or used on a scratch 2.7.0 (Researcher's probes, addendum B
|
||
+// section 2). Anything not listed, including every verb addendum B lists
|
||
+// as never granted, is refused. A pair missing here that a role really
|
||
+// needs gets added after a probe shows Vikunja accepts it.
|
||
+export const VIKUNJA_GRANTABLE = Object.freeze({
|
||
+ projects: Object.freeze(["read_one", "views_buckets", "views_buckets_tasks", "views_buckets_tasks_get"]),
|
||
+ projects_views: Object.freeze(["read_all"]),
|
||
+ tasks: Object.freeze(["read_all", "read_one", "create", "update"]),
|
||
+ tasks_comments: Object.freeze(["read_all", "create"]),
|
||
+ tasks_assignees: Object.freeze(["create", "delete"]),
|
||
+ tasks_relations: Object.freeze(["create", "delete"]),
|
||
+ tasks_labels: Object.freeze(["create", "delete"]),
|
||
+ labels: Object.freeze(["read_all"]),
|
||
+});
|
||
+
|
||
+// Model families the launch block may cap, and the PRD's ceiling for each
|
||
+// (REQ-LAUNCH-1: at most 4 Opus and 4 Sonnet sessions at once).
|
||
+export const LAUNCH_CEILING = Object.freeze({ opus: 4, sonnet: 4 });
|
||
+
|
||
+export const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||
diff --git a/packages/business/tests/business.test.mjs b/packages/business/tests/business.test.mjs
|
||
new file mode 100644
|
||
index 00000000..43c72f56
|
||
--- /dev/null
|
||
+++ b/packages/business/tests/business.test.mjs
|
||
@@ -0,0 +1,195 @@
|
||
+import { test } from "node:test";
|
||
+import assert from "node:assert/strict";
|
||
+import { chmodSync, lstatSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
||
+import { join } from "node:path";
|
||
+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.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/);
|
||
+ assert.throws(() => loadBusiness("acme", { dir }), /needs rolesDir/);
|
||
+});
|
||
+
|
||
+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 `<root>/secrets`, and a project root under `<root>`.
|
||
+export function businessDoc(root, id = "acme") {
|
||
+ const secrets = join(root, "secrets");
|
||
+ const ref = (role, service) => service === "gitea"
|
||
+ ? { file: tokenFile(secrets, `${role}-gitea.token`), rotateBy: FAR_DATE }
|
||
+ : { file: tokenFile(secrets, `${role}-vikunja.token`), expires: FAR_DATE };
|
||
+ const role = (definition, botId, extra = {}) => ({
|
||
+ definition,
|
||
+ tracker: { bot: `bot-${id}-${definition}`, botId },
|
||
+ credentials: { gitea: ref(definition, "gitea"), vikunja: ref(definition, "vikunja") },
|
||
+ ...extra,
|
||
+ });
|
||
+ const projectRoot = join(root, "project");
|
||
+ mkdirSync(projectRoot, { recursive: true });
|
||
+ return {
|
||
+ businessVersion: 1,
|
||
+ id,
|
||
+ human: "jason",
|
||
+ arbiters: { delivery: "pm", technical: "cto" },
|
||
+ projects: { stack: { root: projectRoot } },
|
||
+ vars: { "tracker.baseUrl": "http://127.0.0.1:3456", "gitea.baseUrl": "https://git.example", "tracker.pollSeconds": 60 },
|
||
+ tracker: {
|
||
+ sync: { bot: `bot-${id}-sync`, botId: 9, credentials: { vikunja: ref("sync", "vikunja") } },
|
||
+ labels: {},
|
||
+ },
|
||
+ roles: {
|
||
+ pm: role("pm", 3, { holder: "sage", vars: { harness: "pi", model: "claude-opus-5-5" } }),
|
||
+ cto: role("cto", 4, { holder: "darkwing", vars: { harness: "claude-code" } }),
|
||
+ coder: role("coder", 5, { vars: { harness: "pi", "limits.network": "none" } }),
|
||
+ reviewer: role("reviewer", 6),
|
||
+ },
|
||
+ launch: { by: "pm", instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } },
|
||
+ };
|
||
+}
|
||
+
|
||
+// The system config file mosaic-config.mjs validates, under `root`.
|
||
+export function systemConfig(root) {
|
||
+ const file = join(root, "config", "config.json");
|
||
+ writeJson(file, {
|
||
+ configVersion: 1,
|
||
+ environment: "development",
|
||
+ dataRoot: join(root, "data"),
|
||
+ execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash", adapter: "mock" },
|
||
+ });
|
||
+ return file;
|
||
+}
|
||
+
|
||
+// The checked system layer, as systemVars() returns it, without spawning.
|
||
+export function systemFor(root) {
|
||
+ return {
|
||
+ environment: "development",
|
||
+ dataRoot: join(root, "data"),
|
||
+ "execution.backend": "docker",
|
||
+ "execution.provider": "zai",
|
||
+ "execution.model": "glm-5.3-flash",
|
||
+ "execution.adapter": "mock",
|
||
+ };
|
||
+}
|
||
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..686df198
|
||
--- /dev/null
|
||
+++ b/packages/business/tests/resolve.test.mjs
|
||
@@ -0,0 +1,163 @@
|
||
+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);
|
||
+ const otherLauncher = resolve(setup((d) => { d.launch.by = "cto"; d.launch.instances = ["coder"]; }), "pm");
|
||
+ assert.equal(classify(otherLauncher, "role.launch"), "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 <seat>` and `mosaic seat task <seat> <text>`: seat launch
|
||
# with registration for the control board. See packages/seat/README.md.
|
||
# `mosaic queue <verb>`: the work queue. See packages/queue/README.md.
|
||
+# `mosaic business <verb>`: 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');
|
||
- }
|
||
- 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');
|
||
+ try {
|
||
+ return validateRoleDocument(document, file);
|
||
+ } catch (error) {
|
||
+ if (error instanceof BusinessError) fail(error.exitCode, error.message);
|
||
+ throw error;
|
||
}
|
||
- 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 <roleFile>");
|
||
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"
|