From d9568cec9834af8d47fb66e127dd8e53fccf98e7 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 4 Oct 2026 22:55:37 -0500 Subject: [PATCH] docs(slice1): row 36 S1 round 2 packet (darkwing) Answers Filbert's round 1 (#1518 comment 26724): launch.by must hold role.launch, launch is null when limits.authority drops it, a test for cross-role narrowing, and the business file checked on its open descriptor. Co-Authored-By: Claude Opus 5.5 --- .../work/slice1-s1/build-r2-manifest.sha256 | 34 + agents/darkwing/work/slice1-s1/build-r2.md | 101 + agents/darkwing/work/slice1-s1/build-r2.patch | 3102 +++++++++++++++++ agents/darkwing/work/slice1-s1/mutants-r2.sh | 21 + agents/darkwing/work/slice1-s1/mutants-r2.txt | 8 + agents/darkwing/work/slice1-s1/r1-to-r2.diff | 248 ++ agents/darkwing/work/slice1-s1/suites-r2.txt | 7 + 7 files changed, 3521 insertions(+) create mode 100644 agents/darkwing/work/slice1-s1/build-r2-manifest.sha256 create mode 100644 agents/darkwing/work/slice1-s1/build-r2.md create mode 100644 agents/darkwing/work/slice1-s1/build-r2.patch create mode 100644 agents/darkwing/work/slice1-s1/mutants-r2.sh create mode 100644 agents/darkwing/work/slice1-s1/mutants-r2.txt create mode 100644 agents/darkwing/work/slice1-s1/r1-to-r2.diff create mode 100644 agents/darkwing/work/slice1-s1/suites-r2.txt diff --git a/agents/darkwing/work/slice1-s1/build-r2-manifest.sha256 b/agents/darkwing/work/slice1-s1/build-r2-manifest.sha256 new file mode 100644 index 00000000..1a16e57d --- /dev/null +++ b/agents/darkwing/work/slice1-s1/build-r2-manifest.sha256 @@ -0,0 +1,34 @@ +2e4df0820d431eda19b07d264a55c713cc1cbf7e55cf8a1f648150161f995620 docs/TOOLS.md +8d83d88c68813eea5fb1bd0577736bf89cfc5166f582aca290635d4f0170e477 packages/business/examples/mosaic-stack.example.json +0e15a5be600573ff7fa0b41c425ee6e57dad788be3d42991185c3a84d6090fe9 packages/business/package.json +e54cee19b9188def68180ced4955b1d3d6ebe719e0da6f975f3b5dbd7239161e packages/business/README.md +3aa6f74149773dd78d0251b6a1d4d91cb2c05f7113b5aac2a649157525cb0728 packages/business/src/business.mjs +afba0aa4b751bc079adbded8798c788b669d65c970047f78a182ff01e8c32db9 packages/business/src/cli.mjs +0231361f3063ee841749b2486ebbaa3a521b5687b3e0c1abc0499e7df38c49fa packages/business/src/credentials.mjs +cd4eb5960c17ce32f26d1a49196866ce04efc42c1d90b18b046af5fda8b9ff0a packages/business/src/errors.mjs +956dfb37868b3dd002bd8245995c416cf35dbdf9f82b154a951eb19db334d584 packages/business/src/index.mjs +9472996134c520c31971e66006d1c1683709f6df4f76fe3445bdc97a18637eff packages/business/src/project.mjs +c50340af650aaa2d0eb15224e823525ba942a91f475ed62bd7e7ad42055f182b packages/business/src/resolve.mjs +bfd331d97fe165b2b70512725892b27491c54347ba05233fb4c67c7c1a77e802 packages/business/src/role.mjs +e1f0e70a4bbba8ae2ad6185ab61770b15acda403e90fc708e13027a1e9ec663c packages/business/src/util.mjs +b831cac0224cfb5cd32e482ad7fd4cbea9e16c02d2750d5e8a757785a7c56589 packages/business/src/vars.mjs +1fa6df30bd52b032c065839efcde022456cdc4e2b735fb73884fd842e1794743 packages/business/src/vocabulary.mjs +79dce4db9eed72d925ca6af2d13e9e346a50fd52d53527cf475fbb1d445857ac packages/business/tests/business.test.mjs +69280b0b8390dacf899bab8f56580edcc0cb8ef8275395cc0abe7351180c2eb2 packages/business/tests/cli.test.mjs +820286995fd2f2f258303e6a3563f16afd64c47345e4c61e963878f9fd3794e2 packages/business/tests/credentials.test.mjs +1ea0617318f4a2246ea96d7b44fea1b278cbb0ab2e46b3deb8db11d30bb2ef19 packages/business/tests/helpers.mjs +6aa211be6f5067d3247acf45df2f415221c36ca37720b72dbfe2726d690b3d55 packages/business/tests/project.test.mjs +d4f6e8e72925231464ebb5f7dedfa0db8d3c2d31ac46295f4abce1eac30253c4 packages/business/tests/resolve.test.mjs +21fb723ccd2f8a8e734b527df0402cb08252b3e195022e78dcdafdda386704aa packages/business/tests/role.test.mjs +6e5a57fa22d67ba1a94c91fd4ea338814fc5cace8394b2607a9b33cda3777c9a packages/business/tests/vars.test.mjs +739bd95b73f3ada2438b7cbba745471efcab4feb949fcf79673f1c80c6edff7f roles/coder.json +191e44e3a9868ef8b615db747d6684f32baa8ceb424e4e42c1f7a04668ac3288 roles/coder.md +e394bf806220f5c91344f83bc1f8b18b9b23db4fc05092b88de7651ca4b9d8d1 roles/cto.json +0b2ccc0efe5681fe5b06d37a80a0756c2a05daee2ccb8efe148cbd908a664c29 roles/cto.md +6abb3d3fa80e0e753e7dbc73dfb2e599ad06223ae4211e221b1357996abb6046 roles/pm.json +f27a0809d95e4a1d9b4b69ac9d612a237bb81f4c5fcd16d889c03ea3cf07e15a roles/pm.md +268b93056579d1c64c59f66da3c4fb33e3357871f6a30a584229dd25725e32e1 roles/reviewer.json +8492a681245b5b72c6249b9672ea5cb4752d7defccf70263ff9ba1415f4e9a3b roles/reviewer.md +a0002e8b1f3a723dcbb62c1a3f4d512065bbf89f0771881c42192df4bdbc7756 scripts/mosaic +ceea32f56dc607bf684baa6de4f260ae56ad70bbecc5510bf7a226d4269b6f6d scripts/mosaic-task.mjs +ef8b105fca02283c73b616f1884ce3ae9a0cae3b8530d55584cea416d4d14b9f scripts/test-task.sh diff --git a/agents/darkwing/work/slice1-s1/build-r2.md b/agents/darkwing/work/slice1-s1/build-r2.md new file mode 100644 index 00000000..ab3ef629 --- /dev/null +++ b/agents/darkwing/work/slice1-s1/build-r2.md @@ -0,0 +1,101 @@ +# Row 36, S1, round 2 + +Darkwing, 2026-10-04. Answers Filbert's round 1 review (#1518 comment +26724, record `agents/filbert/work/slice1-s1-review/review-r1.md`). Base is +still `fef4b362`. Nothing in the candidate is committed, staged or pushed. + +`build-r2.patch` (sha256 +`a27890d5c70c5dd38d6b98ce4badfff6334c1cc0c485dcee7c90b1e6438e8a15`) replaces +round 1's `build.patch`. It covers the same 34 files. +`build-r2-manifest.sha256` (sha256 +`869168c702c272b051bc957a4a4314df962553ac432a5c2c5210f5ba1291afc7`) pins +them. Six files changed from round 1, and `r1-to-r2.diff` shows only +those. In a fresh clone at `fef4b362` the patch applies, the manifest +checks 34/34, and the business tests pass 60/60. Round 1's `build.md` +still describes everything else. + +## B1, `launch` and `role.launch` + +- `checkLaunch` refuses a `launch.by` whose definition doesn't hold + `role.launch` within-role. `launch.by: "cto"` now refuses with "launch.by + names cto, whose role cto doesn't hold role.launch within-role". +- `resolveInstance` sets `launch` only when the instance is `launch.by` + and `role.launch` is still within-role after `limits.authority`. + Otherwise `launch` is null and `role.launch` is removed from both lists. + `launch` is set exactly when `classify(record, "role.launch")` is + `within`. The README says so, and still tells a launcher to ask + `classify` (your n4). +- Tests: + - the cto refusal, in `business.test.mjs`; + - `limits.authority` without `role.launch` for pm, at the agent layer + and at the project layer: `launch` is null and the verb is gated; + - a second pm-definition instance, `pm2`, as the launcher: `pm` is gated + with `launch` null, and `pm2` is within with `launch` set. This + replaces round 1's `launch.by: "cto"` case, which now refuses at + validation. + +## B2, cross-role narrowing + +New test: coder with `limits.authority` of `task.update.assigned`, +`git.push.working` and `task.scope.change`. `task.reassign` (cross-role in +the definition, left out of the allowlist) classifies as `gated`, +`task.scope.change` stays `cross`, and `review.request` is `gated`. Your +M3 is now caught (R3 below). + +## n1, the business file check + +I fixed this too, since it was in a file I was already changing. +`readJsonFile` now opens the file once with `O_NOFOLLOW | O_NONBLOCK`, runs +`fstat` on that descriptor, and reads from the same descriptor. The `lstat` +is gone, so there's no second lookup to race. A caller can pass a +`checkStat` callback, which sees the descriptor's stat before the read. +`loadBusiness` uses it for the owner and mode checks. Role and project +files use the same reader without a callback. The error messages and exit +codes are unchanged: +- missing: "not found", 4; +- a symbolic link (`ELOOP`) or anything that isn't a regular file: "must + be a regular, non-symbolic-link file", 4. + +`O_NONBLOCK` keeps a FIFO from blocking the open. Tests: +- A group-writable file with invalid JSON refuses for its mode (660), not + for its JSON. That shows the check runs before the read. +- A directory and a FIFO under the business name both refuse with exit 4. + The FIFO case runs in a child process with a 5 s timeout, because an + `openSync` that blocks would hang the test process past node:test's own + timeout. + +n2: left as is, as you suggested. + +## Tests and suites + +Node 26.8.1 on the host, output teed (`suites-r2.txt`): +- `node --test packages/business/tests/`: 60/60, up from 57. The new + tests are in `resolve.test.mjs` (2) and `business.test.mjs` (1), and three + existing tests gained cases. +- `scripts/test-config.sh` 24/24, `test-task.sh` 98/98, + `test-conductor.sh` 17/17, `test-queue.sh` 27/27. + +Node 24.21.0 in `node:24`, no network, the clone mounted read-only: +`node --test "packages/business/tests/*.test.mjs"` 60/60. + +Mutants (`mutants-r2.sh`, results in `mutants-r2.txt`), each against the +business tests, all caught: + +| Mutant | Failures | +|---|---| +| R1 `launch.by` role check off | 1 | +| R2 `launch` ignores `limits.authority` | 1 | +| R3 your M3: crossRole not narrowed | 1 | +| R4 owner and mode check skipped | 1 | +| R5 owner and mode check after the parse | 1 | +| R6 `O_NOFOLLOW` dropped | 2 | +| R7 a non-regular file accepted | 1 | +| R8 `O_NONBLOCK` dropped | 1 | + +## For S2 + +Two API changes reach Rocko's S2, both narrowing: +- A business file whose `launch.by` lacks `role.launch` now refuses. +- `launch` can be null for the `by` instance when `limits.authority` + drops the verb. +No function names, argument shapes or record keys changed. diff --git a/agents/darkwing/work/slice1-s1/build-r2.patch b/agents/darkwing/work/slice1-s1/build-r2.patch new file mode 100644 index 00000000..c3ba055d --- /dev/null +++ b/agents/darkwing/work/slice1-s1/build-r2.patch @@ -0,0 +1,3102 @@ +diff --git a/docs/TOOLS.md b/docs/TOOLS.md +index ef3d48e0..da706aec 100644 +--- a/docs/TOOLS.md ++++ b/docs/TOOLS.md +@@ -30,7 +30,7 @@ and changes nothing. + | `node scripts/mosaic-task.mjs list` | List runs | task/workspace/session columns | + | `node scripts/mosaic-task.mjs retry ` | Re-execute a run's snapshot | New run dir; `retriedFrom` lineage recorded | + | `node scripts/mosaic-task.mjs prune [--keep=N] [--yes]` | Retention | Dry-run default; receipt in `runs/.pruned.log` | +-| `node scripts/mosaic-task.mjs resolve-role ` | Validate a role contract | Prints `MOSAIC_ROLE_TOOLS` / `MOSAIC_ROLE_NETWORK`; config-free | ++| `node scripts/mosaic-task.mjs resolve-role ` | Validate a role file, version 1 or 2 | Prints `MOSAIC_ROLE_TOOLS` / `MOSAIC_ROLE_NETWORK`, and `MOSAIC_ROLE_CONTRACT` for version 2; config-free | + + Task fields: `prompt` (required), `mission` (path), `expectExact`, + `timeoutSeconds` (5–600), `workspace` (`:run` or named), `capabilities.tools` +@@ -171,6 +171,25 @@ Exit codes: `0` ok · `1` failed · `2` invalid or refused · `3` uncertain, + retry the same op (for a review request, the retry sends nothing; resolve or + abandon it) · `4` usage. Details: `packages/queue/README.md`. + ++## Business files and role instances (`scripts/mosaic business`) ++ ++```bash ++scripts/mosaic business validate ++scripts/mosaic business resolve [--project ] ++``` ++ ++Reads `businesses/.json` next to the system config ++(`$MOSAIC_CONFIG` or `~/.config/mosaic-dev/`), the version 2 role files in ++`roles/` (or `$MOSAIC_ROLES_DIR`) and each project's `.mosaic/project.json`. ++Writes nothing. `validate` checks the whole business, stat-checks every ++credential reference without opening it and prints one digest per role ++instance. `resolve` prints one instance's merged variables, their sources, ++its narrowed tools, network and authority, and its credential references. ++The business file must belong to you and not be group or other writable. ++Exit codes: `0` ok · `2` invalid file or credential reference · `3` system ++config problem · `4` usage or a required file missing. Details: ++`packages/business/README.md`. ++ + ## Discord connector (`scripts/discord.sh`) + + ```bash +diff --git a/packages/business/README.md b/packages/business/README.md +new file mode 100644 +index 00000000..9c3e8d1f +--- /dev/null ++++ b/packages/business/README.md +@@ -0,0 +1,166 @@ ++# Business ++ ++Role definitions, the business and project files, the variable registry and ++the resolver (slice 1 row S1, #1518). Brief: `docs/plans/2026-10-04_slice-1.md`, ++row S1. Design: `agents/darkwing/work/slice1-data-model-2026-10-04.md` and its ++addenda A and B. The package reads files and refuses on any problem. It ++never writes, and it never opens a token file. ++ ++```sh ++scripts/mosaic business validate mosaic-stack ++scripts/mosaic business resolve mosaic-stack coder --project stack ++node --test packages/business/tests/ ++``` ++ ++## Files ++ ++| File | Who writes it | Holds | ++|---|---|---| ++| `roles/.json` | reviewed commits | a role definition: tools, network, authority, credential scopes | ++| `roles/.md` | reviewed commits | the duty contract a version 2 role file names | ++| `/businesses/.json` | Jason | role instances, holders, bots, token references, arbiters, projects, launch limits | ++| `/.mosaic/project.json` | reviewed commits in that project | project variables, per-instance project variables | ++ ++`` is the directory holding the system config: the directory of ++`$MOSAIC_CONFIG`, or `~/.config/mosaic-dev`. Role files come from `roles/` ++in this repository, or `$MOSAIC_ROLES_DIR` if set. ++ ++### Role files, version 2 ++ ++```json ++{ ++ "roleVersion": 2, "name": "coder", "title": "Coder", "contract": "coder.md", ++ "tools": ["read", "write", "edit", "bash", "grep", "find", "ls"], ++ "network": "api-only", ++ "authority": { ++ "withinRole": ["task.update.assigned", "git.push.working", "review.request", "message.send"], ++ "crossRole": ["task.reassign", "task.scope.change"] ++ }, ++ "credentials": [ ++ { "service": "gitea", "scopes": ["write:issue", "write:repository", "read:user"] }, ++ { "service": "vikunja", "scopes": { "tasks": ["read_one", "update"], "tasks_comments": ["create"], "projects": ["views_buckets_tasks"] } } ++ ] ++} ++``` ++ ++- `name` matches the file name. `contract` is a Markdown file name in the ++ same directory, not a path. It must exist, be a regular file and not be ++ empty. ++- `authority` names actions from the closed list in `src/vocabulary.mjs`. ++ `withinRole` actions run without asking. `crossRole` actions need the ++ arbiter. Any action not listed is gated: it goes to Jason. The nine ++ always-gated actions (`deploy`, `spend`, `git.push.protected`, ++ `git.merge.protected`, `credential.mint`, `message.external`, ++ `policy.change`, `prd.approve`, `role.revoke`) can't appear in either list. ++- Gitea scopes are `read:` or `write:`, one level per ++ category. `all` and `admin` are refused. Vikunja scopes map a route group ++ to verbs, and only the pairs in `VIKUNJA_GRANTABLE` pass. ++- Version 1 files (`roleVersion`, `name`, `tools`, `network`) still load. ++ They have no authority and no credentials, and a business can't use them ++ for a role instance. ++ ++`scripts/mosaic-task.mjs resolve-role` checks both versions through this ++package and prints `MOSAIC_ROLE_CONTRACT` for version 2. ++ ++### Business file ++ ++`examples/mosaic-stack.example.json` is a complete example. As shipped it ++refuses on purpose: every `botId` is 0 and every date is `YYYY-MM-DD`. Copy ++it to `/businesses/mosaic-stack.json`, put in the bot ids and ++token dates from `docs/guides/slice-1-identities.md`, fix the token paths ++and run `validate`. The file must belong to you and must not be writable ++by group or other. The loader checks the owner and mode on the descriptor ++it reads from, so the file can't be swapped between the check and the ++read. ++ ++Each role instance names a `definition` (a version 2 role file), an ++optional `holder`, its Vikunja bot (`tracker`, required when the definition ++uses Vikunja), a credential reference for each service the definition ++lists, and optional agent-layer `vars`. Several instances may share one ++definition. Bot names and bot ids are unique across the instances and the ++sync bot. ++ ++A credential reference names a token file by absolute path, or an ++environment variable by name, plus `rotateBy` for Gitea or `expires` for ++Vikunja. The checks use `lstat` and `realpath` only. A token file must be a ++regular file you own, mode 600 or tighter, not empty, and outside the ++repository, `dataRoot` and every project root. A Vikunja token past ++`expires` refuses. One within seven days of it, or a Gitea token past ++`rotateBy`, prints a warning. ++ ++`launch` lets one instance (`by`) start the listed instances, up to `max` ++sessions per model family (Opus 4, Sonnet 4 at most). `by` must name an ++instance whose definition holds `role.launch` within-role; otherwise the ++file refuses. Only that instance keeps `role.launch`. Every other instance ++loses it, so for them it's gated. ++ ++### Project file ++ ++```json ++{ "projectVersion": 1, "id": "stack", ++ "vars": { "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"] }, ++ "roles": { "coder": { "vars": { "limits.tools": ["read", "edit", "bash"] } } } } ++``` ++ ++`id` must match the business file's name for the project. A missing ++project file is a warning in `validate` and an error in ++`resolve --project`. ++ ++## Variables ++ ++Every key is declared once in `src/vars.mjs` with its type, the layers ++that may set it and its merge rule. An unknown key refuses, and so does a ++key set at a layer it doesn't belong to. ++ ++Layers, least specific first: defaults, system (the system config), business ++(`vars`), project (`vars`, then `roles..vars`), agent (the business ++file's `roles..vars`). For a plain key the most specific layer ++wins. `limits.tools`, `limits.network` and `limits.authority` intersect: ++each layer that sets one narrows it, and the result also narrows the role ++definition. `limits.authority` is an allowlist, so a role action it leaves ++out becomes gated. ++ ++`resolve` prints `provenance` beside `vars`. For a plain key it's the ++layer that set the value (`default`, `system`, `business:`, ++`project:`, `project::roles.`, ++`business::roles.`). For a limit it's the list of layers ++that narrowed it. ++ ++## API ++ ++```js ++import { ++ loadBusiness, loadProject, systemVars, resolveInstance, classify, ++ loadRole, validateRoleDocument, checkCredentialRef, ++} from "../packages/business/src/index.mjs"; ++ ++const business = loadBusiness("mosaic-stack", { rolesDir }); // frozen ++const project = loadProject(business.projects.stack.root); // frozen ++const coder = resolveInstance({ system: systemVars(config), business, project, instance: "coder" }); ++classify(coder, "git.push.working"); // "within" | "cross" | "gated" ++``` ++ ++`resolveInstance` returns a frozen record: `business`, `project`, ++`instance`, `definition`, `holder`, `contract` (absolute path), `vars`, ++`provenance`, `limits` (`tools`, `network`, `authority.withinRole`, ++`authority.crossRole`), `credentials` (references only), `tracker` (`bot`, ++`botId` or null), `launch` (the launch block, or null) and `digest`, the ++SHA-256 of the record's canonical JSON. `classify` refuses an action ++outside the vocabulary. `launch` is set exactly when `classify(record, ++"role.launch")` is `within`. If `limits.authority` leaves out ++`role.launch` for the launcher, `launch` is null. A launcher should ++still ask `classify`, not read `launch` alone. ++ ++Errors are `BusinessError` with `exitCode` 2 (invalid) or 4 (a required ++file missing). The CLI adds 3 for a system config problem and 4 for usage. ++ ++## Limits ++ ++- The action vocabulary lives here, on the host. Neither workers nor ++ container images read it, so it isn't under `contracts/`. ++- The checks run as you, against files you can change. They catch ++ mistakes; they don't stop someone who already runs as your user. ++- A token file can change between `validate` and the moment it's opened. ++ The code that opens it at run time repeats these checks (rows S2 and S3). ++- Slice 1 knows two services and one tracker kind. A new one is a ++ reviewed change to `src/vocabulary.mjs` and `src/vars.mjs`. +diff --git a/packages/business/examples/mosaic-stack.example.json b/packages/business/examples/mosaic-stack.example.json +new file mode 100644 +index 00000000..702e8eb1 +--- /dev/null ++++ b/packages/business/examples/mosaic-stack.example.json +@@ -0,0 +1,114 @@ ++{ ++ "businessVersion": 1, ++ "id": "mosaic-stack", ++ "human": "jason", ++ "arbiters": { ++ "delivery": "pm", ++ "technical": "cto" ++ }, ++ "projects": { ++ "stack": { ++ "root": "/mnt/storage/src/mosaic-stack" ++ } ++ }, ++ "vars": { ++ "tracker.baseUrl": "http://127.0.0.1:3456", ++ "gitea.baseUrl": "https://git.mosaicstack.dev" ++ }, ++ "tracker": { ++ "sync": { ++ "bot": "bot-mosaic-stack-sync", ++ "botId": 0, ++ "credentials": { ++ "vikunja": { ++ "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/sync-vikunja.token", ++ "expires": "YYYY-MM-DD" ++ } ++ } ++ }, ++ "labels": {} ++ }, ++ "roles": { ++ "pm": { ++ "definition": "pm", ++ "holder": "sage", ++ "tracker": { ++ "bot": "bot-mosaic-stack-pm", ++ "botId": 0 ++ }, ++ "credentials": { ++ "gitea": { ++ "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/pm-gitea.token", ++ "rotateBy": "YYYY-MM-DD" ++ }, ++ "vikunja": { ++ "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/pm-vikunja.token", ++ "expires": "YYYY-MM-DD" ++ } ++ } ++ }, ++ "cto": { ++ "definition": "cto", ++ "holder": "darkwing", ++ "tracker": { ++ "bot": "bot-mosaic-stack-cto", ++ "botId": 0 ++ }, ++ "credentials": { ++ "gitea": { ++ "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/cto-gitea.token", ++ "rotateBy": "YYYY-MM-DD" ++ }, ++ "vikunja": { ++ "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/cto-vikunja.token", ++ "expires": "YYYY-MM-DD" ++ } ++ } ++ }, ++ "coder": { ++ "definition": "coder", ++ "tracker": { ++ "bot": "bot-mosaic-stack-coder", ++ "botId": 0 ++ }, ++ "credentials": { ++ "gitea": { ++ "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/coder-gitea.token", ++ "rotateBy": "YYYY-MM-DD" ++ }, ++ "vikunja": { ++ "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/coder-vikunja.token", ++ "expires": "YYYY-MM-DD" ++ } ++ } ++ }, ++ "reviewer": { ++ "definition": "reviewer", ++ "tracker": { ++ "bot": "bot-mosaic-stack-reviewer", ++ "botId": 0 ++ }, ++ "credentials": { ++ "gitea": { ++ "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/reviewer-gitea.token", ++ "rotateBy": "YYYY-MM-DD" ++ }, ++ "vikunja": { ++ "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/reviewer-vikunja.token", ++ "expires": "YYYY-MM-DD" ++ } ++ } ++ } ++ }, ++ "launch": { ++ "by": "pm", ++ "instances": [ ++ "coder", ++ "reviewer" ++ ], ++ "max": { ++ "opus": 4, ++ "sonnet": 4 ++ } ++ } ++} +diff --git a/packages/business/package.json b/packages/business/package.json +new file mode 100644 +index 00000000..e59b9dd9 +--- /dev/null ++++ b/packages/business/package.json +@@ -0,0 +1,11 @@ ++{ ++ "name": "@mosaic/business", ++ "version": "0.1.0", ++ "private": true, ++ "description": "Role definitions, business and project files, variable layers and the resolver (slice 1 row S1).", ++ "license": "UNLICENSED", ++ "type": "module", ++ "engines": { "node": ">=24" }, ++ "exports": { ".": "./src/index.mjs" }, ++ "scripts": { "test": "node --test tests/" } ++} +diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs +new file mode 100644 +index 00000000..9e12cea1 +--- /dev/null ++++ b/packages/business/src/business.mjs +@@ -0,0 +1,204 @@ ++// The business file, /businesses/.json (REQ-ROLE-3, note ++// section 2, addendum A sections 7 and 8). Jason writes it; the stack reads ++// it and never writes it. A missing or invalid file refuses (lead decision ++// 46, 6.1). ++ ++import { dirname, isAbsolute, join, normalize } from "node:path"; ++import { homedir } from "node:os"; ++import { refuse } from "./errors.mjs"; ++import { LAUNCH_CEILING } from "./vocabulary.mjs"; ++import { checkVars } from "./vars.mjs"; ++import { parseCredentialRef } from "./credentials.mjs"; ++import { loadRole } from "./role.mjs"; ++import { ++ requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze, ++} from "./util.mjs"; ++ ++const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/; ++const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch"]; ++ ++// The config directory: next to the system config, $MOSAIC_CONFIG or ++// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses. ++export function configDir(env = process.env) { ++ const file = env.MOSAIC_CONFIG || join(homedir(), ".config", "mosaic-dev", "config.json"); ++ return dirname(file); ++} ++ ++export function businessFilePath(id, dir = configDir()) { ++ requireId(id, "business id"); ++ return join(dir, "businesses", `${id}.json`); ++} ++ ++function checkBot(tracker, where) { ++ requireObject(tracker, where); ++ rejectUnknownKeys(tracker, ["bot", "botId"], where); ++ if (typeof tracker.bot !== "string" || !BOT_NAME.test(tracker.bot)) refuse(`${where}.bot must be a Vikunja bot username starting with "bot-"`); ++ requirePositiveInt(tracker.botId, `${where}.botId`); ++ return { bot: tracker.bot, botId: tracker.botId }; ++} ++ ++function checkCredentialMap(map, services, where) { ++ requireObject(map, where); ++ const names = Object.keys(map).sort(); ++ const wanted = [...services].sort(); ++ if (names.join(",") !== wanted.join(",")) { ++ refuse(`${where} must reference exactly the services its role definition needs (${wanted.join(", ") || "none"}; got ${names.join(", ") || "none"})`); ++ } ++ const out = {}; ++ for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`); ++ return out; ++} ++ ++function checkTracker(tracker, file) { ++ const where = `${file} tracker`; ++ requireObject(tracker, where); ++ rejectUnknownKeys(tracker, ["sync", "labels"], where); ++ requireObject(tracker.sync, `${where}.sync`); ++ rejectUnknownKeys(tracker.sync, ["bot", "botId", "credentials"], `${where}.sync`); ++ const sync = { ++ ...checkBot({ bot: tracker.sync.bot, botId: tracker.sync.botId }, `${where}.sync`), ++ credentials: checkCredentialMap(tracker.sync.credentials, ["vikunja"], `${where}.sync.credentials`), ++ }; ++ let labels = {}; ++ if (tracker.labels !== undefined) { ++ requireObject(tracker.labels, `${where}.labels`); ++ // fromEntries defines own properties, so a "__proto__" title stays data. ++ labels = Object.fromEntries(Object.entries(tracker.labels).map(([title, id]) => [ ++ requireString(title, `${where}.labels title`, { max: 100 }), ++ requirePositiveInt(id, `${where}.labels.${title}`), ++ ])); ++ } ++ return { sync, labels }; ++} ++ ++function checkRoles(roles, file, rolesDir) { ++ const where = `${file} roles`; ++ requireObject(roles, where); ++ if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`); ++ const out = {}; ++ const definitions = {}; ++ const bots = new Set(); ++ const botIds = new Set(); ++ for (const [instance, entry] of Object.entries(roles)) { ++ requireId(instance, `${where} instance name`); ++ const at = `${where}.${instance}`; ++ requireObject(entry, at); ++ rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials"], at); ++ requireId(entry.definition, `${at}.definition`); ++ const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition); ++ definitions[entry.definition] = definition; ++ if (definition.roleVersion !== 2) refuse(`${at}: role definition ${entry.definition} is version 1 and can't back a business role instance`); ++ const services = definition.credentials.map((c) => c.service); ++ let tracker = null; ++ if (services.includes("vikunja")) { ++ if (entry.tracker === undefined) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`); ++ tracker = checkBot(entry.tracker, `${at}.tracker`); ++ if (bots.has(tracker.bot)) refuse(`${at}.tracker.bot ${tracker.bot} is used by another instance`); ++ if (botIds.has(tracker.botId)) refuse(`${at}.tracker.botId ${tracker.botId} is used by another instance`); ++ bots.add(tracker.bot); ++ botIds.add(tracker.botId); ++ } else if (entry.tracker !== undefined) { ++ refuse(`${at} has "tracker" but ${entry.definition} uses no vikunja credential`); ++ } ++ out[instance] = { ++ definition: entry.definition, ++ holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`), ++ vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`), ++ tracker, ++ credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`), ++ }; ++ } ++ return { roles: out, definitions }; ++} ++ ++function checkLaunch(launch, roles, definitions, file) { ++ const where = `${file} launch`; ++ requireObject(launch, where); ++ rejectUnknownKeys(launch, ["by", "instances", "max"], where); ++ requireId(launch.by, `${where}.by`); ++ if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`); ++ const launcher = definitions[roles[launch.by].definition]; ++ if (!launcher.authority.withinRole.includes("role.launch")) { ++ refuse(`${where}.by names ${launch.by}, whose role ${launcher.name} doesn't hold role.launch within-role`); ++ } ++ const instances = requireDistinctList(launch.instances, `${where}.instances`, (name) => { ++ requireId(name, `${where}.instances entry`); ++ if (!Object.hasOwn(roles, name)) refuse(`${where}.instances names ${name}, which the business file doesn't declare`); ++ if (name === launch.by) refuse(`${where}.instances can't include the launcher itself (${name})`); ++ }, { nonEmpty: true }); ++ requireObject(launch.max, `${where}.max`); ++ if (Object.keys(launch.max).length === 0) refuse(`${where}.max must name at least one model family`); ++ const max = {}; ++ for (const [family, count] of Object.entries(launch.max)) { ++ const ceiling = Object.hasOwn(LAUNCH_CEILING, family) ? LAUNCH_CEILING[family] : undefined; ++ if (ceiling === undefined) refuse(`${where}.max names unknown model family ${JSON.stringify(family)} (known: ${Object.keys(LAUNCH_CEILING).join(", ")})`); ++ if (!Number.isSafeInteger(count) || count < 0 || count > ceiling) refuse(`${where}.max.${family} must be an integer from 0 to ${ceiling}`); ++ max[family] = count; ++ } ++ return { by: launch.by, instances, max }; ++} ++ ++// Validate a parsed business document read from `file`. `rolesDir` is where ++// role definitions live. Returns a frozen business with its definitions. ++export function validateBusinessDocument(document, file, { rolesDir }) { ++ requireObject(document, "business file"); ++ rejectUnknownKeys(document, TOP_KEYS, "business file"); ++ if (document.businessVersion !== 1) refuse(`business file "businessVersion" must be 1 (${file})`); ++ requireId(document.id, "business id"); ++ const base = file.split("/").pop().replace(/\.json$/, ""); ++ if (document.id !== base) refuse(`business "id" (${document.id}) must match its filename (${base}.json)`); ++ for (const key of ["human", "arbiters", "projects", "tracker", "roles"]) { ++ if (document[key] === undefined) refuse(`business file requires "${key}" (${file})`); ++ } ++ const human = requireId(document.human, "business human"); ++ const vars = checkVars(document.vars ?? {}, "business", `${file} vars`); ++ const { roles, definitions } = checkRoles(document.roles, file, rolesDir); ++ ++ requireObject(document.arbiters, `${file} arbiters`); ++ rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`); ++ const arbiters = {}; ++ for (const kind of ["delivery", "technical"]) { ++ const name = document.arbiters[kind]; ++ requireId(name, `${file} arbiters.${kind}`); ++ if (!Object.hasOwn(roles, name)) refuse(`${file} arbiters.${kind} names ${name}, which the business file doesn't declare`); ++ arbiters[kind] = name; ++ } ++ ++ requireObject(document.projects, `${file} projects`); ++ if (Object.keys(document.projects).length === 0) refuse(`${file} projects must name at least one project`); ++ const projects = {}; ++ for (const [id, entry] of Object.entries(document.projects)) { ++ requireId(id, `${file} project id`); ++ requireObject(entry, `${file} projects.${id}`); ++ rejectUnknownKeys(entry, ["root"], `${file} projects.${id}`); ++ if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) { ++ refuse(`${file} projects.${id}.root must be a normalized absolute path`); ++ } ++ projects[id] = { root: entry.root }; ++ } ++ ++ const tracker = checkTracker(document.tracker, file); ++ for (const [instance, role] of Object.entries(roles)) { ++ if (role.tracker && (role.tracker.bot === tracker.sync.bot || role.tracker.botId === tracker.sync.botId)) { ++ refuse(`${file} tracker.sync must use its own bot, not the one roles.${instance} uses`); ++ } ++ } ++ const launch = document.launch === undefined ? null : checkLaunch(document.launch, roles, definitions, file); ++ ++ return deepFreeze({ ++ businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, definitions, ++ }); ++} ++ ++// Load businesses/.json from the config directory. The file holds ++// authority, so it must belong to this user and not be writable by group ++// or other. ++export function loadBusiness(id, { dir = configDir(), rolesDir }) { ++ if (!rolesDir) throw new Error("loadBusiness needs rolesDir"); ++ const file = businessFilePath(id, dir); ++ const document = readJsonFile(file, "business file", (stat) => { ++ if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`); ++ if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`); ++ }); ++ return validateBusinessDocument(document, file, { rolesDir }); ++} +diff --git a/packages/business/src/cli.mjs b/packages/business/src/cli.mjs +new file mode 100644 +index 00000000..09cab38f +--- /dev/null ++++ b/packages/business/src/cli.mjs +@@ -0,0 +1,138 @@ ++#!/usr/bin/env node ++// mosaic business validate ++// mosaic business resolve [--project ] ++// ++// validate loads the business file, every role definition it uses, the ++// project files that exist under its declared roots, and stat-checks every ++// credential reference. resolve prints one role instance's resolved record. ++// Both print JSON on stdout and problems on stderr. ++// ++// Exit codes: 0 ok; 2 invalid (business, role, project file or a credential ++// reference); 3 system config problem; 4 usage, or a required file missing. ++ ++import { spawnSync } from "node:child_process"; ++import { existsSync } from "node:fs"; ++import { dirname, join, resolve } from "node:path"; ++import { fileURLToPath } from "node:url"; ++import { BusinessError } from "./errors.mjs"; ++import { loadBusiness } from "./business.mjs"; ++import { loadProject, projectFilePath } from "./project.mjs"; ++import { checkCredentialRef } from "./credentials.mjs"; ++import { systemVars, resolveInstance } from "./resolve.mjs"; ++ ++const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); ++const USAGE = "usage: mosaic business validate | resolve [--project ]"; ++ ++function fail(code, message) { ++ process.stderr.write(`mosaic business: ${message}\n`); ++ process.exit(code); ++} ++ ++function loadSystem() { ++ const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate"], { ++ encoding: "utf8", ++ maxBuffer: 1024 * 1024, ++ }); ++ if (proc.status !== 0) fail(3, `system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`); ++ try { ++ return JSON.parse(proc.stdout); ++ } catch { ++ fail(3, "system config: mosaic-config printed something that isn't JSON"); ++ } ++} ++ ++function rolesDir() { ++ return resolve(process.env.MOSAIC_ROLES_DIR || join(REPO, "roles")); ++} ++ ++// Check credential references; print warnings; return the problem list. ++function checkRefs(refs, forbiddenRoots) { ++ const problems = []; ++ const warnings = []; ++ for (const ref of refs) { ++ const result = checkCredentialRef(ref, { forbiddenRoots }); ++ problems.push(...result.problems); ++ warnings.push(...result.warnings); ++ } ++ return { problems, warnings }; ++} ++ ++function forbiddenRoots(config, business) { ++ return [REPO, config.dataRoot, ...Object.values(business.projects).map((p) => p.root)]; ++} ++ ++function validate(args) { ++ if (args.length !== 1) fail(4, USAGE); ++ const config = loadSystem(); ++ const system = systemVars(config); ++ const business = loadBusiness(args[0], { rolesDir: rolesDir() }); ++ const projects = {}; ++ const warnings = []; ++ for (const [id, entry] of Object.entries(business.projects)) { ++ const file = projectFilePath(entry.root); ++ if (!existsSync(file)) { ++ projects[id] = "absent"; ++ warnings.push(`project ${id}: no project file at ${file}; it has no project variables`); ++ continue; ++ } ++ const project = loadProject(entry.root); ++ if (project.id !== id) fail(2, `project file ${file} has id ${project.id}, but business ${business.id} declares it as ${id}`); ++ for (const instance of Object.keys(business.roles)) resolveInstance({ system, business, project, instance }); ++ projects[id] = "valid"; ++ } ++ const refs = [ ++ ...Object.values(business.tracker.sync.credentials), ++ ...Object.values(business.roles).flatMap((r) => Object.values(r.credentials)), ++ ]; ++ const checked = checkRefs(refs, forbiddenRoots(config, business)); ++ warnings.push(...checked.warnings); ++ const instances = {}; ++ for (const instance of Object.keys(business.roles)) { ++ instances[instance] = resolveInstance({ system, business, instance }).digest; ++ } ++ for (const w of warnings) process.stderr.write(`mosaic business: warning: ${w}\n`); ++ if (checked.problems.length > 0) { ++ for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`); ++ fail(2, `business ${business.id}: ${checked.problems.length} credential reference problem(s)`); ++ } ++ process.stdout.write(`${JSON.stringify({ business: business.id, file: business.file, projects, instances }, null, 2)}\n`); ++} ++ ++function resolveCommand(args) { ++ let project = null; ++ const rest = []; ++ for (let i = 0; i < args.length; i++) { ++ if (args[i] === "--project") { ++ if (!args[i + 1] || project !== null) fail(4, USAGE); ++ project = args[++i]; ++ } else rest.push(args[i]); ++ } ++ if (rest.length !== 2) fail(4, USAGE); ++ const [businessId, instance] = rest; ++ const config = loadSystem(); ++ const business = loadBusiness(businessId, { rolesDir: rolesDir() }); ++ let loaded = null; ++ if (project !== null) { ++ const entry = Object.hasOwn(business.projects, project) ? business.projects[project] : null; ++ if (!entry) fail(2, `business ${business.id} declares no project ${JSON.stringify(project)}`); ++ loaded = loadProject(entry.root); ++ } ++ const resolved = resolveInstance({ system: systemVars(config), business, project: loaded, instance }); ++ const checked = checkRefs(Object.values(resolved.credentials), forbiddenRoots(config, business)); ++ for (const w of checked.warnings) process.stderr.write(`mosaic business: warning: ${w}\n`); ++ if (checked.problems.length > 0) { ++ for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`); ++ fail(2, `${business.id} ${instance}: ${checked.problems.length} credential reference problem(s)`); ++ } ++ process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`); ++} ++ ++const [verb, ...args] = process.argv.slice(2); ++try { ++ if (verb === "validate") validate(args); ++ else if (verb === "resolve") resolveCommand(args); ++ else fail(4, USAGE); ++} catch (error) { ++ if (error instanceof BusinessError) fail(error.exitCode, error.message); ++ throw error; ++} +diff --git a/packages/business/src/credentials.mjs b/packages/business/src/credentials.mjs +new file mode 100644 +index 00000000..1c6fcc25 +--- /dev/null ++++ b/packages/business/src/credentials.mjs +@@ -0,0 +1,97 @@ ++// Credential references (note section 2, addendum A section 7). A ++// reference names where a token lives, never the token. The checks here use ++// lstat and realpath only; nothing in this package opens a token file. ++ ++import { lstatSync, realpathSync } from "node:fs"; ++import { isAbsolute, normalize, relative, sep } from "node:path"; ++import { refuse } from "./errors.mjs"; ++import { SERVICES } from "./vocabulary.mjs"; ++import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs"; ++ ++const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/; ++const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" }); ++const WARN_DAYS = 7; ++const DAY_MS = 24 * 60 * 60 * 1000; ++ ++// Shape check for one reference. Returns a frozen { service, file | env, ++// rotateBy | expires }. ++export function parseCredentialRef(ref, service, where) { ++ if (!SERVICES.includes(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`); ++ requireObject(ref, where); ++ const dateKey = DATE_KEY[service]; ++ rejectUnknownKeys(ref, ["file", "env", dateKey], where); ++ const hasFile = ref.file !== undefined; ++ const hasEnv = ref.env !== undefined; ++ if (hasFile === hasEnv) refuse(`${where} must name exactly one of "file" or "env"`); ++ const out = { service }; ++ if (hasFile) { ++ if (typeof ref.file !== "string" || !isAbsolute(ref.file) || normalize(ref.file) !== ref.file || ref.file.includes("\0")) { ++ refuse(`${where}.file must be a normalized absolute path`); ++ } ++ out.file = ref.file; ++ } else { ++ if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`); ++ out.env = ref.env; ++ } ++ if (ref[dateKey] === undefined) refuse(`${where} needs "${dateKey}" (YYYY-MM-DD)`); ++ out[dateKey] = requireDate(ref[dateKey], `${where}.${dateKey}`); ++ return Object.freeze(out); ++} ++ ++function inside(root, path) { ++ const rel = relative(root, path); ++ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); ++} ++ ++// Check a parsed reference against the filesystem and the calendar. ++// Returns { problems: [...], warnings: [...] }; a caller that finds any ++// problem refuses. `forbiddenRoots` are directories a token file must not ++// sit in (the repository, dataRoot). `now` is a Date. ++export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(), env = process.env, uid = process.getuid() } = {}) { ++ const problems = []; ++ const warnings = []; ++ const label = `${ref.service} ${ref.file ? `file ${ref.file}` : `env ${ref.env}`}`; ++ if (ref.file) { ++ let stat = null; ++ try { ++ stat = lstatSync(ref.file); ++ } catch { ++ problems.push(`${label}: not found`); ++ } ++ if (stat) { ++ if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`); ++ else { ++ if (stat.uid !== uid) problems.push(`${label}: owned by uid ${stat.uid}, not ${uid}`); ++ if ((stat.mode & 0o077) !== 0) problems.push(`${label}: mode ${(stat.mode & 0o777).toString(8)} gives group or other access; use 600`); ++ if (stat.size === 0) problems.push(`${label}: empty`); ++ let real = ref.file; ++ try { ++ real = realpathSync(ref.file); ++ } catch { ++ problems.push(`${label}: path can't be resolved`); ++ } ++ for (const root of forbiddenRoots) { ++ let realRoot = root; ++ try { ++ realRoot = realpathSync(root); ++ } catch { ++ // A root that doesn't exist yet can't contain the file. ++ } ++ if (inside(realRoot, real)) problems.push(`${label}: inside ${root}; token files live outside the repository and dataRoot`); ++ } ++ } ++ } ++ } else if (env[ref.env] === undefined || env[ref.env] === "") { ++ warnings.push(`${label}: not set in this environment; the launcher must provide it`); ++ } ++ const days = (dateText) => Math.floor((Date.parse(`${dateText}T00:00:00Z`) - now.getTime()) / DAY_MS); ++ if (ref.expires) { ++ const left = days(ref.expires); ++ if (Date.parse(`${ref.expires}T00:00:00Z`) <= now.getTime()) problems.push(`${label}: expired on ${ref.expires}`); ++ else if (left < WARN_DAYS) warnings.push(`${label}: expires on ${ref.expires}`); ++ } ++ if (ref.rotateBy && Date.parse(`${ref.rotateBy}T00:00:00Z`) <= now.getTime()) { ++ warnings.push(`${label}: rotation was due on ${ref.rotateBy}`); ++ } ++ return { problems, warnings }; ++} +diff --git a/packages/business/src/errors.mjs b/packages/business/src/errors.mjs +new file mode 100644 +index 00000000..fdc4b142 +--- /dev/null ++++ b/packages/business/src/errors.mjs +@@ -0,0 +1,13 @@ ++// Exit codes follow docs/TOOLS.md: 2 invalid data or refused, 4 usage or a ++// missing file. Every refusal in this package is a BusinessError. ++export class BusinessError extends Error { ++ constructor(message, exitCode = 2) { ++ super(message); ++ this.name = "BusinessError"; ++ this.exitCode = exitCode; ++ } ++} ++ ++export function refuse(message, exitCode = 2) { ++ throw new BusinessError(message, exitCode); ++} +diff --git a/packages/business/src/index.mjs b/packages/business/src/index.mjs +new file mode 100644 +index 00000000..b935fff5 +--- /dev/null ++++ b/packages/business/src/index.mjs +@@ -0,0 +1,14 @@ ++// @mosaic/business: role definitions, the business and project files, the ++// variable registry and the resolver (slice 1 row S1). Everything here reads ++// files and refuses on a problem; nothing writes. ++ ++export { BusinessError } from "./errors.mjs"; ++export { ++ ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE, LAUNCH_CEILING, ++} from "./vocabulary.mjs"; ++export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs"; ++export { LAYERS, REGISTRY, checkVars, mergeVars } from "./vars.mjs"; ++export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs"; ++export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs"; ++export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs"; ++export { systemVars, resolveInstance, classify } from "./resolve.mjs"; +diff --git a/packages/business/src/project.mjs b/packages/business/src/project.mjs +new file mode 100644 +index 00000000..320ec018 +--- /dev/null ++++ b/packages/business/src/project.mjs +@@ -0,0 +1,36 @@ ++// The project file, /.mosaic/project.json. It lives in the project's ++// repository and changes by reviewed commits there. The stack only reads it. ++ ++import { isAbsolute, join, normalize } from "node:path"; ++import { refuse } from "./errors.mjs"; ++import { checkVars } from "./vars.mjs"; ++import { requireObject, rejectUnknownKeys, requireId, readJsonFile, deepFreeze } from "./util.mjs"; ++ ++export function projectFilePath(root) { ++ return join(root, ".mosaic", "project.json"); ++} ++ ++export function validateProjectDocument(document, file) { ++ requireObject(document, "project file"); ++ rejectUnknownKeys(document, ["projectVersion", "id", "vars", "roles"], "project file"); ++ if (document.projectVersion !== 1) refuse(`project file "projectVersion" must be 1 (${file})`); ++ requireId(document.id, "project id"); ++ const vars = checkVars(document.vars ?? {}, "project", `${file} vars`); ++ const roles = {}; ++ if (document.roles !== undefined) { ++ requireObject(document.roles, `${file} roles`); ++ for (const [instance, entry] of Object.entries(document.roles)) { ++ requireId(instance, `${file} role instance`); ++ requireObject(entry, `${file} roles.${instance}`); ++ rejectUnknownKeys(entry, ["vars"], `${file} roles.${instance}`); ++ roles[instance] = { vars: checkVars(entry.vars ?? {}, "project", `${file} roles.${instance}.vars`) }; ++ } ++ } ++ return deepFreeze({ projectVersion: 1, id: document.id, file, vars, roles }); ++} ++ ++export function loadProject(root) { ++ if (typeof root !== "string" || !isAbsolute(root) || normalize(root) !== root) refuse(`project root must be a normalized absolute path (got ${JSON.stringify(root)})`); ++ const file = projectFilePath(root); ++ return validateProjectDocument(readJsonFile(file, "project file"), file); ++} +diff --git a/packages/business/src/resolve.mjs b/packages/business/src/resolve.mjs +new file mode 100644 +index 00000000..bf769e47 +--- /dev/null ++++ b/packages/business/src/resolve.mjs +@@ -0,0 +1,104 @@ ++// The resolver (REQ-VAR-1 and 2). It joins one role instance's definition, ++// the business file, the optional project file and the system config into ++// the record a launcher or the broker uses. Plain values: the most specific ++// layer wins. Limits: every layer narrows, nothing widens. ++ ++import { refuse } from "./errors.mjs"; ++import { ACTIONS, NETWORKS } from "./vocabulary.mjs"; ++import { checkVars, mergeVars } from "./vars.mjs"; ++import { canonicalJson, sha256, deepFreeze } from "./util.mjs"; ++import { projectFilePath } from "./project.mjs"; ++ ++// The system layer from `mosaic-config.mjs validate` output. ++export function systemVars(config) { ++ return checkVars({ ++ environment: config.environment, ++ dataRoot: config.dataRoot, ++ "execution.backend": config.execution.backend, ++ "execution.provider": config.execution.provider, ++ "execution.model": config.execution.model, ++ "execution.adapter": config.execution.adapter, ++ }, "system", "system config"); ++} ++ ++function narrowerNetwork(a, b) { ++ return NETWORKS.indexOf(a) <= NETWORKS.indexOf(b) ? a : b; ++} ++ ++// resolveInstance({ system, business, project, instance }) ++// system systemVars(...) output ++// business loadBusiness(...) output ++// project loadProject(...) output, or null ++// instance a role instance the business file declares ++export function resolveInstance({ system, business, project = null, instance }) { ++ const entry = Object.hasOwn(business.roles, instance) ? business.roles[instance] : null; ++ if (!entry) refuse(`business ${business.id} declares no role instance ${JSON.stringify(instance)}`); ++ const definition = business.definitions[entry.definition]; ++ ++ const layers = [ ++ { layer: "system", source: "system", vars: system }, ++ { layer: "business", source: `business:${business.id}`, vars: business.vars }, ++ ]; ++ let projectId = null; ++ if (project) { ++ const declared = Object.entries(business.projects).find(([, p]) => projectFilePath(p.root) === project.file); ++ if (!declared || declared[0] !== project.id) { ++ refuse(`project ${project.id} (${project.file}) isn't declared under that id in business ${business.id}`); ++ } ++ projectId = project.id; ++ for (const name of Object.keys(project.roles)) { ++ if (!Object.hasOwn(business.roles, name)) refuse(`project ${project.id} sets vars for role instance ${name}, which business ${business.id} doesn't declare`); ++ } ++ layers.push({ layer: "project", source: `project:${project.id}`, vars: project.vars }); ++ if (Object.hasOwn(project.roles, instance)) { ++ layers.push({ layer: "project", source: `project:${project.id}:roles.${instance}`, vars: project.roles[instance].vars }); ++ } ++ } ++ layers.push({ layer: "agent", source: `business:${business.id}:roles.${instance}`, vars: entry.vars }); ++ const { vars, provenance } = mergeVars(layers); ++ ++ let tools = [...definition.tools]; ++ let network = definition.network; ++ let withinRole = [...definition.authority.withinRole]; ++ let crossRole = [...definition.authority.crossRole]; ++ if (vars["limits.tools"]) tools = tools.filter((t) => vars["limits.tools"].includes(t)); ++ if (vars["limits.network"]) network = narrowerNetwork(network, vars["limits.network"]); ++ if (vars["limits.authority"]) { ++ withinRole = withinRole.filter((a) => vars["limits.authority"].includes(a)); ++ crossRole = crossRole.filter((a) => vars["limits.authority"].includes(a)); ++ } ++ // role.launch needs the business file's launch block naming this ++ // instance (addendum A section 8), and the verb must survive ++ // limits.authority. Otherwise the verb is gated and `launch` is null, ++ // so the two never disagree. ++ const launch = business.launch && business.launch.by === instance && withinRole.includes("role.launch") ? business.launch : null; ++ if (!launch) { ++ withinRole = withinRole.filter((a) => a !== "role.launch"); ++ crossRole = crossRole.filter((a) => a !== "role.launch"); ++ } ++ ++ const resolved = { ++ business: business.id, ++ project: projectId, ++ instance, ++ definition: definition.name, ++ holder: entry.holder, ++ contract: definition.contractPath, ++ vars, ++ provenance, ++ limits: { tools, network, authority: { withinRole, crossRole } }, ++ credentials: entry.credentials, ++ tracker: entry.tracker, ++ launch, ++ }; ++ return deepFreeze({ ...resolved, digest: sha256(canonicalJson(resolved)) }); ++} ++ ++// Classify one action for a resolved instance: "within", "cross" or ++// "gated". An action outside the vocabulary refuses. ++export function classify(resolved, action) { ++ if (!ACTIONS.includes(action)) refuse(`unknown action: ${JSON.stringify(action)}`); ++ if (resolved.limits.authority.withinRole.includes(action)) return "within"; ++ if (resolved.limits.authority.crossRole.includes(action)) return "cross"; ++ return "gated"; ++} +diff --git a/packages/business/src/role.mjs b/packages/business/src/role.mjs +new file mode 100644 +index 00000000..37f99ce1 +--- /dev/null ++++ b/packages/business/src/role.mjs +@@ -0,0 +1,145 @@ ++// Role definitions, roles/.json. Version 2 (REQ-ROLE-1) adds a ++// contract, an authority map over the closed vocabulary and the ++// credentials the role needs, and keeps version 1's tool and network ++// ceilings. Version 1 files still load: they carry no authority, so every ++// vocabulary action is gated for them. ++ ++import { lstatSync } from "node:fs"; ++import { basename, dirname, join } from "node:path"; ++import { refuse } from "./errors.mjs"; ++import { ++ ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE, ++} from "./vocabulary.mjs"; ++import { ++ requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze, ++} from "./util.mjs"; ++ ++const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/; ++const V1_KEYS = ["roleVersion", "name", "tools", "network"]; ++const V2_KEYS = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"]; ++ ++function checkTools(tools) { ++ if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names'); ++ return requireDistinctList(tools, "role tools", (tool) => { ++ if (!TOOLS.includes(tool)) refuse(`unsupported tool: ${JSON.stringify(tool)} (supported: ${TOOLS.join(", ")})`); ++ }); ++} ++ ++function checkNetwork(network) { ++ if (!NETWORKS.includes(network)) refuse(`role "network" must be one of: ${NETWORKS.join(", ")}`); ++ return network; ++} ++ ++function checkAuthority(authority) { ++ requireObject(authority, 'role "authority"'); ++ rejectUnknownKeys(authority, ["withinRole", "crossRole"], 'role "authority"'); ++ const lists = {}; ++ for (const key of ["withinRole", "crossRole"]) { ++ lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => { ++ if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}: ${JSON.stringify(action)}`); ++ if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`); ++ }); ++ } ++ const both = lists.withinRole.filter((a) => lists.crossRole.includes(a)); ++ if (both.length > 0) refuse(`action listed as both withinRole and crossRole: ${both.join(", ")}`); ++ return lists; ++} ++ ++function checkGiteaScopes(scopes) { ++ const list = requireDistinctList(scopes, "gitea scopes", (scope) => { ++ const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null; ++ if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) { ++ refuse(`unsupported gitea scope: ${JSON.stringify(scope)} (expected read: or write:; categories: ${GITEA_SCOPE_CATEGORIES.join(", ")})`); ++ } ++ }, { nonEmpty: true }); ++ const categories = list.map((s) => s.split(":")[1]); ++ const twice = categories.filter((c, i) => categories.indexOf(c) !== i); ++ if (twice.length > 0) refuse(`gitea scopes name ${twice[0]} twice; a token holds one level per category`); ++ return list; ++} ++ ++function checkVikunjaScopes(scopes) { ++ requireObject(scopes, "vikunja scopes"); ++ if (Object.keys(scopes).length === 0) refuse("vikunja scopes must name at least one route group"); ++ const out = {}; ++ for (const [group, verbs] of Object.entries(scopes)) { ++ const allowed = VIKUNJA_GRANTABLE[group]; ++ if (!allowed) refuse(`vikunja route group not grantable to a role: ${JSON.stringify(group)}`); ++ out[group] = requireDistinctList(verbs, `vikunja scopes.${group}`, (verb) => { ++ if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role: ${group}.${JSON.stringify(verb)}`); ++ }, { nonEmpty: true }); ++ } ++ return out; ++} ++ ++function checkCredentials(credentials) { ++ if (!Array.isArray(credentials)) refuse('role "credentials" must be an array'); ++ const seen = new Set(); ++ return credentials.map((entry, i) => { ++ requireObject(entry, `role credentials[${i}]`); ++ rejectUnknownKeys(entry, ["service", "scopes"], `role credentials[${i}]`); ++ if (!SERVICES.includes(entry.service)) refuse(`role credentials[${i}].service must be one of: ${SERVICES.join(", ")}`); ++ if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`); ++ seen.add(entry.service); ++ const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes); ++ return { service: entry.service, scopes }; ++ }); ++} ++ ++function checkContract(contract, file) { ++ if (typeof contract !== "string" || !CONTRACT_NAME.test(contract)) { ++ refuse(`role "contract" must be a Markdown file name in the role's directory, matching ${CONTRACT_NAME} (got ${JSON.stringify(contract)})`); ++ } ++ const path = join(dirname(file), contract); ++ let stat; ++ try { ++ stat = lstatSync(path); ++ } catch { ++ refuse(`role contract not found: ${path}`); ++ } ++ if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) refuse(`role contract must be a non-empty regular file: ${path}`); ++ return path; ++} ++ ++// Validate a parsed role document read from `file`. Returns a frozen role: ++// { roleVersion, name, title, contract, contractPath, tools, network, ++// authority: { withinRole, crossRole }, credentials: [{ service, scopes }] }. ++export function validateRoleDocument(document, file) { ++ requireObject(document, "role"); ++ if (document.roleVersion !== 1 && document.roleVersion !== 2) refuse('role "roleVersion" must be 1 or 2'); ++ rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role"); ++ requireId(document.name, "role name"); ++ const base = basename(file).replace(/\.json$/, ""); ++ if (document.name !== base) refuse(`role "name" (${document.name}) must match its filename (${base}.json)`); ++ const tools = checkTools(document.tools); ++ if (document.roleVersion === 1) { ++ const network = document.network === undefined ? "none" : checkNetwork(document.network); ++ return deepFreeze({ ++ roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network, ++ authority: { withinRole: [], crossRole: [] }, credentials: [], ++ }); ++ } ++ for (const key of V2_KEYS) { ++ if (document[key] === undefined) refuse(`role version 2 requires "${key}"`); ++ } ++ return deepFreeze({ ++ roleVersion: 2, ++ name: document.name, ++ title: requireString(document.title, 'role "title"', { max: 80 }), ++ contract: document.contract, ++ contractPath: checkContract(document.contract, file), ++ tools, ++ network: checkNetwork(document.network), ++ authority: checkAuthority(document.authority), ++ credentials: checkCredentials(document.credentials), ++ }); ++} ++ ++export function loadRoleFile(file) { ++ return validateRoleDocument(readJsonFile(file, "role file"), file); ++} ++ ++export function loadRole(rolesDir, name) { ++ requireId(name, "role name"); ++ return loadRoleFile(join(rolesDir, `${name}.json`)); ++} +diff --git a/packages/business/src/util.mjs b/packages/business/src/util.mjs +new file mode 100644 +index 00000000..fa6519d4 +--- /dev/null ++++ b/packages/business/src/util.mjs +@@ -0,0 +1,109 @@ ++import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs"; ++import { createHash } from "node:crypto"; ++import { refuse } from "./errors.mjs"; ++import { ID_PATTERN } from "./vocabulary.mjs"; ++ ++export function isPlainObject(value) { ++ return typeof value === "object" && value !== null && !Array.isArray(value); ++} ++ ++export function requireObject(value, where) { ++ if (!isPlainObject(value)) refuse(`${where} must be a JSON object`); ++ return value; ++} ++ ++export function rejectUnknownKeys(object, allowed, where) { ++ for (const key of Object.keys(object)) { ++ if (!allowed.includes(key)) refuse(`unsupported ${where} key: ${JSON.stringify(key)}`); ++ } ++} ++ ++export function requireId(value, where) { ++ if (typeof value !== "string" || !ID_PATTERN.test(value)) { ++ refuse(`${where} must match ${ID_PATTERN} (got ${JSON.stringify(value)})`); ++ } ++ return value; ++} ++ ++export function requireString(value, where, { max = 200 } = {}) { ++ if (typeof value !== "string" || value.trim().length === 0 || value.length > max || value.includes("\0")) { ++ refuse(`${where} must be a non-empty string of at most ${max} characters`); ++ } ++ return value; ++} ++ ++export function requirePositiveInt(value, where) { ++ if (!Number.isSafeInteger(value) || value < 1) refuse(`${where} must be a positive integer (got ${JSON.stringify(value)})`); ++ return value; ++} ++ ++// A list of distinct strings, each checked by `check`. ++export function requireDistinctList(value, where, check, { nonEmpty = false } = {}) { ++ if (!Array.isArray(value)) refuse(`${where} must be an array`); ++ if (nonEmpty && value.length === 0) refuse(`${where} must not be empty`); ++ const seen = new Set(); ++ for (const item of value) { ++ check(item); ++ if (seen.has(item)) refuse(`duplicate entry in ${where}: ${JSON.stringify(item)}`); ++ seen.add(item); ++ } ++ return [...seen]; ++} ++ ++// Calendar date YYYY-MM-DD that exists (2026-02-30 refuses). ++export function requireDate(value, where) { ++ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${where} must be a date YYYY-MM-DD (got ${JSON.stringify(value)})`); ++ const d = new Date(`${value}T00:00:00Z`); ++ if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) refuse(`${where} is not a real date: ${value}`); ++ return value; ++} ++ ++// Read a JSON file that must be a regular file, not a symbolic link. ++// Missing or not a regular file is 4; unparseable is 2. One descriptor, ++// opened without following a link, serves every check and the read. ++// `checkStat` sees that descriptor's stat before the read, so the file ++// can't be swapped between the check and the read. ++export function readJsonFile(file, what, checkStat) { ++ let fd; ++ try { ++ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); ++ } catch (error) { ++ if (error.code === "ENOENT") refuse(`${what} not found: ${file}`, 4); ++ refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4); ++ } ++ let text; ++ try { ++ const stat = fstatSync(fd); ++ if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4); ++ checkStat?.(stat); ++ text = readFileSync(fd, "utf8"); ++ } finally { ++ closeSync(fd); ++ } ++ try { ++ return JSON.parse(text); ++ } catch (error) { ++ refuse(`${what} is not valid JSON (${file}): ${error.message}`); ++ } ++} ++ ++// JSON with object keys sorted at every level, for digests. ++export function canonicalJson(value) { ++ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; ++ if (isPlainObject(value)) { ++ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`; ++ } ++ return JSON.stringify(value); ++} ++ ++export function sha256(text) { ++ return createHash("sha256").update(text).digest("hex"); ++} ++ ++export function deepFreeze(value) { ++ if (typeof value === "object" && value !== null && !Object.isFrozen(value)) { ++ Object.freeze(value); ++ for (const v of Object.values(value)) deepFreeze(v); ++ } ++ return value; ++} +diff --git a/packages/business/src/vars.mjs b/packages/business/src/vars.mjs +new file mode 100644 +index 00000000..9591ce52 +--- /dev/null ++++ b/packages/business/src/vars.mjs +@@ -0,0 +1,168 @@ ++// The variable key registry (REQ-VAR-1, note section 2, addendum A section ++// 7). Every key is declared here once, with its type, the layers allowed to ++// set it and its merge rule. A key that isn't here refuses, and so does a ++// key set at a layer it isn't allowed in. ++// ++// Layers, least to most specific: ++// system ~/.config/mosaic-dev/config.json, resolved by mosaic-config.mjs ++// business vars in businesses/.json ++// project vars in /.mosaic/project.json, then that file's ++// roles..vars (still the project layer, applied after) ++// agent roles..vars in the business file ++// ++// Merge rules: ++// replace the most specific layer that sets the key wins ++// intersect every layer that sets the key narrows it; nothing widens ++ ++import { refuse } from "./errors.mjs"; ++import { ACTIONS, NETWORKS, TOOLS } from "./vocabulary.mjs"; ++import { isPlainObject, requireDistinctList, requirePositiveInt, requireString } from "./util.mjs"; ++ ++export const LAYERS = Object.freeze(["system", "business", "project", "agent"]); ++ ++const BRANCH = /^(?!-)(?!.*\.\.)(?!.*\/\/)(?!.*\.lock$)[A-Za-z0-9._/-]{1,100}(? requireString(v, `variable ${key}`, { max }); ++} ++ ++function oneOf(values) { ++ return (v, key) => { ++ if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")} (got ${JSON.stringify(v)})`); ++ return v; ++ }; ++} ++ ++function integer(min) { ++ return (v, key) => { ++ if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min} (got ${JSON.stringify(v)})`); ++ return v; ++ }; ++} ++ ++// http or https, no user info, query or fragment, no trailing slash. ++function baseUrl(v, key) { ++ requireString(v, `variable ${key}`, { max: 300 }); ++ let url; ++ try { ++ url = new URL(v); ++ } catch { ++ refuse(`variable ${key} must be an http or https URL (got ${JSON.stringify(v)})`); ++ } ++ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || v.endsWith("/")) { ++ refuse(`variable ${key} must be an http or https base URL with no credentials, query, fragment or trailing slash`); ++ } ++ return v; ++} ++ ++function branch(v, key) { ++ if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name (got ${JSON.stringify(v)})`); ++ return v; ++} ++ ++function list(check, { nonEmpty = true } = {}) { ++ return (v, key) => requireDistinctList(v, `variable ${key}`, (item) => check(item, `${key} entry`), { nonEmpty }); ++} ++ ++function absolutePath(v, key) { ++ if (typeof v !== "string" || !v.startsWith("/") || v.includes("\0")) refuse(`variable ${key} must be an absolute path`); ++ return v; ++} ++ ++const def = (layers, check, extra = {}) => Object.freeze({ layers: Object.freeze(layers), merge: "replace", check, default: undefined, ...extra }); ++ ++export const REGISTRY = Object.freeze({ ++ // System. mosaic-config.mjs owns their validation; these checks only ++ // keep the resolver honest about types. ++ environment: def(["system"], string(64)), ++ dataRoot: def(["system"], absolutePath), ++ "execution.backend": def(["system"], string(64)), ++ "execution.provider": def(["system"], string(64)), ++ "execution.model": def(["system"], string(200)), ++ "execution.adapter": def(["system"], string(64)), ++ ++ // Business. ++ "tracker.kind": def(["business"], oneOf(["vikunja"]), { default: "vikunja" }), ++ "tracker.baseUrl": def(["business"], baseUrl), ++ "tracker.reconcileMinutes": def(["business"], integer(1), { default: 60 }), ++ "tracker.pollSeconds": def(["business", "project"], integer(10), { default: 30 }), ++ "human.discordUserId": def(["business"], (v, key) => { ++ if (typeof v !== "string" || !/^[1-9][0-9]{16,19}$/.test(v)) refuse(`variable ${key} must be a Discord user id, 17 to 20 digits as a string`); ++ return v; ++ }), ++ "gitea.baseUrl": def(["business"], baseUrl), ++ ++ // Project. ++ "tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)), ++ "git.workingBranch": def(["project"], branch), ++ "git.protectedBranches": def(["project"], list(branch)), ++ suites: def(["project"], list(string(300))), ++ "issues.repo": def(["project"], (v, key) => { ++ if (typeof v !== "string" || !/^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(v)) refuse(`variable ${key} must be owner/name`); ++ return v; ++ }), ++ ++ // Agent: one role instance. ++ harness: def(["agent"], oneOf(["pi", "claude-code"])), ++ model: def(["agent"], string(200)), ++ thinking: def(["agent"], oneOf(["off", "minimal", "low", "medium", "high", "xhigh"])), ++ ++ // Limits narrow the role definition's ceilings. limits.authority is an ++ // allowlist: a role action it doesn't name becomes gated. ++ "limits.tools": def(["business", "project", "agent"], list((v, key) => { ++ if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool: ${JSON.stringify(v)}`); ++ }, { nonEmpty: false }), { merge: "intersect" }), ++ "limits.network": def(["business", "project", "agent"], oneOf(NETWORKS), { merge: "intersect" }), ++ "limits.authority": def(["business", "project", "agent"], list((v, key) => { ++ if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action: ${JSON.stringify(v)}`); ++ }, { nonEmpty: false }), { merge: "intersect" }), ++}); ++ ++// Check one layer's vars object. `where` names the file and path for the ++// message. Returns a frozen copy with checked values. ++export function checkVars(vars, layer, where) { ++ if (!LAYERS.includes(layer)) throw new Error(`unknown layer ${layer}`); ++ if (!isPlainObject(vars)) refuse(`${where} must be a JSON object`); ++ const out = {}; ++ for (const [key, value] of Object.entries(vars)) { ++ const entry = Object.hasOwn(REGISTRY, key) ? REGISTRY[key] : null; ++ if (!entry) refuse(`${where}: unknown variable ${JSON.stringify(key)}`); ++ if (!entry.layers.includes(layer)) refuse(`${where}: variable ${key} can't be set at the ${layer} layer (allowed: ${entry.layers.join(", ")})`); ++ out[key] = entry.check(value, key); ++ } ++ return Object.freeze(out); ++} ++ ++function narrower(a, b) { ++ return NETWORKS.indexOf(a) <= NETWORKS.indexOf(b) ? a : b; ++} ++ ++// Merge checked layers, given least specific first as [{ layer, source, ++// vars }]. Returns { vars, provenance }. provenance[key] is the source ++// that set a replace key, or the list of sources that narrowed a limit. ++export function mergeVars(layers) { ++ const vars = {}; ++ const provenance = {}; ++ for (const [key, entry] of Object.entries(REGISTRY)) { ++ if (entry.default !== undefined) { ++ vars[key] = entry.default; ++ provenance[key] = "default"; ++ } ++ } ++ for (const { source, vars: layerVars } of layers) { ++ for (const [key, value] of Object.entries(layerVars)) { ++ const entry = REGISTRY[key]; ++ if (entry.merge === "replace" || vars[key] === undefined) { ++ vars[key] = value; ++ provenance[key] = entry.merge === "replace" ? source : [source]; ++ } else if (key === "limits.network") { ++ vars[key] = narrower(vars[key], value); ++ provenance[key].push(source); ++ } else { ++ vars[key] = vars[key].filter((item) => value.includes(item)); ++ provenance[key].push(source); ++ } ++ } ++ } ++ return { vars, provenance }; ++} +diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs +new file mode 100644 +index 00000000..4b151af0 +--- /dev/null ++++ b/packages/business/src/vocabulary.mjs +@@ -0,0 +1,88 @@ ++// The closed action vocabulary and the other frozen lists a role file is ++// checked against. Design: agents/darkwing/work/slice1-data-model-2026-10-04.md ++// section 1.4, addendum A sections 2 and 8, addendum B section 2. ++// ++// A change to any list here is a reviewed commit to this file. Role files ++// that name something outside these lists are refused. ++ ++// Every action with an outside effect that slice 1 touches. Routine work ++// (editing files, running tests, writing docs, retrying) has no outside ++// effect and isn't listed. ++export const ACTIONS = Object.freeze([ ++ "task.create", ++ "task.assign", ++ "task.schedule", ++ "task.update.assigned", ++ "task.close", ++ "task.reassign", ++ "task.scope.change", ++ "task.priority.change", ++ "git.push.working", ++ "git.push.protected", ++ "git.merge.protected", ++ "review.request", ++ "review.verdict", ++ "message.send", ++ "message.external", ++ "role.launch", ++ "role.revoke", ++ "credential.mint", ++ "spend", ++ "deploy", ++ "policy.change", ++ "prd.approve", ++ "decision.resolve.technical", ++]); ++ ++// Always gated: a role file may not list these under withinRole or ++// crossRole. role.revoke is here because lead decision 46 (6.7) keeps every ++// revoke gated in slice 1. ++export const GATED_ONLY = Object.freeze([ ++ "credential.mint", ++ "git.merge.protected", ++ "git.push.protected", ++ "deploy", ++ "spend", ++ "message.external", ++ "policy.change", ++ "prd.approve", ++ "role.revoke", ++]); ++ ++// pi's documented built-in tools; the same list scripts/mosaic-task.mjs ++// accepts for tasks. ++export const TOOLS = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls"]); ++ ++// Ordered from narrowest to widest. Intersecting two values keeps the ++// narrower one. ++export const NETWORKS = Object.freeze(["none", "api-only", "open"]); ++ ++export const SERVICES = Object.freeze(["gitea", "vikunja"]); ++ ++// Gitea token scopes are :. "all" and the admin ++// category are never granted to a role. ++export const GITEA_SCOPE_CATEGORIES = Object.freeze([ ++ "activitypub", "issue", "misc", "notification", "organization", "package", "repository", "user", ++]); ++ ++// Vikunja 2.7.0 route groups and verbs a role token may hold. Each pair was ++// minted or used on a scratch 2.7.0 (Researcher's probes, addendum B ++// section 2). Anything not listed, including every verb addendum B lists ++// as never granted, is refused. A pair missing here that a role really ++// needs gets added after a probe shows Vikunja accepts it. ++export const VIKUNJA_GRANTABLE = Object.freeze({ ++ projects: Object.freeze(["read_one", "views_buckets", "views_buckets_tasks", "views_buckets_tasks_get"]), ++ projects_views: Object.freeze(["read_all"]), ++ tasks: Object.freeze(["read_all", "read_one", "create", "update"]), ++ tasks_comments: Object.freeze(["read_all", "create"]), ++ tasks_assignees: Object.freeze(["create", "delete"]), ++ tasks_relations: Object.freeze(["create", "delete"]), ++ tasks_labels: Object.freeze(["create", "delete"]), ++ labels: Object.freeze(["read_all"]), ++}); ++ ++// Model families the launch block may cap, and the PRD's ceiling for each ++// (REQ-LAUNCH-1: at most 4 Opus and 4 Sonnet sessions at once). ++export const LAUNCH_CEILING = Object.freeze({ opus: 4, sonnet: 4 }); ++ ++export const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; +diff --git a/packages/business/tests/business.test.mjs b/packages/business/tests/business.test.mjs +new file mode 100644 +index 00000000..ec3ccc65 +--- /dev/null ++++ b/packages/business/tests/business.test.mjs +@@ -0,0 +1,220 @@ ++import { test } from "node:test"; ++import assert from "node:assert/strict"; ++import { execFileSync, spawnSync } from "node:child_process"; ++import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; ++import { join } from "node:path"; ++import { pathToFileURL } from "node:url"; ++import { BusinessError, businessFilePath, configDir, loadBusiness, validateBusinessDocument } from "../src/index.mjs"; ++import { businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs"; ++ ++function refuses(fn, pattern, exitCode = 2) { ++ assert.throws(fn, (error) => { ++ assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`); ++ assert.equal(error.exitCode, exitCode); ++ assert.match(error.message, pattern); ++ return true; ++ }); ++} ++ ++// Validate a mutated copy of the fixture document. ++function check(mutate, { rolesDir = REPO_ROLES } = {}) { ++ const root = tmp(); ++ const doc = businessDoc(root); ++ mutate?.(doc, root); ++ return validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir }); ++} ++ ++test("config directory and file path follow MOSAIC_CONFIG", () => { ++ assert.equal(configDir({ MOSAIC_CONFIG: "/x/cfg/config.json" }), "/x/cfg"); ++ assert.match(configDir({}), /\.config\/mosaic-dev$/); ++ assert.equal(businessFilePath("acme", "/x/cfg"), "/x/cfg/businesses/acme.json"); ++ refuses(() => businessFilePath("../acme", "/x"), /business id/); ++}); ++ ++test("the fixture business validates and comes back frozen", () => { ++ const business = check(); ++ assert.equal(business.id, "acme"); ++ assert.deepEqual(Object.keys(business.roles), ["pm", "cto", "coder", "reviewer"]); ++ assert.equal(business.roles.pm.holder, "sage"); ++ assert.equal(business.roles.reviewer.holder, null); ++ assert.equal(business.roles.coder.vars["limits.network"], "none"); ++ assert.equal(business.definitions.pm.roleVersion, 2); ++ assert.deepEqual(business.launch, { by: "pm", instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } }); ++ assert.ok(Object.isFrozen(business.roles.pm.credentials.gitea)); ++ assert.throws(() => { business.roles.pm.holder = "x"; }, TypeError); ++}); ++ ++test("two instances may share a definition", () => { ++ const business = check((doc) => { ++ doc.roles.coder2 = { ...structuredClone(doc.roles.coder), tracker: { bot: "bot-acme-coder2", botId: 7 } }; ++ }); ++ assert.equal(business.roles.coder2.definition, "coder"); ++}); ++ ++test("top-level refusals", () => { ++ refuses(() => check((d) => { d.businessVersion = 2; }), /businessVersion/); ++ refuses(() => check((d) => { d.owner = "x"; }), /unsupported business file key: "owner"/); ++ refuses(() => check((d) => { d.id = "other"; }), /must match its filename/); ++ refuses(() => check((d) => { d.id = "Acme"; }), /business id/); ++ for (const key of ["human", "arbiters", "projects", "tracker", "roles"]) { ++ refuses(() => check((d) => { delete d[key]; }), new RegExp(`requires "${key}"`)); ++ } ++ refuses(() => check((d) => { d.vars.harness = "pi"; }), /harness can't be set at the business layer/); ++ refuses(() => check((d) => { d.vars.secret = "x"; }), /unknown variable "secret"/); ++}); ++ ++test("arbiters and projects", () => { ++ refuses(() => check((d) => { d.arbiters.technical = "ghost"; }), /arbiters\.technical names ghost/); ++ refuses(() => check((d) => { delete d.arbiters.delivery; }), /arbiters\.delivery/); ++ refuses(() => check((d) => { d.arbiters.final = "pm"; }), /unsupported .* arbiters key/); ++ refuses(() => check((d) => { d.projects = {}; }), /at least one project/); ++ refuses(() => check((d) => { d.projects.stack.root = "relative/path"; }), /normalized absolute path/); ++ refuses(() => check((d) => { d.projects.stack.root += "/../x"; }), /normalized absolute path/); ++ refuses(() => check((d) => { d.projects.stack.branch = "main"; }), /unsupported .* key: "branch"/); ++}); ++ ++test("role instances", () => { ++ refuses(() => check((d) => { d.roles = {}; }), /at least one role instance/); ++ refuses(() => check((d) => { d.roles.pm.definition = "ghost"; }), /not found/, 4); ++ refuses(() => check((d) => { d.roles.pm.definition = "researcher"; }), /version 1 and can't back/); ++ refuses(() => check((d) => { d.roles.pm.token = "x"; }), /unsupported .*roles\.pm key: "token"/); ++ refuses(() => check((d) => { d.roles.pm.holder = "Sage!"; }), /holder/); ++ refuses(() => check((d) => { d.roles.pm.vars["tracker.baseUrl"] = "http://x"; }), /can't be set at the agent layer/); ++ refuses(() => check((d) => { d.roles.pm.vars["limits.authority"] = ["deploy.prod"]; }), /unknown action/); ++}); ++ ++test("Vikunja bots", () => { ++ refuses(() => check((d) => { delete d.roles.pm.tracker; }), /needs "tracker"/); ++ refuses(() => check((d) => { d.roles.pm.tracker.botId = 0; }), /botId must be a positive integer/); ++ refuses(() => check((d) => { d.roles.pm.tracker.bot = "pm-bot"; }), /starting with "bot-"/); ++ refuses(() => check((d) => { d.roles.cto.tracker.bot = d.roles.pm.tracker.bot; }), /used by another instance/); ++ refuses(() => check((d) => { d.roles.cto.tracker.botId = d.roles.pm.tracker.botId; }), /botId 3 is used by another instance/); ++ refuses(() => check((d) => { d.tracker.sync.bot = d.roles.pm.tracker.bot; }), /sync must use its own bot/); ++ refuses(() => check((d) => { d.tracker.sync.botId = d.roles.cto.tracker.botId; }), /sync must use its own bot/); ++ refuses(() => check((d) => { d.tracker.sync.botId = 0; }), /sync\.botId/); ++ refuses(() => check((d) => { d.tracker.labels = { "needs-jason": 0 }; }), /labels\.needs-jason/); ++ refuses(() => check((d) => { d.tracker.webhook = "x"; }), /unsupported .* tracker key/); ++ assert.deepEqual(check((d) => { d.tracker.labels = { "needs-jason": 12 }; }).tracker.labels, { "needs-jason": 12 }); ++}); ++ ++test("a role without Vikunja takes no tracker block", () => { ++ const root = tmp(); ++ const rolesDir = rolesCopy(root); ++ const pm = JSON.parse(readFileSync(join(rolesDir, "pm.json"), "utf8")); ++ pm.credentials = pm.credentials.filter((c) => c.service === "gitea"); ++ writeJson(join(rolesDir, "pm.json"), pm); ++ refuses(() => check(undefined, { rolesDir }), /has "tracker" but pm uses no vikunja credential/); ++ refuses(() => check((d) => { delete d.roles.pm.tracker; }, { rolesDir }), /exactly the services its role definition needs \(gitea; got gitea, vikunja\)/); ++ const business = check((d) => { delete d.roles.pm.tracker; delete d.roles.pm.credentials.vikunja; }, { rolesDir }); ++ assert.equal(business.roles.pm.tracker, null); ++}); ++ ++test("credential references match the definition's services", () => { ++ refuses(() => check((d) => { delete d.roles.cto.credentials.gitea; }), /exactly the services .* \(gitea, vikunja; got vikunja\)/); ++ refuses(() => check((d) => { d.roles.cto.credentials.github = { env: "X", rotateBy: "2099-01-01" }; }), /exactly the services/); ++ refuses(() => check((d) => { d.roles.cto.credentials.gitea = { env: "X" }; }), /needs "rotateBy"/); ++ refuses(() => check((d) => { d.roles.cto.credentials.vikunja.token = "tk_x"; }), /unsupported .* key: "token"/); ++ refuses(() => check((d) => { d.tracker.sync.credentials = {}; }), /exactly the services .* \(vikunja; got none\)/); ++}); ++ ++test("launch", () => { ++ assert.equal(check((d) => { delete d.launch; }).launch, null); ++ refuses(() => check((d) => { d.launch.by = "ghost"; }), /launch\.by names ghost/); ++ refuses(() => check((d) => { d.launch.by = "cto"; d.launch.instances = ["coder"]; }), /launch\.by names cto, whose role cto doesn't hold role\.launch within-role/); ++ refuses(() => check((d) => { d.launch.instances = []; }), /must not be empty/); ++ refuses(() => check((d) => { d.launch.instances = ["coder", "coder"]; }), /duplicate/); ++ refuses(() => check((d) => { d.launch.instances = ["ghost"]; }), /instances names ghost/); ++ refuses(() => check((d) => { d.launch.instances = ["pm"]; }), /can't include the launcher itself/); ++ refuses(() => check((d) => { d.launch.max = {}; }), /at least one model family/); ++ refuses(() => check((d) => { d.launch.max = { glm: 1 }; }), /unknown model family "glm"/); ++ refuses(() => check((d) => { d.launch.max = { opus: 5 }; }), /from 0 to 4/); ++ refuses(() => check((d) => { d.launch.max = { sonnet: -1 }; }), /from 0 to 4/); ++ refuses(() => check((d) => { d.launch.max = { opus: 1.5 }; }), /from 0 to 4/); ++ refuses(() => check((d) => { d.launch.cap = 1; }), /unsupported .* launch key/); ++ assert.deepEqual(check((d) => { d.launch.max = { opus: 0 }; }).launch.max, { opus: 0 }); ++}); ++ ++test("loadBusiness: file checks", () => { ++ const root = tmp(); ++ const dir = join(root, "config"); ++ refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /business file not found/, 4); ++ ++ const file = writeJson(join(dir, "businesses", "acme.json"), businessDoc(root)); ++ assert.equal(loadBusiness("acme", { dir, rolesDir: REPO_ROLES }).file, file); ++ ++ chmodSync(file, 0o620); ++ refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /writable by group or other \(mode 620\)/); ++ chmodSync(file, 0o602); ++ refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /writable by group or other/); ++ chmodSync(file, 0o644); ++ assert.equal(loadBusiness("acme", { dir, rolesDir: REPO_ROLES }).id, "acme"); ++ ++ writeJson(join(dir, "businesses", "real.json"), businessDoc(root, "linked")); ++ symlinkSync("real.json", join(dir, "businesses", "linked.json")); ++ refuses(() => loadBusiness("linked", { dir, rolesDir: REPO_ROLES }), /non-symbolic-link/, 4); ++ ++ writeFileSync(join(dir, "businesses", "broken.json"), "{ not json"); ++ refuses(() => loadBusiness("broken", { dir, rolesDir: REPO_ROLES }), /not valid JSON/); ++ // The owner and mode checks run on the opened file before the read, so ++ // a group-writable file refuses for its mode even when it won't parse. ++ chmodSync(join(dir, "businesses", "broken.json"), 0o660); ++ refuses(() => loadBusiness("broken", { dir, rolesDir: REPO_ROLES }), /writable by group or other \(mode 660\)/); ++ assert.throws(() => loadBusiness("acme", { dir }), /needs rolesDir/); ++}); ++ ++// A directory or a FIFO under the business name refuses without a read. ++// Opening a FIFO without O_NONBLOCK would block the whole process, which ++// node:test can't time out, so that case runs in a child with a timeout. ++test("loadBusiness: not a regular file", () => { ++ const dir = join(tmp(), "config"); ++ mkdirSync(join(dir, "businesses", "folder.json"), { recursive: true }); ++ refuses(() => loadBusiness("folder", { dir, rolesDir: REPO_ROLES }), /must be a regular, non-symbolic-link file/, 4); ++ execFileSync("mkfifo", [join(dir, "businesses", "pipe.json")]); ++ const index = pathToFileURL(join(REPO, "packages", "business", "src", "index.mjs")).href; ++ const child = spawnSync(process.execPath, ["--input-type=module", "-e", ` ++ import { loadBusiness } from ${JSON.stringify(index)}; ++ try { loadBusiness("pipe", { dir: ${JSON.stringify(dir)}, rolesDir: ${JSON.stringify(REPO_ROLES)} }); } ++ catch (error) { console.log(error.exitCode, error.message); } ++ `], { encoding: "utf8", timeout: 5000 }); ++ assert.equal(child.signal, null, "opening the FIFO waited for a writer"); ++ assert.match(child.stdout, /^4 .*must be a regular, non-symbolic-link file/); ++}); ++ ++test("loading writes nothing", () => { ++ const root = tmp(); ++ const dir = join(root, "config"); ++ const file = writeJson(join(dir, "businesses", "acme.json"), businessDoc(root)); ++ const listing = (d) => readdirSync(d, { recursive: true }).sort().join("\n"); ++ const before = { tree: listing(root), mtime: lstatSync(file).mtimeMs, text: readFileSync(file, "utf8") }; ++ loadBusiness("acme", { dir, rolesDir: REPO_ROLES }); ++ assert.equal(listing(root), before.tree); ++ assert.equal(lstatSync(file).mtimeMs, before.mtime); ++ assert.equal(readFileSync(file, "utf8"), before.text); ++}); ++ ++test("names that are Object.prototype properties don't count as declared", () => { ++ refuses(() => check((d) => { d.arbiters.delivery = "constructor"; }), /arbiters\.delivery names constructor/); ++ refuses(() => check((d) => { d.launch.by = "constructor"; }), /launch\.by names constructor/); ++ refuses(() => check((d) => { d.launch.instances = ["constructor"]; }), /instances names constructor/); ++ refuses(() => check((d) => { d.launch.max = { constructor: 1 }; }), /unknown model family "constructor"/); ++ refuses(() => check((d) => { d.roles.pm.definition = "constructor"; }), /not found/, 4); ++ const labels = check((d) => { d.tracker.labels = JSON.parse('{"__proto__": 5}'); }).tracker.labels; ++ assert.equal(Object.getPrototypeOf(labels), Object.prototype); ++ assert.equal(Object.getOwnPropertyDescriptor(labels, "__proto__").value, 5); ++}); ++ ++test("the shipped example refuses as written and validates once filled in", () => { ++ const example = readFileSync(join(REPO, "packages", "business", "examples", "mosaic-stack.example.json"), "utf8"); ++ const root = tmp(); ++ const file = join(root, "businesses", "mosaic-stack.json"); ++ refuses(() => validateBusinessDocument(JSON.parse(example), file, { rolesDir: REPO_ROLES }), /botId must be a positive integer/); ++ let id = 10; ++ const filled = JSON.parse(example ++ .replaceAll('"botId": 0', () => `"botId": ${id++}`) ++ .replaceAll("YYYY-MM-DD", "2099-01-01") ++ .replaceAll("/home/you", root)); ++ const business = validateBusinessDocument(filled, file, { rolesDir: REPO_ROLES }); ++ assert.deepEqual(Object.keys(business.roles), ["pm", "cto", "coder", "reviewer"]); ++ assert.equal(business.tracker.sync.credentials.vikunja.file, join(root, ".config/mosaic-dev/secrets/mosaic-stack/sync-vikunja.token")); ++ assert.equal(id, 15); ++}); +diff --git a/packages/business/tests/cli.test.mjs b/packages/business/tests/cli.test.mjs +new file mode 100644 +index 00000000..af87bba6 +--- /dev/null ++++ b/packages/business/tests/cli.test.mjs +@@ -0,0 +1,159 @@ ++import { test } from "node:test"; ++import assert from "node:assert/strict"; ++import { spawnSync } from "node:child_process"; ++import { chmodSync, readFileSync, writeFileSync } from "node:fs"; ++import { join } from "node:path"; ++import { projectFilePath } from "../src/index.mjs"; ++import { businessDoc, REPO, rolesCopy, systemConfig, tmp, tokenFile, writeJson } from "./helpers.mjs"; ++ ++const CLI = join(REPO, "packages", "business", "src", "cli.mjs"); ++ ++// A complete scratch setup: system config, business file, roles copy. ++// HOME points into the scratch tree too, so nothing reads the real one. ++function setup(mutate) { ++ const root = tmp(); ++ const config = systemConfig(root); ++ const doc = businessDoc(root); ++ mutate?.(doc, root); ++ writeJson(join(root, "config", "businesses", `${doc.id}.json`), doc); ++ const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesCopy(root) }; ++ return { root, doc, env }; ++} ++ ++function run(s, ...args) { ++ const proc = spawnSync(process.execPath, [CLI, ...args], { env: s.env, encoding: "utf8" }); ++ return { code: proc.status, out: proc.stdout, err: proc.stderr }; ++} ++ ++test("usage errors exit 4", () => { ++ const s = setup(); ++ assert.equal(run(s).code, 4); ++ assert.equal(run(s, "show", "acme").code, 4); ++ assert.equal(run(s, "validate").code, 4); ++ assert.equal(run(s, "validate", "acme", "extra").code, 4); ++ assert.equal(run(s, "resolve", "acme").code, 4); ++ assert.equal(run(s, "resolve", "acme", "pm", "--project").code, 4); ++ assert.equal(run(s, "resolve", "acme", "pm", "--project", "a", "--project", "b").code, 4); ++ assert.match(run(s).err, /usage: mosaic business validate/); ++}); ++ ++test("validate: a good business exits 0 and prints instance digests", () => { ++ const s = setup(); ++ const r = run(s, "validate", "acme"); ++ assert.equal(r.code, 0, r.err); ++ const out = JSON.parse(r.out); ++ assert.equal(out.business, "acme"); ++ assert.deepEqual(out.projects, { stack: "absent" }); ++ assert.deepEqual(Object.keys(out.instances), ["pm", "cto", "coder", "reviewer"]); ++ for (const digest of Object.values(out.instances)) assert.match(digest, /^[0-9a-f]{64}$/); ++ assert.match(r.err, /warning: project stack: no project file/); ++ assert.doesNotMatch(r.out + r.err, /placeholder-not-a-token/); ++}); ++ ++test("validate: project files", () => { ++ const s = setup(); ++ const file = projectFilePath(s.doc.projects.stack.root); ++ writeJson(file, { projectVersion: 1, id: "stack", vars: { "tracker.project": 3 } }); ++ const r = run(s, "validate", "acme"); ++ assert.equal(r.code, 0, r.err); ++ assert.deepEqual(JSON.parse(r.out).projects, { stack: "valid" }); ++ ++ writeJson(file, { projectVersion: 1, id: "other" }); ++ assert.match(run(s, "validate", "acme").err, /has id other, but business acme declares it as stack/); ++ assert.equal(run(s, "validate", "acme").code, 2); ++ writeJson(file, { projectVersion: 1, id: "stack", roles: { ghost: {} } }); ++ const ghost = run(s, "validate", "acme"); ++ assert.equal(ghost.code, 2); ++ assert.match(ghost.err, /role instance ghost/); ++ writeJson(file, { projectVersion: 1, id: "stack", vars: { harness: "pi" } }); ++ assert.equal(run(s, "validate", "acme").code, 2); ++}); ++ ++test("validate: missing files and a broken system config", () => { ++ const s = setup(); ++ const missing = run(s, "validate", "nobody"); ++ assert.equal(missing.code, 4); ++ assert.match(missing.err, /business file not found/); ++ assert.equal(run(s, "validate", "Bad!").code, 2); ++ ++ const noConfig = { ...s, env: { ...s.env, MOSAIC_CONFIG: join(s.root, "absent", "config.json") } }; ++ const r = run(noConfig, "validate", "acme"); ++ assert.equal(r.code, 3); ++ assert.match(r.err, /system config problem/); ++ writeFileSync(s.env.MOSAIC_CONFIG, "{"); ++ assert.equal(run(s, "validate", "acme").code, 3); ++}); ++ ++test("validate: credential reference problems exit 2 and name each one", () => { ++ const s = setup((doc, root) => { ++ chmodSync(doc.roles.cto.credentials.gitea.file, 0o644); ++ doc.roles.coder.credentials.vikunja.file = tokenFile(join(root, "data", "tokens"), "coder.token"); ++ doc.roles.reviewer.credentials.vikunja.expires = "2026-01-01"; ++ }); ++ const r = run(s, "validate", "acme"); ++ assert.equal(r.code, 2); ++ assert.equal(r.out, ""); ++ assert.match(r.err, /cto-gitea\.token: mode 644/); ++ assert.match(r.err, /coder\.token: inside .*data/); ++ assert.match(r.err, /expired on 2026-01-01/); ++ assert.match(r.err, /3 credential reference problem/); ++}); ++ ++test("validate: a token file inside the repository is refused", () => { ++ const s = setup((doc) => { doc.roles.pm.credentials.gitea.file = join(REPO, "roles", "pm.md"); }); ++ const r = run(s, "validate", "acme"); ++ assert.equal(r.code, 2); ++ assert.match(r.err, /inside .*; token files live outside the repository and dataRoot/); ++}); ++ ++test("validate: role definitions come from MOSAIC_ROLES_DIR", () => { ++ const s = setup(); ++ const before = JSON.parse(run(s, "validate", "acme").out).instances; ++ const pmFile = join(s.env.MOSAIC_ROLES_DIR, "pm.json"); ++ const pm = JSON.parse(readFileSync(pmFile, "utf8")); ++ pm.tools = pm.tools.filter((t) => t !== "write"); ++ writeJson(pmFile, pm); ++ const after = JSON.parse(run(s, "validate", "acme").out).instances; ++ assert.notEqual(after.pm, before.pm); ++ assert.equal(after.cto, before.cto); ++ pm.authority.withinRole.push("deploy"); ++ writeJson(pmFile, pm); ++ const gated = run(s, "validate", "acme"); ++ assert.equal(gated.code, 2); ++ assert.match(gated.err, /always gated/); ++}); ++ ++test("resolve: prints one instance's record", () => { ++ const s = setup(); ++ writeJson(projectFilePath(s.doc.projects.stack.root), { ++ projectVersion: 1, id: "stack", vars: { "tracker.project": 3 }, roles: { coder: { vars: { "limits.tools": ["read", "bash"] } } }, ++ }); ++ const r = run(s, "resolve", "acme", "coder", "--project", "stack"); ++ assert.equal(r.code, 0, r.err); ++ const coder = JSON.parse(r.out); ++ assert.equal(coder.project, "stack"); ++ assert.equal(coder.vars["tracker.project"], 3); ++ assert.deepEqual(coder.limits.tools, ["read", "bash"]); ++ assert.equal(coder.limits.network, "none"); ++ assert.equal(coder.contract, join(s.env.MOSAIC_ROLES_DIR, "coder.md")); ++ assert.doesNotMatch(r.out, /placeholder-not-a-token/); ++ ++ const pm = JSON.parse(run(s, "resolve", "acme", "pm").out); ++ assert.equal(pm.project, null); ++ assert.ok(pm.limits.authority.withinRole.includes("role.launch")); ++ const validated = JSON.parse(run(s, "validate", "acme").out).instances; ++ assert.equal(pm.digest, validated.pm); ++}); ++ ++test("resolve: refusals", () => { ++ const s = setup((doc) => { chmodSync(doc.roles.reviewer.credentials.gitea.file, 0o604); }); ++ assert.equal(run(s, "resolve", "acme", "pm").code, 0); ++ const reviewer = run(s, "resolve", "acme", "reviewer"); ++ assert.equal(reviewer.code, 2); ++ assert.equal(reviewer.out, ""); ++ assert.match(reviewer.err, /mode 604/); ++ assert.equal(run(s, "resolve", "acme", "ghost").code, 2); ++ assert.equal(run(s, "resolve", "acme", "constructor").code, 2); ++ assert.match(run(s, "resolve", "acme", "pm", "--project", "ghost").err, /declares no project "ghost"/); ++ assert.equal(run(s, "resolve", "acme", "pm", "--project", "stack").code, 4); ++}); +diff --git a/packages/business/tests/credentials.test.mjs b/packages/business/tests/credentials.test.mjs +new file mode 100644 +index 00000000..b6223766 +--- /dev/null ++++ b/packages/business/tests/credentials.test.mjs +@@ -0,0 +1,114 @@ ++import { test } from "node:test"; ++import assert from "node:assert/strict"; ++import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; ++import { join } from "node:path"; ++import { BusinessError, checkCredentialRef, parseCredentialRef } from "../src/index.mjs"; ++import { FAR_DATE, tmp, tokenFile } from "./helpers.mjs"; ++ ++function refuses(fn, pattern) { ++ assert.throws(fn, (error) => { ++ assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`); ++ assert.equal(error.exitCode, 2); ++ assert.match(error.message, pattern); ++ return true; ++ }); ++} ++ ++const NOW = new Date("2026-10-04T12:00:00Z"); ++ ++test("parse: exactly one of file or env, plus the service's date", () => { ++ assert.deepEqual({ ...parseCredentialRef({ file: "/s/pm-gitea.token", rotateBy: FAR_DATE }, "gitea", "r") }, ++ { service: "gitea", file: "/s/pm-gitea.token", rotateBy: FAR_DATE }); ++ assert.deepEqual({ ...parseCredentialRef({ env: "PM_VIKUNJA_TOKEN", expires: FAR_DATE }, "vikunja", "r") }, ++ { service: "vikunja", env: "PM_VIKUNJA_TOKEN", expires: FAR_DATE }); ++ refuses(() => parseCredentialRef({ file: "/a", env: "A", rotateBy: FAR_DATE }, "gitea", "r"), /exactly one/); ++ refuses(() => parseCredentialRef({ rotateBy: FAR_DATE }, "gitea", "r"), /exactly one/); ++ refuses(() => parseCredentialRef({ file: "/a" }, "gitea", "r"), /needs "rotateBy"/); ++ refuses(() => parseCredentialRef({ file: "/a", expires: FAR_DATE }, "gitea", "r"), /unsupported r key: "expires"/); ++ refuses(() => parseCredentialRef({ file: "/a", rotateBy: FAR_DATE }, "vikunja", "r"), /unsupported r key: "rotateBy"/); ++ refuses(() => parseCredentialRef({ file: "/a", expires: FAR_DATE, token: "x" }, "vikunja", "r"), /unsupported r key: "token"/); ++ refuses(() => parseCredentialRef({ file: "a/b", rotateBy: FAR_DATE }, "gitea", "r"), /normalized absolute path/); ++ refuses(() => parseCredentialRef({ file: "/a/../b", rotateBy: FAR_DATE }, "gitea", "r"), /normalized absolute path/); ++ refuses(() => parseCredentialRef({ file: "/a//b", rotateBy: FAR_DATE }, "gitea", "r"), /normalized absolute path/); ++ refuses(() => parseCredentialRef({ env: "pm_token", rotateBy: FAR_DATE }, "gitea", "r"), /\.env must match/); ++ refuses(() => parseCredentialRef({ env: "A", rotateBy: "2026-02-30" }, "gitea", "r"), /not a real date/); ++ refuses(() => parseCredentialRef({ env: "A", expires: "2026-10-04T00:00:00Z" }, "vikunja", "r"), /YYYY-MM-DD/); ++ refuses(() => parseCredentialRef({ env: "A" }, "github", "r"), /unknown credential service/); ++}); ++ ++test("check: a good file has no problems", () => { ++ const dir = tmp(); ++ const ref = parseCredentialRef({ file: tokenFile(join(dir, "s"), "t"), expires: FAR_DATE }, "vikunja", "r"); ++ assert.deepEqual(checkCredentialRef(ref, { now: NOW, forbiddenRoots: [join(dir, "repo")] }), { problems: [], warnings: [] }); ++}); ++ ++test("check never opens the file: a write-only token passes", () => { ++ const dir = tmp(); ++ const file = tokenFile(join(dir, "s"), "t"); ++ chmodSync(file, 0o200); ++ const ref = parseCredentialRef({ file, rotateBy: FAR_DATE }, "gitea", "r"); ++ assert.deepEqual(checkCredentialRef(ref, { now: NOW }).problems, []); ++}); ++ ++test("check: file problems", () => { ++ const dir = tmp(); ++ const s = join(dir, "s"); ++ const problems = (file, opts = {}) => checkCredentialRef(parseCredentialRef({ file, rotateBy: FAR_DATE }, "gitea", "r"), { now: NOW, ...opts }).problems; ++ assert.match(problems(join(s, "absent")).join(), /not found/); ++ ++ const loose = tokenFile(s, "loose"); ++ chmodSync(loose, 0o640); ++ assert.match(problems(loose).join(), /mode 640 gives group or other access/); ++ ++ const empty = join(s, "empty"); ++ writeFileSync(empty, ""); ++ chmodSync(empty, 0o600); ++ assert.match(problems(empty).join(), /empty/); ++ ++ const link = join(s, "link"); ++ symlinkSync(tokenFile(s, "real"), link); ++ assert.match(problems(link).join(), /not a symbolic link/); ++ ++ mkdirSync(join(s, "dir")); ++ assert.match(problems(join(s, "dir")).join(), /regular file/); ++ ++ assert.deepEqual(problems(tokenFile(s, "other")), []); ++ assert.match(problems(tokenFile(s, "uid"), { uid: process.getuid() + 1 }).join(), /owned by uid/); ++}); ++ ++test("check: token files can't live in the repository or dataRoot, even through a linked directory", () => { ++ const dir = tmp(); ++ const repo = join(dir, "repo"); ++ const inRepo = tokenFile(join(repo, "secrets"), "t"); ++ const ref = (file) => parseCredentialRef({ file, rotateBy: FAR_DATE }, "gitea", "r"); ++ assert.match(checkCredentialRef(ref(inRepo), { now: NOW, forbiddenRoots: [repo] }).problems.join(), /inside .*repo/); ++ symlinkSync(join(repo, "secrets"), join(dir, "elsewhere")); ++ const viaLink = join(dir, "elsewhere", "t"); ++ assert.match(checkCredentialRef(ref(viaLink), { now: NOW, forbiddenRoots: [repo] }).problems.join(), /inside/); ++ const repoLink = join(dir, "repo-link"); ++ symlinkSync(repo, repoLink); ++ assert.match(checkCredentialRef(ref(inRepo), { now: NOW, forbiddenRoots: [repoLink] }).problems.join(), /inside/); ++ const sibling = tokenFile(join(dir, "repo-secrets"), "t"); ++ assert.deepEqual(checkCredentialRef(ref(sibling), { now: NOW, forbiddenRoots: [repo, join(dir, "absent-root")] }).problems, []); ++}); ++ ++test("check: dates and environment references", () => { ++ const vikunja = (expires) => checkCredentialRef(parseCredentialRef({ env: "TOK", expires }, "vikunja", "r"), { now: NOW, env: { TOK: "set" } }); ++ assert.match(vikunja("2026-10-04").problems.join(), /expired on 2026-10-04/); ++ assert.match(vikunja("2026-10-01").problems.join(), /expired/); ++ assert.deepEqual(vikunja("2026-10-05").problems, []); ++ assert.match(vikunja("2026-10-05").warnings.join(), /expires on 2026-10-05/); ++ assert.match(vikunja("2026-10-10").warnings.join(), /expires on/); ++ assert.deepEqual(vikunja("2026-10-12"), { problems: [], warnings: [] }); ++ ++ const gitea = (rotateBy) => checkCredentialRef(parseCredentialRef({ env: "TOK", rotateBy }, "gitea", "r"), { now: NOW, env: { TOK: "set" } }); ++ assert.deepEqual(gitea("2026-10-01").problems, []); ++ assert.match(gitea("2026-10-01").warnings.join(), /rotation was due on 2026-10-01/); ++ assert.deepEqual(gitea(FAR_DATE), { problems: [], warnings: [] }); ++ ++ const unset = checkCredentialRef(parseCredentialRef({ env: "TOK", rotateBy: FAR_DATE }, "gitea", "r"), { now: NOW, env: {} }); ++ assert.deepEqual(unset.problems, []); ++ assert.match(unset.warnings.join(), /not set in this environment/); ++ const blank = checkCredentialRef(parseCredentialRef({ env: "TOK", rotateBy: FAR_DATE }, "gitea", "r"), { now: NOW, env: { TOK: "" } }); ++ assert.match(blank.warnings.join(), /not set/); ++}); +diff --git a/packages/business/tests/helpers.mjs b/packages/business/tests/helpers.mjs +new file mode 100644 +index 00000000..334fbe26 +--- /dev/null ++++ b/packages/business/tests/helpers.mjs +@@ -0,0 +1,98 @@ ++// Shared fixtures. Every test works in its own temporary directory and ++// points MOSAIC_CONFIG there; nothing reads the real ~/.config. ++ ++import { chmodSync, cpSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; ++import { tmpdir } from "node:os"; ++import { dirname, join, resolve } from "node:path"; ++import { fileURLToPath } from "node:url"; ++ ++export const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); ++export const REPO_ROLES = join(REPO, "roles"); ++export const FAR_DATE = "2099-01-01"; ++ ++export function tmp() { ++ return mkdtempSync(join(tmpdir(), "mosaic-business-")); ++} ++ ++export function writeJson(file, value, mode = 0o600) { ++ mkdirSync(dirname(file), { recursive: true }); ++ writeFileSync(file, typeof value === "string" ? value : `${JSON.stringify(value, null, 2)}\n`); ++ chmodSync(file, mode); ++ return file; ++} ++ ++// A token file with a placeholder value; the package never reads it. ++export function tokenFile(dir, name) { ++ mkdirSync(dir, { recursive: true, mode: 0o700 }); ++ const file = join(dir, name); ++ writeFileSync(file, "placeholder-not-a-token"); ++ chmodSync(file, 0o600); ++ return file; ++} ++ ++// A copy of the shipped role files in a scratch roles directory. ++export function rolesCopy(root) { ++ const dir = join(root, "roles"); ++ cpSync(REPO_ROLES, dir, { recursive: true }); ++ return dir; ++} ++ ++// A complete valid business document for the shipped four roles, with ++// token files under `/secrets`, and a project root under ``. ++export function businessDoc(root, id = "acme") { ++ const secrets = join(root, "secrets"); ++ const ref = (role, service) => service === "gitea" ++ ? { file: tokenFile(secrets, `${role}-gitea.token`), rotateBy: FAR_DATE } ++ : { file: tokenFile(secrets, `${role}-vikunja.token`), expires: FAR_DATE }; ++ const role = (definition, botId, extra = {}) => ({ ++ definition, ++ tracker: { bot: `bot-${id}-${definition}`, botId }, ++ credentials: { gitea: ref(definition, "gitea"), vikunja: ref(definition, "vikunja") }, ++ ...extra, ++ }); ++ const projectRoot = join(root, "project"); ++ mkdirSync(projectRoot, { recursive: true }); ++ return { ++ businessVersion: 1, ++ id, ++ human: "jason", ++ arbiters: { delivery: "pm", technical: "cto" }, ++ projects: { stack: { root: projectRoot } }, ++ vars: { "tracker.baseUrl": "http://127.0.0.1:3456", "gitea.baseUrl": "https://git.example", "tracker.pollSeconds": 60 }, ++ tracker: { ++ sync: { bot: `bot-${id}-sync`, botId: 9, credentials: { vikunja: ref("sync", "vikunja") } }, ++ labels: {}, ++ }, ++ roles: { ++ pm: role("pm", 3, { holder: "sage", vars: { harness: "pi", model: "claude-opus-5-5" } }), ++ cto: role("cto", 4, { holder: "darkwing", vars: { harness: "claude-code" } }), ++ coder: role("coder", 5, { vars: { harness: "pi", "limits.network": "none" } }), ++ reviewer: role("reviewer", 6), ++ }, ++ launch: { by: "pm", instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } }, ++ }; ++} ++ ++// The system config file mosaic-config.mjs validates, under `root`. ++export function systemConfig(root) { ++ const file = join(root, "config", "config.json"); ++ writeJson(file, { ++ configVersion: 1, ++ environment: "development", ++ dataRoot: join(root, "data"), ++ execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash", adapter: "mock" }, ++ }); ++ return file; ++} ++ ++// The checked system layer, as systemVars() returns it, without spawning. ++export function systemFor(root) { ++ return { ++ environment: "development", ++ dataRoot: join(root, "data"), ++ "execution.backend": "docker", ++ "execution.provider": "zai", ++ "execution.model": "glm-5.3-flash", ++ "execution.adapter": "mock", ++ }; ++} +diff --git a/packages/business/tests/project.test.mjs b/packages/business/tests/project.test.mjs +new file mode 100644 +index 00000000..8f653056 +--- /dev/null ++++ b/packages/business/tests/project.test.mjs +@@ -0,0 +1,48 @@ ++import { test } from "node:test"; ++import assert from "node:assert/strict"; ++import { join } from "node:path"; ++import { BusinessError, loadProject, projectFilePath, validateProjectDocument } from "../src/index.mjs"; ++import { tmp, writeJson } from "./helpers.mjs"; ++ ++function refuses(fn, pattern, exitCode = 2) { ++ assert.throws(fn, (error) => { ++ assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`); ++ assert.equal(error.exitCode, exitCode); ++ assert.match(error.message, pattern); ++ return true; ++ }); ++} ++ ++const doc = (extra = {}) => ({ ++ projectVersion: 1, id: "stack", ++ vars: { "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"] }, ++ roles: { coder: { vars: { "limits.tools": ["read", "bash"] } } }, ++ ...extra, ++}); ++ ++test("path and load", () => { ++ assert.equal(projectFilePath("/w/stack"), "/w/stack/.mosaic/project.json"); ++ const root = tmp(); ++ writeJson(projectFilePath(root), doc()); ++ const project = loadProject(root); ++ assert.equal(project.id, "stack"); ++ assert.equal(project.file, projectFilePath(root)); ++ assert.deepEqual(project.roles.coder.vars["limits.tools"], ["read", "bash"]); ++ assert.ok(Object.isFrozen(project.vars)); ++}); ++ ++test("refusals", () => { ++ refuses(() => loadProject("relative"), /normalized absolute path/); ++ refuses(() => loadProject("/a/../b"), /normalized absolute path/); ++ refuses(() => loadProject(tmp()), /project file not found/, 4); ++ const v = (d) => validateProjectDocument(d, "/p/.mosaic/project.json"); ++ refuses(() => v(doc({ projectVersion: 2 })), /projectVersion/); ++ refuses(() => v(doc({ id: "Stack" })), /project id/); ++ refuses(() => v(doc({ owner: "x" })), /unsupported project file key: "owner"/); ++ refuses(() => v(doc({ vars: { harness: "pi" } })), /harness can't be set at the project layer/); ++ refuses(() => v(doc({ vars: { "tracker.baseUrl": "http://x" } })), /project layer/); ++ refuses(() => v(doc({ roles: { coder: { vars: { model: "x" } } } })), /roles\.coder\.vars: variable model can't be set at the project layer/); ++ refuses(() => v(doc({ roles: { coder: { holder: "x" } } })), /unsupported .*roles\.coder key: "holder"/); ++ refuses(() => v(doc({ roles: { "Coder!": {} } })), /role instance/); ++ assert.deepEqual(v(doc({ vars: undefined, roles: undefined })).roles, {}); ++}); +diff --git a/packages/business/tests/resolve.test.mjs b/packages/business/tests/resolve.test.mjs +new file mode 100644 +index 00000000..0995eb5c +--- /dev/null ++++ b/packages/business/tests/resolve.test.mjs +@@ -0,0 +1,194 @@ ++import { test } from "node:test"; ++import assert from "node:assert/strict"; ++import { join } from "node:path"; ++import { ++ BusinessError, classify, projectFilePath, resolveInstance, systemVars, validateBusinessDocument, validateProjectDocument, ++} from "../src/index.mjs"; ++import { businessDoc, REPO_ROLES, systemFor, tmp } from "./helpers.mjs"; ++ ++function refuses(fn, pattern) { ++ assert.throws(fn, (error) => { ++ assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`); ++ assert.equal(error.exitCode, 2); ++ assert.match(error.message, pattern); ++ return true; ++ }); ++} ++ ++function setup(mutate, projectDoc) { ++ const root = tmp(); ++ const doc = businessDoc(root); ++ mutate?.(doc); ++ const business = validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES }); ++ const project = projectDoc ++ ? validateProjectDocument({ projectVersion: 1, id: "stack", ...projectDoc }, projectFilePath(doc.projects.stack.root)) ++ : null; ++ return { root, business, project, system: systemFor(root) }; ++} ++ ++const resolve = (s, instance) => resolveInstance({ system: s.system, business: s.business, project: s.project, instance }); ++ ++test("systemVars flattens the validated config", () => { ++ const vars = systemVars({ environment: "development", dataRoot: "/d", execution: { backend: "docker", provider: "zai", model: "m", adapter: "pi" } }); ++ assert.deepEqual({ ...vars }, { ++ environment: "development", dataRoot: "/d", "execution.backend": "docker", ++ "execution.provider": "zai", "execution.model": "m", "execution.adapter": "pi", ++ }); ++ refuses(() => systemVars({ environment: "development", dataRoot: "relative", execution: { backend: "b", provider: "p", model: "m", adapter: "a" } }), /dataRoot must be an absolute path/); ++}); ++ ++test("precedence: system, business, project, project role, agent", () => { ++ const s = setup((d) => { ++ d.roles.coder.vars.model = "agent-model"; ++ }, { ++ vars: { "tracker.pollSeconds": 20, "tracker.project": 3 }, ++ roles: { coder: { vars: { "tracker.pollSeconds": 15 } } }, ++ }); ++ const coder = resolve(s, "coder"); ++ assert.equal(coder.project, "stack"); ++ assert.equal(coder.vars["tracker.pollSeconds"], 15); ++ assert.equal(coder.provenance["tracker.pollSeconds"], "project:stack:roles.coder"); ++ assert.equal(coder.vars["tracker.project"], 3); ++ assert.equal(coder.provenance["tracker.project"], "project:stack"); ++ assert.equal(coder.vars["tracker.baseUrl"], "http://127.0.0.1:3456"); ++ assert.equal(coder.provenance["tracker.baseUrl"], "business:acme"); ++ assert.equal(coder.vars.model, "agent-model"); ++ assert.equal(coder.provenance.model, "business:acme:roles.coder"); ++ assert.equal(coder.vars["execution.adapter"], "mock"); ++ assert.equal(coder.provenance["execution.adapter"], "system"); ++ assert.equal(coder.provenance["tracker.reconcileMinutes"], "default"); ++ ++ const pm = resolve(s, "pm"); ++ assert.equal(pm.vars["tracker.pollSeconds"], 20); ++ assert.equal(pm.provenance["tracker.pollSeconds"], "project:stack"); ++ const noProject = resolveInstance({ system: s.system, business: s.business, instance: "pm" }); ++ assert.equal(noProject.project, null); ++ assert.equal(noProject.vars["tracker.pollSeconds"], 60); ++}); ++ ++test("limits narrow the definition and never widen it", () => { ++ const s = setup((d) => { ++ d.vars["limits.network"] = "open"; ++ d.roles.cto.vars["limits.tools"] = ["read", "grep", "bash", "write"]; ++ d.roles.reviewer.vars = { "limits.tools": ["read", "write"], "limits.authority": ["review.verdict", "task.close"] }; ++ }, { roles: { cto: { vars: { "limits.tools": ["read", "bash", "write", "edit"] } } } }); ++ const coder = resolve(s, "coder"); ++ assert.equal(coder.limits.network, "none"); ++ assert.deepEqual(coder.provenance["limits.network"], ["business:acme", "business:acme:roles.coder"]); ++ assert.equal(resolve(s, "pm").limits.network, "api-only"); ++ ++ const cto = resolve(s, "cto"); ++ assert.deepEqual(cto.limits.tools, ["read", "write", "bash"]); ++ assert.deepEqual(cto.provenance["limits.tools"], ["project:stack:roles.cto", "business:acme:roles.cto"]); ++ ++ const reviewer = resolve(s, "reviewer"); ++ assert.deepEqual(reviewer.limits.tools, ["read"]); ++ assert.deepEqual(reviewer.limits.authority, { withinRole: ["review.verdict"], crossRole: [] }); ++ assert.equal(classify(reviewer, "message.send"), "gated"); ++ assert.equal(classify(reviewer, "task.close"), "gated"); ++}); ++ ++test("role.launch stays within-role only for the instance the launch block names", () => { ++ const pm = resolve(setup(), "pm"); ++ assert.equal(classify(pm, "role.launch"), "within"); ++ assert.deepEqual(pm.launch.instances, ["coder", "reviewer"]); ++ assert.equal(resolve(setup(), "cto").launch, null); ++ ++ const noLaunch = resolve(setup((d) => { delete d.launch; }), "pm"); ++ assert.equal(classify(noLaunch, "role.launch"), "gated"); ++ assert.equal(noLaunch.launch, null); ++ ++ // A second pm instance is the launcher, so the first one may not launch. ++ const two = setup((d) => { ++ d.roles.pm2 = { ...d.roles.pm, holder: undefined, tracker: { bot: "bot-acme-pm2", botId: 7 } }; ++ d.launch.by = "pm2"; ++ }); ++ assert.equal(classify(resolve(two, "pm"), "role.launch"), "gated"); ++ assert.equal(resolve(two, "pm").launch, null); ++ assert.equal(classify(resolve(two, "pm2"), "role.launch"), "within"); ++ assert.equal(resolve(two, "pm2").launch.by, "pm2"); ++}); ++ ++test("limits.authority without role.launch leaves the launcher with no launch block", () => { ++ for (const layer of ["agent", "project"]) { ++ const authority = ["task.create", "task.assign", "message.send"]; ++ const s = layer === "agent" ++ ? setup((d) => { d.roles.pm.vars["limits.authority"] = authority; }) ++ : setup(undefined, { roles: { pm: { vars: { "limits.authority": authority } } } }); ++ const pm = resolve(s, "pm"); ++ assert.equal(classify(pm, "role.launch"), "gated", layer); ++ assert.equal(pm.launch, null, layer); ++ } ++}); ++ ++test("limits.authority narrows cross-role actions too", () => { ++ const s = setup((d) => { ++ d.roles.coder.vars["limits.authority"] = ["task.update.assigned", "git.push.working", "task.scope.change"]; ++ }); ++ const coder = resolve(s, "coder"); ++ assert.deepEqual(coder.limits.authority, { withinRole: ["task.update.assigned", "git.push.working"], crossRole: ["task.scope.change"] }); ++ assert.equal(classify(coder, "task.reassign"), "gated"); ++ assert.equal(classify(coder, "task.scope.change"), "cross"); ++ assert.equal(classify(coder, "review.request"), "gated"); ++}); ++ ++test("classify", () => { ++ const s = setup(); ++ const coder = resolve(s, "coder"); ++ assert.equal(classify(coder, "git.push.working"), "within"); ++ assert.equal(classify(coder, "task.reassign"), "cross"); ++ assert.equal(classify(coder, "task.close"), "gated"); ++ assert.equal(classify(coder, "deploy"), "gated"); ++ assert.equal(classify(resolve(s, "pm"), "task.priority.change"), "cross"); ++ assert.equal(classify(resolve(s, "cto"), "decision.resolve.technical"), "within"); ++ refuses(() => classify(coder, "task.delete"), /unknown action/); ++}); ++ ++test("the record carries what the broker and launcher need", () => { ++ const s = setup(); ++ const pm = resolve(s, "pm"); ++ assert.deepEqual(Object.keys(pm).sort(), [ ++ "business", "contract", "credentials", "definition", "digest", "holder", "instance", "launch", "limits", "project", "provenance", "tracker", "vars", ++ ]); ++ assert.equal(pm.contract, join(REPO_ROLES, "pm.md")); ++ assert.equal(pm.holder, "sage"); ++ assert.deepEqual(pm.tracker, { bot: "bot-acme-pm", botId: 3 }); ++ assert.deepEqual(Object.keys(pm.credentials).sort(), ["gitea", "vikunja"]); ++ assert.match(pm.credentials.gitea.file, /pm-gitea\.token$/); ++ assert.ok(Object.isFrozen(pm.limits.authority.withinRole)); ++}); ++ ++test("digest: key order doesn't matter, any value change does", () => { ++ const root = tmp(); ++ const file = join(root, "businesses", "acme.json"); ++ const load = (mutate) => { ++ const doc = businessDoc(root); ++ mutate?.(doc); ++ return validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }); ++ }; ++ const system = systemFor(root); ++ const digest = (business, instance = "coder", sys = system) => resolveInstance({ system: sys, business, instance }).digest; ++ const base = digest(load()); ++ assert.match(base, /^[0-9a-f]{64}$/); ++ assert.equal(digest(load()), base); ++ assert.equal(digest(load((d) => { d.roles.coder.vars = { "limits.network": "none", harness: "pi" }; })), base); ++ assert.equal(digest(load((d) => { d.vars = Object.fromEntries(Object.entries(d.vars).reverse()); })), base); ++ assert.notEqual(digest(load((d) => { d.roles.coder.vars.harness = "claude-code"; })), base); ++ assert.notEqual(digest(load((d) => { d.roles.coder.holder = "rocko"; })), base); ++ assert.notEqual(digest(load((d) => { d.roles.coder.credentials.gitea.rotateBy = "2098-01-01"; })), base); ++ assert.notEqual(digest(load(), "coder", { ...system, "execution.model": "other" }), base); ++ assert.notEqual(digest(load(), "reviewer"), base); ++}); ++ ++test("refusals", () => { ++ const s = setup(); ++ refuses(() => resolveInstance({ system: s.system, business: s.business, instance: "ghost" }), /declares no role instance "ghost"/); ++ refuses(() => resolveInstance({ system: s.system, business: s.business, instance: "constructor" }), /declares no role instance/); ++ const misnamed = setup(undefined, {}); ++ const wrongId = validateProjectDocument({ projectVersion: 1, id: "other" }, misnamed.project.file); ++ refuses(() => resolveInstance({ system: misnamed.system, business: misnamed.business, project: wrongId, instance: "pm" }), /isn't declared under that id/); ++ const elsewhere = validateProjectDocument({ projectVersion: 1, id: "stack" }, "/elsewhere/.mosaic/project.json"); ++ refuses(() => resolveInstance({ system: s.system, business: s.business, project: elsewhere, instance: "pm" }), /isn't declared under that id/); ++ const extraRole = setup(undefined, { roles: { ghost: { vars: {} } } }); ++ refuses(() => resolve(extraRole, "pm"), /sets vars for role instance ghost/); ++}); +diff --git a/packages/business/tests/role.test.mjs b/packages/business/tests/role.test.mjs +new file mode 100644 +index 00000000..1a401307 +--- /dev/null ++++ b/packages/business/tests/role.test.mjs +@@ -0,0 +1,158 @@ ++import { test } from "node:test"; ++import assert from "node:assert/strict"; ++import { symlinkSync, writeFileSync } from "node:fs"; ++import { join } from "node:path"; ++import { loadRole, loadRoleFile, validateRoleDocument, GATED_ONLY, BusinessError } from "../src/index.mjs"; ++import { REPO_ROLES, tmp, writeJson } from "./helpers.mjs"; ++ ++function refuses(fn, pattern, exitCode = 2) { ++ assert.throws(fn, (error) => { ++ assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`); ++ assert.equal(error.exitCode, exitCode); ++ assert.match(error.message, pattern); ++ return true; ++ }); ++} ++ ++function scratchRole(overrides = {}, contract = "# contract\n") { ++ const dir = tmp(); ++ if (contract !== null) writeFileSync(join(dir, "r.md"), contract); ++ const doc = { ++ roleVersion: 2, name: "r", title: "R", contract: "r.md", tools: ["read"], network: "none", ++ authority: { withinRole: ["message.send"], crossRole: [] }, ++ credentials: [{ service: "gitea", scopes: ["read:issue"] }], ++ ...overrides, ++ }; ++ return { dir, file: join(dir, "r.json"), doc }; ++} ++ ++const check = (overrides, contract) => { ++ const { file, doc } = scratchRole(overrides, contract); ++ return validateRoleDocument(doc, file); ++}; ++ ++test("the four shipped version 2 roles load", () => { ++ for (const name of ["pm", "cto", "coder", "reviewer"]) { ++ const role = loadRole(REPO_ROLES, name); ++ assert.equal(role.roleVersion, 2); ++ assert.equal(role.contractPath, join(REPO_ROLES, `${name}.md`)); ++ assert.deepEqual(role.credentials.map((c) => c.service), ["gitea", "vikunja"]); ++ for (const action of [...role.authority.withinRole, ...role.authority.crossRole]) { ++ assert.ok(!GATED_ONLY.includes(action), `${name} lists gated ${action}`); ++ } ++ assert.ok(Object.isFrozen(role.authority.withinRole)); ++ } ++}); ++ ++test("shipped role scopes match addendum B section 2 and the SR runbook", () => { ++ const scopes = (name, service) => loadRole(REPO_ROLES, name).credentials.find((c) => c.service === service).scopes; ++ assert.deepEqual(scopes("pm", "vikunja"), { ++ tasks: ["read_one", "create", "update"], tasks_assignees: ["create", "delete"], ++ tasks_relations: ["create", "delete"], tasks_labels: ["create", "delete"], ++ tasks_comments: ["create"], labels: ["read_all"], projects: ["views_buckets_tasks"], ++ }); ++ for (const worker of ["cto", "coder", "reviewer"]) { ++ assert.deepEqual(scopes(worker, "vikunja"), { tasks: ["read_one", "update"], tasks_comments: ["create"], projects: ["views_buckets_tasks"] }); ++ assert.deepEqual(scopes(worker, "gitea"), ["write:issue", "write:repository", "read:user"]); ++ } ++ assert.deepEqual(scopes("pm", "gitea"), ["write:issue", "read:repository", "read:user"]); ++}); ++ ++test("shipped authority follows the note's table", () => { ++ const auth = (name) => loadRole(REPO_ROLES, name).authority; ++ assert.ok(auth("coder").withinRole.includes("git.push.working")); ++ assert.ok(!auth("reviewer").withinRole.includes("git.push.working")); ++ assert.deepEqual(auth("reviewer").crossRole, []); ++ assert.ok(auth("pm").withinRole.includes("role.launch")); ++ assert.ok(auth("cto").withinRole.includes("decision.resolve.technical")); ++ assert.ok(!auth("pm").withinRole.includes("decision.resolve.technical")); ++}); ++ ++test("version 1 files keep loading with no authority", () => { ++ const role = loadRole(REPO_ROLES, "researcher"); ++ assert.equal(role.roleVersion, 1); ++ assert.equal(role.contractPath, null); ++ assert.deepEqual(role.authority, { withinRole: [], crossRole: [] }); ++ assert.deepEqual(role.tools, ["read", "grep", "find", "ls", "bash"]); ++ const dir = tmp(); ++ const file = writeJson(join(dir, "plain.json"), { roleVersion: 1, name: "plain", tools: ["read"] }); ++ assert.equal(loadRoleFile(file).network, "none"); ++ refuses(() => loadRoleFile(writeJson(join(dir, "v1x.json"), { roleVersion: 1, name: "v1x", tools: ["read"], authority: {} })), /unsupported role key: "authority"/); ++}); ++ ++test("the conductor policy isn't a role", () => { ++ refuses(() => loadRole(REPO_ROLES, "conductor-policy"), /roleVersion/); ++}); ++ ++test("a missing role file is exit 4, a symbolic link too", () => { ++ const dir = tmp(); ++ refuses(() => loadRole(dir, "absent"), /not found/, 4); ++ writeJson(join(dir, "real.json"), { roleVersion: 1, name: "link", tools: ["read"] }); ++ symlinkSync("real.json", join(dir, "link.json")); ++ refuses(() => loadRole(dir, "link"), /non-symbolic-link/, 4); ++ refuses(() => loadRole(dir, "../etc"), /role name/); ++}); ++ ++test("version 2 refusals", () => { ++ assert.equal(check({}).name, "r"); ++ refuses(() => check({ roleVersion: 3 }), /roleVersion/); ++ refuses(() => check({ extra: 1 }), /unsupported role key: "extra"/); ++ refuses(() => check({ name: "other" }), /must match its filename/); ++ refuses(() => check({ title: undefined }), /requires "title"/); ++ refuses(() => check({ title: "x".repeat(81) }), /title/); ++ refuses(() => check({ network: "everywhere" }), /network/); ++ refuses(() => check({ tools: [] }), /tools/); ++ refuses(() => check({ tools: ["read", "read"] }), /duplicate/); ++ refuses(() => check({ tools: ["render3d"] }), /unsupported tool/); ++}); ++ ++test("authority: closed vocabulary, no gated-only action, no overlap", () => { ++ refuses(() => check({ authority: { withinRole: ["task.delete"], crossRole: [] } }), /unknown action/); ++ for (const gated of GATED_ONLY) { ++ refuses(() => check({ authority: { withinRole: [gated], crossRole: [] } }), /always gated/); ++ refuses(() => check({ authority: { withinRole: [], crossRole: [gated] } }), /always gated/); ++ } ++ refuses(() => check({ authority: { withinRole: ["task.reassign"], crossRole: ["task.reassign"] } }), /both withinRole and crossRole/); ++ refuses(() => check({ authority: { withinRole: [] } }), /crossRole must be an array/); ++ refuses(() => check({ authority: { withinRole: [], crossRole: [], gated: [] } }), /unsupported role "authority" key/); ++}); ++ ++test("credentials: Gitea scopes", () => { ++ const gitea = (scopes) => check({ credentials: [{ service: "gitea", scopes }] }); ++ assert.deepEqual(gitea(["write:repository", "read:user"]).credentials[0].scopes, ["write:repository", "read:user"]); ++ refuses(() => gitea(["write:admin"]), /unsupported gitea scope/); ++ refuses(() => gitea(["all"]), /unsupported gitea scope/); ++ refuses(() => gitea(["sudo:repository"]), /unsupported gitea scope/); ++ refuses(() => gitea(["read:issue", "write:issue"]), /name issue twice/); ++ refuses(() => gitea([]), /must not be empty/); ++}); ++ ++test("credentials: Vikunja scopes are a group-to-verbs map from the grantable list", () => { ++ const vikunja = (scopes) => check({ credentials: [{ service: "vikunja", scopes }] }); ++ assert.deepEqual(vikunja({ tasks: ["read_one"] }).credentials[0].scopes, { tasks: ["read_one"] }); ++ refuses(() => vikunja({ tasks: ["delete"] }), /not grantable/); ++ refuses(() => vikunja({ projects: ["create"] }), /not grantable/); ++ refuses(() => vikunja({ tokens: ["read_all"] }), /route group not grantable/); ++ refuses(() => vikunja({ projects_webhooks: ["create"] }), /route group not grantable/); ++ refuses(() => vikunja({ tasks: [] }), /must not be empty/); ++ refuses(() => vikunja({}), /at least one route group/); ++ refuses(() => vikunja(["tasks.read"]), /JSON object/); ++}); ++ ++test("credentials: services", () => { ++ refuses(() => check({ credentials: [{ service: "github", scopes: [] }] }), /service must be one of/); ++ refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"] }, { service: "gitea", scopes: ["read:user"] }] }), /twice/); ++ refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"], token: "x" }] }), /unsupported role credentials\[0\] key/); ++ assert.deepEqual(check({ credentials: [] }).credentials, []); ++}); ++ ++test("contract: a non-empty regular Markdown file beside the role file", () => { ++ refuses(() => check({}, null), /contract not found/); ++ refuses(() => check({}, ""), /non-empty regular file/); ++ refuses(() => check({ contract: "../r.md" }), /Markdown file name/); ++ refuses(() => check({ contract: "roles/r.md" }), /Markdown file name/); ++ refuses(() => check({ contract: "r.txt" }), /Markdown file name/); ++ const { dir, file, doc } = scratchRole({ contract: "link.md" }); ++ symlinkSync("r.md", join(dir, "link.md")); ++ refuses(() => validateRoleDocument(doc, file), /non-empty regular file/); ++}); +diff --git a/packages/business/tests/vars.test.mjs b/packages/business/tests/vars.test.mjs +new file mode 100644 +index 00000000..251dbbd5 +--- /dev/null ++++ b/packages/business/tests/vars.test.mjs +@@ -0,0 +1,114 @@ ++import { test } from "node:test"; ++import assert from "node:assert/strict"; ++import { BusinessError, LAYERS, REGISTRY, checkVars, mergeVars } from "../src/index.mjs"; ++ ++function refuses(fn, pattern) { ++ assert.throws(fn, (error) => { ++ assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`); ++ assert.equal(error.exitCode, 2); ++ assert.match(error.message, pattern); ++ return true; ++ }); ++} ++ ++test("every key names known layers and a merge rule", () => { ++ for (const [key, entry] of Object.entries(REGISTRY)) { ++ assert.ok(entry.layers.length > 0, key); ++ for (const layer of entry.layers) assert.ok(LAYERS.includes(layer), `${key}: ${layer}`); ++ assert.ok(["replace", "intersect"].includes(entry.merge), key); ++ if (entry.merge === "intersect") assert.ok(key.startsWith("limits."), key); ++ } ++}); ++ ++test("unknown keys and wrong layers refuse", () => { ++ refuses(() => checkVars({ colour: "blue" }, "business", "b.vars"), /b\.vars: unknown variable "colour"/); ++ refuses(() => checkVars({ harness: "pi" }, "business", "b.vars"), /can't be set at the business layer \(allowed: agent\)/); ++ refuses(() => checkVars({ "tracker.baseUrl": "http://x" }, "project", "p.vars"), /project layer/); ++ refuses(() => checkVars({ "tracker.project": 3 }, "agent", "a.vars"), /agent layer/); ++ refuses(() => checkVars({ dataRoot: "/x" }, "business", "b.vars"), /business layer/); ++ refuses(() => checkVars(["harness"], "agent", "a.vars"), /JSON object/); ++ refuses(() => checkVars({ constructor: 1 }, "agent", "a.vars"), /unknown variable "constructor"/); ++ assert.throws(() => checkVars({}, "fleet", "x"), /unknown layer/); ++}); ++ ++test("types", () => { ++ const ok = (vars, layer) => assert.deepEqual({ ...checkVars(vars, layer, "v") }, vars); ++ ok({ "tracker.baseUrl": "https://tasks.example:3456", "tracker.reconcileMinutes": 1, "tracker.pollSeconds": 10, "human.discordUserId": "123456789012345678" }, "business"); ++ ok({ "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"], suites: ["scripts/test-task.sh"], "issues.repo": "mosaicstack/stack" }, "project"); ++ ok({ harness: "claude-code", model: "claude-opus-5-5", thinking: "high", "limits.tools": [], "limits.network": "none", "limits.authority": ["message.send"] }, "agent"); ++ ++ const bad = [ ++ [{ "tracker.baseUrl": "ftp://x" }, "business", /http or https/], ++ [{ "tracker.baseUrl": "https://x/" }, "business", /trailing slash/], ++ [{ "tracker.baseUrl": "https://u:p@x" }, "business", /no credentials/], ++ [{ "tracker.baseUrl": "https://x?a=1" }, "business", /query/], ++ [{ "tracker.baseUrl": "not a url" }, "business", /http or https URL/], ++ [{ "tracker.kind": "jira" }, "business", /one of: vikunja/], ++ [{ "tracker.pollSeconds": 9 }, "business", /at least 10/], ++ [{ "tracker.pollSeconds": "30" }, "project", /at least 10/], ++ [{ "tracker.reconcileMinutes": 0 }, "business", /at least 1/], ++ [{ "human.discordUserId": 123456789012345678 }, "business", /as a string/], ++ [{ "human.discordUserId": "0123456789012345678" }, "business", /Discord user id/], ++ [{ "tracker.project": 0 }, "project", /positive integer/], ++ [{ "git.workingBranch": "-x" }, "project", /git branch name/], ++ [{ "git.workingBranch": "a..b" }, "project", /git branch name/], ++ [{ "git.workingBranch": "x.lock" }, "project", /git branch name/], ++ [{ "git.workingBranch": "feat/" }, "project", /git branch name/], ++ [{ "git.protectedBranches": [] }, "project", /must not be empty/], ++ [{ "git.protectedBranches": ["main", "main"] }, "project", /duplicate/], ++ [{ suites: "scripts/test-task.sh" }, "project", /must be an array/], ++ [{ "issues.repo": "stack" }, "project", /owner\/name/], ++ [{ harness: "codex" }, "agent", /one of: pi, claude-code/], ++ [{ thinking: "max" }, "agent", /one of/], ++ [{ model: "" }, "agent", /non-empty string/], ++ [{ "limits.tools": ["render3d"] }, "agent", /unsupported tool/], ++ [{ "limits.network": "lan" }, "agent", /one of: none, api-only, open/], ++ [{ "limits.authority": ["deploy.prod"] }, "agent", /unknown action/], ++ ]; ++ for (const [vars, layer, pattern] of bad) refuses(() => checkVars(vars, layer, "v"), pattern); ++}); ++ ++test("merge: defaults, then the most specific layer wins", () => { ++ const { vars, provenance } = mergeVars([ ++ { layer: "business", source: "business", vars: checkVars({ "tracker.pollSeconds": 60 }, "business", "b") }, ++ { layer: "project", source: "project", vars: checkVars({ "tracker.pollSeconds": 20 }, "project", "p") }, ++ ]); ++ assert.equal(vars["tracker.pollSeconds"], 20); ++ assert.equal(provenance["tracker.pollSeconds"], "project"); ++ assert.equal(vars["tracker.reconcileMinutes"], 60); ++ assert.equal(provenance["tracker.reconcileMinutes"], "default"); ++ assert.equal(vars["tracker.kind"], "vikunja"); ++ assert.equal(vars.harness, undefined); ++ assert.equal(provenance.harness, undefined); ++}); ++ ++test("merge: limits only narrow, and provenance lists each source", () => { ++ const layer = (source, layerName, v) => ({ layer: layerName, source, vars: checkVars(v, layerName, source) }); ++ const { vars, provenance } = mergeVars([ ++ layer("business", "business", { "limits.network": "none", "limits.tools": ["read", "grep", "bash"] }), ++ layer("project", "project", { "limits.network": "open", "limits.tools": ["read", "bash", "write"] }), ++ layer("agent", "agent", { "limits.tools": ["bash", "read", "edit"] }), ++ ]); ++ assert.equal(vars["limits.network"], "none"); ++ assert.deepEqual(provenance["limits.network"], ["business", "project"]); ++ assert.deepEqual(vars["limits.tools"], ["read", "bash"]); ++ assert.deepEqual(provenance["limits.tools"], ["business", "project", "agent"]); ++ ++ const widen = mergeVars([ ++ layer("business", "business", { "limits.authority": ["message.send"] }), ++ layer("agent", "agent", { "limits.authority": ["message.send", "task.close"] }), ++ ]); ++ assert.deepEqual(widen.vars["limits.authority"], ["message.send"]); ++ const empty = mergeVars([layer("agent", "agent", { "limits.tools": [] })]); ++ assert.deepEqual(empty.vars["limits.tools"], []); ++}); ++ ++test("merge doesn't change its inputs", () => { ++ const business = checkVars({ "limits.tools": ["read", "bash"] }, "business", "b"); ++ const snapshot = JSON.stringify(business); ++ mergeVars([ ++ { layer: "business", source: "business", vars: business }, ++ { layer: "agent", source: "agent", vars: checkVars({ "limits.tools": ["read"] }, "agent", "a") }, ++ ]); ++ assert.equal(JSON.stringify(business), snapshot); ++}); +diff --git a/roles/coder.json b/roles/coder.json +new file mode 100644 +index 00000000..2a7325a1 +--- /dev/null ++++ b/roles/coder.json +@@ -0,0 +1,26 @@ ++{ ++ "roleVersion": 2, ++ "name": "coder", ++ "title": "Coder", ++ "contract": "coder.md", ++ "tools": ["read", "write", "edit", "bash", "grep", "find", "ls"], ++ "network": "api-only", ++ "authority": { ++ "withinRole": ["task.update.assigned", "git.push.working", "review.request", "message.send"], ++ "crossRole": ["task.reassign", "task.scope.change"] ++ }, ++ "credentials": [ ++ { ++ "service": "gitea", ++ "scopes": ["write:issue", "write:repository", "read:user"] ++ }, ++ { ++ "service": "vikunja", ++ "scopes": { ++ "tasks": ["read_one", "update"], ++ "tasks_comments": ["create"], ++ "projects": ["views_buckets_tasks"] ++ } ++ } ++ ] ++} +diff --git a/roles/coder.md b/roles/coder.md +new file mode 100644 +index 00000000..768ef907 +--- /dev/null ++++ b/roles/coder.md +@@ -0,0 +1,32 @@ ++# Role contract: coder ++ ++You hold a `coder` role instance for one business. You do the tasks ++assigned to you and hand finished work to review. ++ ++## Duties ++ ++- Work only on tasks assigned to you. Update a task's state and comments ++ as the work moves. ++- Push only to the project's working branch. Protected branches belong ++ to the human. ++- Run the project's suites before you ask for review, and include their ++ results in the request. ++- Request a review when a task's acceptance evidence is complete. ++ ++## Authority ++ ++Your role file lists what you do on your own, what needs another role's ++arbiter, and what goes to the human. The launch record you received shows ++the resolved list for this business and project, which may be narrower. ++Anything the list doesn't name is gated: raise a decision for the human ++and wait for it. Never route around a refusal. ++ ++Handing a task to someone else, or changing its scope, is cross-role. ++Raise it as a decision for the delivery arbiter. ++ ++## Protocol ++ ++- You act on the tracker and on Gitea only through the typed tools the ++ broker gives you. You never hold a token. ++- Send messages through the bus, addressed to a role, not a person. ++- Report a blocker as soon as you find it, with what you tried. +diff --git a/roles/cto.json b/roles/cto.json +new file mode 100644 +index 00000000..bb5bd504 +--- /dev/null ++++ b/roles/cto.json +@@ -0,0 +1,26 @@ ++{ ++ "roleVersion": 2, ++ "name": "cto", ++ "title": "Chief technology officer", ++ "contract": "cto.md", ++ "tools": ["read", "write", "edit", "bash", "grep", "find", "ls"], ++ "network": "api-only", ++ "authority": { ++ "withinRole": ["review.request", "task.update.assigned", "message.send", "decision.resolve.technical"], ++ "crossRole": ["task.scope.change"] ++ }, ++ "credentials": [ ++ { ++ "service": "gitea", ++ "scopes": ["write:issue", "write:repository", "read:user"] ++ }, ++ { ++ "service": "vikunja", ++ "scopes": { ++ "tasks": ["read_one", "update"], ++ "tasks_comments": ["create"], ++ "projects": ["views_buckets_tasks"] ++ } ++ } ++ ] ++} +diff --git a/roles/cto.md b/roles/cto.md +new file mode 100644 +index 00000000..2f8b8209 +--- /dev/null ++++ b/roles/cto.md +@@ -0,0 +1,33 @@ ++# Role contract: chief technology officer (cto) ++ ++You hold the `cto` role for one business. You set technical direction, ++review technical risk and arbitrate technical conflicts. You can do ++assigned engineering work yourself. ++ ++## Duties ++ ++- Decide how the system is built: design, interfaces, data shapes and ++ which risks are acceptable. Write your decisions down where the next ++ session can find them. ++- You arbitrate technical conflicts. When roles disagree on a technical ++ question, the decision comes to you, and you resolve it with a reason. ++- Request reviews for the work you produce, and do the tasks assigned to ++ you. ++ ++## Authority ++ ++Your role file lists what you do on your own, what needs another role's ++arbiter, and what goes to the human. The launch record you received shows ++the resolved list for this business and project, which may be narrower. ++Anything the list doesn't name is gated: raise a decision for the human ++and wait for it. Never route around a refusal. ++ ++Changing a task's scope is cross-role. Raise it as a decision for the ++delivery arbiter. ++ ++## Protocol ++ ++- You act on the tracker and on Gitea only through the typed tools the ++ broker gives you. You never hold a token. ++- Send messages through the bus, addressed to a role, not a person. ++- Never review your own work. +diff --git a/roles/pm.json b/roles/pm.json +new file mode 100644 +index 00000000..62d71bef +--- /dev/null ++++ b/roles/pm.json +@@ -0,0 +1,30 @@ ++{ ++ "roleVersion": 2, ++ "name": "pm", ++ "title": "Product manager", ++ "contract": "pm.md", ++ "tools": ["read", "write", "edit", "grep", "find", "ls"], ++ "network": "api-only", ++ "authority": { ++ "withinRole": ["task.create", "task.assign", "task.reassign", "task.schedule", "task.update.assigned", "task.close", "role.launch", "message.send"], ++ "crossRole": ["task.priority.change", "task.scope.change"] ++ }, ++ "credentials": [ ++ { ++ "service": "gitea", ++ "scopes": ["write:issue", "read:repository", "read:user"] ++ }, ++ { ++ "service": "vikunja", ++ "scopes": { ++ "tasks": ["read_one", "create", "update"], ++ "tasks_assignees": ["create", "delete"], ++ "tasks_relations": ["create", "delete"], ++ "tasks_labels": ["create", "delete"], ++ "tasks_comments": ["create"], ++ "labels": ["read_all"], ++ "projects": ["views_buckets_tasks"] ++ } ++ } ++ ] ++} +diff --git a/roles/pm.md b/roles/pm.md +new file mode 100644 +index 00000000..4cbd3d8e +--- /dev/null ++++ b/roles/pm.md +@@ -0,0 +1,37 @@ ++# Role contract: product manager (pm) ++ ++You hold the `pm` role for one business. You turn the business's approved ++requirements into tasks, keep the board in order and decide delivery ++order. You don't write the product's code and you don't review it. ++ ++## Duties ++ ++- Break each approved requirement into tasks on the tracker. Every task ++ you create cites the requirement id it serves, such as `REQ-TASK-1`, ++ and the human request that asked for it. A task without both is ++ refused. ++- Assign each task to a role instance the business file declares, set ++ its schedule and priority, and close it when its acceptance evidence ++ is in. ++- You arbitrate delivery order. When two roles disagree about what ships ++ first, the decision comes to you, and you resolve it with a reason. ++- Launch the coder and reviewer seats the business file lets you launch, ++ within its limits. A launch outside them is refused, not queued. ++ ++## Authority ++ ++Your role file lists what you do on your own, what needs another role's ++arbiter, and what goes to the human. The launch record you received shows ++the resolved list for this business and project, which may be narrower. ++Anything the list doesn't name is gated: raise a decision for the human ++and wait for it. Never route around a refusal. ++ ++Changing a task's priority or scope against the CTO's technical call is ++cross-role. Raise it as a decision for the technical arbiter. ++ ++## Protocol ++ ++- You act on the tracker and on Gitea only through the typed tools the ++ broker gives you. You never hold a token. ++- Send messages through the bus, addressed to a role, not a person. ++- Report a blocker as soon as you find it, with what you tried. +diff --git a/roles/reviewer.json b/roles/reviewer.json +new file mode 100644 +index 00000000..45e82435 +--- /dev/null ++++ b/roles/reviewer.json +@@ -0,0 +1,26 @@ ++{ ++ "roleVersion": 2, ++ "name": "reviewer", ++ "title": "Reviewer", ++ "contract": "reviewer.md", ++ "tools": ["read", "grep", "find", "ls", "bash"], ++ "network": "api-only", ++ "authority": { ++ "withinRole": ["review.verdict", "message.send", "task.update.assigned"], ++ "crossRole": [] ++ }, ++ "credentials": [ ++ { ++ "service": "gitea", ++ "scopes": ["write:issue", "write:repository", "read:user"] ++ }, ++ { ++ "service": "vikunja", ++ "scopes": { ++ "tasks": ["read_one", "update"], ++ "tasks_comments": ["create"], ++ "projects": ["views_buckets_tasks"] ++ } ++ } ++ ] ++} +diff --git a/roles/reviewer.md b/roles/reviewer.md +new file mode 100644 +index 00000000..f33e4770 +--- /dev/null ++++ b/roles/reviewer.md +@@ -0,0 +1,30 @@ ++# Role contract: reviewer ++ ++You hold a `reviewer` role instance for one business. You review ++candidates and record a verdict. You don't change the code you review. ++ ++## Duties ++ ++- Review the candidate named in the request against the task's ++ acceptance criteria. Run the suites yourself when the request says ++ they pass. ++- Record one verdict per round, approve or changes, with each finding ++ stated so the author can act on it. ++- Never review your own work, or work you helped write. ++ ++## Authority ++ ++Your role file lists what you do on your own, what needs another role's ++arbiter, and what goes to the human. The launch record you received shows ++the resolved list for this business and project, which may be narrower. ++Anything the list doesn't name is gated: raise a decision for the human ++and wait for it. Never route around a refusal. ++ ++Your verdict gates the work in the queue and on the bus. An approval in ++Gitea is advisory. ++ ++## Protocol ++ ++- You act on the tracker and on Gitea only through the typed tools the ++ broker gives you. You never hold a token. ++- Send messages through the bus, addressed to a role, not a person. +diff --git a/scripts/mosaic b/scripts/mosaic +index 3fb631d6..c636d3f3 100755 +--- a/scripts/mosaic ++++ b/scripts/mosaic +@@ -2,6 +2,8 @@ + # `mosaic launch ` and `mosaic seat task `: seat launch + # with registration for the control board. See packages/seat/README.md. + # `mosaic queue `: the work queue. See packages/queue/README.md. ++# `mosaic business `: business files and role instances. See ++# packages/business/README.md. + # Not the npm-global `mosaic` CLI from the estate tooling; this one is + # repository-local and only reachable as scripts/mosaic. + set -euo pipefail +@@ -10,4 +12,8 @@ if [ "${1:-}" = queue ]; then + shift + exec node "$REPO/packages/queue/src/cli.mjs" "$@" + fi ++if [ "${1:-}" = business ]; then ++ shift ++ exec node "$REPO/packages/business/src/cli.mjs" "$@" ++fi + exec node "$REPO/packages/seat/src/cli.mjs" "$@" +diff --git a/scripts/mosaic-task.mjs b/scripts/mosaic-task.mjs +index ffd063e8..6d10ef0a 100755 +--- a/scripts/mosaic-task.mjs ++++ b/scripts/mosaic-task.mjs +@@ -34,6 +34,7 @@ import process from "node:process"; + import { randomBytes } from "node:crypto"; + import { spawnSync } from "node:child_process"; + import { fileURLToPath } from "node:url"; ++import { BusinessError, validateRoleDocument } from "../packages/business/src/index.mjs"; + + const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const RUNS_DIRNAME = "runs"; +@@ -245,31 +246,18 @@ function validateTask(document, file) { + }; + } + +-// Role contract (M18): seat-declared role authority. The tools array is a +-// ceiling — seats may narrow it, never escalate past it. network is declared +-// now and enforced when network policy lands. Strict schema: unknown keys +-// refuse, name must match the filename, wrong document kind refuses. ++// Role contract (M18, slice 1 row S1): seat-declared role authority. The ++// tools array is a ceiling - seats may narrow it, never escalate past it. ++// Version 2 adds a contract, an authority map and credential needs; ++// packages/business validates both versions. Unknown keys refuse, name must ++// match the filename, wrong document kind refuses. + function validateRole(document, file) { +- rejectUnknownKeys(document, ["roleVersion", "name", "tools", "network"], "role"); +- if (document.roleVersion !== 1) fail(2, 'role "roleVersion" must be 1'); +- validateId(document.name, "role name"); +- const base = path.basename(file).replace(/\.json$/, ""); +- if (document.name !== base) fail(2, `role "name" (${document.name}) must match its filename (${base}.json)`); +- if (!Array.isArray(document.tools) || document.tools.length === 0) { +- fail(2, 'role "tools" must be a non-empty array of tool names'); +- } +- 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 "); + 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" diff --git a/agents/darkwing/work/slice1-s1/mutants-r2.sh b/agents/darkwing/work/slice1-s1/mutants-r2.sh new file mode 100644 index 00000000..ab1055dc --- /dev/null +++ b/agents/darkwing/work/slice1-s1/mutants-r2.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Round 2 mutants: each edits one source file in a copy, runs the business tests, restores. +cd ~/darkwing-scratch/s1-mut +export TMPDIR=~/darkwing-scratch/tmp +run() { # name file python-replace-old python-replace-new + local name=$1 file=$2 + cp "$file" "$file.orig" + python3 -c "import sys;p=sys.argv[1];s=open(p).read();o=sys.argv[2];n=sys.argv[3];assert o in s,'pattern missing';open(p,'w').write(s.replace(o,n,1))" "$file" "$3" "$4" || { echo "$name: PATTERN MISSING"; mv "$file.orig" "$file"; return; } + local fails + fails=$(node --test packages/business/tests/ 2>&1 | sed -n 's/^ℹ fail //p') + mv "$file.orig" "$file" + if [ "${fails:-0}" -gt 0 ]; then echo "$name: killed ($fails)"; else echo "$name: SURVIVED"; fi +} +run R1-launcher-role-check-off packages/business/src/business.mjs 'if (!launcher.authority.withinRole.includes("role.launch")) {' 'if (false) {' +run R2-launch-ignores-narrowing packages/business/src/resolve.mjs ' && withinRole.includes("role.launch") ?' ' ?' +run R3-filbert-M3-crossRole-not-narrowed packages/business/src/resolve.mjs ' crossRole = crossRole.filter((a) => vars["limits.authority"].includes(a));' '' +run R4-stat-check-skipped packages/business/src/util.mjs ' checkStat?.(stat);' '' +run R5-stat-check-after-parse packages/business/src/util.mjs $' checkStat?.(stat);\n text = readFileSync(fd, "utf8");\n } finally {\n closeSync(fd);\n }\n try {\n return JSON.parse(text);' $' text = readFileSync(fd, "utf8");\n } finally {\n closeSync(fd);\n }\n try {\n const parsed = JSON.parse(text); checkStat?.(fstatSync(openSync(file, "r"))); return parsed;' +run R6-follows-links packages/business/src/util.mjs ' | constants.O_NOFOLLOW' '' +run R7-not-regular-accepted packages/business/src/util.mjs ' if (!stat.isFile()) refuse' ' if (false) refuse' +run R8-fifo-blocks packages/business/src/util.mjs ' | constants.O_NONBLOCK' '' diff --git a/agents/darkwing/work/slice1-s1/mutants-r2.txt b/agents/darkwing/work/slice1-s1/mutants-r2.txt new file mode 100644 index 00000000..8f5f82ad --- /dev/null +++ b/agents/darkwing/work/slice1-s1/mutants-r2.txt @@ -0,0 +1,8 @@ +R1-launcher-role-check-off: killed (1) +R2-launch-ignores-narrowing: killed (1) +R3-filbert-M3-crossRole-not-narrowed: killed (1) +R4-stat-check-skipped: killed (1) +R5-stat-check-after-parse: killed (1) +R6-follows-links: killed (2) +R7-not-regular-accepted: killed (1) +R8-fifo-blocks: killed (1) diff --git a/agents/darkwing/work/slice1-s1/r1-to-r2.diff b/agents/darkwing/work/slice1-s1/r1-to-r2.diff new file mode 100644 index 00000000..2b22a209 --- /dev/null +++ b/agents/darkwing/work/slice1-s1/r1-to-r2.diff @@ -0,0 +1,248 @@ +--- r1/packages/business/README.md ++++ r2/packages/business/README.md +@@ -69,7 +69,9 @@ + it to `/businesses/mosaic-stack.json`, put in the bot ids and + token dates from `docs/guides/slice-1-identities.md`, fix the token paths + and run `validate`. The file must belong to you and must not be writable +-by group or other. ++by group or other. The loader checks the owner and mode on the descriptor ++it reads from, so the file can't be swapped between the check and the ++read. + + Each role instance names a `definition` (a version 2 role file), an + optional `holder`, its Vikunja bot (`tracker`, required when the definition +@@ -87,9 +89,10 @@ + `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. ++sessions per model family (Opus 4, Sonnet 4 at most). `by` must name an ++instance whose definition holds `role.launch` within-role; otherwise the ++file refuses. Only that instance keeps `role.launch`. Every other instance ++loses it, so for them it's gated. + + ### Project file + +@@ -143,7 +146,10 @@ + `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. ++outside the vocabulary. `launch` is set exactly when `classify(record, ++"role.launch")` is `within`. If `limits.authority` leaves out ++`role.launch` for the launcher, `launch` is null. A launcher should ++still ask `classify`, not read `launch` alone. + + Errors are `BusinessError` with `exitCode` 2 (invalid) or 4 (a required + file missing). The CLI adds 3 for a system config problem and 4 for usage. +--- r1/packages/business/src/business.mjs ++++ r2/packages/business/src/business.mjs +@@ -3,7 +3,6 @@ + // 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"; +@@ -112,12 +111,16 @@ + return { roles: out, definitions }; + } + +-function checkLaunch(launch, roles, file) { ++function checkLaunch(launch, roles, definitions, file) { + const where = `${file} launch`; + requireObject(launch, where); + rejectUnknownKeys(launch, ["by", "instances", "max"], where); + requireId(launch.by, `${where}.by`); + if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`); ++ const launcher = definitions[roles[launch.by].definition]; ++ if (!launcher.authority.withinRole.includes("role.launch")) { ++ refuse(`${where}.by names ${launch.by}, whose role ${launcher.name} doesn't hold role.launch within-role`); ++ } + const instances = requireDistinctList(launch.instances, `${where}.instances`, (name) => { + requireId(name, `${where}.instances entry`); + if (!Object.hasOwn(roles, name)) refuse(`${where}.instances names ${name}, which the business file doesn't declare`); +@@ -180,7 +183,7 @@ + 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); ++ const launch = document.launch === undefined ? null : checkLaunch(document.launch, roles, definitions, file); + + return deepFreeze({ + businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, definitions, +@@ -193,9 +196,9 @@ + 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}`); ++ const document = readJsonFile(file, "business file", (stat) => { ++ if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`); ++ if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`); ++ }); + return validateBusinessDocument(document, file, { rolesDir }); + } +--- r1/packages/business/src/resolve.mjs ++++ r2/packages/business/src/resolve.mjs +@@ -68,8 +68,10 @@ + 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; ++ // instance (addendum A section 8), and the verb must survive ++ // limits.authority. Otherwise the verb is gated and `launch` is null, ++ // so the two never disagree. ++ const launch = business.launch && business.launch.by === instance && withinRole.includes("role.launch") ? business.launch : null; + if (!launch) { + withinRole = withinRole.filter((a) => a !== "role.launch"); + crossRole = crossRole.filter((a) => a !== "role.launch"); +--- r1/packages/business/src/util.mjs ++++ r2/packages/business/src/util.mjs +@@ -1,4 +1,4 @@ +-import { lstatSync, readFileSync } from "node:fs"; ++import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs"; + import { createHash } from "node:crypto"; + import { refuse } from "./errors.mjs"; + import { ID_PATTERN } from "./vocabulary.mjs"; +@@ -59,17 +59,29 @@ + } + + // 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; ++// Missing or not a regular file is 4; unparseable is 2. One descriptor, ++// opened without following a link, serves every check and the read. ++// `checkStat` sees that descriptor's stat before the read, so the file ++// can't be swapped between the check and the read. ++export function readJsonFile(file, what, checkStat) { ++ let fd; + try { +- stat = lstatSync(file); +- } catch { +- refuse(`${what} not found: ${file}`, 4); ++ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); ++ } catch (error) { ++ if (error.code === "ENOENT") refuse(`${what} not found: ${file}`, 4); ++ refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4); ++ } ++ let text; ++ try { ++ const stat = fstatSync(fd); ++ if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4); ++ checkStat?.(stat); ++ text = readFileSync(fd, "utf8"); ++ } finally { ++ closeSync(fd); + } +- if (!stat.isFile() || stat.isSymbolicLink()) refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4); + try { +- return JSON.parse(readFileSync(file, "utf8")); ++ return JSON.parse(text); + } catch (error) { + refuse(`${what} is not valid JSON (${file}): ${error.message}`); + } +--- r1/packages/business/tests/business.test.mjs ++++ r2/packages/business/tests/business.test.mjs +@@ -1,7 +1,9 @@ + import { test } from "node:test"; + import assert from "node:assert/strict"; +-import { chmodSync, lstatSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; ++import { execFileSync, spawnSync } from "node:child_process"; ++import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; ++import { pathToFileURL } from "node:url"; + import { BusinessError, businessFilePath, configDir, loadBusiness, validateBusinessDocument } from "../src/index.mjs"; + import { businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs"; + +@@ -118,6 +120,7 @@ + test("launch", () => { + assert.equal(check((d) => { delete d.launch; }).launch, null); + refuses(() => check((d) => { d.launch.by = "ghost"; }), /launch\.by names ghost/); ++ refuses(() => check((d) => { d.launch.by = "cto"; d.launch.instances = ["coder"]; }), /launch\.by names cto, whose role cto doesn't hold role\.launch within-role/); + refuses(() => check((d) => { d.launch.instances = []; }), /must not be empty/); + refuses(() => check((d) => { d.launch.instances = ["coder", "coder"]; }), /duplicate/); + refuses(() => check((d) => { d.launch.instances = ["ghost"]; }), /instances names ghost/); +@@ -152,9 +155,31 @@ + + writeFileSync(join(dir, "businesses", "broken.json"), "{ not json"); + refuses(() => loadBusiness("broken", { dir, rolesDir: REPO_ROLES }), /not valid JSON/); ++ // The owner and mode checks run on the opened file before the read, so ++ // a group-writable file refuses for its mode even when it won't parse. ++ chmodSync(join(dir, "businesses", "broken.json"), 0o660); ++ refuses(() => loadBusiness("broken", { dir, rolesDir: REPO_ROLES }), /writable by group or other \(mode 660\)/); + assert.throws(() => loadBusiness("acme", { dir }), /needs rolesDir/); + }); + ++// A directory or a FIFO under the business name refuses without a read. ++// Opening a FIFO without O_NONBLOCK would block the whole process, which ++// node:test can't time out, so that case runs in a child with a timeout. ++test("loadBusiness: not a regular file", () => { ++ const dir = join(tmp(), "config"); ++ mkdirSync(join(dir, "businesses", "folder.json"), { recursive: true }); ++ refuses(() => loadBusiness("folder", { dir, rolesDir: REPO_ROLES }), /must be a regular, non-symbolic-link file/, 4); ++ execFileSync("mkfifo", [join(dir, "businesses", "pipe.json")]); ++ const index = pathToFileURL(join(REPO, "packages", "business", "src", "index.mjs")).href; ++ const child = spawnSync(process.execPath, ["--input-type=module", "-e", ` ++ import { loadBusiness } from ${JSON.stringify(index)}; ++ try { loadBusiness("pipe", { dir: ${JSON.stringify(dir)}, rolesDir: ${JSON.stringify(REPO_ROLES)} }); } ++ catch (error) { console.log(error.exitCode, error.message); } ++ `], { encoding: "utf8", timeout: 5000 }); ++ assert.equal(child.signal, null, "opening the FIFO waited for a writer"); ++ assert.match(child.stdout, /^4 .*must be a regular, non-symbolic-link file/); ++}); ++ + test("loading writes nothing", () => { + const root = tmp(); + const dir = join(root, "config"); +--- r1/packages/business/tests/resolve.test.mjs ++++ r2/packages/business/tests/resolve.test.mjs +@@ -97,8 +97,39 @@ + 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"); ++ ++ // A second pm instance is the launcher, so the first one may not launch. ++ const two = setup((d) => { ++ d.roles.pm2 = { ...d.roles.pm, holder: undefined, tracker: { bot: "bot-acme-pm2", botId: 7 } }; ++ d.launch.by = "pm2"; ++ }); ++ assert.equal(classify(resolve(two, "pm"), "role.launch"), "gated"); ++ assert.equal(resolve(two, "pm").launch, null); ++ assert.equal(classify(resolve(two, "pm2"), "role.launch"), "within"); ++ assert.equal(resolve(two, "pm2").launch.by, "pm2"); ++}); ++ ++test("limits.authority without role.launch leaves the launcher with no launch block", () => { ++ for (const layer of ["agent", "project"]) { ++ const authority = ["task.create", "task.assign", "message.send"]; ++ const s = layer === "agent" ++ ? setup((d) => { d.roles.pm.vars["limits.authority"] = authority; }) ++ : setup(undefined, { roles: { pm: { vars: { "limits.authority": authority } } } }); ++ const pm = resolve(s, "pm"); ++ assert.equal(classify(pm, "role.launch"), "gated", layer); ++ assert.equal(pm.launch, null, layer); ++ } ++}); ++ ++test("limits.authority narrows cross-role actions too", () => { ++ const s = setup((d) => { ++ d.roles.coder.vars["limits.authority"] = ["task.update.assigned", "git.push.working", "task.scope.change"]; ++ }); ++ const coder = resolve(s, "coder"); ++ assert.deepEqual(coder.limits.authority, { withinRole: ["task.update.assigned", "git.push.working"], crossRole: ["task.scope.change"] }); ++ assert.equal(classify(coder, "task.reassign"), "gated"); ++ assert.equal(classify(coder, "task.scope.change"), "cross"); ++ assert.equal(classify(coder, "review.request"), "gated"); + }); + + test("classify", () => { diff --git a/agents/darkwing/work/slice1-s1/suites-r2.txt b/agents/darkwing/work/slice1-s1/suites-r2.txt new file mode 100644 index 00000000..b464e37a --- /dev/null +++ b/agents/darkwing/work/slice1-s1/suites-r2.txt @@ -0,0 +1,7 @@ +v26.8.1 +business exit 0 +test-config exit 0 +test-task exit 0 +test-conductor exit 0 +test-queue exit 0 +node24 exit 0