feat(business): roles v2, business and project files, variable layers (row 36, S1, darkwing)
Darkwing's round 2 candidate, approved by Filbert (#1518 comment 26730). build-r2.patch a27890d5, manifest 869168c7, 34 files, applied on HEAD and checked 34/34. Integration gate on an export of HEAD plus the patch: business 60/60 on Node 24 and 26, every package test and every scripts/test-*.sh green, test-task 98/98 with the live-provider cases. Conductor, queue, conversation and discord confirmed in git worktrees of HEAD with and without the patch, identical results. Lead decision 63 accepts the vocabulary location, the example path and the business branch. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
# `mosaic launch <seat>` and `mosaic seat task <seat> <text>`: seat launch
|
||||
# with registration for the control board. See packages/seat/README.md.
|
||||
# `mosaic queue <verb>`: the work queue. See packages/queue/README.md.
|
||||
# `mosaic business <verb>`: business files and role instances. See
|
||||
# packages/business/README.md.
|
||||
# Not the npm-global `mosaic` CLI from the estate tooling; this one is
|
||||
# repository-local and only reachable as scripts/mosaic.
|
||||
set -euo pipefail
|
||||
@@ -10,4 +12,8 @@ if [ "${1:-}" = queue ]; then
|
||||
shift
|
||||
exec node "$REPO/packages/queue/src/cli.mjs" "$@"
|
||||
fi
|
||||
if [ "${1:-}" = business ]; then
|
||||
shift
|
||||
exec node "$REPO/packages/business/src/cli.mjs" "$@"
|
||||
fi
|
||||
exec node "$REPO/packages/seat/src/cli.mjs" "$@"
|
||||
|
||||
+13
-24
@@ -34,6 +34,7 @@ import process from "node:process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BusinessError, validateRoleDocument } from "../packages/business/src/index.mjs";
|
||||
|
||||
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const RUNS_DIRNAME = "runs";
|
||||
@@ -245,31 +246,18 @@ function validateTask(document, file) {
|
||||
};
|
||||
}
|
||||
|
||||
// Role contract (M18): seat-declared role authority. The tools array is a
|
||||
// ceiling — seats may narrow it, never escalate past it. network is declared
|
||||
// now and enforced when network policy lands. Strict schema: unknown keys
|
||||
// refuse, name must match the filename, wrong document kind refuses.
|
||||
// Role contract (M18, slice 1 row S1): seat-declared role authority. The
|
||||
// tools array is a ceiling - seats may narrow it, never escalate past it.
|
||||
// Version 2 adds a contract, an authority map and credential needs;
|
||||
// packages/business validates both versions. Unknown keys refuse, name must
|
||||
// match the filename, wrong document kind refuses.
|
||||
function validateRole(document, file) {
|
||||
rejectUnknownKeys(document, ["roleVersion", "name", "tools", "network"], "role");
|
||||
if (document.roleVersion !== 1) fail(2, 'role "roleVersion" must be 1');
|
||||
validateId(document.name, "role name");
|
||||
const base = path.basename(file).replace(/\.json$/, "");
|
||||
if (document.name !== base) fail(2, `role "name" (${document.name}) must match its filename (${base}.json)`);
|
||||
if (!Array.isArray(document.tools) || document.tools.length === 0) {
|
||||
fail(2, 'role "tools" must be a non-empty array of tool names');
|
||||
try {
|
||||
return validateRoleDocument(document, file);
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessError) fail(error.exitCode, error.message);
|
||||
throw error;
|
||||
}
|
||||
const seen = new Set();
|
||||
for (const tool of document.tools) {
|
||||
if (!SUPPORTED_TOOLS.includes(tool)) {
|
||||
fail(2, `unsupported tool: ${JSON.stringify(tool)} (supported: ${SUPPORTED_TOOLS.join(", ")})`);
|
||||
}
|
||||
if (seen.has(tool)) fail(2, `duplicate tool in role tools: ${tool}`);
|
||||
seen.add(tool);
|
||||
}
|
||||
if (document.network !== undefined && !["none", "api-only", "open"].includes(document.network)) {
|
||||
fail(2, 'role "network" must be one of: none, api-only, open');
|
||||
}
|
||||
return { roleVersion: 1, name: document.name, tools: [...seen], network: document.network ?? "none" };
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
@@ -671,7 +659,8 @@ switch (operation) {
|
||||
if (!target) fail(4, "usage: mosaic-task.mjs resolve-role <roleFile>");
|
||||
const file = path.resolve(target);
|
||||
const role = validateRole(readJsonFile(file, "role contract"), file);
|
||||
process.stdout.write(`MOSAIC_ROLE_TOOLS=${role.tools.join(",")}\nMOSAIC_ROLE_NETWORK=${role.network}\n`);
|
||||
const contract = role.contractPath === null ? "" : `MOSAIC_ROLE_CONTRACT=${role.contractPath}\n`;
|
||||
process.stdout.write(`MOSAIC_ROLE_TOOLS=${role.tools.join(",")}\nMOSAIC_ROLE_NETWORK=${role.network}\n${contract}`);
|
||||
process.exit(0);
|
||||
}
|
||||
case "retry":
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user