Compare commits

..
12 Commits
Author SHA1 Message Date
jason.woltje 755ba3fa8b fix(plugins): starter-pack re-check round (#1873 SP1)
Recognise only a native promise from starterPack() and attach the catch
through Promise.prototype.then, so an installed pack's own then or then
getter never runs; such a pack is omitted and logged again instead of
failing the listing. A link count of 0 after the open reports the file
changed, not a hard link. Tests: a throwing then getter, a self-resolving
thenable, and a 0000 file refusing as unreadable.
2026-11-12 15:38:01 -06:00
jason.woltje f162505056 fix(plugins): review round for the starter-pack reader (#1873 SP1)
readSavedTemplate refuses a hard-linked file and an account directory
open to group or other; an open error other than ELOOP reads "can't be
read", not "symlink"; a file that grows during the read has its own
message. installedStarterPacks refuses a promise from starterPack() and
swallows its later rejection. Tests pin each refusal's message, so a
check that a neighbour shadows is still covered.
2026-11-12 15:38:01 -06:00
jason.woltje 2a34603a2e test(plugins): loader maps list the starter-pack kind (#1873 SP1)
Adding a kind adds its key to every loaded() map. Outside the SP1 owner
list; kept as its own commit so it can be dropped or moved.
2026-11-12 15:38:01 -06:00
jason.woltje ff33b19854 feat(plugins): starter-pack kind and its checker (#1873 SP1)
KINDS gains "starter-pack" (interface 1, export starterPack) with a new
switch "none": namesFor refuses any setting for it with bad-setting, so an
org file can never switch a pack on through plugins.

starter-pack.mjs adds checkPack(doc, {origin}), narrowsTemplate and
widerLines (step 7's line rules), installedStarterPacks (shipped and
installed packs through loadInstalled, origin set; a refused core pack
fails the listing, a refused installed pack is omitted and logged) and
readSavedTemplate (account against ID_PATTERN, name against NAME, then
lstat, no symlink, owner uid, mode 0600 or narrower, 256 KiB checked
before the read, then parseJsonFileText and checkPack with origin saved).

refusals.mjs gains starter-pack-invalid, starter-pack-not-found and
starter-pack-template-changed.
2026-11-12 15:38:01 -06:00
jason.woltje 1e65ff3f8b fix(org): applyPack requires the secret check on every write (starter packs SP3 security P2-1)
A board, workflow or pack write without assertClean now refuses
definition-secret-check-required, and the scan covers every key and
string in the plan before the writer reads any of it.
2026-11-12 15:30:07 -06:00
jason.woltje 9cfb5f64fe test(org): a definition a workflow names can still be removed (starter packs SP3 review P2) 2026-11-12 15:30:07 -06:00
jason.woltje 2af12340ae feat(org): packs, board and workflows keys and the applyPack writer op (starter packs SP3)
The organization file gains packs, board and workflows, and each code
project gains board and workflows. The reader checks their shapes and keeps
them on the loaded organization and on projects[id]. A workflow step's roles
are checked for shape only at load; applyPack resolves them at write time
(org definition, V2 template or human). An inline proposed hire keeps its
full role entry beside its {inline, sha256} pin, bounded as a definition
entry is.

applyPack(id, plan, {revision, actor, assertClean}) writes definitions,
board, workflows, project boards and workflows and one packs entry in one
commit. It keeps setDefinition's definition rules, refuses an agent writing
a definition with human-required, and refuses any other plan key.
2026-11-12 15:30:07 -06:00
jason.woltje c0a60ed3ae docs(plans): starter packs fix round 3, system types removed outright 2026-11-12 15:18:16 -06:00
jason.woltje dd878cefcf docs(plans): starter packs security and re-check fixes
Saved templates: owner-scoped path, file checks before read, provenance
reduced to id and version, agent verbs refuse origin saved. Pack, board
and workflow writes stay owner only until row 193. Board and workflow
verbs, writer path, read paths and owner lists now sit in named slices.
SP5 matches organization.set (decision-required). SP9 red test is red
today. Kind removal moves to a follow-up row; SP10 is the conversion.
2026-11-12 14:57:58 -06:00
jason.woltje 2404729232 docs(plans): starter packs slices ready for one go
Each slice names its owned files, the exact KINDS, org file key,
VERB_RIGHTS and CONSOLE_VERBS change it makes, acceptance and a test
that fails before it. No slice edits roles/ or contracts/. Open
questions say which slice each answer changes.
2026-11-12 14:42:39 -06:00
jason.woltje 2912482cd0 docs(plans): starter packs review round
Design review (8 findings) and security review (6 findings) fixed:
expected flagged lines per proposed hire, applyPack writer op, loader-set
origin, organization.change holders for board and workflow writes,
filtered-view board labels, workflow prompt guidance (inert, no runner),
system-type retired for one release before removal, installed packs
classified as executable PL1 code, saved-template placeholders and
free-text preview, pinned proposals, and removal tombstones.
2026-11-12 14:39:50 -06:00
jason.woltje bf65ea9802 docs(plans): starter packs design; system-type toolsets design superseded
Design only, per Jason's 2026-11-12 starter-packs ruling. Inventory of
existing building blocks, pack format, apply and re-apply, customizing
from inside, saved templates, system-type retirement, five shipped
packs, build slices and three open questions.
2026-11-12 14:25:04 -06:00
11 changed files with 3179 additions and 13 deletions

No files matched your search

@@ -1,5 +1,7 @@
# System-type toolsets at launch
Superseded 2026-11-12 by `docs/plans/2026-11-12_starter-packs-design.md` under Jason's starter-packs ruling; do not build this design.
Status: design only. Nothing here is built and no `roles/` or contract file is
changed. Prepared 2026-11-10 against `refactor` `8630b8dfc` for phase P2 of
#1873 (unified workspace). PRD requirement: REQ-SYS-2, "An agent working in a
File diff suppressed because it is too large. Load diff
+17
View File
@@ -184,6 +184,23 @@ group(
"R3/role scope: configured project and service state",
"Read the supported project/service status and ask its owner to repair missing configuration or state before retrying."
);
// #1873 SP1: starter packs. A pack copies items into an organization and grants nothing; a refusal
// names positions and checked names only, never a pack's text or a file path.
group(
"starter-pack-invalid",
"R8/R10: a pack copies no grant above the shipped templates",
"Read the refusal's position in the pack and correct it there. A role must narrow the template its from names; a pack never carries credentials, members, plugins, launch settings or a root hire. A shipped or installed pack needs a reviewed fix or its plug-in owner."
);
group(
"starter-pack-not-found",
"R10: only the caller's own saved templates",
"List your saved templates and use one of those names. A saved template is visible only to the owner who saved it."
);
group(
"starter-pack-template-changed",
"R8: a pack's hash pin must match the current template",
"The template the pack pins has changed since the pack was written. Review the current template, then re-pin the pack to its new hash, or use a pack that already matches."
);
group(
"not-resolver",
"Role resolve right and decision route",
@@ -11,9 +11,9 @@ import { checkModels, checkLimits, checkAccount } from "./launch.mjs";
import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
import { checkDefinitions, checkDefinitionName, orgDefinitionName, ORG_PREFIX } from "./definitions.mjs";
import { checkDefinition, checkDefinitions, checkDefinitionName, orgDefinitionName, ORG_PREFIX } from "./definitions.mjs";
import { END_PROTECTED, parseRepoRemote } from "./vocabulary.mjs";
import { checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
import { checkPlugins, checkUsageSettings, NAME as KIND_NAME } from "../../plugins/src/kinds.mjs";
import { hasModule } from "../../plugins/src/loader.mjs";
import { readPluginLock } from "../../plugins/src/pinned.mjs";
import { checkMapping } from "../../library/src/mapping.mjs";
@@ -29,7 +29,148 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// How long a thread's session stays up with no turn before the launcher stops
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
export const THREAD_IDLE_SECONDS = 900;
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions", "autonomy", "copyBindings"];
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions", "autonomy", "copyBindings", "packs", "board", "workflows"];
// Starter packs SP3 (design dd878cefc sections 2, 3 and 8): the board style, the workflows and the
// record of each pack applied. The task tracker's states are fixed (REQ-TASK-1); a board names
// each once, with a label and an order. These are the bus store's states (broker.mjs STORE_STATES).
const STORE_STATES = Object.freeze(["todo", "in-progress", "in-review", "blocked", "done"]);
const BOARD_LAYOUTS = Object.freeze(["kanban", "list"]);
const PACK_ORIGINS = Object.freeze(["shipped", "installed", "saved"]);
const PACK_KEYS = Object.freeze(["id", "version", "sha256", "origin", "scope", "project", "at", "by", "added", "proposedHires"]);
const HEX64 = /^[0-9a-f]{64}$/;
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
// A workflow, step role or pack name: the plug-in kinds' NAME, at most 64 characters (ID_PATTERN's
// length, so a step role can be read as a template or definition name at write time).
const isKindName = (value) => typeof value === "string" && value.length <= 64 && KIND_NAME.test(value);
// What a pack apply wrote, as item paths (design section 3, re-apply).
const ADDED_PATH = /^(definitions\.[a-z0-9][a-z0-9._-]{0,63}|board|workflows\.[a-z][a-z0-9-]{0,63}|projects\.[a-z0-9][a-z0-9._-]{0,63}\.(board|workflows\.[a-z][a-z0-9-]{0,63}))$/;
// A proposed hire's pin (design section 3): {template, sha256} for a template reference,
// {definition, rev} for a definition the apply wrote, {inline, sha256} for a pack definition it did
// not write (inline names it; sha256 is the canonical hash of the full entry stored beside the pin).
const PIN_SHAPES = Object.freeze([["template", "sha256"], ["definition", "rev"], ["inline", "sha256"]]);
// board: {columns: [{state, label}] x5, layout}. Returns a copy.
export function checkBoard(board, where) {
requireObject(board, where);
rejectUnknownKeys(board, ["columns", "layout"], where);
if (!Array.isArray(board.columns) || board.columns.length !== STORE_STATES.length) refuse(`${where}.columns must list the ${STORE_STATES.length} task states`);
const seen = new Set();
const columns = board.columns.map((column, i) => {
const at = `${where}.columns[${i}]`;
requireObject(column, at);
rejectUnknownKeys(column, ["state", "label"], at);
if (!STORE_STATES.includes(column.state)) refuse(`${at}.state must be one of ${STORE_STATES.join(", ")}`);
if (seen.has(column.state)) refuse(`${where}.columns names ${column.state} twice`);
seen.add(column.state);
requireString(column.label, `${at}.label`, { max: 32 });
return { state: column.state, label: column.label };
});
if (!BOARD_LAYOUTS.includes(board.layout)) refuse(`${where}.layout must be ${BOARD_LAYOUTS.join(" or ")}`);
return { columns, layout: board.layout };
}
// workflows: {<name>: {title?, description?, steps: [{title, state, roles}]}}. The load checks each
// step role's shape only: a role that no longer resolves (a definition removed, a template changed)
// must not fail the whole organization. applyPack resolves the roles of the workflows it writes.
export function checkWorkflows(workflows, where) {
requireObject(workflows, where);
const out = {};
Object.entries(workflows).forEach(([name, workflow], n) => {
if (!isKindName(name)) refuse(`${where} key ${n + 1} must be a workflow name matching ${KIND_NAME}, at most 64 characters`);
const at = `${where}.${name}`;
requireObject(workflow, at);
rejectUnknownKeys(workflow, ["title", "description", "steps"], at);
if (Object.hasOwn(workflow, "title")) requireString(workflow.title, `${at}.title`, { max: 120 });
if (Object.hasOwn(workflow, "description")) requireString(workflow.description, `${at}.description`, { max: 1000 });
if (!Array.isArray(workflow.steps) || workflow.steps.length < 1 || workflow.steps.length > 20) refuse(`${at}.steps must list 1 to 20 steps`);
const steps = workflow.steps.map((step, i) => {
const s = `${at}.steps[${i}]`;
requireObject(step, s);
rejectUnknownKeys(step, ["title", "state", "roles"], s);
requireString(step.title, `${s}.title`, { max: 120 });
if (!STORE_STATES.includes(step.state)) refuse(`${s}.state must be one of ${STORE_STATES.join(", ")}`);
if (!Array.isArray(step.roles) || step.roles.length < 1 || step.roles.length > 8) refuse(`${s}.roles must list 1 to 8 roles`);
const roles = requireDistinctList(step.roles, `${s}.roles`, (role) => {
if (!isKindName(role)) refuse(`${s}.roles entries must be role names matching ${KIND_NAME}, or human`);
});
return { title: step.title, state: step.state, roles };
});
out[name] = {
...(Object.hasOwn(workflow, "title") ? { title: workflow.title } : {}),
...(Object.hasOwn(workflow, "description") ? { description: workflow.description } : {}), steps,
};
});
return out;
}
function checkPin(pin, where) {
requireObject(pin, where);
const keys = Object.keys(pin);
const shape = PIN_SHAPES.find((s) => s.length === keys.length && s.every((k) => keys.includes(k)));
if (!shape) refuse(`${where} must be {template, sha256}, {definition, rev} or {inline}`);
if (shape[0] === "template") {
requireId(pin.template, `${where}.template`);
if (typeof pin.sha256 !== "string" || !HEX64.test(pin.sha256)) refuse(`${where}.sha256 must be 64 lowercase hex characters`);
} else if (shape[0] === "definition") {
checkDefinitionName(pin.definition, `${where}.definition`);
requirePositiveInt(pin.rev, `${where}.rev`);
} else {
checkDefinitionName(pin.inline, `${where}.inline`);
if (typeof pin.sha256 !== "string" || !HEX64.test(pin.sha256)) refuse(`${where}.sha256 must be 64 lowercase hex characters`);
}
return { ...pin };
}
// packs: the record of each pack applied, oldest first. Each entry is history: its project is not
// checked against today's projects, and its proposals are pins, not hires.
export function checkPacks(packs, where) {
if (!Array.isArray(packs)) refuse(`${where} must be a list`);
return packs.map((entry, i) => {
const at = `${where}[${i}]`;
requireObject(entry, at);
rejectUnknownKeys(entry, PACK_KEYS, at);
for (const key of PACK_KEYS) if (!Object.hasOwn(entry, key)) refuse(`${at} requires "${key}"`);
if (!isKindName(entry.id)) refuse(`${at}.id must match ${KIND_NAME}, at most 64 characters`);
if (typeof entry.version !== "string" || entry.version.length > 64 || !SEMVER.test(entry.version)) refuse(`${at}.version must be a semver version`);
if (typeof entry.sha256 !== "string" || !HEX64.test(entry.sha256)) refuse(`${at}.sha256 must be 64 lowercase hex characters`);
if (!PACK_ORIGINS.includes(entry.origin)) refuse(`${at}.origin must be one of ${PACK_ORIGINS.join(", ")}`);
if (entry.scope === "org") { if (entry.project !== null) refuse(`${at}.project must be null for an org pack`); }
else if (entry.scope === "project") requireId(entry.project, `${at}.project`);
else refuse(`${at}.scope must be org or project`);
if (typeof entry.at !== "string" || Number.isNaN(Date.parse(entry.at)) || new Date(entry.at).toISOString() !== entry.at) refuse(`${at}.at must be an ISO timestamp`);
requireId(entry.by, `${at}.by`);
const added = requireDistinctList(entry.added, `${at}.added`, (path) => {
if (typeof path !== "string" || !ADDED_PATH.test(path)) refuse(`${at}.added entries must be item paths`);
});
if (!Array.isArray(entry.proposedHires) || entry.proposedHires.length > 8) refuse(`${at}.proposedHires must list at most 8 hires`);
const names = new Set();
const proposedHires = entry.proposedHires.map((hire, j) => {
const h = `${at}.proposedHires[${j}]`;
requireObject(hire, h);
rejectUnknownKeys(hire, ["name", "reason", "pin", "entry"], h);
requireId(hire.name, `${h}.name`);
if (names.has(hire.name)) refuse(`${at}.proposedHires names ${hire.name} twice`);
names.add(hire.name);
if (Object.hasOwn(hire, "reason")) requireString(hire.reason, `${h}.reason`, { max: 500 });
if (!Object.hasOwn(hire, "pin")) refuse(`${h} requires "pin"`);
const pin = checkPin(hire.pin, `${h}.pin`);
// An inline proposal keeps the full role entry, so the raise can read it back and check its
// hash; no other pin carries one. It is bounded as a definition entry is (keys, then
// checkComposition), with no rev of its own required. The hash is not checked here: a stale
// proposal is the raise's refusal, not a reason the organization fails to load.
if (Object.hasOwn(pin, "inline") !== Object.hasOwn(hire, "entry")) refuse(`${h} carries an entry exactly when its pin is inline`);
let entry;
if (Object.hasOwn(hire, "entry")) {
requireObject(hire.entry, `${h}.entry`);
checkDefinition(pin.inline, Object.hasOwn(hire.entry, "rev") ? hire.entry : { ...hire.entry, rev: 1 }, { where: `${h}.entry` });
entry = structuredClone(hire.entry);
}
return { name: hire.name, ...(Object.hasOwn(hire, "reason") ? { reason: hire.reason } : {}), pin, ...(entry === undefined ? {} : { entry }) };
});
return { id: entry.id, version: entry.version, sha256: entry.sha256, origin: entry.origin, scope: entry.scope, project: entry.project, at: entry.at, by: entry.by, added, proposedHires };
});
}
// The config directory: next to the system config, $MOSAIC_CONFIG or
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
@@ -303,7 +444,7 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
projects[id] = checkMapping(entry, `${file} projects.${id}`, { fail: (code, message) => refuse(`${code}: ${message}`) });
continue;
}
rejectUnknownKeys(entry, ["root", "repo", "remote", "git", "agents"], `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root", "repo", "remote", "git", "agents", "board", "workflows"], `${file} projects.${id}`);
// #1873 A1: the project's own agent entries. Checked so a bad entry refuses the whole file, but
// not copied below: nothing a bus loads sees them; only the Console's agent-levels read does.
if (Object.hasOwn(entry, "agents")) checkProjectAgents(entry.agents, { rolesDir, where: `projects.${id}.agents`, definitions:document.definitions });
@@ -327,7 +468,9 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
}
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
...(Object.hasOwn(entry, "remote") ? { remote: entry.remote } : {}),
...(Object.hasOwn(entry, "git") ? { git: { ...entry.git } } : {}) };
...(Object.hasOwn(entry, "git") ? { git: { ...entry.git } } : {}),
...(Object.hasOwn(entry, "board") ? { board: checkBoard(entry.board, `${file} projects.${id}.board`) } : {}),
...(Object.hasOwn(entry, "workflows") ? { workflows: checkWorkflows(entry.workflows, `${file} projects.${id}.workflows`) } : {}) };
}
for (const [instance, role] of Object.entries(roles)) {
for (const project of role.publish ?? []) {
@@ -375,11 +518,15 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
}
const appearance = checkAppearance(document.appearance, `${file} appearance`);
const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file);
const board = document.board === undefined ? undefined : checkBoard(document.board, `${file} board`);
const workflows = document.workflows === undefined ? undefined : checkWorkflows(document.workflows, `${file} workflows`);
const packs = document.packs === undefined ? undefined : checkPacks(document.packs, `${file} packs`);
return deepFreeze({
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
...(autonomy === undefined ? {} : { autonomy }), ...(retired ? { retired } : {}), ...(usage === undefined ? {} : { usage }), ...(appearance === undefined ? {} : { appearance }),
...(copyBindings === undefined ? {} : { copyBindings }),
...(board === undefined ? {} : { board }), ...(workflows === undefined ? {} : { workflows }), ...(packs === undefined ? {} : { packs }),
});
}
+194 -1
View File
@@ -441,6 +441,199 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
return { ...out, rev: logged.rev };
}
// Starter packs SP3 (design dd878cefc sections 3 and 8): the one writer of `board`, `workflows`,
// each code project's `board` and `workflows`, and `packs`, and a pack's way to add definitions.
// Plan keys, and nothing else (invalid-request):
// definitions {name: entry without rev}: adds only, at rev 1; needs a packs entry
// board a board, or null to remove it
// workflows {name: workflow, or null to remove it}, merged by name
// projects.<p>.board as board, for code project p
// projects.<p>.workflows as workflows, for code project p
// packs the one entry to append, without at, by and added: this writer stamps them
// A plan with a packs entry only adds: it overwrites and removes nothing, an org pack writes no
// project key, and a project pack writes only its own project's keys. A board or workflow write is
// a plan holding that key alone. One commit: a refusal or a fault leaves all of it or none.
// setDefinition's rules hold for each definition: an agent or the tester never writes one
// (human-required), a name an instance uses refuses definition-in-use, the credential store's
// check is required and checkDefinition runs it with checkComposition. An agent never applies a
// saved template either. The rest is organization.change's, with no decision (Jason, 11-12).
// assertClean, the credential store's check, is required on every call and runs on every key and
// string in the plan before anything else reads it: workflow text reaches agents' prompts and a
// pack's inline proposals are role entries. Each workflow written must name
// roles that resolve now: an org definition (after this plan), a V2 template, or human. The load
// checks only their shape, so a later removal never fails the file.
const PLAN_TOP = ["definitions", "board", "workflows", "packs"];
const PLAN_PROJECT = /^projects\.([a-z0-9][a-z0-9._-]{0,63})\.(board|workflows)$/;
const PLAN_PACK_KEYS = ["id", "version", "sha256", "origin", "scope", "project", "proposedHires"];
const PLAN_NAME = /^[a-z][a-z0-9-]{0,63}$/;
const planObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && [Object.prototype, null].includes(Object.getPrototypeOf(v));
function applyPack(id, plan, { revision, actor, assertClean, detail }) {
if (typeof assertClean !== "function") fail("definition-secret-check-required", "a pack, board or workflow write needs the credential store's secret check; write it through the bus");
if (!planObject(plan) || Object.keys(plan).length === 0) fail("invalid-request", "a pack plan is an object naming at least one item");
const scan = (value) => {
if (typeof value === "string") assertClean(value);
else if (value !== null && typeof value === "object") for (const [k, v] of Object.entries(value)) { assertClean(k); scan(v); }
};
scan(plan);
const projectKeys = [];
Object.keys(plan).forEach((key, i) => {
const match = PLAN_PROJECT.exec(key);
if (match) projectKeys.push({ key, project: match[1], item: match[2] });
else if (!PLAN_TOP.includes(key)) fail("invalid-request", `plan key ${i + 1} is not one a pack writes (definitions, board, workflows, projects.<p>.board, projects.<p>.workflows, packs)`);
});
const pack = plan.packs;
if (pack !== undefined) {
if (!planObject(pack)) fail("invalid-request", "plan packs must be one entry");
for (const key of Object.keys(pack)) {
if (["at", "by", "added"].includes(key)) fail("invalid-request", `plan packs carries ${key}, which the writer stamps`);
if (!PLAN_PACK_KEYS.includes(key)) fail("invalid-request", "plan packs carries an unknown key");
}
if (pack.scope === "project") {
if (Object.hasOwn(plan, "board") || Object.hasOwn(plan, "workflows")) fail("invalid-request", "a project pack writes only its project's board and workflows");
if (projectKeys.some((k) => k.project !== pack.project)) fail("invalid-request", "a project pack writes only its own project's keys");
} else if (projectKeys.length) fail("invalid-request", "an org pack writes no project key");
} else if (Object.hasOwn(plan, "definitions")) {
fail("invalid-request", "definitions arrive only with a packs entry; write one definition through setDefinition");
}
const at = new Date().toISOString();
const logged = { ...detail, verb: pack ? "pack.apply" : "organization.layout", pack: pack ? pack.id : null, added: null };
const build = (old) => {
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
const agent = actor === "tester" || agents.includes(actor) || (old.retired ?? []).includes(actor);
if (agent && (Object.hasOwn(plan, "definitions") || pack?.origin === "saved")) fail("human-required");
const next = structuredClone(old);
const added = [];
const written = [];
if (Object.hasOwn(plan, "definitions")) {
if (!planObject(plan.definitions) || Object.keys(plan.definitions).length === 0) fail("invalid-request", "plan definitions must name at least one definition");
const definitions = { ...(old.definitions ?? {}) };
for (const [name, entry] of Object.entries(plan.definitions)) {
checkDefinitionName(name, "plan definition name");
if (Object.hasOwn(definitions, name)) {
const users = instancesNaming(old, name);
if (users.length) fail("definition-in-use", `definition ${name} is named by ${users.join(", ")}; a change to a hired agent's role is a change decision`);
fail("definition-exists", `definitions already declares ${name}; a pack apply overwrites nothing`);
}
if (!planObject(entry)) fail("invalid-request", `plan definition ${name} must be an object`);
if (Object.hasOwn(entry, "rev")) fail("definition-rev-conflict", `a pack definition carries no rev; apply writes ${name} at rev 1`);
const value = { ...structuredClone(entry), rev: 1 };
checkDefinition(name, value, { assertClean });
definitions[name] = value;
added.push(`definitions.${name}`);
}
next.definitions = definitions;
}
const setBoard = (target, value, path) => {
if (value === null) {
if (pack) fail("invalid-request", "a pack apply removes nothing");
if (!Object.hasOwn(target, "board")) fail("invalid-request", `${path} is not set`);
delete target.board;
return;
}
if (pack && Object.hasOwn(target, "board")) fail("invalid-request", `${path} is already set; a pack apply overwrites nothing`);
target.board = structuredClone(value);
added.push(path);
};
const setWorkflows = (target, value, path) => {
if (!planObject(value) || Object.keys(value).length === 0) fail("invalid-request", `${path} must name at least one workflow`);
const workflows = { ...(target.workflows ?? {}) };
for (const [name, workflow] of Object.entries(value)) {
if (!PLAN_NAME.test(name)) fail("invalid-request", `${path} keys must be workflow names matching ${PLAN_NAME}`);
const exists = Object.hasOwn(workflows, name);
if (workflow === null) {
if (pack) fail("invalid-request", "a pack apply removes nothing");
if (!exists) fail("invalid-request", `${path}.${name} is not set`);
delete workflows[name];
continue;
}
if (pack && exists) fail("invalid-request", `${path}.${name} is already set; a pack apply overwrites nothing`);
workflows[name] = structuredClone(workflow);
added.push(`${path}.${name}`);
written.push({ path: `${path}.${name}`, workflow: workflows[name] });
}
if (Object.keys(workflows).length) target.workflows = workflows;
else delete target.workflows;
};
if (Object.hasOwn(plan, "board")) setBoard(next, plan.board, "board");
if (Object.hasOwn(plan, "workflows")) setWorkflows(next, plan.workflows, "workflows");
for (const { key, project, item } of projectKeys) {
const entry = Object.hasOwn(next.projects ?? {}, project) ? next.projects[project] : null;
if (!planObject(entry) || entry.kind === "library") fail("invalid-request", `projects declares no code project ${project}`);
if (item === "board") setBoard(entry, plan[key], `projects.${project}.board`);
else setWorkflows(entry, plan[key], `projects.${project}.workflows`);
}
// Each role a written workflow names resolves now. A role that isn't a name is left to the
// file check, which refuses it; only a name reaches readTemplate.
const templates = new Map();
const template = (name) => {
if (!templates.has(name)) {
try { readTemplate(rolesDir, name); templates.set(name, true); } catch (error) {
if (error?.name !== "BusinessError") throw error;
templates.set(name, false);
}
}
return templates.get(name);
};
for (const { path, workflow } of written) {
(Array.isArray(workflow?.steps) ? workflow.steps : []).forEach((step, i) => {
for (const role of Array.isArray(step?.roles) ? step.roles : []) {
if (role === "human" || typeof role !== "string" || !PLAN_NAME.test(role)) continue;
if (Object.hasOwn(next.definitions ?? {}, role) || template(role)) continue;
fail("invalid-request", `${path} step ${i + 1} names ${role}, which is not an org definition, a V2 template or human`);
}
});
}
if (pack) {
// A {definition, rev} pin names a definition this apply wrote; an {inline, sha256} pin is the
// canonical hash of the entry stored beside it (design section 3). The file check bounds the
// entry's shape; this is the write's own check that the pin means what it says.
for (const hire of Array.isArray(pack.proposedHires) ? pack.proposedHires : []) {
const pin = hire?.pin;
if (planObject(pin) && Object.hasOwn(pin, "definition") && !(added.includes(`definitions.${pin.definition}`) && pin.rev === 1)) {
fail("invalid-request", "a {definition, rev} proposal pins a definition this apply writes, at rev 1");
}
if (planObject(pin) && Object.hasOwn(pin, "inline") && planObject(hire.entry) && pin.sha256 !== sha256(canonicalJson(hire.entry))) {
fail("invalid-request", "an {inline, sha256} proposal's sha256 is the canonical hash of its entry");
}
}
next.packs = [...(old.packs ?? []), {
id: pack.id, version: pack.version, sha256: pack.sha256, origin: pack.origin, scope: pack.scope, project: pack.project,
at, by: actor, added, proposedHires: structuredClone(pack.proposedHires),
}];
}
logged.added = added;
return { next, added };
};
let result;
const guard = (old, document) => {
if (canonicalJson(document) !== canonicalJson(build(old).next)) fail("invalid-request", "the document is not the plan's");
// The key fence itself, whatever build does: only the plan's keys change, definitions and
// packs only grow, and a project changes only in a planned board or workflows.
for (const key of new Set([...Object.keys(old), ...Object.keys(document)])) {
if (key === "projects" || PLAN_TOP.includes(key)) continue;
if (canonicalJson(old[key] ?? null) !== canonicalJson(document[key] ?? null)) fail("invalid-request", "a pack plan changes only definitions, board, workflows, packs and project boards and workflows");
}
for (const [name, entry] of Object.entries(old.definitions ?? {})) {
if (canonicalJson(entry) !== canonicalJson(document.definitions?.[name] ?? null)) fail("organization-definitions-only", "a pack apply changes no existing definition");
}
const oldPacks = old.packs ?? [];
if (canonicalJson(oldPacks) !== canonicalJson((document.packs ?? []).slice(0, oldPacks.length))) fail("invalid-request", "a pack apply keeps every earlier packs entry");
const planned = new Set(projectKeys.map((k) => `${k.project}.${k.item}`));
const projects = (doc) => doc?.projects ?? {};
for (const project of new Set([...Object.keys(projects(old)), ...Object.keys(projects(document))])) {
const a = projects(old)[project] ?? null;
const b = projects(document)[project] ?? null;
const rest = (e) => (planObject(e) ? Object.fromEntries(Object.entries(e).filter(([k]) => k !== "board" && k !== "workflows")) : e);
if (canonicalJson(rest(a)) !== canonicalJson(rest(b))) fail("organization-projects-protected");
for (const item of ["board", "workflows"]) {
if (canonicalJson(a?.[item] ?? null) !== canonicalJson(b?.[item] ?? null) && !planned.has(`${project}.${item}`)) fail("organization-projects-protected");
}
}
};
const out = commit(id, (old) => { result = build(old); return result.next; }, { revision, actor, guard, pathsOnly: true, detail: logged });
return { ...out, added: result.added };
}
// Lifecycle Task 2: the only writer that adds a project mapping, and only the exact bootstrap
// addition: one new key under the managed product layout, a receipt naming the request and plan, and
// no other change. It compares both the document it built and the bytes it will publish. A stale
@@ -779,5 +972,5 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
});
}
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, reverseHireDecision, applyCopyBindingDecision, stageCopyGrant, commitCopyGrant, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyPack, applyHireDecision, reverseHireDecision, applyCopyBindingDecision, stageCopyGrant, commitCopyGrant, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
}
@@ -0,0 +1,425 @@
// Starter packs SP3 (design dd878cefc sections 3 and 8, fix round 3 c0a60ed3a): the organization file's packs, board and
// workflows keys, each project's board and workflows, and the writer's applyPack operation.
// Fixtures live in a temp directory; nothing reads the real config.
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { organizationWriter } from "../src/organization.mjs";
import { validateBusinessDocument } from "../src/organization-config.mjs";
import { businessDoc, rolesCopy, tmp, writeJson } from "./helpers.mjs";
import { canonicalJson, sha256 } from "../src/util.mjs";
const SECRET = "sk-test-NOT-A-SECRET-1234";
const assertClean = (value) => { if (String(value).includes(SECRET)) throw new Error("credential-leak"); };
const HASH = "a".repeat(64);
const PLAIN = { role: { tools: ["read"], network: "none", authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: "Read and report.\n" };
// An inline proposal: a pack definition the apply did not write, kept whole beside its pin.
const WRITER = { ...PLAIN, instructions: "Write the docs.\n" };
const WRITER_HASH = sha256(canonicalJson(WRITER));
const BOARD = {
columns: [
{ state: "todo", label: "Backlog" }, { state: "in-progress", label: "Building" },
{ state: "in-review", label: "Review" }, { state: "blocked", label: "Blocked" }, { state: "done", label: "Merged" },
],
layout: "kanban",
};
const LIST_BOARD = { ...structuredClone(BOARD), layout: "list" };
const WORKFLOWS = {
change: {
title: "Change", description: "One change from task to merge.",
steps: [
{ title: "Write the task", state: "todo", roles: ["pm"] },
{ title: "Implement", state: "in-progress", roles: ["coder"] },
{ title: "Approve the merge", state: "done", roles: ["human"] },
],
},
};
const PACK = {
id: "coding", version: "1.0.0", sha256: HASH, origin: "shipped", scope: "org", project: null,
at: "2026-11-12T20:00:00.000Z", by: "jason", added: ["definitions.scout", "board", "workflows.change"],
proposedHires: [
{ name: "coder-1", reason: "writes the code", pin: { template: "coder", sha256: HASH } },
{ name: "scout-1", pin: { definition: "scout", rev: 1 } },
{ name: "writer-1", pin: { inline: "writer", sha256: WRITER_HASH }, entry: WRITER },
],
};
function fixture(t, edit = (doc) => doc, barrier) {
const root = tmp();
t.after(() => rmSync(root, { recursive: true, force: true }));
const dir = join(root, "config");
const rolesDir = rolesCopy(root);
const document = edit(businessDoc(root));
const file = writeJson(join(dir, "businesses/acme.json"), document);
return { root, dir, file, rolesDir, document, writer: organizationWriter({ dir, rolesDir, barrier }) };
}
const rev = (f) => f.writer.read("acme").revision;
const doc = (f) => JSON.parse(readFileSync(f.file, "utf8"));
const bytes = (f) => readFileSync(f.file, "utf8");
const load = (f, document) => validateBusinessDocument(document, f.file, { rolesDir: f.rolesDir });
const without = (object, ...keys) => Object.fromEntries(Object.entries(object).filter(([k]) => !keys.includes(k)));
// An undefined value in `extra` drops that key from the plan.
const packPlan = (extra = {}) => Object.fromEntries(Object.entries({
definitions: { scout: PLAIN }, board: BOARD, workflows: WORKFLOWS,
packs: without({ ...PACK, proposedHires: PACK.proposedHires.slice(0, 2) }, "at", "by", "added"), ...extra,
}).filter(([, value]) => value !== undefined));
test("SP3 red before: a file with a board key loads, and the loaded organization keeps it", (t) => {
const f = fixture(t);
const loaded = load(f, { ...f.document, board: BOARD });
assert.deepEqual(loaded.board, BOARD);
});
test("SP3 valid packs, board and workflows load, and the organization and each project keep them", (t) => {
const f = fixture(t);
const document = {
...f.document, board: BOARD, workflows: WORKFLOWS, packs: [PACK, { ...PACK, origin: "saved", scope: "project", project: "stack", added: ["projects.stack.board", "projects.stack.workflows.change"], proposedHires: [] }],
projects: { stack: { ...f.document.projects.stack, board: LIST_BOARD, workflows: WORKFLOWS } },
};
const loaded = load(f, document);
assert.deepEqual(loaded.board, BOARD);
assert.deepEqual(loaded.workflows, WORKFLOWS);
assert.deepEqual(loaded.packs, document.packs);
assert.deepEqual(loaded.projects.stack.board, LIST_BOARD);
assert.deepEqual(loaded.projects.stack.workflows, WORKFLOWS);
assert.ok(Object.isFrozen(loaded.projects.stack.board));
// Title and description are optional; the absent keys stay absent.
const bare = load(f, { ...f.document, workflows: { quick: { steps: [{ title: "Do it", state: "done", roles: ["human"] }] } } });
assert.deepEqual(bare.workflows, { quick: { steps: [{ title: "Do it", state: "done", roles: ["human"] }] } });
// A file without the keys loads as it did, with none of them on the loaded object.
const plain = load(f, f.document);
for (const key of ["board", "workflows", "packs"]) assert.ok(!Object.hasOwn(plain, key), key);
assert.ok(!Object.hasOwn(plain.projects.stack, "board"));
});
test("SP3 a workflow naming a role that does not exist still loads (the load checks shape only)", (t) => {
const f = fixture(t);
const ghost = { change: { steps: [{ title: "Haunt", state: "todo", roles: ["ghost-role", "no-such-template"] }] } };
assert.deepEqual(load(f, { ...f.document, workflows: ghost }).workflows, ghost);
assert.deepEqual(load(f, { ...f.document, projects: { stack: { ...f.document.projects.stack, workflows: ghost } } }).projects.stack.workflows, ghost);
});
test("SP3 a definition a workflow names can still be removed, and the file then still loads", (t) => {
// Design c0a60ed3a SP3 acceptance: "an org file whose step names a definition removed since loads,
// and removing that definition succeeds".
const f = fixture(t);
const scouting = { scouting: { steps: [{ title: "Scout", state: "todo", roles: ["scout", "human"] }] } };
const packs = { ...packPlan().packs, proposedHires: [] };
f.writer.applyPack("acme", { definitions: { scout: PLAIN }, workflows: scouting, packs }, { revision: rev(f), actor: "jason", assertClean });
f.writer.applyPack("acme", { "projects.stack.workflows": scouting }, { revision: rev(f), actor: "jason", assertClean });
assert.deepEqual(doc(f).definitions.scout, { ...PLAIN, rev: 1 });
f.writer.setDefinition("acme", "scout", () => null, { revision: rev(f), actor: "jason", assertClean });
const after = doc(f);
assert.equal(after.definitions?.scout, undefined);
assert.deepEqual(after.workflows, scouting);
assert.deepEqual(after.projects.stack.workflows, scouting);
const loaded = load(f, after);
assert.deepEqual(loaded.workflows, scouting);
assert.deepEqual(loaded.projects.stack.workflows, scouting);
// The writer reads it back too; only a new write of that workflow resolves its roles again.
assert.equal(f.writer.read("acme").revision, rev(f));
assert.throws(() => f.writer.applyPack("acme", { workflows: { again: scouting.scouting } }, { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
});
test("SP3 each invalid shape refuses", (t) => {
const f = fixture(t);
const col = (i, patch) => ({ ...BOARD, columns: BOARD.columns.map((c, j) => (j === i ? { ...c, ...patch } : c)) });
const step = (patch) => ({ change: { ...WORKFLOWS.change, steps: [{ ...WORKFLOWS.change.steps[0], ...patch }] } });
const pack = (patch) => [{ ...PACK, ...patch }];
const steps = (n) => Array.from({ length: n }, () => ({ title: "Step", state: "todo", roles: ["human"] }));
const cases = {
"board not an object": { board: [] },
"board unknown key": { board: { ...BOARD, colour: "red" } },
"board missing layout": { board: without(BOARD, "layout") },
"board four columns": { board: { ...BOARD, columns: BOARD.columns.slice(0, 4) } },
"board six columns": { board: { ...BOARD, columns: [...BOARD.columns, BOARD.columns[0]] } },
"board duplicate state": { board: col(1, { state: "todo" }) },
"board unknown state": { board: col(0, { state: "backlog" }) },
"board column unknown key": { board: col(0, { colour: "red" }) },
"board label empty": { board: col(0, { label: "" }) },
"board label 33 characters": { board: col(0, { label: "x".repeat(33) }) },
"board layout grid": { board: { ...BOARD, layout: "grid" } },
"workflows not an object": { workflows: [] },
"workflow name not NAME": { workflows: { Change_1: WORKFLOWS.change } },
"workflow unknown key": { workflows: { change: { ...WORKFLOWS.change, owner: "pm" } } },
"workflow no steps": { workflows: { change: { ...WORKFLOWS.change, steps: [] } } },
"workflow 21 steps": { workflows: { change: { steps: steps(21) } } },
"workflow title 121 characters": { workflows: { change: { ...WORKFLOWS.change, title: "x".repeat(121) } } },
"workflow description 1001 characters": { workflows: { change: { ...WORKFLOWS.change, description: "x".repeat(1001) } } },
"step unknown key": { workflows: step({ id: "plan" }) },
"step missing roles": { workflows: { change: { steps: [{ title: "Step", state: "todo" }] } } },
"step title 121 characters": { workflows: step({ title: "x".repeat(121) }) },
"step unknown state": { workflows: step({ state: "doing" }) },
"step roles empty": { workflows: step({ roles: [] }) },
"step nine roles": { workflows: step({ roles: ["a", "b", "c", "d", "e", "f", "g", "h", "i"] }) },
"step role not NAME": { workflows: step({ roles: ["Coder_1"] }) },
"step duplicate role": { workflows: step({ roles: ["pm", "pm"] }) },
"packs not a list": { packs: {} },
"pack unknown key": { packs: pack({ hires: [] }) },
"pack missing key": { packs: [without(PACK, "sha256")] },
"pack origin unknown": { packs: pack({ origin: "borrowed" }) },
"pack scope root": { packs: pack({ scope: "root" }) },
"org pack with project": { packs: pack({ project: "stack" }) },
"project pack without project": { packs: pack({ scope: "project", project: null }) },
"pack id not NAME": { packs: pack({ id: "Coding" }) },
"pack version not semver": { packs: pack({ version: "v1" }) },
"pack sha256 not hex": { packs: pack({ sha256: "A".repeat(64) }) },
"pack at not a timestamp": { packs: pack({ at: "yesterday" }) },
"pack by not an id": { packs: pack({ by: "Not An Id" }) },
"pack added bad path": { packs: pack({ added: ["plugins.theme"] }) },
"pack nine proposed hires": { packs: pack({ proposedHires: Array.from({ length: 9 }, (_, i) => ({ name: `coder-${i}`, pin: { template: "coder", sha256: HASH } })) }) },
"proposed hire two pins": { packs: pack({ proposedHires: [{ name: "coder-1", pin: { template: "coder", sha256: HASH, inline: "writer" } }] }) },
"proposed hire no pin": { packs: pack({ proposedHires: [{ name: "coder-1" }] }) },
"proposed hire unknown key": { packs: pack({ proposedHires: [{ name: "coder-1", level: "org", pin: { template: "coder", sha256: HASH } }] }) },
"proposed hire duplicate name": { packs: pack({ proposedHires: [{ name: "c", pin: { template: "coder", sha256: HASH } }, { name: "c", pin: { template: "coder", sha256: HASH } }] }) },
"inline pin old hash-only shape": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: HASH }, entry: WRITER }] }) },
"inline pin without entry": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "writer", sha256: WRITER_HASH } }] }) },
"inline pin name not an id": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "Writer One", sha256: WRITER_HASH }, entry: WRITER }] }) },
"inline pin sha256 not hex": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "writer", sha256: "x" }, entry: WRITER }] }) },
"template pin with entry": { packs: pack({ proposedHires: [{ name: "w", pin: { template: "coder", sha256: HASH }, entry: WRITER }] }) },
"inline entry not an object": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "writer", sha256: WRITER_HASH }, entry: "Write the docs." }] }) },
"inline entry unknown key": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "writer", sha256: WRITER_HASH }, entry: { ...WRITER, level: "org" } }] }) },
"inline entry unknown tool": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "writer", sha256: WRITER_HASH }, entry: { ...WRITER, role: { ...WRITER.role, tools: ["teleport"] } } }] }) },
"inline entry without role": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "writer", sha256: WRITER_HASH }, entry: without(WRITER, "role") }] }) },
"inline entry rev zero": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "writer", sha256: WRITER_HASH }, entry: { ...WRITER, rev: 0 } }] }) },
"proposed hire rev zero": { packs: pack({ proposedHires: [{ name: "c", pin: { definition: "scout", rev: 0 } }] }) },
};
for (const [name, patch] of Object.entries(cases)) {
assert.throws(() => load(f, { ...f.document, ...patch }), { name: "BusinessError" }, name);
}
// The project keys refuse the same shapes, and other project keys still refuse.
const project = (patch) => ({ ...f.document, projects: { stack: { ...f.document.projects.stack, ...patch } } });
for (const [name, patch] of Object.entries({
"project board layout": { board: { ...BOARD, layout: "grid" } },
"project workflow 0 steps": { workflows: { change: { steps: [] } } },
"project unknown key": { packs: [] },
})) assert.throws(() => load(f, project(patch)), { name: "BusinessError" }, name);
});
test("SP3 applyPack writes definitions, board, workflows and the packs entry in one commit", (t) => {
const f = fixture(t);
const before = doc(f);
const out = f.writer.applyPack("acme", packPlan(), { revision: rev(f), actor: "jason", assertClean });
assert.deepEqual(out.added, ["definitions.scout", "board", "workflows.change"]);
assert.equal(out.revision, rev(f));
const after = doc(f);
assert.deepEqual(after.definitions.scout, { ...PLAIN, rev: 1 });
assert.deepEqual(after.board, BOARD);
assert.deepEqual(after.workflows, WORKFLOWS);
assert.equal(after.packs.length, 1);
const entry = after.packs[0];
assert.deepEqual(without(entry, "at"), { ...without(PACK, "at"), by: "jason", added: out.added, proposedHires: PACK.proposedHires.slice(0, 2) });
assert.match(entry.at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
// Nothing else changed.
assert.deepEqual(without(after, "definitions", "board", "workflows", "packs"), without(before, "definitions", "board", "workflows", "packs"));
// One prepared and one committed receipt, and the receipt logs paths, not values.
const log = readFileSync(f.writer.logFile("acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
assert.deepEqual(log.map((e) => e.state), ["prepared", "committed"]);
assert.ok(!JSON.stringify(log).includes("Backlog"));
// A second pack appends; the first entry stays.
const second = { packs: { ...packPlan().packs, id: "review", proposedHires: [] }, workflows: { review: WORKFLOWS.change } };
f.writer.applyPack("acme", second, { revision: rev(f), actor: "jason", assertClean });
assert.deepEqual(doc(f).packs.map((p) => p.id), ["coding", "review"]);
assert.deepEqual(doc(f).packs[0], entry);
assert.deepEqual(doc(f).packs[1].added, ["workflows.review"]);
});
test("SP3 applyPack writes everything or nothing", (t) => {
// A plan item after the definitions is bad: the valid definition is not written either.
const f = fixture(t);
const original = bytes(f);
const bad = packPlan({ workflows: { change: { steps: [{ title: "Haunt", state: "todo", roles: ["ghost-role"] }] } } });
assert.throws(() => f.writer.applyPack("acme", bad, { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
assert.equal(bytes(f), original);
const badBoard = packPlan({ board: { ...BOARD, layout: "grid" } });
assert.throws(() => f.writer.applyPack("acme", badBoard, { revision: rev(f), actor: "jason", assertClean }), { name: "BusinessError" });
assert.equal(bytes(f), original);
// A fault after the candidate is prepared, with every item checked, leaves the file unchanged.
const crash = organizationWriter({ dir: f.dir, rolesDir: f.rolesDir, barrier: (stage) => { if (stage === "prepared") throw new Error("fixture crash"); } });
assert.throws(() => crash.applyPack("acme", packPlan(), { revision: rev(f), actor: "jason", assertClean }), /fixture crash/);
assert.equal(bytes(f), original);
f.writer.recover("acme");
assert.equal(bytes(f), original);
assert.equal(JSON.parse(readFileSync(f.writer.logFile("acme"), "utf8").trim().split("\n").at(-1)).state, "aborted");
// The same plan then lands whole.
f.writer.applyPack("acme", packPlan(), { revision: rev(f), actor: "jason", assertClean });
assert.ok(doc(f).definitions.scout && doc(f).board && doc(f).workflows && doc(f).packs);
});
test("SP3 an agent actor with a definition in the plan refuses human-required; board and workflow writes pass", (t) => {
const f = fixture(t);
const original = bytes(f);
for (const actor of ["coder", "chief-of-staff.chief", "tester"]) {
assert.throws(() => f.writer.applyPack("acme", packPlan(), { revision: rev(f), actor, assertClean }), { code: "human-required" }, actor);
}
// A saved template carries invented roles; an agent never applies one.
const saved = { packs: { ...packPlan().packs, origin: "saved", proposedHires: [] }, board: BOARD };
assert.throws(() => f.writer.applyPack("acme", saved, { revision: rev(f), actor: "chief-of-staff.chief", assertClean }), { code: "human-required" });
assert.equal(bytes(f), original);
// The non-definition half of a pack, and a board write, need no human (Jason's answer 2).
const half = { packs: { ...packPlan().packs, proposedHires: [{ name: "coder-1", pin: { template: "coder", sha256: HASH } }] }, workflows: WORKFLOWS };
f.writer.applyPack("acme", half, { revision: rev(f), actor: "chief-of-staff.chief", assertClean });
assert.equal(doc(f).packs[0].by, "chief-of-staff.chief");
f.writer.applyPack("acme", { board: BOARD }, { revision: rev(f), actor: "chief-of-staff.chief", assertClean });
assert.deepEqual(doc(f).board, BOARD);
});
test("SP3 a plan holding any other key refuses", (t) => {
const f = fixture(t);
const original = bytes(f);
for (const [name, plan] of Object.entries({
plugins: { plugins: { theme: ["dark"] } },
roles: { roles: { pm: { definition: "pm" } } },
limits: { limits: { total: 3 } },
credentials: { credentials: { gitea: { file: "/x" } } },
launch: { launch: {} },
copyBindings: { copyBindings: {} },
autonomy: { autonomy: "near" },
"projects whole": { projects: { stack: { root: "/x" } } },
"projects root": { "projects.stack.root": "/x" },
"projects agents": { "projects.stack.agents": {} },
"board beside plugins": { board: BOARD, plugins: {} },
"dotted workflow item": { "workflows.change": WORKFLOWS.change },
"empty plan": {},
"not an object": [],
})) {
assert.throws(() => f.writer.applyPack("acme", plan, { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" }, name);
}
assert.equal(bytes(f), original);
});
test("SP3 a board-only plan changes only board, and a project board only that project's board", (t) => {
const f = fixture(t);
const before = doc(f);
f.writer.applyPack("acme", { board: BOARD }, { revision: rev(f), actor: "jason", assertClean });
assert.deepEqual(without(doc(f), "board"), before);
assert.deepEqual(doc(f).board, BOARD);
// board.set replaces a board, and null removes it.
f.writer.applyPack("acme", { board: LIST_BOARD }, { revision: rev(f), actor: "jason", assertClean });
assert.deepEqual(doc(f).board, LIST_BOARD);
f.writer.applyPack("acme", { board: null }, { revision: rev(f), actor: "jason", assertClean });
assert.deepEqual(doc(f), before);
// A project's board.
f.writer.applyPack("acme", { "projects.stack.board": BOARD }, { revision: rev(f), actor: "jason", assertClean });
assert.deepEqual(doc(f).projects.stack, { ...before.projects.stack, board: BOARD });
assert.deepEqual(without(doc(f), "projects"), without(before, "projects"));
assert.deepEqual(f.writer.read("acme").document.projects.stack.board, BOARD);
// Workflows merge by name; null removes one; a project's workflows likewise.
f.writer.applyPack("acme", { workflows: WORKFLOWS }, { revision: rev(f), actor: "jason", assertClean });
f.writer.applyPack("acme", { workflows: { review: WORKFLOWS.change } }, { revision: rev(f), actor: "jason", assertClean });
assert.deepEqual(Object.keys(doc(f).workflows), ["change", "review"]);
f.writer.applyPack("acme", { workflows: { change: null } }, { revision: rev(f), actor: "jason", assertClean });
assert.deepEqual(Object.keys(doc(f).workflows), ["review"]);
f.writer.applyPack("acme", { workflows: { review: null } }, { revision: rev(f), actor: "jason", assertClean });
assert.ok(!Object.hasOwn(doc(f), "workflows"));
f.writer.applyPack("acme", { "projects.stack.workflows": WORKFLOWS }, { revision: rev(f), actor: "jason", assertClean });
assert.deepEqual(doc(f).projects.stack.workflows, WORKFLOWS);
// Removing what isn't there refuses.
assert.throws(() => f.writer.applyPack("acme", { board: null }, { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
assert.throws(() => f.writer.applyPack("acme", { workflows: { nope: null } }, { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
});
test("SP3 a workflow in the plan must name an org definition, a V2 template or human", (t) => {
const f = fixture(t);
const original = bytes(f);
const flow = (roles) => ({ workflows: { change: { steps: [{ title: "Step", state: "todo", roles }] } } });
for (const roles of [["ghost-role"], ["pm", "ghost-role"], ["conductor-policy"]]) {
assert.throws(() => f.writer.applyPack("acme", flow(roles), { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" }, roles.join());
assert.throws(() => f.writer.applyPack("acme", { "projects.stack.workflows": flow(roles).workflows }, { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
}
assert.equal(bytes(f), original);
// A template, human, and a definition the same plan adds.
f.writer.applyPack("acme", flow(["pm", "human"]), { revision: rev(f), actor: "jason", assertClean });
f.writer.applyPack("acme", packPlan({ board: undefined, workflows: { scouting: { steps: [{ title: "Scout", state: "todo", roles: ["scout"] }] } } }), { revision: rev(f), actor: "jason", assertClean });
assert.deepEqual(Object.keys(doc(f).workflows), ["change", "scouting"]);
});
test("SP3 applyPack keeps setDefinition's definition rules", (t) => {
const f = fixture(t, (d) => ({ ...d, definitions: { scout: { ...PLAIN, rev: 1 }, spare: { ...PLAIN, rev: 2 } }, roles: { ...d.roles, looker: { definition: "org:scout" } } }));
const original = bytes(f);
const plan = (definitions) => packPlan({ definitions, board: undefined, workflows: undefined });
assert.throws(() => f.writer.applyPack("acme", plan({ scout: PLAIN }), { revision: rev(f), actor: "jason", assertClean }), { code: "definition-in-use" });
assert.throws(() => f.writer.applyPack("acme", plan({ spare: PLAIN }), { revision: rev(f), actor: "jason", assertClean }), { code: "definition-exists" });
assert.throws(() => f.writer.applyPack("acme", plan({ fresh: { ...PLAIN, rev: 1 } }), { revision: rev(f), actor: "jason", assertClean }), { code: "definition-rev-conflict" });
// The credential store's check is required on every apply, not only one that writes definitions.
assert.throws(() => f.writer.applyPack("acme", plan({ fresh: PLAIN }), { revision: rev(f), actor: "jason" }), { code: "definition-secret-check-required" });
for (const bare of [{ board: BOARD }, { workflows: WORKFLOWS }, { "projects.stack.board": BOARD }, { packs: { ...packPlan().packs, proposedHires: [] }, workflows: WORKFLOWS }]) {
assert.throws(() => f.writer.applyPack("acme", bare, { revision: rev(f), actor: "jason" }), { code: "definition-secret-check-required" }, Object.keys(bare).join());
}
// A secret in workflow text, a plan key or an inline proposal's entry refuses before anything is written.
const leak = { ...WORKFLOWS.change, description: SECRET };
assert.throws(() => f.writer.applyPack("acme", { workflows: { change: leak } }, { revision: rev(f), actor: "jason", assertClean }), /credential-leak/);
assert.throws(() => f.writer.applyPack("acme", { [SECRET]: BOARD }, { revision: rev(f), actor: "jason", assertClean }), /credential-leak/);
assert.throws(() => f.writer.applyPack("acme", plan({ fresh: { ...PLAIN, instructions: `token ${SECRET}\n` } }), { revision: rev(f), actor: "jason", assertClean }), /credential-leak/);
assert.throws(() => f.writer.applyPack("acme", plan({ fresh: { ...PLAIN, role: { ...PLAIN.role, tools: ["no-such-tool"] } } }), { revision: rev(f), actor: "jason", assertClean }), { name: "BusinessError" });
// checkComposition runs: a hired composition never holds role.hire.
const hiring = { ...PLAIN, role: { ...PLAIN.role, authority: { withinRole: [], crossRole: ["role.hire"] } } };
assert.throws(() => f.writer.applyPack("acme", plan({ fresh: hiring }), { revision: rev(f), actor: "jason", assertClean }));
// A definition needs a packs entry, and a {definition, rev} pin names a definition this apply writes at rev 1.
assert.throws(() => f.writer.applyPack("acme", { definitions: { fresh: PLAIN } }, { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
const pin = (p) => ({ ...plan({ fresh: PLAIN }), packs: { ...packPlan().packs, proposedHires: [{ name: "x-1", pin: p }] } });
assert.throws(() => f.writer.applyPack("acme", pin({ definition: "spare", rev: 2 }), { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
assert.throws(() => f.writer.applyPack("acme", pin({ definition: "fresh", rev: 2 }), { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
// A held secret in a board label or workflow text refuses when the caller passes the check.
assert.throws(() => f.writer.applyPack("acme", { board: { ...BOARD, columns: [{ state: "todo", label: SECRET }, ...BOARD.columns.slice(1)] } }, { revision: rev(f), actor: "jason", assertClean }), /credential-leak/);
assert.equal(bytes(f), original);
f.writer.applyPack("acme", pin({ definition: "fresh", rev: 1 }), { revision: rev(f), actor: "jason", assertClean });
assert.deepEqual(doc(f).definitions.fresh, { ...PLAIN, rev: 1 });
});
test("SP3 a pack apply only adds, and its scope fences the project keys", (t) => {
const f = fixture(t, (d) => ({ ...d, board: BOARD, workflows: WORKFLOWS }));
const original = bytes(f);
// No definitions in these plans, so no {definition, rev} pin: each refusal below is the one named.
const packs = { ...packPlan().packs, proposedHires: PACK.proposedHires.slice(0, 1) };
const go = (plan) => f.writer.applyPack("acme", plan, { revision: rev(f), actor: "jason", assertClean });
// Nothing is overwritten or removed by an apply.
assert.throws(() => go({ packs, board: LIST_BOARD }), { code: "invalid-request" });
assert.throws(() => go({ packs, workflows: { change: WORKFLOWS.change } }), { code: "invalid-request" });
assert.throws(() => go({ packs, workflows: { other: null } }), { code: "invalid-request" });
// The writer stamps at, by and added.
for (const key of ["at", "by", "added"]) assert.throws(() => go({ packs: { ...packs, [key]: PACK[key] }, workflows: { other: WORKFLOWS.change } }), { code: "invalid-request" }, key);
// An org pack writes no project key; a project pack writes only its project's keys.
assert.throws(() => go({ packs, "projects.stack.board": BOARD }), { code: "invalid-request" });
const projectPack = { ...packs, scope: "project", project: "stack" };
assert.throws(() => go({ packs: projectPack, workflows: { other: WORKFLOWS.change } }), { code: "invalid-request" });
assert.throws(() => go({ packs: { ...projectPack, project: "nowhere" }, "projects.nowhere.board": BOARD }), { code: "invalid-request" });
assert.throws(() => go({ "projects.nowhere.board": BOARD }), { code: "invalid-request" });
assert.equal(bytes(f), original);
go({ packs: projectPack, "projects.stack.board": BOARD, "projects.stack.workflows": WORKFLOWS });
assert.deepEqual(doc(f).packs[0].added, ["projects.stack.board", "projects.stack.workflows.change"]);
assert.deepEqual(doc(f).projects.stack.board, BOARD);
});
test("SP3 the generic writer still refuses project board changes", (t) => {
const f = fixture(t);
const next = structuredClone(f.document);
next.projects.stack.board = BOARD;
assert.throws(() => f.writer.write("acme", next, { revision: rev(f), actor: "jason" }), { code: "organization-projects-protected" });
});
test("SP3 an inline proposal keeps its full role entry beside the pin, and the pin is the entry's hash", (t) => {
const f = fixture(t);
const inline = PACK.proposedHires[2];
// The loaded organization keeps the entry, so the raise can read it back.
const loaded = load(f, { ...f.document, packs: [{ ...PACK, proposedHires: [inline] }] });
assert.deepEqual(loaded.packs[0].proposedHires[0], inline);
// A recorded hash that no longer matches still loads: staleness is the raise's refusal.
const stale = { ...inline, pin: { ...inline.pin, sha256: HASH } };
assert.deepEqual(load(f, { ...f.document, packs: [{ ...PACK, proposedHires: [stale] }] }).packs[0].proposedHires[0], stale);
// An apply records the entry, and refuses a pin that is not the entry's canonical hash.
const original = bytes(f);
const plan = (hire) => ({ packs: { ...packPlan().packs, proposedHires: [hire] }, workflows: WORKFLOWS });
assert.throws(() => f.writer.applyPack("acme", plan(stale), { revision: rev(f), actor: "chief-of-staff.chief", assertClean }), { code: "invalid-request" });
assert.throws(() => f.writer.applyPack("acme", plan({ ...inline, entry: { ...WRITER, instructions: "Something else.\n" } }), { revision: rev(f), actor: "chief-of-staff.chief", assertClean }), { code: "invalid-request" });
// A held secret in the entry refuses when the caller passes the check.
const leaky = { ...WRITER, instructions: SECRET };
assert.throws(() => f.writer.applyPack("acme", plan({ ...inline, pin: { ...inline.pin, sha256: sha256(canonicalJson(leaky)) }, entry: leaky }), { revision: rev(f), actor: "chief-of-staff.chief", assertClean }), /credential-leak/);
assert.equal(bytes(f), original);
// An agent apply writes no definition, so its hires are template or inline pins.
f.writer.applyPack("acme", plan(inline), { revision: rev(f), actor: "chief-of-staff.chief", assertClean });
assert.deepEqual(doc(f).packs[0].proposedHires, [inline]);
assert.equal(doc(f).definitions?.writer, undefined);
assert.deepEqual(load(f, doc(f)).packs[0].proposedHires, [inline]);
});
+9 -1
View File
@@ -84,6 +84,12 @@ export const KINDS = Object.freeze({
"system-type": Object.freeze({
kind: "system-type", interfaceVersion: 1, exports: Object.freeze(["systemType"]), switch: "list",
}),
// A starter pack (#1873 SP1): starterPack() takes nothing and returns a pack document, which
// starter-pack.mjs checks with the origin of where it was read. A pack is copied into an
// organization, never switched on, so its switch is "none": `plugins` can never name one.
"starter-pack": Object.freeze({
kind: "starter-pack", interfaceVersion: 1, exports: Object.freeze(["starterPack"]), switch: "none",
}),
// A Console colour theme (stream S4, PLAN C5). A module is data only: theme() takes nothing and returns
// { displayName, tokens: { light, dim, dark } }
// where each mode is a flat map of the Console's colour custom properties to #rrggbb strings. theme.mjs
@@ -118,8 +124,10 @@ const raise = (code, message, name) => {
};
// The module names one kind's setting switches on, checked against the
// kind's cardinality and the name pattern. A list may be empty.
// kind's cardinality and the name pattern. A list may be empty. A kind whose
// switch is "none" takes no setting at all.
export function namesFor(spec, setting, where, fail = raise) {
if (spec.switch === "none") fail("bad-setting", `${where} can't be set; a ${spec.kind} is never switched on`);
if (spec.switch === "one") {
if (typeof setting !== "string") fail("bad-setting", `${where} must be one module name`);
} else if (!Array.isArray(setting)) {
+482
View File
@@ -0,0 +1,482 @@
// Starter packs, slice SP1 (#1873): the pack kind's checker and its three readers. Design:
// starter-packs-design section 2 and SP1 in section 8.
//
// A pack is a declarative document that lists definitions, hires, a board style, workflows and
// skills to copy into an organization or a project. It grants nothing and never stays switched on.
// Three sources, each with the origin its reader sets (a closed set, never a field the document
// supplies):
// shipped a core module packages/plugins/starter-pack/<name>/index.mjs, through the loader;
// installed a PL1 hash-pinned plug-in of kind starter-pack, through the same loader;
// saved <configDir>/templates/<account>/<name>.json, read as data, never imported.
// Origin `saved` relaxes exactly two rules: it may carry `packs`, and a definition may have no
// `from`. Every other check runs for every origin.
//
// A refusal names a position (a key, an index) and names that already passed a strict pattern,
// never a value from the document and never a host path.
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { types } from "node:util";
import { ENTRY_KEYS, checkDefinition, readTemplate } from "../../organization/src/definitions.mjs";
import { parseJsonFileText } from "../../organization/src/util.mjs";
import { ID_PATTERN, NETWORKS } from "../../organization/src/vocabulary.mjs";
import { NAME, PluginError } from "./kinds.mjs";
import { hasModule, loadInstalled } from "./loader.mjs";
export const KIND = "starter-pack";
export const PACK_VERSION = 1;
export const ORIGINS = Object.freeze(["shipped", "installed", "saved"]);
// The task tracker's five states. The same list as STORE_STATES in packages/bus/src/broker.mjs;
// plugins does not import the bus, so the test pins the two together.
export const STORE_STATES = Object.freeze(["todo", "in-progress", "in-review", "blocked", "done"]);
export const LAYOUTS = Object.freeze(["kanban", "list"]);
export const HUMAN_STEP = "human";
export const PACK_BOUNDS = Object.freeze({
hires: 8, reason: 500, label: 64, description: 1000, columnLabel: 32,
steps: 20, stepTitle: 120, stepRoles: 8, workflowDescription: 1000,
skills: 64, packs: 32, version: 64,
});
export const SAVED_MAX_BYTES = 256 * 1024;
const TOP_KEYS = Object.freeze(["packVersion", "id", "version", "label", "description", "scope", "definitions",
"hires", "board", "workflows", "skills"]);
const SAVED_KEYS = Object.freeze([...TOP_KEYS, "packs"]);
const SCOPES = Object.freeze(["org", "project"]);
const REF_KEYS = Object.freeze(["template", "sha256"]);
// A pack definition is a T1 entry without rev; apply sets rev 1.
const PACK_ENTRY_KEYS = Object.freeze(ENTRY_KEYS.filter((key) => key !== "rev"));
const HIRE_KEYS = Object.freeze(["name", "definition", "reason"]);
const BOARD_KEYS = Object.freeze(["columns", "layout"]);
const COLUMN_KEYS = Object.freeze(["state", "label"]);
const WORKFLOW_KEYS = Object.freeze(["description", "steps"]);
const STEP_KEYS = Object.freeze(["id", "title", "roles", "state"]);
const PACKS_ENTRY_KEYS = Object.freeze(["id", "version"]);
const MESSAGING_KEYS = Object.freeze(["sendTo", "receiveFrom"]);
const HEX64 = /^[0-9a-f]{64}$/;
// Semantic Versioning 2.0.0, the pattern semver.org gives.
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
// The shipped roles, as organization-service.mjs resolves them.
const ROLES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../../../roles");
const fail = (code, message, name = null) => {
throw new PluginError(code, message, name);
};
const plain = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
const isSemver = (value) => typeof value === "string" && value.length <= PACK_BOUNDS.version && SEMVER.test(value);
// Non-blank, one line, no NUL, at most max characters.
const lineText = (value, max) => typeof value === "string" && value.trim().length > 0 && value.length <= max
&& !/[\r\n\0]/.test(value);
// ---- Narrowing ----
function credentialLines(credentials) {
const out = [];
for (const { service, scopes } of credentials ?? []) {
if (scopes === undefined) out.push([service, service]);
else if (Array.isArray(scopes)) for (const scope of scopes) out.push([service, scope]);
else for (const [group, verbs] of Object.entries(scopes)) for (const verb of verbs) out.push([service, `${group}.${verb}`]);
}
return out;
}
const listOf = (value) => (Array.isArray(value) ? value : []);
// Every line of `role` its template does not hold, as [{ field, value }], in the order
// checkComposition's step 7 reads them. A within-role action is held only within-role; a
// cross-role one either way. A template with no messaging key lets the role message anyone; a role
// with no messaging key against a template that has one is wider ("*"). `role` is a composition
// (checkDefinition's result, or a T1 role); `template` is a version 2 role document.
export function widerLines(role, template) {
const out = [];
const add = (field, value) => out.push({ field, value });
const within = listOf(template.authority?.withinRole);
const any = [...within, ...listOf(template.authority?.crossRole)];
listOf(role.authority?.withinRole).forEach((a) => { if (!within.includes(a)) add("authority.withinRole", a); });
listOf(role.authority?.crossRole).forEach((a) => { if (!any.includes(a)) add("authority.crossRole", a); });
const tools = listOf(template.tools);
listOf(role.tools).forEach((t) => { if (!tools.includes(t)) add("tools", t); });
const network = NETWORKS.indexOf(role.network);
if (network === -1 || network > NETWORKS.indexOf(template.network)) add("network", role.network);
const held = new Set(credentialLines(template.credentials).map(([s, v]) => `${s}\n${v}`));
for (const [service, value] of credentialLines(role.credentials)) {
if (!held.has(`${service}\n${value}`)) add(`credentials.${service}`, value);
}
const templateMessaging = template.messaging ?? null;
const roleMessaging = role.messaging ?? null;
if (templateMessaging) {
for (const key of MESSAGING_KEYS) {
if (roleMessaging === null) add(`messaging.${key}`, "*");
else listOf(roleMessaging[key]).forEach((t) => { if (!listOf(templateMessaging[key]).includes(t)) add(`messaging.${key}`, t); });
}
}
const skills = listOf(template.skills);
listOf(role.skills).forEach((s) => { if (!skills.includes(s)) add("skills", s); });
return out;
}
// True when every tool, action, skill, messaging target and credential request line of `role` is
// in `template`, and its network is no wider: "no grant above the templates".
export function narrowsTemplate(role, template) {
return widerLines(role, template).length === 0;
}
// ---- The checker ----
function keysOnly(object, allowed, where, id) {
Object.keys(object).forEach((key, i) => {
if (!allowed.includes(key)) fail("starter-pack-invalid", `${where} has an unknown key (key ${i + 1}; allowed: ${allowed.join(", ")})`, id);
});
}
function requireKeys(object, required, where, id) {
for (const key of required) {
if (!Object.hasOwn(object, key)) fail("starter-pack-invalid", `${where} requires "${key}"`, id);
}
}
// One template, read now: { role, sha256 } or null when the name is no usable version 2 template.
function templateOrNull(rolesDir, name) {
if (typeof name !== "string" || !ID_PATTERN.test(name)) return null;
try {
return readTemplate(rolesDir, name);
} catch (error) {
if (error?.name === "BusinessError") return null;
throw error;
}
}
function checkDefinitions(definitions, { origin, rolesDir, id }) {
if (!plain(definitions)) fail("starter-pack-invalid", "starter pack definitions must be a JSON object", id);
const templates = new Map();
const template = (name) => {
if (!templates.has(name)) templates.set(name, templateOrNull(rolesDir, name));
return templates.get(name);
};
Object.keys(definitions).forEach((name, i) => {
if (!ID_PATTERN.test(name)) fail("starter-pack-invalid", `starter pack definitions key ${i + 1} must be a definition name matching ${ID_PATTERN}`, id);
const at = `starter pack definitions.${name}`;
const entry = definitions[name];
if (!plain(entry)) fail("starter-pack-invalid", `${at} must be a JSON object`, id);
const keys = Object.keys(entry);
if (keys.length === REF_KEYS.length && REF_KEYS.every((k) => keys.includes(k))) {
// A template reference: the whole template, pinned by its hash.
if (typeof entry.template !== "string" || !ID_PATTERN.test(entry.template)) fail("starter-pack-invalid", `${at}.template must be a template name matching ${ID_PATTERN}`, id);
if (typeof entry.sha256 !== "string" || !HEX64.test(entry.sha256)) fail("starter-pack-invalid", `${at}.sha256 must be 64 lowercase hex characters`, id);
const t = template(entry.template);
if (!t) fail("starter-pack-invalid", `${at} names template ${entry.template}, which is not a version 2 role`, id);
if (t.sha256 !== entry.sha256) fail("starter-pack-template-changed", `${at} pins template ${entry.template} at a hash it no longer has`, id);
return;
}
// A full entry in the T1 shape, without rev.
keysOnly(entry, PACK_ENTRY_KEYS, at, id);
let composition;
try {
({ composition } = checkDefinition(name, { ...entry, rev: 1 }, { where: "starter pack definitions" }));
} catch (error) {
// definitions.mjs and composition.mjs quote only names that passed a strict pattern.
if (error?.name === "BusinessError") fail("starter-pack-invalid", error.message, id);
throw error;
}
if (entry.from === undefined) {
if (origin !== "saved") fail("starter-pack-invalid", `${at} has no "from"; only a saved template may carry a role that copies no template`, id);
return;
}
const t = template(entry.from.template);
if (!t) fail("starter-pack-invalid", `${at}.from names template ${entry.from.template}, which is not a version 2 role`, id);
if (t.sha256 !== entry.from.sha256) fail("starter-pack-template-changed", `${at}.from pins template ${entry.from.template} at a hash it no longer has`, id);
const wider = widerLines(composition, t.role);
if (wider.length) {
const fields = [...new Set(wider.map((l) => l.field))].join(", ");
fail("starter-pack-invalid", `${at}.role is wider than template ${entry.from.template} (${fields})`, id);
}
});
}
function checkHires(hires, definitions, id) {
if (!Array.isArray(hires)) fail("starter-pack-invalid", "starter pack hires must be an array", id);
if (hires.length > PACK_BOUNDS.hires) fail("starter-pack-invalid", `starter pack hires must hold at most ${PACK_BOUNDS.hires} entries`, id);
const names = new Set();
hires.forEach((hire, i) => {
const at = `starter pack hires[${i}]`;
if (!plain(hire)) fail("starter-pack-invalid", `${at} must be a JSON object`, id);
// No level and no route: a hire is org-level in an org pack, project-level in a project pack,
// never root, and the raiser supplies the route.
keysOnly(hire, HIRE_KEYS, at, id);
requireKeys(hire, HIRE_KEYS, at, id);
if (typeof hire.name !== "string" || !ID_PATTERN.test(hire.name)) fail("starter-pack-invalid", `${at}.name must match ${ID_PATTERN}`, id);
if (names.has(hire.name)) fail("starter-pack-invalid", `${at}.name repeats an earlier hire`, id);
names.add(hire.name);
if (typeof hire.definition !== "string" || !Object.hasOwn(definitions, hire.definition)) {
fail("starter-pack-invalid", `${at}.definition must name a definition in this pack`, id);
}
if (typeof hire.reason !== "string" || hire.reason.trim().length === 0 || hire.reason.length > PACK_BOUNDS.reason || hire.reason.includes("\0")) {
fail("starter-pack-invalid", `${at}.reason must be a non-blank string of at most ${PACK_BOUNDS.reason} characters`, id);
}
});
}
function checkBoard(board, id) {
if (!plain(board)) fail("starter-pack-invalid", "starter pack board must be a JSON object", id);
keysOnly(board, BOARD_KEYS, "starter pack board", id);
requireKeys(board, BOARD_KEYS, "starter pack board", id);
if (!LAYOUTS.includes(board.layout)) fail("starter-pack-invalid", `starter pack board.layout must be one of ${LAYOUTS.join(", ")}`, id);
const columns = board.columns;
if (!Array.isArray(columns) || columns.length !== STORE_STATES.length) {
fail("starter-pack-invalid", `starter pack board.columns must hold the ${STORE_STATES.length} task states, each once`, id);
}
const seen = new Set();
columns.forEach((column, i) => {
const at = `starter pack board.columns[${i}]`;
if (!plain(column)) fail("starter-pack-invalid", `${at} must be a JSON object`, id);
keysOnly(column, COLUMN_KEYS, at, id);
requireKeys(column, COLUMN_KEYS, at, id);
if (!STORE_STATES.includes(column.state)) fail("starter-pack-invalid", `${at}.state must be one of ${STORE_STATES.join(", ")}`, id);
if (seen.has(column.state)) fail("starter-pack-invalid", `${at}.state ${column.state} repeats an earlier column`, id);
seen.add(column.state);
if (!lineText(column.label, PACK_BOUNDS.columnLabel)) fail("starter-pack-invalid", `${at}.label must be one line of 1 to ${PACK_BOUNDS.columnLabel} characters`, id);
});
}
function checkWorkflows(workflows, definitions, { rolesDir, id }) {
if (!plain(workflows)) fail("starter-pack-invalid", "starter pack workflows must be a JSON object", id);
const templates = new Map();
const isTemplate = (name) => {
if (!templates.has(name)) templates.set(name, templateOrNull(rolesDir, name) !== null);
return templates.get(name);
};
Object.keys(workflows).forEach((name, i) => {
if (!NAME.test(name)) fail("starter-pack-invalid", `starter pack workflows key ${i + 1} must match ${NAME}`, id);
const at = `starter pack workflows.${name}`;
const workflow = workflows[name];
if (!plain(workflow)) fail("starter-pack-invalid", `${at} must be a JSON object`, id);
keysOnly(workflow, WORKFLOW_KEYS, at, id);
requireKeys(workflow, WORKFLOW_KEYS, at, id);
if (typeof workflow.description !== "string" || workflow.description.trim().length === 0
|| workflow.description.length > PACK_BOUNDS.workflowDescription || workflow.description.includes("\0")) {
fail("starter-pack-invalid", `${at}.description must be a non-blank string of at most ${PACK_BOUNDS.workflowDescription} characters`, id);
}
const steps = workflow.steps;
if (!Array.isArray(steps) || steps.length < 1 || steps.length > PACK_BOUNDS.steps) {
fail("starter-pack-invalid", `${at}.steps must hold 1 to ${PACK_BOUNDS.steps} steps`, id);
}
const ids = new Set();
steps.forEach((step, j) => {
const where = `${at}.steps[${j}]`;
if (!plain(step)) fail("starter-pack-invalid", `${where} must be a JSON object`, id);
keysOnly(step, STEP_KEYS, where, id);
requireKeys(step, STEP_KEYS, where, id);
if (typeof step.id !== "string" || !NAME.test(step.id)) fail("starter-pack-invalid", `${where}.id must match ${NAME}`, id);
if (ids.has(step.id)) fail("starter-pack-invalid", `${where}.id repeats an earlier step`, id);
ids.add(step.id);
if (!lineText(step.title, PACK_BOUNDS.stepTitle)) fail("starter-pack-invalid", `${where}.title must be one line of 1 to ${PACK_BOUNDS.stepTitle} characters`, id);
if (!STORE_STATES.includes(step.state)) fail("starter-pack-invalid", `${where}.state must be one of ${STORE_STATES.join(", ")}`, id);
const roles = step.roles;
if (!Array.isArray(roles) || roles.length < 1 || roles.length > PACK_BOUNDS.stepRoles) {
fail("starter-pack-invalid", `${where}.roles must hold 1 to ${PACK_BOUNDS.stepRoles} names`, id);
}
const seen = new Set();
roles.forEach((role, k) => {
const ok = typeof role === "string"
&& (role === HUMAN_STEP || Object.hasOwn(definitions, role) || isTemplate(role));
if (!ok) fail("starter-pack-invalid", `${where}.roles[${k}] must be a definition in this pack, a version 2 template or "${HUMAN_STEP}"`, id);
if (seen.has(role)) fail("starter-pack-invalid", `${where}.roles[${k}] repeats an earlier name`, id);
seen.add(role);
});
});
});
}
function checkNameList(value, where, max, id) {
if (!Array.isArray(value)) fail("starter-pack-invalid", `${where} must be an array`, id);
if (value.length > max) fail("starter-pack-invalid", `${where} must hold at most ${max} entries`, id);
const seen = new Set();
value.forEach((name, i) => {
if (typeof name !== "string" || !NAME.test(name)) fail("starter-pack-invalid", `${where}[${i}] must match ${NAME}`, id);
if (seen.has(name)) fail("starter-pack-invalid", `${where}[${i}] repeats an earlier entry`, id);
seen.add(name);
});
}
function checkPacksList(packs, id) {
if (!Array.isArray(packs)) fail("starter-pack-invalid", "starter pack packs must be an array", id);
if (packs.length > PACK_BOUNDS.packs) fail("starter-pack-invalid", `starter pack packs must hold at most ${PACK_BOUNDS.packs} entries`, id);
packs.forEach((entry, i) => {
const at = `starter pack packs[${i}]`;
if (!plain(entry)) fail("starter-pack-invalid", `${at} must be a JSON object`, id);
keysOnly(entry, PACKS_ENTRY_KEYS, at, id);
requireKeys(entry, PACKS_ENTRY_KEYS, at, id);
if (typeof entry.id !== "string" || !NAME.test(entry.id)) fail("starter-pack-invalid", `${at}.id must match ${NAME}`, id);
if (!isSemver(entry.version)) fail("starter-pack-invalid", `${at}.version must be a semantic version`, id);
});
}
// Checks one pack document for its origin and returns a checked copy; the input is not read again
// after the copy. options.rolesDir is the shipped roles directory (tests pass their own);
// options.name, for a module, is the module's name, which the pack's id must equal.
export function checkPack(doc, { origin, rolesDir = ROLES_DIR, name } = {}) {
if (!ORIGINS.includes(origin)) fail("starter-pack-invalid", `starter pack origin must be one of ${ORIGINS.join(", ")}`);
let pack;
try {
pack = JSON.parse(JSON.stringify(doc));
} catch {
fail("starter-pack-invalid", "starter pack must be a JSON document");
}
if (!plain(pack)) fail("starter-pack-invalid", "starter pack must be a JSON object");
const id = typeof pack.id === "string" && NAME.test(pack.id) ? pack.id : null;
keysOnly(pack, origin === "saved" ? SAVED_KEYS : TOP_KEYS, "starter pack", id);
requireKeys(pack, TOP_KEYS, "starter pack", id);
if (pack.packVersion !== PACK_VERSION) fail("starter-pack-invalid", `starter pack packVersion must be ${PACK_VERSION}`, id);
if (id === null) fail("starter-pack-invalid", `starter pack id must match ${NAME}`);
if (name !== undefined && id !== name) fail("starter-pack-invalid", `starter pack id must equal its module name ${name}`, id);
if (!isSemver(pack.version)) fail("starter-pack-invalid", "starter pack version must be a semantic version", id);
if (!lineText(pack.label, PACK_BOUNDS.label)) fail("starter-pack-invalid", `starter pack label must be one line of 1 to ${PACK_BOUNDS.label} characters`, id);
if (!lineText(pack.description, PACK_BOUNDS.description)) fail("starter-pack-invalid", `starter pack description must be one line of 1 to ${PACK_BOUNDS.description} characters`, id);
if (!SCOPES.includes(pack.scope)) fail("starter-pack-invalid", `starter pack scope must be one of ${SCOPES.join(", ")}`, id);
checkDefinitions(pack.definitions, { origin, rolesDir, id });
checkHires(pack.hires, pack.definitions, id);
checkBoard(pack.board, id);
checkWorkflows(pack.workflows, pack.definitions, { rolesDir, id });
checkNameList(pack.skills, "starter pack skills", PACK_BOUNDS.skills, id);
if (Object.hasOwn(pack, "packs")) checkPacksList(pack.packs, id);
return pack;
}
// ---- Shipped and installed packs ----
// Lists shipped and installed packs through the loader, each checked with the origin of where it
// came from. A refused core pack fails the listing, as system types do. A refused installed pack is
// omitted and logged. Returns { packs: [{ id, origin, pack }], refused }.
export async function installedStarterPacks({ dataRoot = null, rolesDir = ROLES_DIR, log = () => {} } = {}) {
const { modules, refused: loadRefused } = await loadInstalled(KIND, { dataRoot });
const refused = [];
const omit = (name, error) => {
const entry = { event: "starter-pack-refused", kind: KIND, name, code: error.code, message: error.message };
log(entry);
refused.push(entry);
};
for (const entry of loadRefused) {
// A refusal with no name, or under a core module's name, is the core tree: fail closed.
if (entry.name === null || hasModule(KIND, entry.name)) {
fail("starter-pack-invalid", "a shipped starter pack was refused", entry.name);
}
omit(entry.name, entry);
}
const packs = [];
for (const { name, module, installed } of modules) {
const origin = installed ? "installed" : "shipped";
try {
let doc;
try {
doc = module.starterPack();
} catch {
fail("starter-pack-invalid", `starter pack ${name} failed to describe itself`, name);
}
// A pack is returned, never awaited: a promise refuses below, and a later rejection of it
// must not reach the process as an unhandled one. Only a native promise is recognised, and
// the built-in then is used, so the module's own then or getter never runs here; any other
// object goes to checkPack, whose closed key sets refuse it.
if (types.isPromise(doc)) {
Promise.prototype.then.call(doc, undefined, () => {});
fail("starter-pack-invalid", `starter pack ${name} must return its document, not a promise`, name);
}
packs.push({ id: name, origin, pack: checkPack(doc, { origin, rolesDir, name }) });
} catch (error) {
if (!(error instanceof PluginError)) throw error;
if (origin === "shipped") throw error;
omit(name, error);
}
}
return { packs, refused };
}
// ---- Saved templates ----
function lstatOrNull(path) {
try {
return lstatSync(path);
} catch (error) {
if (error.code === "ENOENT" || error.code === "ENOTDIR") return null;
fail("starter-pack-invalid", "the saved template can't be read");
}
}
// A directory on the way to a saved template: a real directory owned by the instance's user that
// no group or other user can write. `private` also refuses group and other read and list, for the
// account's own directory, which the writer creates 0700.
function checkDirectory(path, uid, { private: closed = false } = {}) {
const st = lstatOrNull(path);
if (st === null) fail("starter-pack-not-found", "no saved template has that name");
if (st.isSymbolicLink() || !st.isDirectory()) fail("starter-pack-invalid", "the saved templates directory must be a real directory");
if (st.uid !== uid) fail("starter-pack-invalid", "the saved templates directory must belong to the instance's user");
if ((st.mode & 0o022) !== 0) fail("starter-pack-invalid", "the saved templates directory must not be writable by group or others");
if (closed && (st.mode & 0o077) !== 0) fail("starter-pack-invalid", "the account's saved templates directory must be mode 0700 or narrower");
}
// Reads <configDir>/templates/<account>/<name>.json as data and checks it with origin `saved`. The
// account is the calling owner's id (s.human), checked against ID_PATTERN; the name is checked
// against NAME. Both are checked before any path is built, so neither ".." nor "/" reaches the file
// system. The account directory must be mode 0700 or narrower. The file must be a regular file, not
// a symlink or a hard link, owned by the instance's user, mode 0600 or narrower, at most 256 KiB,
// checked before the read. Returns { name, origin: "saved", pack }.
export function readSavedTemplate({ configDir, account, name, rolesDir = ROLES_DIR, uid = process.getuid() } = {}) {
if (typeof account !== "string" || !ID_PATTERN.test(account)) fail("starter-pack-invalid", `the saved template account must match ${ID_PATTERN}`);
if (typeof name !== "string" || !NAME.test(name)) fail("starter-pack-invalid", `the saved template name must match ${NAME}`);
if (typeof configDir !== "string" || !isAbsolute(configDir)) fail("starter-pack-invalid", "the configuration directory must be an absolute path");
const templates = join(configDir, "templates");
const accountDir = join(templates, account);
checkDirectory(templates, uid);
checkDirectory(accountDir, uid, { private: true });
const file = join(accountDir, `${name}.json`);
const before = lstatOrNull(file);
if (before === null) fail("starter-pack-not-found", "no saved template has that name", name);
if (before.isSymbolicLink() || !before.isFile()) fail("starter-pack-invalid", "the saved template must be a regular file, not a symlink", name);
let fd;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
} catch (error) {
if (error.code === "ENOENT") fail("starter-pack-not-found", "no saved template has that name", name);
if (error.code === "ELOOP") fail("starter-pack-invalid", "the saved template must be a regular file, not a symlink", name);
fail("starter-pack-invalid", "the saved template can't be read", name);
}
let bytes;
try {
const st = fstatSync(fd);
if (!st.isFile() || st.dev !== before.dev || st.ino !== before.ino) fail("starter-pack-invalid", "the saved template changed while it was read", name);
if (st.uid !== uid) fail("starter-pack-invalid", "the saved template must belong to the instance's user", name);
if (st.nlink === 0) fail("starter-pack-invalid", "the saved template changed while it was read", name);
if (st.nlink !== 1) fail("starter-pack-invalid", "the saved template must not be a hard link", name);
if ((st.mode & 0o077) !== 0) fail("starter-pack-invalid", "the saved template must be mode 0600 or narrower", name);
if (st.size > SAVED_MAX_BYTES) fail("starter-pack-invalid", `the saved template must be at most ${SAVED_MAX_BYTES} bytes`, name);
// Read at most one byte past the limit, so a file that grew after the fstat still refuses.
const buffer = Buffer.alloc(SAVED_MAX_BYTES + 1);
let length = 0;
for (;;) {
const n = readSync(fd, buffer, length, buffer.length - length, null);
if (n === 0) break;
length += n;
if (length > SAVED_MAX_BYTES) fail("starter-pack-invalid", `the saved template grew past ${SAVED_MAX_BYTES} bytes while it was read`, name);
}
bytes = buffer.subarray(0, length);
} finally {
closeSync(fd);
}
let text;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
fail("starter-pack-invalid", "the saved template is not valid UTF-8", name);
}
let doc;
try {
doc = parseJsonFileText(text, file, "saved template");
} catch {
fail("starter-pack-invalid", "the saved template is not valid JSON", name);
}
return { name, origin: "saved", pack: checkPack(doc, { origin: "saved", rolesDir }) };
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs";
const code = expected => e => e instanceof PluginError && e.code === expected;
test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1, limit-check at 2 (row 125), each a list", () => {
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "system-type", "theme", "task-tracker"]);
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "system-type", "starter-pack", "theme", "task-tracker"]);
assert.deepEqual({ ...KINDS["task-tracker"], exports: [...KINDS["task-tracker"].exports] }, { kind: "task-tracker", interfaceVersion: 1, exports: ["adapter"], switch: "one" });
assert.ok(Object.isFrozen(KINDS["task-tracker"]) && Object.isFrozen(KINDS["task-tracker"].exports));
// Stream S4 (PLAN C5): a theme is data. interfaceVersion 1, one export, a list switch.
+5 -5
View File
@@ -12,7 +12,7 @@ import { validateBusinessDocument } from "../../organization/src/organization-co
const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] };
// What loaded() reports for GOOD: search-source is a kind too, switched off here.
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] };
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], "starter-pack": [], theme: [], "task-tracker": [] };
// A tree with one good module of each kind, already loaded.
async function loadedTree(t, options) {
@@ -56,7 +56,7 @@ test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization
const loader = await s.loader();
const result = await loader.load(business.plugins);
assert.deepEqual(result.refused, []);
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], "starter-pack": [], theme: [], "task-tracker": [] });
assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage");
assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit");
assert.equal(loader.modules("limit-check")[0].mustStop(), false);
@@ -171,7 +171,7 @@ test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses a
assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/);
assert.deepEqual(result.refused[0].kept, ["meter"]);
assert.deepEqual(s.loader.logged.slice(before), result.refused);
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], "starter-pack": [], theme: [], "task-tracker": [] });
} finally {
if (lock) chmodSync(lock, 0o755);
}
@@ -233,7 +233,7 @@ test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and t
const loader = await s.loader();
// meter loads, absent refuses: the whole kind stays off, not half on.
const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], "starter-pack": [], theme: [], "task-tracker": [] });
assert.deepEqual(loader.loaded(), result.loaded);
assert.deepEqual(loader.modules("usage-source"), []);
assert.equal(loader.logged.length, 1);
@@ -261,7 +261,7 @@ test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole se
await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/);
// An absent key switches the kind off.
result = await s.loader.load({ "limit-check": ["cap"] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], "starter-pack": [], theme: [], "task-tracker": [] });
});
test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => {
@@ -0,0 +1,609 @@
// #1873 SP1: the starter-pack kind, checkPack, narrowsTemplate and the three readers.
// The starter-pack module is imported lazily so each test reports its own failure on a tree
// without it (the red-before run).
import test from "node:test";
import assert from "node:assert/strict";
import { chmodSync, cpSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath, pathToFileURL } from "node:url";
import { KINDS, PluginError, checkPlugins, namesFor } from "../src/kinds.mjs";
import { createLoader } from "../src/loader.mjs";
import { pluginHash } from "../src/pinned.mjs";
import { installPlugin } from "../src/install.mjs";
import { templateHash } from "../../organization/src/definitions.mjs";
import { REFUSALS } from "../../bus/src/refusals.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, "../../..");
const ROLES = join(REPO, "roles");
const MARK = "MARK-sp1-not-echoed-7c1f";
let loaded;
const sp = async () => (loaded ??= await import("../src/starter-pack.mjs"));
const code = (expected, message) => (e) => {
assert.equal(e?.name, "PluginError", e?.stack);
assert.equal(e.code, expected, e.message);
if (message) assert.match(e.message, message);
assert.ok(!e.message.includes(MARK), e.message);
assert.ok(!e.message.includes(REPO), e.message);
return true;
};
const H = { coder: templateHash(ROLES, "coder"), reviewer: templateHash(ROLES, "reviewer") };
const writer = () => ({
from: { template: "coder", sha256: H.coder },
role: {
tools: ["read", "grep"], network: "none",
authority: { withinRole: ["record.write"], crossRole: ["task.scope.change"] },
credentials: [{ service: "gitea", scopes: ["read:user"] }, { service: "vikunja", scopes: { tasks: ["read_one"] } }],
skills: ["ms-unslop"],
},
instructions: "Write the release notes for each merged change.",
personality: { summary: "Plain and short." },
});
const columns = () => [
{ state: "todo", label: "Backlog" }, { state: "in-progress", label: "Building" },
{ state: "in-review", label: "Review" }, { state: "blocked", label: "Blocked" }, { state: "done", label: "Merged" },
];
const pack = (patch = (p) => p) => {
const p = {
packVersion: 1, id: "coding", version: "1.0.0", label: "Coding",
description: "A small software team with review before merge.", scope: "org",
definitions: { coder: { template: "coder", sha256: H.coder }, reviewer: { template: "reviewer", sha256: H.reviewer }, "release-writer": writer() },
hires: [
{ name: "coder-1", definition: "coder", reason: "writes the code" },
{ name: "reviewer-1", definition: "reviewer", reason: "reviews each change" },
],
board: { columns: columns(), layout: "kanban" },
workflows: {
change: {
description: "One change from task to merge.",
steps: [
{ id: "plan", title: "Write the task", roles: ["pm"], state: "todo" },
{ id: "build", title: "Implement", roles: ["coder", "release-writer"], state: "in-progress" },
{ id: "review", title: "Review", roles: ["reviewer"], state: "in-review" },
{ id: "merge", title: "Approve the merge", roles: ["human"], state: "done" },
],
},
},
skills: ["ms-sdlc", "ms-unslop"],
};
return patch(p) ?? p;
};
const shipped = (doc) => sp().then((m) => m.checkPack(doc, { origin: "shipped", rolesDir: ROLES }));
const saved = (doc) => sp().then((m) => m.checkPack(doc, { origin: "saved", rolesDir: ROLES }));
// ---- Red before ----
test("SP1 red before: a definition carrying copyBindings refuses starter-pack-invalid", async () => {
const doc = pack((p) => { p.definitions["release-writer"].copyBindings = { gitea: { copyId: MARK } }; });
await assert.rejects(shipped(doc), code("starter-pack-invalid"));
});
test("SP1 red before: plugins naming starter-pack refuses bad-setting, never switching a pack on", () => {
assert.throws(() => checkPlugins({ "starter-pack": ["coding"] }, "plugins"), (e) => e instanceof PluginError && e.code === "bad-setting");
});
// ---- The kind ----
test("SP1 kind: starter-pack at interface 1, export starterPack, switch none; namesFor refuses every setting", async () => {
assert.deepEqual({ ...KINDS["starter-pack"], exports: [...KINDS["starter-pack"].exports] },
{ kind: "starter-pack", interfaceVersion: 1, exports: ["starterPack"], switch: "none" });
assert.ok(Object.isFrozen(KINDS["starter-pack"]) && Object.isFrozen(KINDS["starter-pack"].exports));
for (const setting of [[], ["coding"], "coding", null, {}]) {
assert.throws(() => namesFor(KINDS["starter-pack"], setting, "plugins.starter-pack"), (e) => e instanceof PluginError && e.code === "bad-setting");
assert.throws(() => checkPlugins({ "starter-pack": setting }, "plugins"), (e) => e instanceof PluginError && e.code === "bad-setting");
}
// The loader switches nothing on for the kind: present, the key is refused; absent, it loads [].
const log = [];
const loader = createLoader({ log: (e) => log.push(e) });
const first = await loader.load({ "starter-pack": ["coding"] });
assert.deepEqual(first.loaded["starter-pack"], []);
assert.deepEqual(first.refused.map((r) => [r.kind, r.code]), [["starter-pack", "bad-setting"]]);
const second = await loader.load({});
assert.deepEqual(second.loaded["starter-pack"], []);
assert.deepEqual(second.refused, []);
});
test("SP1 refusal table: each new code has a rail and a next step that names no path", () => {
for (const c of ["starter-pack-invalid", "starter-pack-not-found", "starter-pack-template-changed"]) {
assert.ok(Object.hasOwn(REFUSALS, c), c);
assert.ok(REFUSALS[c].rail?.length > 5, c);
assert.ok(REFUSALS[c].nextStep?.length > 20, c);
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[c].nextStep), c);
}
});
test("SP1 STORE_STATES matches the bus's task states", async () => {
const { STORE_STATES } = await sp();
const broker = readFileSync(join(REPO, "packages/bus/src/broker.mjs"), "utf8");
const m = /export const STORE_STATES = Object\.freeze\((\[[^\]]*\])\)/.exec(broker);
assert.ok(m);
assert.deepEqual([...STORE_STATES], JSON.parse(m[1].replaceAll("'", '"')));
});
// ---- checkPack ----
test("SP1 a valid pack loads for every origin and comes back as a checked copy", async () => {
const { checkPack } = await sp();
for (const origin of ["shipped", "installed", "saved"]) {
const doc = pack();
const out = checkPack(doc, { origin, rolesDir: ROLES });
assert.deepEqual(out, doc);
assert.notEqual(out, doc);
assert.notEqual(out.definitions, doc.definitions);
}
// The shipped roles are the default.
assert.deepEqual(checkPack(pack(), { origin: "shipped" }), pack());
// A project pack, empty lists and a list layout load too.
const small = pack((p) => { p.scope = "project"; p.hires = []; p.workflows = {}; p.skills = []; p.board.layout = "list"; p.board.columns.reverse(); });
assert.deepEqual(checkPack(small, { origin: "shipped", rolesDir: ROLES }), small);
});
test("SP1 the origin is a closed set and a module's pack id must equal its name", async () => {
const { checkPack } = await sp();
for (const origin of [undefined, "core", "Saved", MARK]) {
assert.throws(() => checkPack(pack(), { origin, rolesDir: ROLES }), code("starter-pack-invalid"));
}
assert.throws(() => checkPack(pack(), { origin: "shipped", rolesDir: ROLES, name: "other" }), code("starter-pack-invalid"));
assert.ok(checkPack(pack(), { origin: "shipped", rolesDir: ROLES, name: "coding" }));
});
test("SP1 a forbidden key refuses, at the top, in a definition, a role, a hire, the board and a step", async () => {
const forbidden = [
(p) => { p.plugins = { "task-tracker": MARK }; },
(p) => { p.launch = { model: MARK }; },
(p) => { p.members = [MARK]; },
(p) => { p.credentials = [MARK]; },
(p) => { p.definitions["release-writer"].credentials = { token: MARK }; },
(p) => { p.definitions["release-writer"].rev = 1; },
(p) => { p.definitions["release-writer"].role.copyBindings = MARK; },
(p) => { p.definitions.coder.copyId = MARK; },
(p) => { p.hires[0].route = MARK; },
(p) => { p.board.theme = MARK; },
(p) => { p.board.columns[0].color = MARK; },
(p) => { p.workflows.change.title = MARK; },
(p) => { p.workflows.change.steps[0].assignee = MARK; },
];
for (const mutate of forbidden) await assert.rejects(shipped(pack(mutate)), code("starter-pack-invalid"));
});
test("SP1 a stale template hash refuses starter-pack-template-changed, for a reference and a from", async () => {
const stale = "0".repeat(64);
await assert.rejects(shipped(pack((p) => { p.definitions.coder.sha256 = stale; })), code("starter-pack-template-changed"));
await assert.rejects(shipped(pack((p) => { p.definitions["release-writer"].from.sha256 = stale; })), code("starter-pack-template-changed"));
await assert.rejects(saved(pack((p) => { p.definitions["release-writer"].from.sha256 = stale; })), code("starter-pack-template-changed"));
// A name that is no version 2 template, and a bad hash shape, are invalid, not changed.
await assert.rejects(shipped(pack((p) => { p.definitions.coder.template = "no-such-role"; })), code("starter-pack-invalid"));
await assert.rejects(shipped(pack((p) => { p.definitions.coder.template = "conductor-policy"; })), code("starter-pack-invalid"));
await assert.rejects(shipped(pack((p) => { p.definitions.coder.sha256 = MARK; })), code("starter-pack-invalid"));
await assert.rejects(shipped(pack((p) => { p.definitions.coder.template = `../${MARK}`; })), code("starter-pack-invalid"));
});
test("SP1 a role wider than its template refuses: a tool, an action, the network, a skill, within for cross", async () => {
const role = (f) => pack((p) => { f(p.definitions["release-writer"].role); });
for (const f of [
(r) => { r.network = "open"; },
(r) => { r.skills.push("ms-git-operations"); },
(r) => { r.authority.withinRole = ["record.write", "task.scope.change"]; r.authority.crossRole = []; },
]) await assert.rejects(shipped(role(f)), code("starter-pack-invalid"));
// An action the template does not hold. pm's within-role actions include one coder lacks.
const pm = JSON.parse(readFileSync(join(ROLES, "pm.json"), "utf8"));
const coder = JSON.parse(readFileSync(join(ROLES, "coder.json"), "utf8"));
const extra = pm.authority.withinRole.find((a) => ![...coder.authority.withinRole, ...coder.authority.crossRole].includes(a)
&& !a.startsWith("decision.resolve.") && !["role.hire", "role.fire", "role.change"].includes(a));
assert.ok(extra, "pm holds a within-role action coder lacks");
await assert.rejects(shipped(role((r) => { r.authority.withinRole.push(extra); })), code("starter-pack-invalid"));
// A tool coder lacks.
await assert.rejects(shipped(role((r) => { r.tools.push("web"); r.network = "api-only"; })), code("starter-pack-invalid"));
// A saved role with a from narrows too.
await assert.rejects(saved(role((r) => { r.network = "open"; })), code("starter-pack-invalid"));
// A cross-role action of the template may be held cross-role; a within-role one may move to cross.
const ok = role((r) => { r.authority.withinRole = []; r.authority.crossRole = ["task.scope.change", "record.write"]; });
assert.ok(await shipped(ok));
});
test("SP1 a credential line wider than its template refuses", async () => {
const cred = (credentials) => pack((p) => { p.definitions["release-writer"].role.credentials = credentials; });
for (const credentials of [
[{ service: "gitea", scopes: ["write:user"] }],
[{ service: "gitea", scopes: ["read:user", "write:organization"] }],
[{ service: "vikunja", scopes: { tasks: ["create"] } }],
[{ service: "vikunja", scopes: { labels: ["read_all"] } }],
]) await assert.rejects(shipped(cred(credentials)), code("starter-pack-invalid"));
assert.ok(await shipped(cred([])));
assert.ok(await shipped(cred([{ service: "vikunja", scopes: { tasks: ["read_one", "update"], projects: ["views_buckets_tasks"] } }])));
});
test("SP1 narrowsTemplate and widerLines read every line the way step 7 does", async () => {
const { narrowsTemplate, widerLines } = await sp();
const template = {
tools: ["read", "grep"], network: "api-only",
authority: { withinRole: ["record.write"], crossRole: ["task.scope.change"] },
credentials: [{ service: "gitea", scopes: ["read:user"] }, { service: "vikunja", scopes: { tasks: ["read_one"] } }, { service: "host.box" }],
messaging: { sendTo: ["pm"], receiveFrom: ["pm", "coder"] }, skills: ["ms-unslop"],
};
const base = { tools: ["read"], network: "none", authority: { withinRole: [], crossRole: ["record.write"] },
credentials: [{ service: "host.box" }, { service: "vikunja", scopes: { tasks: ["read_one"] } }],
messaging: { sendTo: [], receiveFrom: ["coder"] }, skills: [] };
assert.equal(narrowsTemplate(base, template), true);
assert.deepEqual(widerLines({ ...base, messaging: null }, template), [
{ field: "messaging.sendTo", value: "*" }, { field: "messaging.receiveFrom", value: "*" }]);
assert.deepEqual(widerLines({ ...base, messaging: { sendTo: ["coder"], receiveFrom: [] } }, template), [{ field: "messaging.sendTo", value: "coder" }]);
// A template with no messaging lets the role message anyone.
assert.equal(narrowsTemplate({ ...base, messaging: { sendTo: ["x"], receiveFrom: ["y"] } }, { ...template, messaging: null }), true);
assert.deepEqual(widerLines({ ...base, network: "open", tools: ["bash"], skills: ["s"],
authority: { withinRole: ["task.scope.change"], crossRole: ["x.y"] },
credentials: [{ service: "gitea" }, { service: "gitea", scopes: ["write:user"] }, { service: "vikunja", scopes: { tasks: ["update"] } }] }, template), [
{ field: "authority.withinRole", value: "task.scope.change" }, { field: "authority.crossRole", value: "x.y" },
{ field: "tools", value: "bash" }, { field: "network", value: "open" },
{ field: "credentials.gitea", value: "gitea" }, { field: "credentials.gitea", value: "write:user" },
{ field: "credentials.vikunja", value: "tasks.update" }, { field: "skills", value: "s" },
]);
assert.equal(narrowsTemplate({ ...base, network: "bogus" }, template), false);
});
test("SP1 a root hire, a ninth hire, a hire naming no pack definition and other bad hires refuse", async () => {
const hire = (i) => ({ name: `coder-${i}`, definition: "coder", reason: "writes code" });
for (const mutate of [
(p) => { p.hires[0].level = "root"; },
(p) => { p.hires[0].level = "org"; },
(p) => { p.hires = Array.from({ length: 9 }, (_, i) => hire(i + 1)); },
(p) => { p.hires[0].definition = "pm"; },
(p) => { p.hires[0].definition = "__proto__"; },
(p) => { p.hires[1].name = p.hires[0].name; },
(p) => { p.hires[0].name = "Root/../x"; },
(p) => { p.hires[0].reason = " "; },
(p) => { p.hires[0].reason = "x".repeat(501); },
(p) => { delete p.hires[0].reason; },
(p) => { p.hires = {}; },
]) await assert.rejects(shipped(pack(mutate)), code("starter-pack-invalid"));
assert.ok(await shipped(pack((p) => { p.hires = Array.from({ length: 8 }, (_, i) => hire(i + 1)); })));
});
test("SP1 a bad state and a duplicate column refuse, and the board keeps its bounds", async () => {
for (const mutate of [
(p) => { p.board.columns[0].state = "doing"; },
(p) => { p.workflows.change.steps[1].state = "doing"; },
(p) => { p.board.columns[1] = { state: "todo", label: "Again" }; },
(p) => { p.board.columns.pop(); },
(p) => { p.board.columns.push({ state: "done", label: "More" }); },
(p) => { p.board.columns[0].label = ""; },
(p) => { p.board.columns[0].label = "x".repeat(33); },
(p) => { p.board.columns[0].label = "two\nlines"; },
(p) => { p.board.layout = "grid"; },
(p) => { delete p.board.layout; },
]) await assert.rejects(shipped(pack(mutate)), code("starter-pack-invalid"));
assert.ok(await shipped(pack((p) => { p.board.columns[0].label = "x".repeat(32); })));
});
test("SP1 workflows: 1 to 20 steps, 1 to 8 known roles, bounded titles and descriptions", async () => {
const step = (i) => ({ id: `s${i}`, title: "Step", roles: ["human"], state: "todo" });
for (const mutate of [
(p) => { p.workflows.change.steps = []; },
(p) => { p.workflows.change.steps = Array.from({ length: 21 }, (_, i) => step(i)); },
(p) => { p.workflows.change.steps[0].roles = []; },
(p) => { p.workflows.change.steps[0].roles = ["pm", "coder", "reviewer", "human", "cto", "assistant", "research", "ideas", "companion"]; },
(p) => { p.workflows.change.steps[0].roles = ["no-such-role"]; },
(p) => { p.workflows.change.steps[0].roles = ["conductor-policy"]; },
(p) => { p.workflows.change.steps[0].roles = ["pm", "pm"]; },
(p) => { p.workflows.change.steps[0].title = "x".repeat(121); },
(p) => { p.workflows.change.steps[1].id = "plan"; },
(p) => { p.workflows.change.description = "x".repeat(1001); },
(p) => { p.workflows.change.description = ""; },
(p) => { p.workflows.Change = p.workflows.change; },
]) await assert.rejects(shipped(pack(mutate)), code("starter-pack-invalid"));
assert.ok(await shipped(pack((p) => {
p.workflows.change.steps = Array.from({ length: 20 }, (_, i) => step(i));
p.workflows.change.steps[0].roles = ["pm", "coder", "reviewer", "human", "cto", "assistant", "research", "ideas"];
p.workflows.change.steps[0].title = "x".repeat(120);
p.workflows.change.description = "x".repeat(1000);
})));
});
test("SP1 top-level fields: packVersion, id, semver version, label, description, scope and skills", async () => {
for (const mutate of [
(p) => { p.packVersion = 2; },
(p) => { delete p.packVersion; },
(p) => { p.id = "Coding"; },
(p) => { p.id = "../x"; },
(p) => { p.version = "1.0"; },
(p) => { p.version = "v1.0.0"; },
(p) => { p.version = "01.0.0"; },
(p) => { p.label = ""; },
(p) => { p.label = "x".repeat(65); },
(p) => { p.description = "a\nb"; },
(p) => { p.scope = "root"; },
(p) => { delete p.skills; },
(p) => { p.skills = ["ms_sdlc"]; },
(p) => { p.skills = ["ms-sdlc", "ms-sdlc"]; },
(p) => { p.definitions = []; },
(p) => { p.definitions = { "Bad Name": { template: "coder", sha256: H.coder } }; },
]) await assert.rejects(shipped(pack(mutate)), code("starter-pack-invalid"));
for (const version of ["1.0.0-rc.1", "2.10.3+build.5", "0.0.1"]) assert.ok(await shipped(pack((p) => { p.version = version; })));
const { checkPack } = await sp();
for (const doc of [null, [], "x", 1, { toJSON() { throw new Error(MARK); } }]) {
assert.throws(() => checkPack(doc, { origin: "shipped", rolesDir: ROLES }), code("starter-pack-invalid"));
}
});
test("SP1 a definition that fails checkDefinition refuses without quoting the document", async () => {
for (const mutate of [
(p) => { p.definitions["release-writer"].instructions = ""; },
(p) => { p.definitions["release-writer"].role.authority.withinRole.push("role.hire"); },
(p) => { p.definitions["release-writer"].role.tools.push(MARK); },
(p) => { p.definitions["release-writer"].from.template = MARK.toLowerCase(); },
]) await assert.rejects(shipped(pack(mutate)), code("starter-pack-invalid"));
});
test("SP1 a shipped or installed pack with packs or a role without from refuses; a saved one with either passes", async () => {
const withPacks = pack((p) => { p.packs = [{ id: "marketing", version: "1.2.0" }]; });
const noFrom = pack((p) => { delete p.definitions["release-writer"].from; });
const { checkPack } = await sp();
for (const origin of ["shipped", "installed"]) {
assert.throws(() => checkPack(withPacks, { origin, rolesDir: ROLES }), code("starter-pack-invalid"));
assert.throws(() => checkPack(noFrom, { origin, rolesDir: ROLES }), code("starter-pack-invalid"));
}
assert.deepEqual(await saved(withPacks), withPacks);
assert.deepEqual(await saved(noFrom), noFrom);
// A saved role without from is still checked by checkDefinition.
await assert.rejects(saved(pack((p) => { delete p.definitions["release-writer"].from; p.definitions["release-writer"].role.authority.withinRole.push("role.fire"); })), code("starter-pack-invalid"));
});
test("SP1 a saved packs list: a 33rd entry, a key other than id and version, or a non-semver version refuses", async () => {
const entry = (i) => ({ id: `pack-${i}`, version: "1.0.0" });
for (const packs of [
Array.from({ length: 33 }, (_, i) => entry(i)),
[{ id: "coding", version: "1.0.0", sha256: "0".repeat(64) }],
[{ id: "coding", version: "1.0.0", origin: "shipped" }],
[{ id: "coding", version: "1.0" }],
[{ id: "coding", version: MARK }],
[{ id: "coding" }],
[{ id: "Coding", version: "1.0.0" }],
{},
]) await assert.rejects(saved(pack((p) => { p.packs = packs; })), code("starter-pack-invalid"));
const ok = pack((p) => { p.packs = Array.from({ length: 32 }, (_, i) => entry(i)); });
assert.deepEqual(await saved(ok), ok);
});
// ---- readSavedTemplate ----
function savedFixture(t, account = "alice.b") {
const root = mkdtempSync(join(tmpdir(), "sp1-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const configDir = join(root, "cfg");
const dir = join(configDir, "templates", account);
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(join(configDir, "templates"), 0o700);
chmodSync(dir, 0o700);
const put = (name, text, mode = 0o600) => {
const file = join(dir, `${name}.json`);
writeFileSync(file, text, { mode });
chmodSync(file, mode);
return file;
};
return { root, configDir, dir, put };
}
test("SP1 readSavedTemplate reads a saved template as origin saved; an account with a dot passes", async (t) => {
const { readSavedTemplate } = await sp();
const f = savedFixture(t, "alice.b");
const doc = pack((p) => { p.packs = [{ id: "coding", version: "1.0.0" }]; delete p.definitions["release-writer"].from; });
f.put("my-team", JSON.stringify(doc));
const out = readSavedTemplate({ configDir: f.configDir, account: "alice.b", name: "my-team", rolesDir: ROLES });
assert.deepEqual(out, { name: "my-team", origin: "saved", pack: doc });
// Owner ids may hold an underscore or start with a digit.
for (const account of ["bob_c", "9lives"]) {
const g = savedFixture(t, account);
g.put("t", JSON.stringify(pack()));
assert.equal(readSavedTemplate({ configDir: g.configDir, account, name: "t", rolesDir: ROLES }).pack.id, "coding");
}
});
test("SP1 readSavedTemplate refuses .., a name or account holding /, a symlink, a 0644 file, a 300 KiB file; a missing one is not found", async (t) => {
const { readSavedTemplate } = await sp();
const f = savedFixture(t, "alice.b");
const read = (name, account = "alice.b", extra = {}) => () => readSavedTemplate({ configDir: f.configDir, account, name, rolesDir: ROLES, ...extra });
f.put("good", JSON.stringify(pack()));
assert.ok(read("good")());
for (const name of ["..", "a/b", "../alice.b/good", "/etc/passwd", "Good", "", null, 3]) assert.throws(read(name), code("starter-pack-invalid"));
for (const account of ["..", "a/b", ".", ".alice", "_alice", "-alice", "../alice.b", "Alice", "", null]) assert.throws(read("good", account), code("starter-pack-invalid"));
// A symlink to a good 0600 file refuses.
const real = join(f.root, "real.json");
writeFileSync(real, JSON.stringify(pack()), { mode: 0o600 });
symlinkSync(real, join(f.dir, "link.json"));
assert.throws(read("link"), code("starter-pack-invalid", /regular file, not a symlink/));
// A 0644 file and a 0640 file refuse.
f.put("wide", JSON.stringify(pack()), 0o644);
assert.throws(read("wide"), code("starter-pack-invalid", /mode 0600 or narrower/));
f.put("group", JSON.stringify(pack()), 0o640);
assert.throws(read("group"), code("starter-pack-invalid", /mode 0600 or narrower/));
// A hard link to a file outside the account's directory refuses.
const solo = f.put("solo", JSON.stringify(pack()));
linkSync(solo, join(f.root, "alias.json"));
assert.throws(read("solo"), code("starter-pack-invalid", /hard link/));
f.put("narrow", JSON.stringify(pack()), 0o400);
assert.ok(read("narrow")());
// A file its owner can't open refuses as unreadable, not as a symlink.
if (process.getuid() !== 0) {
f.put("shut", JSON.stringify(pack()), 0o000);
assert.throws(read("shut"), code("starter-pack-invalid", /can't be read/));
}
// 300 KiB refuses before the read; JSON padding keeps the file otherwise valid.
const big = JSON.stringify(pack());
f.put("big", big + " ".repeat(300 * 1024 - big.length));
assert.equal(lstatSync(join(f.dir, "big.json")).size, 300 * 1024);
assert.throws(read("big"), code("starter-pack-invalid", /must be at most 262144 bytes/));
// A missing file, and a missing account directory, are not found.
assert.throws(read("missing"), code("starter-pack-not-found"));
assert.throws(read("good", "carol"), code("starter-pack-not-found"));
// A directory where the file should be, bad JSON and bad UTF-8 refuse invalid.
mkdirSync(join(f.dir, "dir.json"));
assert.throws(read("dir"), code("starter-pack-invalid"));
f.put("bad", `{"id": "${MARK}"`);
assert.throws(read("bad"), code("starter-pack-invalid"));
writeFileSync(join(f.dir, "utf.json"), Buffer.from([0x7b, 0xff, 0x7d]), { mode: 0o600 });
assert.throws(read("utf"), code("starter-pack-invalid"));
// A file the pack checks refuse is invalid too.
f.put("forbidden", JSON.stringify(pack((p) => { p.definitions.coder.copyBindings = MARK; })));
assert.throws(read("forbidden"), code("starter-pack-invalid"));
// Another user's file, and a relative configDir, refuse.
assert.throws(read("good", "alice.b", { uid: process.getuid() + 1 }), code("starter-pack-invalid", /directory must belong to the instance's user/));
assert.throws(() => readSavedTemplate({ configDir: "cfg", account: "alice.b", name: "good", rolesDir: ROLES }), code("starter-pack-invalid"));
});
test("SP1 readSavedTemplate refuses a symlinked or group-writable templates directory", async (t) => {
const { readSavedTemplate } = await sp();
const f = savedFixture(t, "alice.b");
f.put("good", JSON.stringify(pack()));
const read = () => readSavedTemplate({ configDir: f.configDir, account: "alice.b", name: "good", rolesDir: ROLES });
chmodSync(f.dir, 0o770);
assert.throws(read, code("starter-pack-invalid", /not be writable by group or others/));
// The account's directory is 0700 or narrower: group or other read or list refuses too.
for (const mode of [0o750, 0o705, 0o755]) {
chmodSync(f.dir, mode);
assert.throws(read, code("starter-pack-invalid", /mode 0700 or narrower/));
}
chmodSync(f.dir, 0o700);
assert.ok(read());
// The templates directory itself may be listed by others; it only must not be writable.
chmodSync(join(f.configDir, "templates"), 0o755);
assert.ok(read());
chmodSync(join(f.configDir, "templates"), 0o775);
assert.throws(read, code("starter-pack-invalid", /not be writable by group or others/));
chmodSync(join(f.configDir, "templates"), 0o700);
// The account directory as a symlink to a real one refuses.
const g = savedFixture(t, "other");
g.put("good", JSON.stringify(pack()));
symlinkSync(g.dir, join(f.configDir, "templates", "linked"));
assert.throws(() => readSavedTemplate({ configDir: f.configDir, account: "linked", name: "good", rolesDir: ROLES }), code("starter-pack-invalid", /must be a real directory/));
// The templates directory as a symlink refuses.
const h = mkdtempSync(join(tmpdir(), "sp1-"));
t.after(() => rmSync(h, { recursive: true, force: true }));
mkdirSync(join(h, "cfg"), { mode: 0o700 });
symlinkSync(join(f.configDir, "templates"), join(h, "cfg", "templates"));
assert.throws(() => readSavedTemplate({ configDir: join(h, "cfg"), account: "alice.b", name: "good", rolesDir: ROLES }), code("starter-pack-invalid", /must be a real directory/));
});
// ---- installedStarterPacks ----
// A scratch copy of the plugin and organization sources, so a core pack module can be added
// without touching the repository tree.
function tree(t) {
const root = mkdtempSync(join(tmpdir(), "sp1t-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
for (const dir of ["packages/plugins/src", "packages/organization/src"]) cpSync(join(REPO, dir), join(root, dir), { recursive: true });
const core = (name, body) => {
mkdirSync(join(root, "packages/plugins/starter-pack", name), { recursive: true });
writeFileSync(join(root, "packages/plugins/starter-pack", name, "index.mjs"), body);
};
const load = () => import(pathToFileURL(join(root, "packages/plugins/src/starter-pack.mjs")).href);
return { root, core, load };
}
const moduleText = (doc) => `export const interfaceVersion = 1;\nexport function starterPack() { return ${JSON.stringify(doc)}; }\n`;
test("SP1 installedStarterPacks lists shipped packs with origin shipped; the repository ships none yet", async (t) => {
const { installedStarterPacks } = await sp();
assert.deepEqual(await installedStarterPacks({ rolesDir: ROLES }), { packs: [], refused: [] });
const f = tree(t);
f.core("coding", moduleText(pack()));
f.core("tiny", moduleText(pack((p) => { p.id = "tiny"; p.hires = []; })));
const m = await f.load();
const out = await m.installedStarterPacks({ rolesDir: ROLES });
assert.deepEqual(out.refused, []);
assert.deepEqual(out.packs.map((p) => [p.id, p.origin]), [["coding", "shipped"], ["tiny", "shipped"]]);
assert.deepEqual(out.packs[0].pack, pack());
});
test("SP1 a refused shipped pack fails the listing", async (t) => {
for (const body of [
moduleText(pack((p) => { p.packs = []; })),
moduleText(pack((p) => { p.id = "other"; })),
`export const interfaceVersion = 1;\nexport function starterPack() { throw new Error(${JSON.stringify(MARK)}); }\n`,
"export const interfaceVersion = 2;\nexport function starterPack() { return {}; }\n",
"export const interfaceVersion = 1;\n",
]) {
const f = tree(t);
f.core("coding", body);
const m = await f.load();
await assert.rejects(m.installedStarterPacks({ rolesDir: ROLES }), code("starter-pack-invalid"));
}
});
function installed(t, name, body) {
const root = mkdtempSync(join(tmpdir(), "sp1i-"));
t.after(() => {
const thaw = (p) => { const st = lstatSync(p); if (st.isSymbolicLink()) return; if (st.isDirectory()) { chmodSync(p, 0o700); for (const n of readdirSync(p)) thaw(join(p, n)); } };
thaw(root);
rmSync(root, { recursive: true, force: true });
});
const src = join(root, "src"), dataRoot = join(root, "data");
mkdirSync(join(src, "starter-pack"), { recursive: true });
mkdirSync(dataRoot);
writeFileSync(join(src, "starter-pack", "index.mjs"), body);
const manifest = { name, version: "1.0.0", kinds: ["starter-pack"], interfaceVersions: { "starter-pack": 1 }, sha256: "0".repeat(64),
source: { path: "fixture", version: "1.0.0" }, signature: null, tools: [], actions: [], services: [], roleTemplates: [] };
writeFileSync(join(src, "plugin.json"), JSON.stringify(manifest));
manifest.sha256 = pluginHash(src);
writeFileSync(join(src, "plugin.json"), JSON.stringify(manifest));
installPlugin({ dataRoot, source: src, expectedHash: manifest.sha256 });
return { root, dataRoot };
}
test("SP1 an installed pack lists with origin installed; a refused one is omitted and logged", async (t) => {
const { installedStarterPacks } = await sp();
const good = installed(t, "acmepack", moduleText(pack((p) => { p.id = "acmepack"; })));
const out = await installedStarterPacks({ dataRoot: good.dataRoot, rolesDir: ROLES });
assert.deepEqual(out.refused, []);
assert.deepEqual(out.packs.map((p) => [p.id, p.origin]), [["acmepack", "installed"]]);
// An installed pack carrying packs, or a role without from, is omitted and logged.
for (const mutate of [(p) => { p.packs = []; }, (p) => { delete p.definitions["release-writer"].from; }, (p) => { p.hires[0].level = "root"; }]) {
const bad = installed(t, "badpack", moduleText(pack((p) => { p.id = "badpack"; mutate(p); })));
const log = [];
const res = await installedStarterPacks({ dataRoot: bad.dataRoot, rolesDir: ROLES, log: (e) => log.push(e) });
assert.deepEqual(res.packs, []);
assert.equal(res.refused.length, 1);
assert.deepEqual(log, res.refused);
assert.deepEqual([res.refused[0].event, res.refused[0].kind, res.refused[0].name, res.refused[0].code],
["starter-pack-refused", "starter-pack", "badpack", "starter-pack-invalid"]);
}
// An installed module that fails to load is omitted and logged too.
const broken = installed(t, "brokenpack", "export const interfaceVersion = 1;\nexport const starterPack = 3;\n");
const log = [];
const res = await installedStarterPacks({ dataRoot: broken.dataRoot, rolesDir: ROLES, log: (e) => log.push(e) });
assert.deepEqual(res.packs, []);
assert.deepEqual(log.map((e) => [e.name, e.code]), [["brokenpack", "missing-export"]]);
// An async starterPack is refused, and its later rejection never reaches the process.
const unhandled = [];
const onUnhandled = (reason) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandled);
t.after(() => process.off("unhandledRejection", onUnhandled));
const later = installed(t, "asyncpack", "export const interfaceVersion = 1;\nexport async function starterPack() { await new Promise((r) => setTimeout(r, 20)); throw new Error('late'); }\n");
const res2 = await installedStarterPacks({ dataRoot: later.dataRoot, rolesDir: ROLES, log() {} });
assert.deepEqual(res2.packs, []);
assert.deepEqual(res2.refused.map((e) => [e.name, e.code]), [["asyncpack", "starter-pack-invalid"]]);
assert.match(res2.refused[0].message, /not a promise/);
await new Promise((r) => setTimeout(r, 100));
assert.deepEqual(unhandled, []);
// A thenable that is not a promise never has its then read or called: a throwing then getter
// and a then that resolves with itself are both omitted and logged, and a shipped pack beside
// them is unaffected.
const thenables = {
getterpack: "export const interfaceVersion = 1;\nexport function starterPack() { return { get then() { throw new Error('getter'); } }; }\n",
selfpack: "export const interfaceVersion = 1;\nlet calls = 0;\nexport function starterPack() { const o = { then(r) { calls += 1; if (calls > 3) throw new Error('then called'); r(o); } }; return o; }\n",
};
for (const [name, text] of Object.entries(thenables)) {
const odd = installed(t, name, text);
const res3 = await installedStarterPacks({ dataRoot: odd.dataRoot, rolesDir: ROLES, log() {} });
assert.deepEqual(res3.packs, []);
assert.deepEqual(res3.refused.map((e) => [e.name, e.code]), [[name, "starter-pack-invalid"]]);
}
await new Promise((r) => setTimeout(r, 50));
assert.deepEqual(unhandled, []);
});