feat(org): pack preview, apply, board and workflow writes (SP4, #1873) #1955

Merged
jason.woltje merged 3 commits from sp4 into refactor 2026-11-12 23:53:54 +00:00
18 changed files with 1360 additions and 21 deletions

No files matched your search

+45 -1
View File
@@ -182,7 +182,9 @@ export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.rem
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console',
// #1873 SP4: the owner's starter packs, board and workflows.
'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console']);
// What an owner's console cap may ask of the request lifecycle (a read, the intake controls, and Task 4's mode and draft).
const INTAKE_CONSOLE = Object.freeze(['artifactConsole', 'get', 'answer', 'attach', 'cancel', 'mode', 'draft']);
const REQUEST_VERBS = Object.freeze({ 'request.open': 'open', 'request.get': 'get', 'request.projects': 'projects', 'request.propose': 'propose',
@@ -208,6 +210,17 @@ export const DEFINITION_BUS_KEYS = Object.freeze({
'definition.edit': [['revision', 'rev', 'name', 'definition'], ['revision', 'rev', 'name', 'definition']],
'definition.remove': [['revision', 'rev', 'name'], ['revision', 'rev', 'name']],
});
// #1873 SP4: the starter-pack, board and workflow verbs, each with its [allowed, required] argument
// keys. The CLI calls the unsuffixed name as the owner's human cap, the Console the .console one.
// None is a tester verb: TESTER_ORGANIZATION_VERBS stays as it is.
const PACK_KEYS = Object.freeze({
'packs': [[], []],
'pack.preview': [['pack', 'project', 'as'], ['pack']],
'pack.apply': [['pack', 'project', 'as', 'digest'], ['pack', 'digest']],
'board.set': [['revision', 'board', 'project'], ['revision', 'board']],
'workflow.set': [['revision', 'name', 'workflow', 'project'], ['revision', 'name', 'workflow']],
});
export const PACK_BUS_KEYS = Object.freeze(Object.fromEntries(Object.entries(PACK_KEYS).flatMap(([verb, k]) => [[verb, k], [`${verb}.console`, k]])));
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
@@ -6281,6 +6294,28 @@ export class Broker {
return this.#hireConsole(s,request.verb,args).catch(e=>{throw this.#refused(s,new BusError(e.code??'outcome-unknown'),request);});
if (request.verb === 'grants.scope' || request.verb === 'grants.scope.set')
return this.#grantScopeVerb(s, request.verb, structuredClone(args));
// #1873 SP4: the owner's starter packs, board and workflows. Only the organization's owner calls
// them: the human cap from the CLI, or the owner's console cap (a hire member's cap is refused
// above). The tester is refused before any argument check. A preview and an apply raise no
// decision; the hires a plan proposes skip any an open hire decision already names.
if (Object.hasOwn(PACK_BUS_KEYS, request.verb)) {
if (s.tester) fail('tester-refused');
const owner = this.#business(s.business).human;
if (request.verb.endsWith('.console')) { if (!s.console || s.human !== owner) fail('human-required'); }
else { this.#human(s); if (s.human !== owner) fail('human-required'); }
keys(args, ...PACK_BUS_KEYS[request.verb]);
if (!this.#organization) fail('not-configured');
const verb = request.verb.replace(/\.console$/, '');
const openHires = verb === 'pack.preview' || verb === 'pack.apply'
? this.#store.all(`SELECT d.id FROM decisions d WHERE d.business=? AND d.action='role.hire' AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED}) ORDER BY d.at, d.id`, s.business)
.map(({ id }) => this.#raised(s.business, id)?.hire).filter(object).map(h => ({ name: h.name, project: h.project ?? null }))
: [];
let result;
try { result = this.#organization(s.business, verb, structuredClone(args), s.human, { openHires }); }
catch (e) { this.#packFail(e); }
const done = (value) => { this.#secretCheck(value); return value; };
return result?.then ? result.then(done, e => this.#packFail(e)) : done(result);
}
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb) || Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
@@ -6885,6 +6920,15 @@ export class Broker {
};
return result?.then ? result.then(finish) : finish(result);
}
// #1873 SP4: a pack, board or workflow refusal passes by its code when it is one the plan's checks
// name; a file check (BusinessError) is invalid-request, anything else outcome-unknown.
#packFail(e) {
const code = e?.code ?? '';
if (['credential-leak', 'pack-plan-stale', 'revision-conflict', 'invalid-request', 'human-required', 'not-configured'].includes(code)
|| /^(starter-pack|definition|organization)-[a-z-]+$/.test(code)) fail(code);
if (e?.name === 'BusinessError') fail('invalid-request');
throw new BusError('outcome-unknown');
}
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
// outcome-unknown: the write may have committed.
#organizationFail(e, verb) {
+7
View File
@@ -204,6 +204,13 @@ group(
"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."
);
// #1873 SP4: apply writes exactly the plan the owner previewed. The digest binds the plan to the
// organization file's revision; any change in between refuses.
group(
"pack-plan-stale",
"R8: apply writes only the plan its digest names",
"The organization file or the pack changed since the preview. Preview again and apply the new digest."
);
group(
"not-resolver",
"Role resolve right and decision route",
+219 -3
View File
@@ -1,5 +1,5 @@
import { autonomyHost, authorityHost } from './autonomy-host.mjs';
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { credentialLines, hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationPlugins } from '../../plugins/src/organization.mjs';
import { pluginService } from '../../plugins/src/tools.mjs';
import { readPluginLock } from '../../plugins/src/pinned.mjs';
@@ -40,7 +40,10 @@ import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } fr
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
import { definitionVerbs, DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
import { ORG_PREFIX, orgDefinitionName } from '../../organization/src/definitions.mjs';
import { ORG_PREFIX, orgDefinitionName, compositionOf, readTemplate, entryFromTemplate, lineDiff, PRIMITIVES } from '../../organization/src/definitions.mjs';
import { installedStarterPacks, readSavedTemplate } from '../../plugins/src/starter-pack.mjs';
import { checkComposition } from '../../organization/src/composition.mjs';
import { validateBusinessDocument } from '../../organization/src/organization-config.mjs';
import { DEFINITION_HOLDS_CREDENTIAL, heldPath, scanDefinitions } from './definition-scan.mjs';
import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs';
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
@@ -52,7 +55,7 @@ import { copyRevoker as makeCopyRevoker, giteaCopyRemote } from './copy-revoke.m
import { readOrgEntrySync } from '../../organization/src/org-registry.mjs';
import { loadRole } from '../../organization/src/role.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { canonicalJson, sha256 as textSha256 } from '../../organization/src/util.mjs';
import { selectModelProvider } from '../../plugins/src/model-provider-selection.mjs';
import { busOrganization } from './organization-context.mjs';
import { webService } from './web.mjs';
@@ -286,6 +289,209 @@ function modelRunInventory(dataRoot, bound, credentials) {
// #1873 CB4: how often the bus re-checks the copies holding a grant, and retries a pending revoke.
const COPY_SWEEP_MS = 60000;
// #1873 SP4: the human's pack, board and workflow ops (design section 3). preview reads the pack and
// the organization file and returns the plan with each item's mark, the lines each proposed hire
// would carry against the chief, the noHire marks and the skills this machine has not enabled, plus
// a digest of the plan and the file revision. apply rebuilds the plan, refuses pack-plan-stale when
// the digest differs and commits it in one applyPack. board.set and workflow.set are one-key plans.
// Nothing here reads a saved template but the caller's own (readSavedTemplate, account = actor), and
// a pack's origin comes from where it was found, never from the request.
const PACK_AS = /^(definitions|workflows)\.([a-z0-9][a-z0-9._-]{0,63})=([a-z0-9][a-z0-9._-]{0,63})$/;
const SAVED_PREFIX = 'saved:';
// Each op, and whether it writes.
const PACK_OPS = Object.freeze({ packs: false, 'pack.preview': false, 'pack.apply': true, 'board.set': true, 'workflow.set': true });
const EMPTY_CHIEF = Object.freeze({ tools: [], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] });
function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean, configDir }) {
const bad = () => { throw new BusError('invalid-request'); };
const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
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); }
};
const installed = async () => (await installedStarterPacks({ dataRoot, rolesDir })).packs;
async function loadPack(name, actor) {
if (typeof name !== 'string' || name.length > 128) bad();
if (name.startsWith(SAVED_PREFIX)) return readSavedTemplate({ configDir, account: actor, name: name.slice(SAVED_PREFIX.length), rolesDir });
const found = (await installed()).find(p => p.id === name);
if (!found) throw new BusError('starter-pack-not-found');
return found;
}
function renames(as) {
const out = { definitions: new Map(), workflows: new Map() };
if (as === undefined) return out;
if (!Array.isArray(as) || as.length > 32) bad();
for (const value of as) {
const m = typeof value === 'string' ? PACK_AS.exec(value) : null;
// `human` is the reserved step role: no rename may write a definition by that name.
if (!m || out[m[1]].has(m[2]) || [...out[m[1]].values()].includes(m[3]) || (m[1] === 'definitions' && m[3] === 'human')) bad();
out[m[1]].set(m[2], m[3]);
}
return out;
}
// The entry a pack definition writes: a template reference becomes the template's own entry, pinned
// by its hash (a moved hash refuses), and a full entry is copied as it is. Neither carries rev.
function entryOf(value) {
if (Object.hasOwn(value, 'template') && Object.hasOwn(value, 'sha256') && Object.keys(value).length === 2) {
const template = readTemplate(rolesDir, value.template);
if (template.sha256 !== value.sha256) throw new BusError('starter-pack-template-changed');
const { rev: _rev, ...entry } = entryFromTemplate(template);
return { entry, ref: { template: value.template, sha256: value.sha256 } };
}
return { entry: structuredClone(value), ref: null };
}
function chiefOf(doc, file) {
const loaded = validateBusinessDocument(doc, file, { rolesDir, dataRoot });
const chief = loaded.definitions[doc.roles?.[doc.launch?.by]?.definition];
if (!chief) return null;
return { ...chief, ...resolveInstance({ system, business: loaded, instance: doc.launch.by }).limits };
}
// Each earlier packs entry of this id and origin (and, for a project pack, this project) lists what
// it added; an item absent now that one of them added was removed on purpose, so it stays removed.
// A saved template may reuse a shipped id, so the origin is part of the key.
const sameApply = (e, id, origin, project) => e.id === id && e.origin === origin && (e.project ?? null) === project;
const tombstones = (doc, id, origin, project) => new Set((doc.packs ?? []).filter(e => sameApply(e, id, origin, project)).flatMap(e => e.added ?? []));
async function build(id, args, actor, context) {
const { document: doc, revision } = writer.read(id);
const found = await loadPack(args.pack, actor);
const pack = found.pack;
const origin = found.origin;
let project = null;
if (pack.scope === 'org') { if (args.project !== undefined) bad(); }
else {
project = args.project;
const entry = typeof project === 'string' && Object.hasOwn(doc.projects ?? {}, project) ? doc.projects[project] : null;
if (!isObject(entry) || entry.kind === 'library') bad();
}
const as = renames(args.as);
for (const name of as.definitions.keys()) if (!Object.hasOwn(pack.definitions, name)) bad();
for (const name of as.workflows.keys()) if (!Object.hasOwn(pack.workflows, name)) bad();
// Two pack items of one kind never land on one name: a rename onto another item's own name
// would show both as add while the later one replaced the earlier in the plan.
for (const kind of ['definitions', 'workflows']) {
const targets = Object.keys(pack[kind]).map(name => as[kind].get(name) ?? name);
if (new Set(targets).size !== targets.length) bad();
}
scan(pack);
const gone = tombstones(doc, pack.id, origin, project);
const scope = project === null ? doc : doc.projects[project];
const prefix = project === null ? '' : `projects.${project}.`;
const chief = chiefOf(doc, `${id}.json`);
const registry = registryFor(doc);
const noHire = doc.launch?.noHire ?? [];
const items = [];
const plan = {};
// Definitions are organization-wide in either scope: a project's agents name org definitions.
const definitions = {};
const checked = {};
for (const [name, value] of Object.entries(pack.definitions)) {
const target = as.definitions.get(name) ?? name;
const { entry, ref } = entryOf(value);
const { composition, flagged } = checkComposition(compositionOf(entry), registry, chief ?? EMPTY_CHIEF, { assertClean });
checked[name] = { target, entry, ref, flagged: chief ? flagged : null, credentialLines: credentialLines(composition.credentials) };
const path = `definitions.${target}`;
const current = doc.definitions?.[target];
let mark;
if (current === undefined) mark = gone.has(path) ? 'removed' : 'add';
else mark = canonicalJson(compositionOf(current)) === canonicalJson(compositionOf(entry)) ? 'same' : 'kept';
const item = { item: 'definition', name, target, path, mark };
if (mark === 'kept') {
const before = compositionOf(current), after = compositionOf(entry);
const primitives = PRIMITIVES.filter(k => canonicalJson(before[k] ?? null) !== canonicalJson(after[k] ?? null));
item.diff = { primitives, instructions: primitives.includes('instructions') ? lineDiff(before.instructions, after.instructions) : [] };
}
if (mark === 'add') definitions[target] = entry;
checked[name].added = mark === 'add';
items.push(item);
}
if (Object.keys(definitions).length) plan.definitions = definitions;
{
const path = `${prefix}board`;
let mark;
if (scope.board === undefined) mark = gone.has(path) ? 'removed' : 'add';
else mark = canonicalJson(scope.board) === canonicalJson(pack.board) ? 'same' : 'kept';
items.push({ item: 'board', path, mark });
if (mark === 'add') plan[path] = structuredClone(pack.board);
}
const workflows = {};
for (const [name, value] of Object.entries(pack.workflows)) {
const target = as.workflows.get(name) ?? name;
// The step ids are the pack's own; the organization's workflow keeps title, state and roles.
// A role naming one of this pack's definitions follows that definition's --as rename; the
// reserved `human` role never moves. A step whose mapped roles collide refuses here, not at apply.
const role = r => r !== 'human' && Object.hasOwn(pack.definitions, r) ? as.definitions.get(r) ?? r : r;
const workflow = { description: value.description, steps: value.steps.map(s => ({ title: s.title, state: s.state, roles: s.roles.map(role) })) };
if (workflow.steps.some(s => new Set(s.roles).size !== s.roles.length)) bad();
const path = `${prefix}workflows.${target}`;
const current = scope.workflows?.[target];
let mark;
if (current === undefined) mark = gone.has(path) ? 'removed' : 'add';
else mark = canonicalJson(current) === canonicalJson(workflow) ? 'same' : 'kept';
items.push({ item: 'workflow', name, target, path, mark });
if (mark === 'add') workflows[target] = workflow;
}
if (Object.keys(workflows).length) plan[`${prefix}workflows`] = workflows;
// A proposal is filled when an instance by that name exists, or an open hire decision names it.
const instances = project === null ? doc.roles ?? {} : doc.projects[project].agents ?? {};
const openHires = context?.openHires ?? [];
const hires = [];
const proposedHires = [];
for (const hire of pack.hires) {
const d = checked[hire.definition];
const dropped = Object.hasOwn(instances, hire.name) || openHires.some(h => h.name === hire.name && (h.project ?? null) === project);
const pin = d.added ? { definition: d.target, rev: 1 } : d.ref ? { ...d.ref } : { inline: d.target, sha256: textSha256(canonicalJson(d.entry)) };
const marked = [d.target, hire.definition, d.ref?.template, d.entry.from?.template].some(n => n !== undefined && noHire.includes(n));
hires.push({ name: hire.name, definition: d.target, mark: dropped ? 'dropped' : 'propose', pin, flagged: d.flagged, credentialLines: d.credentialLines, noHire: marked });
if (!dropped) proposedHires.push({ name: hire.name, reason: hire.reason, pin, ...(Object.hasOwn(pin, 'inline') ? { entry: structuredClone(d.entry) } : {}) });
}
plan.packs = { id: pack.id, version: pack.version, sha256: textSha256(canonicalJson(pack)), origin, scope: pack.scope, project, proposedHires };
scan(plan);
const notEnabled = pack.skills.filter(name => { try { lstatSync(join(dataRoot, 'skills-enabled', name)); return false; } catch { return true; } });
const digest = textSha256(canonicalJson({ plan, revision }));
return {
pack: { id: pack.id, version: pack.version, label: pack.label, origin, scope: pack.scope }, project, revision,
items, hires, skills: { notEnabled }, chief: chief !== null, plan, digest,
};
}
const write = (id, plan, revision, actor, detail) => {
const out = writer.applyPack(id, plan, { revision, actor, assertClean, detail });
return { revision: out.revision, added: out.added };
};
const scoped = (args, key) => {
if (args.project === undefined) return key;
if (typeof args.project !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(args.project)) bad();
return `projects.${args.project}.${key}`;
};
const revisionOf = args => { if (typeof args.revision !== 'string' || !/^[0-9a-f]{64}$/.test(args.revision)) bad(); return args.revision; };
return {
// Shipped and installed packs only: a saved template is its owner's, read by name.
packs: async () => ({ packs: (await installed()).map(({ id, origin, pack }) => ({ id, origin, pack })) }),
'pack.preview': (id, args, actor, context) => build(id, args, actor, context),
'pack.apply': async (id, args, actor, context) => {
if (typeof args.digest !== 'string') bad();
const preview = await build(id, args, actor, context);
if (preview.digest !== args.digest) throw new BusError('pack-plan-stale');
// An apply of the same pack (same sha256) that adds nothing and proposes what the last apply of
// it proposed writes nothing, so a retry loop does not fill the packs list to its cap. A changed
// pack still records its entry. The digest pins the revision.
const { plan } = preview;
const last = (writer.read(id).document.packs ?? []).filter(e => sameApply(e, plan.packs.id, plan.packs.origin, plan.packs.project)).at(-1);
if (Object.keys(plan).every(k => k === 'packs') && last && last.sha256 === plan.packs.sha256
&& canonicalJson(last.proposedHires ?? []) === canonicalJson(plan.packs.proposedHires))
return { revision: preview.revision, added: [], pack: preview.pack.id };
return { ...write(id, plan, preview.revision, actor, {}), pack: preview.pack.id };
},
'board.set': (id, args, actor) => {
if (args.board !== null && !isObject(args.board)) bad();
return write(id, { [scoped(args, 'board')]: args.board }, revisionOf(args), actor, {});
},
'workflow.set': (id, args, actor) => {
if (typeof args.name !== 'string' || (args.workflow !== null && !isObject(args.workflow))) bad();
return write(id, { [scoped(args, 'workflows')]: { [args.name]: args.workflow } }, revisionOf(args), actor, {});
},
};
}
export async function startBroker({
dataRoot,
instanceRoot = null,
@@ -932,6 +1138,16 @@ export async function startBroker({
known.set(id, canonicalJson(next.projects));
return getProjectRemote(writer, id, args.project);
}
// #1873 SP4: the human's pack, board and workflow ops. The broker passes only the business's human;
// the reads return here, and each write reloads the organization and refreshes its limit checks.
if (Object.hasOwn(PACK_OPS, verb)) {
const ops = packOps({ writer: organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}), rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot,
system: organization.system, registryFor: doc => organizationPlugins(instanceRoot ?? dataRoot, doc.plugins).registry, assertClean: value => credentials.assertClean(value), configDir: organization.dir });
const out = ops[verb](id, args, actor, context);
if (!PACK_OPS[verb]) return out;
const committed = value => { reload(id); limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message})); return value; };
return out?.then ? out.then(committed) : committed(out);
}
if (verb === 'system-types.installed') return installedSystemTypes({dataRoot:instanceRoot ?? dataRoot});
if (verb === 'system-types.current') return settings.current(id).plugins?.['system-type'] ?? [];
if(verb==='limits')return withTokens(id,settings.read(id));
+3 -1
View File
@@ -90,7 +90,9 @@ function setup(t) {
// no unsuffixed verb is ever a console verb.
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console',
// Starter packs SP4 (#1873): the owner's pack list and preview, and the three pack writes.
'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console']);
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
assert.ok(Object.isFrozen(CONSOLE_VERBS));
+399
View File
@@ -0,0 +1,399 @@
// #1873 SP4: starter packs at the bus. The owner previews and applies a pack; the plan is the write.
// Preview writes nothing; apply writes only what preview marked `add` plus one packs entry, and
// raises no decision. board.set and workflow.set are one-key plans through the same writer.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PACK_BUS_KEYS, TESTER_ORGANIZATION_VERBS } from '../src/broker.mjs';
import { pluginHash } from '../../plugins/src/pinned.mjs';
import { installPlugin } from '../../plugins/src/install.mjs';
import { starterPack as codingPack } from '../../plugins/starter-pack/coding/index.mjs';
const TOKEN = 'fixture-held-token-sp4-7d1f2b';
const SUBJECT = { provider: 'test', iss: 'test', sub: 'owner' };
async function harness(t, { mutate = () => {} } = {}) {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'packs-'));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
doc.plugins = { harness: ['pi'] };
mutate(doc);
writeJson(join(root, 'businesses/demo.json'), doc);
const other = businessDoc(join(root, 'b2'), 'other');
other.human = 'alex';
writeJson(join(root, 'businesses/other.json'), other);
const data = join(root, 'data');
mkdirSync(data, { mode: 0o700 });
const token = join(root, 'held.token');
writeFileSync(token, TOKEN, { mode: 0o600 });
chmodSync(token, 0o600);
const roles = { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } };
const businesses = {
demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles },
other: { id: 'other', human: 'alex', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: roles.chief } },
};
const bus = await startBroker({ dataRoot: data, businesses, organization: { dir: root, rolesDir, system: systemFor(root) } });
// An installed plugin's directories are read-only; open them so the scratch tree can go.
const thaw = (p) => { const st = lstatSync(p); if (st.isSymbolicLink()) return; if (st.isDirectory()) { chmodSync(p, 0o700); for (const e of readdirSync(p)) thaw(join(p, e)); } };
t.after(async () => { await bus.close(); thaw(root); rmSync(root, { recursive: true, force: true }); });
const b = bus.broker;
const consoleOwner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = async (verb, args = {}, cap = consoleOwner) => b.request(cap, { verb, args });
const path = join(root, 'businesses/demo.json');
const file = () => JSON.parse(readFileSync(path, 'utf8'));
const revision = () => b.request(human, { verb: 'limits' }).revision;
// A pack installed on this machine (origin installed), as the plugin installer writes it.
let n = 0;
const install = (pack) => {
const src = join(root, `src-${n++}`);
mkdirSync(join(src, 'starter-pack'), { recursive: true });
writeFileSync(join(src, 'starter-pack', 'index.mjs'), `export const interfaceVersion = 1;\nexport function starterPack() { return ${JSON.stringify(pack)}; }\n`);
const manifest = { name: pack.id, 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: data, source: src, expectedHash: manifest.sha256 });
};
// A pack an owner saved, under <configDir>/templates/<account>/<name>.json (origin saved).
const save = (account, name, pack) => {
const dir = join(root, 'templates', account);
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(join(root, 'templates'), 0o700);
chmodSync(dir, 0o700);
writeFileSync(join(dir, `${name}.json`), JSON.stringify(pack), { mode: 0o600 });
};
const decisions = () => ['roles', 'recent'].map(list => bus.broker.request(human, { verb: 'decisions', args: { list } }));
return { bus, b, root, rolesDir, path, consoleOwner, human, call, file, revision, install, save, decisions };
}
// A pack built from the shipped coding pack, renamed and patched.
const variant = (id, patch = () => {}) => { const p = structuredClone(codingPack()); p.id = id; patch(p); return p; };
const marks = (preview) => Object.fromEntries(preview.items.map(i => [i.path, i.mark]));
test('SP4 preview writes nothing; apply writes the add items and one packs entry, raises no decision, and a second apply is all same', async t => {
const { call, file, path, decisions, root, rolesDir } = await harness(t);
const before = readFileSync(path);
const decided = JSON.stringify(decisions());
const preview = await call('pack.preview.console', { pack: 'coding' });
assert.deepEqual(readFileSync(path), before);
assert.equal(preview.pack.origin, 'shipped');
assert.ok(preview.items.every(i => i.mark === 'add'), JSON.stringify(preview.items));
assert.deepEqual(preview.hires.map(h => [h.name, h.mark, h.noHire]), [['pm-1', 'propose', false], ['coder-1', 'propose', false], ['reviewer-1', 'propose', false]]);
// The chief is known, so every proposed hire lists its flagged and credential lines.
assert.equal(preview.chief, true);
for (const h of preview.hires) { assert.ok(Array.isArray(h.flagged)); assert.ok(Array.isArray(h.credentialLines)); }
// coder-1's credential lines name the services the coder template reaches.
const coderServices = JSON.parse(readFileSync(join(rolesDir, 'coder.json'), 'utf8')).credentials.map(c => c.service).sort();
assert.ok(coderServices.length > 0);
const lines = preview.hires.find(h => h.name === 'coder-1').credentialLines;
assert.ok(lines.length >= coderServices.length);
assert.deepEqual([...new Set(lines.map(l => l.service))].sort(), [...new Set(coderServices)]);
// On an empty data root every pack skill is not enabled; an enabled one drops out.
assert.deepEqual(preview.skills.notEnabled, codingPack().skills);
mkdirSync(join(root, 'data', 'skills-enabled', 'ms-sdlc'), { recursive: true });
assert.deepEqual((await call('pack.preview.console', { pack: 'coding' })).skills.notEnabled, codingPack().skills.filter(n => n !== 'ms-sdlc'));
assert.match(preview.digest, /^[0-9a-f]{64}$/);
const out = await call('pack.apply.console', { pack: 'coding', digest: preview.digest });
assert.equal(out.pack, 'coding');
const after = file();
assert.deepEqual(Object.keys(after.definitions).sort(), ['coder', 'cto', 'merge-gate', 'pm', 'reviewer', 'security-reviewer']);
assert.equal(after.board.layout, 'kanban');
// The organization's workflow drops the pack's step ids.
assert.deepEqual(after.workflows.change.steps[0], { title: 'Write the task', state: 'todo', roles: ['pm'] });
assert.ok(after.workflows.change.steps.every(s => !Object.hasOwn(s, 'id')));
assert.equal(after.packs.length, 1);
const entry = after.packs[0];
assert.equal(entry.id, 'coding');
assert.equal(entry.origin, 'shipped');
assert.ok(entry.added.includes('definitions.coder') && entry.added.includes('board') && entry.added.includes('workflows.change'));
assert.deepEqual(entry.proposedHires.find(h => h.name === 'coder-1').pin, { definition: 'coder', rev: 1 });
// No decision is raised: the owner hires from the proposals later.
assert.equal(JSON.stringify(decisions()), decided);
const again = await call('pack.preview.console', { pack: 'coding' });
assert.ok(again.items.every(i => i.mark === 'same'), JSON.stringify(again.items));
await call('pack.apply.console', { pack: 'coding', digest: again.digest });
assert.deepEqual(file().definitions, after.definitions);
assert.deepEqual(file().workflows, after.workflows);
// An apply that adds nothing and proposes what the last apply proposed writes nothing at all.
const settled = readFileSync(path);
const third = await call('pack.preview.console', { pack: 'coding' });
const out3 = await call('pack.apply.console', { pack: 'coding', digest: third.digest });
assert.deepEqual(out3.added, []);
assert.equal(out3.revision, third.revision);
assert.deepEqual(readFileSync(path), settled);
assert.equal(file().packs.length, 2);
});
test('SP4 a stale digest refuses pack-plan-stale and writes nothing', async t => {
const { call, path, revision } = await harness(t);
const preview = await call('pack.preview.console', { pack: 'coding' });
await call('workflow.set.console', { revision: revision(), name: 'intake', workflow: { description: 'Intake.', steps: [{ title: 'Take it', state: 'todo', roles: ['human'] }] } });
const before = readFileSync(path);
await assert.rejects(call('pack.apply.console', { pack: 'coding', digest: preview.digest }), /pack-plan-stale/);
await assert.rejects(call('pack.apply.console', { pack: 'coding', digest: 'f'.repeat(64) }), /pack-plan-stale/);
assert.deepEqual(readFileSync(path), before);
});
test('SP4 an edited definition is kept with its diff; a removed item stays removed; another pack naming it adds it', async t => {
const { call, human, file, revision, install, save } = await harness(t);
const first = await call('pack.preview.console', { pack: 'coding' });
await call('pack.apply.console', { pack: 'coding', digest: first.digest });
const coder = file().definitions.coder;
const { rev } = coder;
const definition = { role: coder.role, instructions: `${coder.instructions}Keep each change small.\n`, ...(coder.personality === undefined ? {} : { personality: coder.personality }) };
await call('definition.edit', { revision: revision(), rev, name: 'coder', definition }, human);
await call('definition.remove', { revision: revision(), rev: file().definitions.reviewer.rev, name: 'reviewer' }, human);
await call('board.set.console', { revision: revision(), board: null });
await call('workflow.set.console', { revision: revision(), name: 'change', workflow: null });
assert.equal(file().board, undefined);
assert.equal(file().workflows?.change, undefined);
const next = await call('pack.preview.console', { pack: 'coding' });
const m = marks(next);
assert.equal(m['definitions.coder'], 'kept');
assert.deepEqual(next.items.find(i => i.path === 'definitions.coder').diff.primitives, ['instructions']);
assert.equal(m['definitions.reviewer'], 'removed');
assert.equal(m.board, 'removed');
assert.equal(m['workflows.change'], 'removed');
assert.equal(m['definitions.pm'], 'same');
// The reviewer instance, its definition gone, pins the template the pack names.
assert.deepEqual(next.hires.find(h => h.name === 'reviewer-1').pin, { template: 'reviewer', sha256: codingPack().definitions.reviewer.sha256 });
const kept = file();
await call('pack.apply.console', { pack: 'coding', digest: next.digest });
assert.deepEqual(file().definitions, kept.definitions);
assert.equal(file().board, undefined);
assert.deepEqual(file().packs.at(-1).added, []);
// A different pack naming the same items adds them: a tombstone is per pack.
install(variant('teamkit'));
const other = await call('pack.preview.console', { pack: 'teamkit' });
assert.equal(other.pack.origin, 'installed');
const o = marks(other);
assert.equal(o['definitions.reviewer'], 'add');
assert.equal(o.board, 'add');
assert.equal(o['workflows.change'], 'add');
assert.equal(o['definitions.coder'], 'kept');
// A saved template reusing the shipped id is another pack: the shipped apply's removals don't bind it.
save('jason', 'mine', variant('coding'));
const mine = await call('pack.preview.console', { pack: 'saved:mine' });
assert.equal(mine.pack.origin, 'saved');
assert.equal(marks(mine)['definitions.reviewer'], 'add');
assert.equal(marks(mine).board, 'add');
});
test('SP4 --as renames a definition or workflow and pins the hire to the new name; a bad rename refuses', async t => {
const { call, file } = await harness(t);
const preview = await call('pack.preview.console', { pack: 'coding', as: ['definitions.coder=builder', 'workflows.change=ship'] });
const m = marks(preview);
assert.equal(m['definitions.builder'], 'add');
assert.equal(m['workflows.ship'], 'add');
assert.deepEqual(preview.hires.find(h => h.name === 'coder-1').pin, { definition: 'builder', rev: 1 });
await call('pack.apply.console', { pack: 'coding', as: ['definitions.coder=builder', 'workflows.change=ship'], digest: preview.digest });
assert.ok(file().definitions.builder);
assert.equal(file().definitions.coder, undefined);
assert.ok(file().workflows.ship);
// A rename onto another pack item's own name would show both as add while one replaced the other.
for (const as of [['definitions.nobody=x'], ['definitions.coder=a', 'definitions.coder=b'], ['definitions.coder=x', 'definitions.pm=x'], ['roles.coder=x'], 'definitions.coder=x', ['definitions.coder=reviewer'], ['definitions.coder=human'], ['definitions.merge-gate=human']])
await assert.rejects(call('pack.preview.console', { pack: 'coding', as }), /invalid-request/);
});
test('SP4 --as onto another workflow of the pack refuses; a renamed inline definition renames the workflow roles naming it', async t => {
const { call, file, install, rolesDir } = await harness(t);
install(variant('twoflow', p => { p.workflows.other = structuredClone(p.workflows.change); }));
await assert.rejects(call('pack.preview.console', { pack: 'twoflow', as: ['workflows.change=other'] }), /invalid-request/);
const { entryFromTemplate, readTemplate } = await import('../../organization/src/definitions.mjs');
const { rev: _rev, ...scribe } = entryFromTemplate(readTemplate(rolesDir, 'coder'));
install(variant('scribekit', p => {
p.definitions = { scribe };
p.hires = [];
p.workflows = { notes: { description: 'Notes.', steps: [{ id: 'write', title: 'Write it', state: 'todo', roles: ['scribe', 'human'] }] } };
}));
const as = ['definitions.scribe=writer'];
const preview = await call('pack.preview.console', { pack: 'scribekit', as });
assert.equal(marks(preview)['definitions.writer'], 'add');
assert.deepEqual(preview.plan.workflows.notes.steps[0].roles, ['writer', 'human']);
await call('pack.apply.console', { pack: 'scribekit', as, digest: preview.digest });
assert.ok(file().definitions.writer);
assert.equal(file().definitions.scribe, undefined);
assert.deepEqual(file().workflows.notes.steps[0].roles, ['writer', 'human']);
// A rename onto a role the same step already names refuses at preview, not only at apply.
install(variant('pairkit', p => {
p.definitions = { scribe };
p.hires = [];
p.workflows = { notes: { description: 'Notes.', steps: [{ id: 'write', title: 'Write it', state: 'todo', roles: ['scribe', 'pm'] }] } };
}));
await call('pack.preview.console', { pack: 'pairkit' });
await assert.rejects(call('pack.preview.console', { pack: 'pairkit', as: ['definitions.scribe=pm'] }), /invalid-request/);
// The reserved human step role never follows a rename, even when the pack defines a definition by that name.
install(variant('humankit', p => {
p.definitions = { human: scribe };
p.hires = [];
p.workflows = { signoff: { description: 'Sign-off.', steps: [{ id: 'sign', title: 'Sign it', state: 'todo', roles: ['human'] }] } };
}));
const renamed = await call('pack.preview.console', { pack: 'humankit', as: ['definitions.human=bot'] });
assert.deepEqual(renamed.plan.workflows.signoff.steps[0].roles, ['human']);
});
test('SP4 a changed pack with nothing to add still records its entry; an identical repeat writes nothing', async t => {
const { call, file, save } = await harness(t);
save('jason', 'mine', variant('mine'));
// The first apply pins the definitions it adds; the second pins the pack's template refs, so it
// records an entry too. From the second on, the proposals are settled.
for (let i = 0; i < 2; i++) {
const p = await call('pack.preview.console', { pack: 'saved:mine' });
await call('pack.apply.console', { pack: 'saved:mine', digest: p.digest });
}
assert.equal(file().packs.length, 2);
save('jason', 'mine', variant('mine', p => { p.version = '1.0.1'; p.description = 'A changed description.'; }));
const changed = await call('pack.preview.console', { pack: 'saved:mine' });
assert.ok(changed.items.every(i => i.mark === 'same'), JSON.stringify(changed.items));
await call('pack.apply.console', { pack: 'saved:mine', digest: changed.digest });
assert.equal(file().packs.length, 3);
assert.equal(file().packs.at(-1).version, '1.0.1');
assert.notEqual(file().packs.at(-1).sha256, file().packs[1].sha256);
const repeat = await call('pack.preview.console', { pack: 'saved:mine' });
const out = await call('pack.apply.console', { pack: 'saved:mine', digest: repeat.digest });
assert.deepEqual(out.added, []);
assert.equal(file().packs.length, 3);
});
test('SP4 an instance by the hire name drops the proposal; launch.noHire marks it', async t => {
const { call } = await harness(t, { mutate: doc => {
doc.roles['coder-1'] = { ...structuredClone(doc.roles.reviewer), definition: 'coder', tracker: { bot: 'bot-demo-coder-1', botId: 7 } };
doc.launch.noHire = ['pm'];
} });
const preview = await call('pack.preview.console', { pack: 'coding' });
const hires = Object.fromEntries(preview.hires.map(h => [h.name, h]));
assert.equal(hires['coder-1'].mark, 'dropped');
assert.equal(hires['pm-1'].noHire, true);
assert.equal(hires['reviewer-1'].noHire, false);
assert.deepEqual(preview.plan.packs.proposedHires.map(h => h.name), ['pm-1', 'reviewer-1']);
});
test('SP4 a held credential in a pack, a board or a workflow refuses credential-leak and is never echoed', async t => {
const { call, path, revision, save } = await harness(t);
save('jason', 'leaky', variant('leaky', p => { p.description = `Team notes ${TOKEN}`; }));
const before = readFileSync(path);
const leaks = [
() => call('pack.preview.console', { pack: 'saved:leaky' }),
() => call('pack.apply.console', { pack: 'saved:leaky', digest: 'a'.repeat(64) }),
() => call('board.set.console', { revision: revision(), board: { columns: codingPack().board.columns.map((c, i) => i ? c : { ...c, label: TOKEN }), layout: 'kanban' } }),
() => call('workflow.set.console', { revision: revision(), name: 'leak', workflow: { description: 'x', steps: [{ title: TOKEN, state: 'todo', roles: ['human'] }] } }),
];
for (const leak of leaks) {
await assert.rejects(leak(), e => { assert.match(e.message, /credential-leak/); assert.ok(!e.message.includes(TOKEN)); assert.ok(!JSON.stringify(e).includes(TOKEN)); return true; });
}
assert.deepEqual(readFileSync(path), before);
});
test('SP4 a template reference to a definition a hired agent may not hold refuses at preview', async t => {
const { call, install, rolesDir } = await harness(t);
const { templateHash } = await import('../../organization/src/definitions.mjs');
const cos = templateHash(rolesDir, 'chief-of-staff');
install(variant('chiefkit', p => { p.definitions.cos = { template: 'chief-of-staff', sha256: cos }; }));
await assert.rejects(call('pack.preview.console', { pack: 'chiefkit' }), /invalid-request/);
});
test('SP4 who may call: the owner only; the tester, a member, an agent and a console cap on the cli name refuse', async t => {
const { b, call, human, revision, path } = await harness(t);
// TESTER_ORGANIZATION_VERBS is unchanged: no pack, board or workflow verb.
assert.deepEqual(TESTER_ORGANIZATION_VERBS, ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
const tester = b.bindTester({ business: 'demo' });
const before = readFileSync(path);
assert.throws(() => b.request(tester, { verb: 'board.set', args: { revision: revision(), board: null } }), /tester-refused/);
assert.throws(() => b.request(tester, { verb: 'workflow.set', args: { revision: revision(), name: 'x', workflow: null } }), /tester-refused/);
assert.throws(() => b.request(tester, { verb: 'pack.preview', args: { pack: 'coding' } }), /tester-refused/);
const member = b.bindHireConsole({ business: 'demo', actor: 'alice', subject: { provider: 'test', iss: 'test', sub: 'alice' }, rights: ['org.hire', 'board.read'] });
for (const [verb, args] of [['pack.apply.console', { pack: 'coding', digest: 'a'.repeat(64) }], ['board.set.console', { revision: revision(), board: null }], ['workflow.set.console', { revision: revision(), name: 'x', workflow: null }], ['packs.console', {}],
['pack.apply', { pack: 'coding', digest: 'a'.repeat(64) }], ['board.set', { revision: revision(), board: null }]])
assert.throws(() => b.request(member, { verb, args }), /human-required/);
const agent = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
assert.throws(() => b.request(agent, { verb: 'pack.apply', args: { pack: 'coding', digest: 'a'.repeat(64) } }), /human-required/);
assert.throws(() => b.request(agent, { verb: 'pack.preview.console', args: { pack: 'coding' } }), /human-required/);
// The owner's console cap calls the .console names; the cli cap the unsuffixed ones.
await assert.rejects(call('pack.preview', { pack: 'coding' }), /human-required/);
await assert.rejects(call('pack.preview.console', { pack: 'coding' }, human), /human-required/);
assert.deepEqual(readFileSync(path), before);
});
test('SP4 the request carries no origin, actor or unknown key; scope and argument checks refuse invalid-request', async t => {
const { call, revision, install } = await harness(t);
for (const args of [{ pack: 'coding', origin: 'shipped' }, { pack: 'coding', actor: 'mallory' }, {}, { pack: 7 }, { pack: 'coding', project: 'stack' }])
await assert.rejects(call('pack.preview.console', args), /invalid-request/);
await assert.rejects(call('pack.apply.console', { pack: 'coding' }), /invalid-request/);
await assert.rejects(call('pack.preview.console', { pack: 'nosuch' }), /starter-pack-not-found/);
await assert.rejects(call('board.set.console', { revision: 'nope', board: null }), /invalid-request/);
await assert.rejects(call('board.set.console', { revision: revision(), board: [] }), /invalid-request/);
await assert.rejects(call('workflow.set.console', { revision: revision(), name: 'x', workflow: 'y' }), /invalid-request/);
// A workflow step naming a role no definition, template or `human` resolves refuses.
await assert.rejects(call('workflow.set.console', { revision: revision(), name: 'x', workflow: { description: 'x', steps: [{ title: 'Do', state: 'todo', roles: ['nobody-at-all'] }] } }), /invalid-request/);
// A project pack needs a code project; an org pack takes none.
install(variant('projkit', p => { p.scope = 'project'; }));
await assert.rejects(call('pack.preview.console', { pack: 'projkit' }), /invalid-request/);
await assert.rejects(call('pack.preview.console', { pack: 'projkit', project: 'nosuch' }), /invalid-request/);
const preview = await call('pack.preview.console', { pack: 'projkit', project: 'stack' });
assert.equal(marks(preview)['projects.stack.board'], 'add');
assert.equal(marks(preview)['projects.stack.workflows.change'], 'add');
assert.equal(marks(preview)['definitions.coder'], 'add');
});
test('SP4 a saved template is read only from its own owner, and packs lists none', async t => {
const { b, call, save } = await harness(t);
save('jason', 'mine', variant('mine'));
const preview = await call('pack.preview.console', { pack: 'saved:mine' });
assert.equal(preview.pack.origin, 'saved');
const alex = b.bindHuman({ business: 'other', human: 'alex', via: 'cli', outsideAgent: true });
await assert.rejects(b.request(alex, { verb: 'pack.preview', args: { pack: 'saved:mine' } }), /starter-pack-not-found/);
const listed = await call('packs.console');
assert.ok(listed.packs.length >= 5);
assert.ok(listed.packs.every(p => p.origin !== 'saved' && p.id !== 'mine'));
for (const name of ['saved:../jason/mine', 'saved:', 'saved:a/b']) await assert.rejects(call('pack.preview.console', { pack: name }), /starter-pack-invalid|invalid-request/);
});
test('SP4 board.set changes only the board; workflow.set and remove change only that workflow', async t => {
const { call, file, revision } = await harness(t);
const before = file();
const board = structuredClone(codingPack().board);
await call('board.set.console', { revision: revision(), board });
const after = file();
assert.deepEqual(after.board, board);
delete after.board;
assert.deepEqual(after, before);
const flow = { description: 'One.', steps: [{ title: 'Do it', state: 'todo', roles: ['coder'] }] };
await call('workflow.set.console', { revision: revision(), name: 'one', workflow: flow });
await call('workflow.set.console', { revision: revision(), name: 'two', workflow: flow });
await call('workflow.set.console', { revision: revision(), name: 'one', workflow: null });
assert.deepEqual(file().workflows, { two: flow });
assert.deepEqual(file().board, board);
});
test('SP4 caps: at most 32 workflows and 256 packs entries', async t => {
const { call, file, revision, path } = await harness(t);
const flow = { description: 'x', steps: [{ title: 'Do it', state: 'todo', roles: ['human'] }] };
const doc = file();
doc.workflows = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`w${i}`, flow]));
writeFileSync(path, JSON.stringify(doc, null, 2) + '\n');
await assert.rejects(call('workflow.set.console', { revision: revision(), name: 'w32', workflow: flow }), /invalid-request/);
await call('workflow.set.console', { revision: revision(), name: 'w0', workflow: null });
const preview = await call('pack.preview.console', { pack: 'coding' });
await call('pack.apply.console', { pack: 'coding', digest: preview.digest });
const full = file();
full.packs = Array.from({ length: 256 }, () => structuredClone(full.packs[0]));
writeFileSync(path, JSON.stringify(full, null, 2) + '\n');
const again = await call('pack.preview.console', { pack: 'coding' });
await assert.rejects(call('pack.apply.console', { pack: 'coding', digest: again.digest }), /invalid-request/);
});
test('SP4 the broker knows each verb under both names', () => {
assert.deepEqual(Object.keys(PACK_BUS_KEYS).sort(), ['board.set', 'board.set.console', 'pack.apply', 'pack.apply.console', 'pack.preview', 'pack.preview.console', 'packs', 'packs.console', 'workflow.set', 'workflow.set.console']);
});
+3 -1
View File
@@ -91,7 +91,9 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
test('the console verb list includes the artifact read and existing owner controls', () => {
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console',
// Starter packs SP4 (#1873): the owner's pack list and preview, and the three pack writes.
'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console']);
});
test('only the signed-in owner capability reaches the console verbs and it reaches nothing else', async t => {
+91 -4
View File
@@ -1,18 +1,23 @@
import { readFile, writeFile } from 'node:fs/promises';
import { configDir } from '../../organization/src/organization-config.mjs';
import { listOrgs, orgLock } from '../../organization/src/org-registry.mjs';
import { basename } from 'node:path';
import { listOrgs, orgLock, resolveOrg } from '../../organization/src/org-registry.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, projectRepoSet, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs';
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
import { installedStarterPacks } from '../../plugins/src/starter-pack.mjs';
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
import { loadSystem } from './config.mjs';
import { loadSystem, orgPaths, rolesDir } from './config.mjs';
import { CliError } from './errors.mjs';
import { busStart, busStopForDelete, copyRemoteFor } from './org-bus.mjs';
import { assertBuildStart, assertDevInstance } from './deployment.mjs';
import { consoleBusRestart } from './org-console.mjs';
import { refuseInsideAgent } from './transport.mjs';
import { busExit, humanTransport, refuseInsideAgent } from './transport.mjs';
import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.mjs';
import { orgOwnerCommand, ORG_OWNER_USAGE, ORG_OWNER_VERBS } from './org-owner.mjs';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | project repo set ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
// Starter packs SP4 (design section 8): the owner's pack, board and workflow words.
export const PACKS_USAGE = 'mosaic org pack list | pack preview ID PACK [--project P] [--as KIND.NAME=NEW]... | pack apply ID PACK --digest D [--project P] [--as KIND.NAME=NEW]... | board set ID FILE [--project P] | workflow set ID NAME FILE [--project P] | workflow remove ID NAME [--project P]';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | project repo set ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | ' + PACKS_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk', '--no-bus'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
// A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916).
const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable';
@@ -28,6 +33,87 @@ function restoreBusCheck(system, env) {
try { assertBuildStart({ devOwner: true }); assertDevInstance(system, env); }
catch (e) { throw new CliError(`restore starts the bus as the local dev owner, and ${e.message}; restore with --no-bus, then start the bus with mosaic bus start`, 3); }
}
// Starter packs SP4: `mosaic org pack|board|workflow`. Every word is the owner's and goes through the
// organization's bus as its human, so it runs only from a human shell: the bus holds the credential
// store whose held-secret check each preview and write needs, and the open hire decisions a preview
// reads. A refusal arrives as its code; a FILE's text is never echoed.
async function packFile(file) {
try { return JSON.parse(await readFile(file, 'utf8')); }
catch (e) { throw new CliError(`cannot read ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : errorCode(e)}`, 2); }
}
// deps.callFor or deps.transport stand in for the bus in tests; otherwise the call is callFor's in cli.mjs.
function packCall(id, io, deps) {
if (typeof deps.callFor === 'function') return deps.callFor({ positional: [id], flags: new Set(), values: { '--business': id } });
if (typeof deps.transport === 'function') return deps.transport(id);
return async (verb, args) => {
const dir = configDir(io.env);
const match = /^mosaic-([a-z][a-z0-9]{1,15})$/.exec(basename(dir));
if (!match) throw new CliError('organization commands require an instance config directory', 3);
const system = deps.system ?? loadSystem({ env: io.env, instance: match[1] });
const org = await resolveOrg({ configDir: dir, dataRoot: system.dataRoot, instance: match[1], env: io.env, system }, id);
if (org.state !== 'active') throw new CliError('organization is not active', 3);
return humanTransport({ socket: orgPaths(org.root).socket, business: id, env: io.env })(verb, args);
};
}
const PACK_REFUSALS = {
'pack-plan-stale': 'the organization or the pack changed since the preview; preview again and apply its digest',
'revision-conflict': 'the organization file changed while this ran; run it again',
'credential-leak': 'the request holds a credential the organization\'s bus holds; nothing was written',
'invalid-request': 'the bus refused the request: a bad argument, a project that is not a code project, or a pack that fails the composition check',
'human-required': 'only the organization\'s owner, from a human shell, may do this',
'starter-pack-not-found': 'no installed, shipped or saved pack has that name',
};
async function packsCommand(args, io, deps) {
const pos = [], values = {}, as = [];
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (['--project', '--digest', '--as'].includes(a)) {
if (!args[i + 1] || args[i + 1].startsWith('--') || (a !== '--as' && values[a] !== undefined)) throw new CliError(`${a} needs one value`, 4);
if (a === '--as') as.push(args[++i]); else values[a] = args[++i];
} else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4);
else pos.push(a);
}
const allowed = (...keys) => { for (const k of [...Object.keys(values), ...(as.length ? ['--as'] : [])]) if (!keys.includes(k)) throw new CliError(`unexpected ${k}`, 4); };
const project = values['--project'] === undefined ? {} : { project: values['--project'] };
const [word, sub, id, name, file] = pos;
// Every word is the human's: an agent run refuses before a file is read, a pack is loaded or the bus is called.
refuseAgentRun(io);
let verb, request, revised = false;
if (word === 'pack' && sub === 'list' && pos.length === 2) { allowed(); verb = 'packs'; request = {}; }
else if (word === 'pack' && ['preview', 'apply'].includes(sub) && pos.length === 4) {
allowed('--project', '--as', ...(sub === 'apply' ? ['--digest'] : []));
if (sub === 'apply' && !values['--digest']) throw new CliError('--digest is required: apply the digest a preview printed', 4);
verb = `pack.${sub}`;
request = { pack: name, ...project, ...(as.length ? { as } : {}), ...(sub === 'apply' ? { digest: values['--digest'] } : {}) };
} else if (word === 'board' && sub === 'set' && pos.length === 4) { allowed('--project'); verb = 'board.set'; revised = true; request = { board: await packFile(name), ...project }; }
else if (word === 'workflow' && sub === 'set' && pos.length === 5) { allowed('--project'); verb = 'workflow.set'; revised = true; request = { name, workflow: await packFile(file), ...project }; }
else if (word === 'workflow' && sub === 'remove' && pos.length === 4) { allowed('--project'); verb = 'workflow.set'; revised = true; request = { name, workflow: null, ...project }; }
else throw new CliError(PACKS_USAGE, 4);
// pack list names no organization, so it reads what the bus's `packs` verb reads (the shipped and
// installed packs, never a saved template) from this instance's data root, as `system-types
// installed` does.
if (verb === 'packs') {
const system = deps.system ?? loadSystem({ env: io.env, instance: devOwnerInstance(io.env) });
const { packs } = await installedStarterPacks({ dataRoot: system.dataRoot, rolesDir: rolesDir(io.env) });
io.stdout.write(JSON.stringify({ packs: packs.map(({ id, origin, pack }) => ({ id, origin, pack })) }, null, 2) + '\n');
return;
}
const call = packCall(id, io, deps);
if (revised) {
const writer = deps.writer ?? organizationWriter({ dir: configDir(io.env), rolesDir: rolesDir(io.env) });
request = { revision: writer.read(id).revision, ...request };
}
let result;
try { result = await call(verb, request); }
catch (e) {
if (typeof e?.code !== 'string' || e.code === '' || e instanceof CliError) throw e;
const why = PACK_REFUSALS[e.code];
const error = new CliError(why ? `${e.code}: ${why}` : e.code, busExit(e.code));
error.code = e.code;
throw error;
}
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
const busStartVerb = id => `mosaic bus start ${id} --dev-local-owner --detach`;
export async function orgCommand(args, io, deps = {}) {
try {
@@ -35,6 +121,7 @@ export async function orgCommand(args, io, deps = {}) {
if (args[0] === 'definitions') return await definitionsCommand(args.slice(1), io, deps);
// E2 (#1873, P1-3): the hire form and the autonomy panel, through the running Console (org-owner.mjs).
if (ORG_OWNER_VERBS.includes(args[0])) return await orgOwnerCommand(args, io, deps);
if (['pack', 'board', 'workflow'].includes(args[0])) return await packsCommand(args, io, deps);
const p = parse(args); const words = p.pos; let result; let accepted = [];
const allowed = (flags, values) => { for (const f of p.flags) if (!flags.includes(f)) throw new CliError(`unexpected ${f}`, 4); for (const f of Object.keys(p.values)) if (!values.includes(f)) throw new CliError(`unexpected ${f}`, 4); };
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
+99
View File
@@ -0,0 +1,99 @@
// Starter packs SP4: `mosaic org pack|board|workflow`. Each word goes through the organization's bus as
// its human; pack apply writes the packs entry, and workflow remove takes out only that workflow.
import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { readFileSync, writeFileSync } from 'node:fs';
import { main } from '../src/cli.mjs';
import { ORG_USAGE, PACKS_USAGE } from '../src/org.mjs';
import { bootConfig, loadSystem } from '../src/config.mjs';
import { BusError } from '../../bus/src/broker.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { fixture, io, tmp } from './helpers.mjs';
const FLOW = (title) => ({ description: `${title}.`, steps: [{ title, state: 'todo', roles: ['human'] }] });
async function setup(t) {
const root = tmp(t);
const f = fixture(root, 'acme');
const file = join(root, 'config/businesses/acme.json');
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
t.after(() => b.close());
const human = b.broker.bindHuman({ business: 'acme', human: f.doc.human, via: 'cli', outsideAgent: true });
const calls = [];
// The human transport's shape: a bus refusal arrives as its code.
const deps = { system: { dataRoot: f.dataRoot }, transport: () => async (verb, args = {}) => {
calls.push([verb, Object.keys(args).sort()]);
try { return structuredClone(await b.broker.request(human, { verb, args })); }
catch (e) { if (!(e instanceof BusError)) throw e; throw Object.assign(new Error(e.code), { code: e.code }); }
} };
const run = async (args, env = f.env) => { const x = io(env); await main(['org', ...args], x, deps); return JSON.parse(x.out.text); };
const doc = () => JSON.parse(readFileSync(file, 'utf8'));
return { root, f, file, calls, run, doc };
}
test('SP4 mosaic org pack: list reads the shipped packs, preview writes nothing, apply with its digest writes the packs entry', async (t) => {
const s = await setup(t);
assert.ok(ORG_USAGE.includes(PACKS_USAGE.slice('mosaic org '.length)));
const listed = await s.run(['pack', 'list']);
assert.ok(listed.packs.some((p) => p.id === 'coding' && p.origin === 'shipped'));
assert.deepEqual(s.calls, [], 'pack list reads the data root; it asks no bus');
const before = readFileSync(s.file);
const preview = await s.run(['pack', 'preview', 'acme', 'coding']);
assert.match(preview.digest, /^[0-9a-f]{64}$/);
assert.deepEqual(readFileSync(s.file), before, 'preview writes nothing');
await assert.rejects(s.run(['pack', 'apply', 'acme', 'coding']), (e) => e.exitCode === 4 && /--digest is required/.test(e.message));
await assert.rejects(s.run(['pack', 'apply', 'acme', 'coding', '--digest', '0'.repeat(64)]), (e) => e.code === 'pack-plan-stale' && /preview again/.test(e.message));
assert.deepEqual(readFileSync(s.file), before);
const applied = await s.run(['pack', 'apply', 'acme', 'coding', '--digest', preview.digest]);
assert.equal(applied.pack, 'coding');
const packs = s.doc().packs;
assert.equal(packs.length, 1);
assert.equal(packs[0].id, 'coding');
assert.equal(packs[0].origin, 'shipped');
assert.deepEqual(s.calls.map(([verb]) => verb), ['pack.preview', 'pack.apply', 'pack.apply']);
assert.deepEqual(s.calls[2][1], ['digest', 'pack']);
});
test('SP4 mosaic org workflow remove takes out only that workflow; board set changes only the board', async (t) => {
const s = await setup(t);
const spec = join(s.root, 'flow.json');
for (const name of ['one', 'two']) {
writeFileSync(spec, JSON.stringify(FLOW(name)));
await s.run(['workflow', 'set', 'acme', name, spec]);
}
writeFileSync(spec, JSON.stringify({ 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' }));
await s.run(['board', 'set', 'acme', spec]);
const before = s.doc();
assert.deepEqual(Object.keys(before.workflows).sort(), ['one', 'two']);
await s.run(['workflow', 'remove', 'acme', 'one']);
const after = s.doc();
assert.deepEqual(after.workflows, { two: before.workflows.two });
const { workflows: _a, ...restBefore } = before;
const { workflows: _b, ...restAfter } = after;
assert.deepEqual(restAfter, restBefore, 'nothing but the one workflow changed');
assert.deepEqual(s.calls.at(-1), ['workflow.set', ['name', 'revision', 'workflow']]);
// A file that is not JSON is named without its text; an unknown word prints the usage.
writeFileSync(spec, '{"title": fixture-not-json');
await assert.rejects(s.run(['workflow', 'set', 'acme', 'three', spec]), (e) => e.exitCode === 2 && !e.message.includes('fixture-not-json'));
await assert.rejects(s.run(['workflow', 'rename', 'acme', 'two']), (e) => e.exitCode === 4 && e.message === PACKS_USAGE);
});
test('SP4 an agent shell refuses every pack, board and workflow word, pack list too, before it reads a file or calls the bus', async (t) => {
const s = await setup(t);
const agent = { ...s.f.env, MOSAIC_RUN_ID: 'run-1' };
const spec = join(s.root, 'flow.json');
writeFileSync(spec, JSON.stringify(FLOW('one')));
const before = readFileSync(s.file);
for (const args of [['pack', 'list'], ['pack', 'preview', 'acme', 'coding'], ['pack', 'apply', 'acme', 'coding', '--digest', 'a'.repeat(64)], ['board', 'set', 'acme', spec], ['workflow', 'set', 'acme', 'one', spec], ['workflow', 'remove', 'acme', 'one']]) {
await assert.rejects(s.run(args, agent), (e) => e.exitCode === 3 && /human shell/.test(e.message), args.join(' '));
}
assert.deepEqual(s.calls, []);
assert.deepEqual(readFileSync(s.file), before);
});
+12
View File
@@ -136,6 +136,18 @@ export function instancesNaming(document, name) {
return users.sort();
}
// SP4: every actor name an agent of the organization can carry: a root instance as `n` and
// `<definition>.n`, a project agent as `n`, `n.<project>` and `<definition>.n.<project>`. A write
// only a human makes refuses any of them.
export function agentActors(document) {
const out = [];
for (const [n, role] of Object.entries(document?.roles ?? {})) out.push(n, `${role?.definition}.${n}`);
for (const [project, p] of Object.entries(document?.projects ?? {})) {
for (const [n, entry] of Object.entries(p?.agents ?? {})) out.push(n, `${n}.${project}`, `${entry?.definition}.${n}.${project}`);
}
return out;
}
// ---- Templates ----
function frame(...parts) {
+7
View File
@@ -90,6 +90,13 @@ export const VERB_RIGHTS = record({
'system-types.console': 'org.setup',
'project.remote.get.console': 'org.setup',
'project.remote.set.console': 'org.setup',
// #1873 SP4: starter packs, the board and workflows. The list is a board read; every write is
// the owner's alone (null): a pack copies roles, and nothing else writes board or workflows.
'packs.console': 'board.read',
'pack.preview.console': null,
'pack.apply.console': null,
'board.set.console': null,
'workflow.set.console': null,
});
function refuse(code) {
@@ -37,6 +37,8 @@ const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "var
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"]);
export const PACKS_MAX = 256;
export const WORKFLOWS_MAX = 32;
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.-]+)?$/;
@@ -75,6 +77,8 @@ export function checkBoard(board, where) {
// must not fail the whole organization. applyPack resolves the roles of the workflows it writes.
export function checkWorkflows(workflows, where) {
requireObject(workflows, where);
// SP4: at most 32 per scope. A pack names a handful; the cap bounds what one apply can grow.
if (Object.keys(workflows).length > WORKFLOWS_MAX) refuse(`${where} must hold at most ${WORKFLOWS_MAX} workflows`);
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`);
@@ -126,6 +130,8 @@ function checkPin(pin, where) {
// 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`);
// SP4: at most 256 entries. Every apply appends one, so the list grows; the cap bounds the file.
if (packs.length > PACKS_MAX) refuse(`${where} must hold at most ${PACKS_MAX} entries`);
return packs.map((entry, i) => {
const at = `${where}[${i}]`;
requireObject(entry, at);
+8 -4
View File
@@ -16,7 +16,7 @@ import { REPO_BOUND, projectBindings, repoBoundMessage } from "./repo-remote.mjs
import { orgIncarnation, withRegistryWriter } from "./org-registry.mjs";
import { REMOTE_OUTCOMES, REVOKE_CAUSES, allGranted, bindingText, definitionPin, samePin } from "./copy-bindings.mjs";
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
import { checkDefinition, checkDefinitionName, instancesNaming, readTemplate } from "./definitions.mjs";
import { agentActors, checkDefinition, checkDefinitionName, instancesNaming, readTemplate } from "./definitions.mjs";
export class OrganizationError extends Error {
constructor(code, message = code) {
@@ -390,8 +390,12 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
const definitionsProtected = (old, document) => {
if (canonicalJson(document?.definitions ?? {}) !== canonicalJson(old.definitions ?? {})) fail("organization-definitions-protected");
};
// SP4: packs, board and workflows change only through applyPack (project keys are under projects).
const packKeysProtected = (old, document) => {
for (const key of ["packs", "board", "workflows"]) if (canonicalJson(document?.[key] ?? null) !== canonicalJson(old[key] ?? null)) fail("organization-packs-protected");
};
function write(id, document, options) {
return commit(id, document, { ...options, capability: null, incarnation: undefined, guard: (old, next) => { projectsProtected(old, next); definitionsProtected(old, next); } });
return commit(id, document, { ...options, capability: null, incarnation: undefined, guard: (old, next) => { projectsProtected(old, next); definitionsProtected(old, next); packKeysProtected(old, next); } });
}
// #1873 T1: the only writer of `definitions`. `next(current)` returns the new entry without its
@@ -405,7 +409,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
checkDefinitionName(name, "definition name");
const logged = { ...detail, definition: name, rev: null };
const guard = (old, document) => {
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
const agents = agentActors(old);
if (actor === "tester" || agents.includes(actor) || (old.retired ?? []).includes(actor)) fail("human-required");
const { definitions: a = {}, ...restOld } = old;
const { definitions: b = {}, ...restNew } = document ?? {};
@@ -501,7 +505,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
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 agents = agentActors(old);
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);
@@ -82,6 +82,13 @@ const VERBS = {
'system-types.console': 'org.setup',
'project.remote.get.console': 'org.setup',
'project.remote.set.console': 'org.setup',
// #1873 SP4: starter packs, the board and workflows. The list is a board read; every write is
// the owner's alone (null): a pack copies roles, and nothing else writes board or workflows.
'packs.console': 'board.read',
'pack.preview.console': null,
'pack.apply.console': null,
'board.set.console': null,
'workflow.set.console': null,
};
test('M1/R1 the eleven rights pin the grantable flags, including compulsory org.view', () => {
@@ -0,0 +1,84 @@
// Starter packs SP4: the writer's fences for the pack keys. The generic write never changes packs,
// board or workflows (only applyPack does), and every actor name an agent carries, a project agent's
// included, is refused a write of definitions.
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 { agentActors } from "../src/definitions.mjs";
import { PACKS_MAX, WORKFLOWS_MAX, checkPacks, checkWorkflows } from "../src/organization-config.mjs";
import { businessDoc, rolesCopy, tmp, writeJson } from "./helpers.mjs";
const assertClean = () => {};
const PLAIN = { role: { tools: ["read"], network: "none", authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: "Read and report.\n" };
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 PACK = { id: "coding", version: "1.0.0", sha256: "a".repeat(64), origin: "shipped", scope: "org", project: null, proposedHires: [] };
const FLOW = { description: "One.", steps: [{ title: "Do it", state: "todo", roles: ["human"] }] };
function fixture(t) {
const root = tmp();
t.after(() => rmSync(root, { recursive: true, force: true }));
const dir = join(root, "config");
const rolesDir = rolesCopy(root);
const file = writeJson(join(dir, "businesses/acme.json"), businessDoc(root));
return { file, writer: organizationWriter({ dir, rolesDir }) };
}
const rev = (f) => f.writer.read("acme").revision;
const doc = (f) => JSON.parse(readFileSync(f.file, "utf8"));
test("SP4 the generic write refuses any change to packs, board or workflows", (t) => {
const f = fixture(t);
const before = readFileSync(f.file, "utf8");
for (const [key, value] of [["board", BOARD], ["workflows", { one: FLOW }], ["packs", []]]) {
const next = { ...doc(f), [key]: value };
assert.throws(() => f.writer.write("acme", next, { revision: rev(f), actor: "jason" }), { code: "organization-packs-protected" }, key);
}
assert.equal(readFileSync(f.file, "utf8"), before);
// Once applyPack wrote them, the generic write may carry them unchanged, but not remove them.
f.writer.applyPack("acme", { board: BOARD, workflows: { one: FLOW } }, { revision: rev(f), actor: "jason", assertClean });
const kept = doc(f);
kept.vars = { ...kept.vars, "tracker.pollSeconds": 90 };
f.writer.write("acme", kept, { revision: rev(f), actor: "jason" });
assert.equal(doc(f).vars["tracker.pollSeconds"], 90);
const { board: _board, ...dropped } = doc(f);
assert.throws(() => f.writer.write("acme", dropped, { revision: rev(f), actor: "jason" }), { code: "organization-packs-protected" });
});
test("SP4 agentActors names root instances and project agents in every form", () => {
const document = {
roles: { coder: { definition: "coder" }, chief: { definition: "chief-of-staff" } },
projects: { stack: { agents: { checker: { definition: "fact-checker" } } }, docs: {} },
};
assert.deepEqual(agentActors(document).sort(), ["checker", "checker.stack", "chief", "chief-of-staff.chief", "coder", "coder.coder", "fact-checker.checker.stack"].sort());
assert.deepEqual(agentActors(null), []);
});
test("SP4 a project agent's actor name refuses a definition write through setDefinition and applyPack", (t) => {
const f = fixture(t);
let { revision } = f.writer.read("acme");
({ revision } = f.writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, "jason"));
const before = readFileSync(f.file, "utf8");
for (const actor of ["checker", "checker.stack", "fact-checker.checker.stack"]) {
assert.throws(() => f.writer.setDefinition("acme", "scout", () => PLAIN, { revision, actor, assertClean }), { code: "human-required" }, actor);
assert.throws(() => f.writer.applyPack("acme", { definitions: { scout: PLAIN }, packs: PACK }, { revision, actor, assertClean }), { code: "human-required" }, actor);
}
assert.equal(readFileSync(f.file, "utf8"), before);
f.writer.setDefinition("acme", "scout", () => PLAIN, { revision, actor: "jason", assertClean });
assert.ok(doc(f).definitions.scout);
});
test("SP4 caps: at most 32 workflows in a scope and 256 packs entries", () => {
const flows = (n) => Object.fromEntries(Array.from({ length: n }, (_, i) => [`w${i}`, FLOW]));
assert.equal(WORKFLOWS_MAX, 32);
assert.equal(PACKS_MAX, 256);
assert.equal(Object.keys(checkWorkflows(flows(32), "workflows")).length, 32);
assert.throws(() => checkWorkflows(flows(33), "workflows"), /at most 32 workflows/);
assert.throws(() => checkPacks(Array.from({ length: 257 }, () => ({})), "packs"), /at most 256 entries/);
});
+4
View File
@@ -7,6 +7,7 @@ import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
import { organizationConsole } from './organizations.mjs';
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
import { SYSTEM_TYPE_VERBS } from './system-types-http.mjs';
import { packsCaller } from './packs-http.mjs';
import { autonomyCaller } from './autonomy-http.mjs';
import { consoleAccess } from './access.mjs';
import { accessContextFor } from '../../cli/src/access.mjs';
@@ -195,6 +196,9 @@ try {
// These options exist only on a --dev-local-owner Console (`organizations` above). The fence passes
// those four verbs and nothing else the console transport carries.
autonomyWrite: session.user === undefined && sendOn ? autonomyCaller(consoleCall({ dataRoot: root, socket: paths.socket, business: id })) : null,
// Starter packs SP4 (#1873): the owner's pack apply, board and workflow writes, the three write
// .console verbs only. Preview and the pack list are reads over requestRead.
packsWrite: session.user === undefined && sendOn ? packsCaller(consoleCall({ dataRoot: root, socket: paths.socket, business: id })) : null,
reads: consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: id, orgRoot: root }),
};
},
+126
View File
@@ -0,0 +1,126 @@
// Starter packs SP4 (#1873, design section 8): the owner's pack, board and workflow routes on a
// selected organization. Owner Console transports only; no caller-supplied business, actor, subject
// or origin reaches the bus.
//
// GET /api/orgs/<id>/packs {packs, writable}: packs.console, the shipped and installed
// packs (never a saved template). /api/packs on a root Console.
// POST /api/orgs/<id>/packs/preview {pack, project?, as?}: pack.preview.console. Writes nothing.
// POST /api/orgs/<id>/packs/apply {pack, digest, project?, as?}: pack.apply.console
// POST /api/orgs/<id>/board {revision, board, project?}: board.set.console (board null removes it)
// POST /api/orgs/<id>/workflows/<name> {revision, workflow, project?}: workflow.set.console
// DELETE /api/orgs/<id>/workflows/<name> {revision, project?}: workflow.set.console with workflow null
//
// `read` is the owner's Console call (consoleCall), used for the two verbs that write nothing. `write`
// is the owner's Console call fenced to the three write verbs (packsCaller), present only for the
// owner's own session under --send. The broker answers every .console verb to the business human's
// console cap alone, and runs the merge, the digest and the held-credential check itself.
export const PACKS_ROUTE = /^\/api\/(?:packs(?:\/(preview|apply))?|board|workflows\/([^/]+))$/;
export const PACK_WRITE_VERBS = Object.freeze(['pack.apply.console', 'board.set.console', 'workflow.set.console']);
export const packsCaller = call => async (verb, args, options) => {
if (!PACK_WRITE_VERBS.includes(verb)) throw new Error(`the pack writer does not call ${verb}`);
return call(verb, args, options);
};
// A pack id, project id or workflow name: the bus's own name rule (vocabulary KIND_NAME, PACK_AS).
const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const SAVED = /^saved:[a-z0-9][a-z0-9._-]{0,63}$/;
const REVISION = /^[a-f0-9]{64}$/;
// A board or workflow body stays small; the organization config caps both (WORKFLOWS_MAX, board columns).
const LIMIT = 65536;
const codes = {
'invalid-request': 400, 'credential-leak': 400, 'starter-pack-invalid': 400, 'definition-invalid': 400,
'starter-pack-not-found': 404, 'project-not-found': 404,
'pack-plan-stale': 409, 'revision-conflict': 409, 'organization-busy': 409,
'human-required': 403, 'read-only': 403,
'not-configured': 503, 'no-bus-host': 503,
};
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
function json(res, status, value) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(value));
}
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
// The body names exactly the required keys and any of the optional ones; nothing else reaches the bus.
async function body(req, required, optional) {
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return null;
const chunks = []; let size = 0;
for await (const chunk of req) { size += chunk.length; if (size > LIMIT) return 'too-large'; chunks.push(chunk); }
let value;
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return null; }
if (!plain(value) || !required.every(k => Object.hasOwn(value, k)) || Object.keys(value).some(k => !required.includes(k) && !optional.includes(k))) return null;
return value;
}
export async function packsHTTP(req, res, { path, owner, read, write, session, live, origin }) {
const refuse = (status, error) => json(res, status, { error });
const fail = error => { const code = codes[error?.code] ? error.code : 'outcome-unknown'; return refuse(codes[code] ?? 503, code); };
const match = PACKS_ROUTE.exec(path);
const step = match?.[1] ?? null;
let name = null;
if (match?.[2] !== undefined) {
try { name = decodeURIComponent(match[2]); } catch { return refuse(400, 'invalid-request'); }
if (!NAME.test(name)) return refuse(400, 'invalid-request');
}
const list = path === '/api/packs';
const allowed = list ? ['GET'] : name !== null ? ['POST', 'DELETE'] : ['POST'];
if (!allowed.includes(req.method)) return refuse(405, 'method-not-allowed');
// A method the route never serves answers 405 first; every served method is the root owner's.
if (!owner) return refuse(403, 'owner-only');
if (!session) return refuse(401, 'sign-in-required');
if (!read) return refuse(503, 'not-configured');
const writes = !list && step !== 'preview';
if (writes && !write) return refuse(403, 'read-only');
const site = one(req, 'sec-fetch-site'), from = one(req, 'origin');
if (req.method !== 'GET' ? from !== origin || site !== 'same-origin'
: (req.headersDistinct.origin && from !== origin) || (req.headersDistinct['sec-fetch-site'] && !['same-origin', 'none'].includes(site)))
return refuse(403, 'cross-origin');
let verb, args = {};
if (list) verb = 'packs.console';
else if (step !== null) {
const value = await body(req, step === 'apply' ? ['pack', 'digest'] : ['pack'], ['project', 'as']);
if (value === 'too-large') return refuse(413, 'invalid-request');
if (!value || typeof value.pack !== 'string' || !(NAME.test(value.pack) || SAVED.test(value.pack))
|| (step === 'apply' && (typeof value.digest !== 'string' || !REVISION.test(value.digest)))
|| (value.project !== undefined && (typeof value.project !== 'string' || !NAME.test(value.project)))
|| (value.as !== undefined && (!Array.isArray(value.as) || value.as.length > 32 || value.as.some(a => typeof a !== 'string' || a.length > 200))))
return refuse(400, 'invalid-request');
verb = `pack.${step}.console`;
args = value;
} else {
const value = await body(req, name === null ? ['revision', 'board'] : req.method === 'DELETE' ? ['revision'] : ['revision', 'workflow'], ['project']);
if (value === 'too-large') return refuse(413, 'invalid-request');
if (!value || typeof value.revision !== 'string' || !REVISION.test(value.revision)
|| (value.project !== undefined && (typeof value.project !== 'string' || !NAME.test(value.project))))
return refuse(400, 'invalid-request');
if (name === null) {
if (value.board !== null && !plain(value.board)) return refuse(400, 'invalid-request');
verb = 'board.set.console';
args = value;
} else {
if (req.method === 'POST' && !plain(value.workflow)) return refuse(400, 'invalid-request');
verb = 'workflow.set.console';
args = { revision: value.revision, name, workflow: req.method === 'DELETE' ? null : value.workflow, ...(value.project === undefined ? {} : { project: value.project }) };
}
}
// The session is checked again after the body is read: a sign-out meanwhile dispatches nothing.
if (live() !== session) return refuse(401, 'sign-in-required');
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
let result;
try { result = writes ? await write(verb, args, { subject }) : await read(verb, args, { subject }); }
catch (error) { return fail(error); }
if (!plain(result)) return refuse(503, 'outcome-unknown');
if (list) {
if (!Array.isArray(result.packs)) return refuse(503, 'outcome-unknown');
return json(res, 200, { packs: result.packs, writable: Boolean(write) });
}
if (step === 'preview') {
if (!REVISION.test(result.digest ?? '')) return refuse(503, 'outcome-unknown');
return json(res, 200, result);
}
// A write admitted may already have committed: its receipt is returned even if the session ended
// meanwhile, never reported as not done.
if (!REVISION.test(result.revision ?? '')) return refuse(503, 'outcome-unknown');
return json(res, 200, { revision: result.revision, added: Array.isArray(result.added) ? result.added : [], ...(step === 'apply' ? { pack: result.pack } : {}) });
}
+18 -7
View File
@@ -21,6 +21,7 @@ import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } fro
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
import { SYSTEM_TYPES_ROUTE, systemTypesHTTP } from './system-types-http.mjs';
import { PACKS_ROUTE, packsHTTP } from './packs-http.mjs';
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
import { consoleRestartHTTP } from './console-restart-http.mjs';
import { workspaceHTTP } from './workspace-read.mjs';
@@ -523,7 +524,9 @@ const AGENT_ROUTES = {
const ORG_OWNER_WRITE = path => AUTONOMY_ROUTE.test(path) || path === '/api/settings' || path === '/api/instances' || path === '/api/routes'
|| Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/')
// Phase P2 (#1873): the System types enable switch and a project's selection; the route keeps its own checks.
|| SYSTEM_TYPES_ROUTE.test(path);
|| SYSTEM_TYPES_ROUTE.test(path)
// Starter packs SP4 (#1873): pack apply, board and workflow writes; the route keeps its own checks.
|| PACKS_ROUTE.test(path);
async function agentSend(req, res, { route, agentWrite, preview, log, note = null }) {
let target = null;
// The instance or name is quoted and cut short so a crafted value can't forge a log line. A reason is never logged.
@@ -860,7 +863,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
let boundAddress = null, boundPort = null;
const server = createServer(async (req, res) => {
// A granted user's request rebinds these below; every other request keeps the owner's.
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [], autonomyWrite = null;
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [], autonomyWrite = null, packsWrite = null;
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
@@ -930,7 +933,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// alone stays open on a loopback Host for the status and release gates (Q-C).
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
// No CORS. JSON content type and same-origin checks keep browser forms out.
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), systemTypesPath = SYSTEM_TYPES_ROUTE.test(path), autonomyPath = AUTONOMY_ROUTE.test(path);
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), systemTypesPath = SYSTEM_TYPES_ROUTE.test(path), autonomyPath = AUTONOMY_ROUTE.test(path),
// GET and HEAD /api/board stay the Board read; every other method on it is the pack route's (POST writes, the rest 405).
packsPath = PACKS_ROUTE.test(path) && !(path === '/api/board' && (req.method === 'GET' || req.method === 'HEAD'));
if (req.headers.origin && req.headers.origin !== (named ? (sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`) : `http://${req.headers.host}`))
return decisionPath ? decisionRefuse(res, 'cross-origin') : requestPath ? requestRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
let signer = preview, ssoSession = null;
@@ -942,7 +947,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!session && path !== '/healthz') {
if (decisionPath) return decisionRefuse(res, 'sign-in-required');
if (requestPath) return requestRefuse(res, 'sign-in-required');
if (remotePath || systemTypesPath || autonomyPath) return json(res, 401, { error: 'sign-in-required' });
if (remotePath || systemTypesPath || autonomyPath || packsPath) return json(res, 401, { error: 'sign-in-required' });
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
return res.end();
@@ -955,7 +960,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const instanceAction = originalPath === '/api/console/restart';
if (session && !session.rootOwner && session.user === undefined && orgId === null && !orgList && !hook?.publicRead && !instanceAction && path !== '/api/config' && path !== '/healthz' && !files.has(path)) return json(res, 404, { error: 'not-found' });
if (session && !session.rootOwner && session.user === undefined) {
bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = autonomyWrite = upstream = null;
bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = autonomyWrite = packsWrite = upstream = null;
}
if (session?.user !== undefined && !session.rootOwner && !instanceAction) {
// #1873: proxy setup answers a grant holder the way the bus answers an agent.
@@ -963,7 +968,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
if (!organizations && userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
if (!organizations) ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null; systemTypesWrite = null;
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null; systemTypesWrite = null; packsWrite = null;
chatGrants = sso.chatGrants?.(session) ?? [];
}
}
@@ -994,7 +999,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
({ bus = null, reads = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null,
projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null,
assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null,
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, autonomyWrite = null, upstream = null } = selected);
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, autonomyWrite = null, packsWrite = null, upstream = null } = selected);
}
// F3: the owner's settings writes reach the selected organization's bus too (limits, routing,
// agents, assistant, instances, model proxy). The routes below keep their own checks.
@@ -1070,6 +1075,12 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return systemTypesHTTP(req, res, { path, read: requestRead, write: send ? systemTypesWrite : null,
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin });
}
// Starter packs SP4 (#1873): the owner's pack, board and workflow routes on the selected organization.
if (packsPath) {
const owner = !(tester || granted || !named || sso === null);
return packsHTTP(req, res, { path, owner, read: requestRead, write: send ? packsWrite : null,
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso?.publicOrigin });
}
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/system-types.js', '/system-types.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
// Row 194 (#1692, D4.1): the tester also launches and terminates.
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
+222
View File
@@ -0,0 +1,222 @@
// Starter packs SP4 (#1873): the owner's pack, board and workflow routes on a selected organization,
// end to end. An ephemeral organization with the real runtime writer and the broker's console socket
// for every call: the read and the fenced write both bind the business human's console cap, as the
// Console's consoleCall does. The SSO session is a fixture authority.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { request } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { busOrganization } from '../../bus/src/organization-context.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { serve } from '../../bus/src/server.mjs';
import { Client } from '../../bus/src/client.mjs';
import { startServer } from '../src/serve.mjs';
import { PACK_WRITE_VERBS, packsCaller } from '../src/packs-http.mjs';
import { close } from './fixture.mjs';
const FLOW = (title) => ({ description: `${title}.`, steps: [{ title, state: 'todo', roles: ['human'] }] });
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' };
async function fixture(t, { send = true } = {}) {
const undo = []; t.after(async () => { for (const step of undo.reverse()) await step(); });
const root = mkdtempSync(join(tmpdir(), 'pk-'));
const rolesDir = rolesCopy(root), system = systemFor(root);
const doc = businessDoc(root, 'demo');
doc.plugins = { ...(doc.plugins ?? {}), harness: ['pi'] };
const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const business = loadBusiness('demo', { dir: root, rolesDir });
const resolved = Object.fromEntries(Object.keys(business.roles).map(instance => [instance, resolveInstance({ system, business, instance })]));
undo.push(() => rmSync(root, { recursive: true, force: true }));
const runtime = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busOrganization(business, resolved) }, organization: { dir: root, rolesDir, system } });
undo.push(() => runtime.close());
const path = join(root, 'b.sock');
const socketServer = await serve({ broker: runtime.broker, path, peerReader: { read: async () => ({}), close: () => {} },
authenticateConsole: proof => runtime.broker.bindConsole({ business: 'demo', human: 'jason', subject: proof.subject }) });
undo.push(() => socketServer.close());
const calls = [];
const consoleCall = kind => async (verb, args, ctx) => { calls.push([kind, verb, Object.keys(args).sort()]); return new Client({ path, console: { subject: ctx.subject } }).call(verb, args); };
const requestRead = consoleCall('read');
const packsWrite = packsCaller(consoleCall('write'));
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason', rootOwner: true };
let active = true;
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
const organizations = {
list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
resolve: async id => id !== 'demo' ? null : { busHealthy: true, send: send ? async () => ({}) : null, requestRead, packsWrite: send ? packsWrite : null,
reads: { business: async () => ({ business: { id: 'demo' } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } },
};
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), organizations, log: () => {} });
const base = `http://127.0.0.1:${web.address().port}`;
sso.publicOrigin = base; sso.publicHost = new URL(base).host;
undo.push(() => close(web));
const onDisk = () => JSON.parse(readFileSync(file, 'utf8'));
return { base, file, calls, onDisk, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } };
}
function call(f, path, method = 'GET', body, headers = {}) {
return new Promise((resolve, reject) => {
const data = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
const req = request(f.base + path, { method, headers: { ...(data !== undefined ? { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...headers } }, res => {
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)]));
});
req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(data);
});
}
const ORG = '/api/orgs/demo';
test('SP4 the owner lists the packs, previews one (nothing written) and applies it with its digest', async t => {
const f = await fixture(t);
const [listed, list] = await call(f, `${ORG}/packs`);
assert.equal(listed, 200, JSON.stringify(list));
assert.equal(list.writable, true);
assert.ok(list.packs.some(p => p.id === 'coding' && p.origin === 'shipped'));
const before = readFileSync(f.file);
const [previewed, preview] = await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' });
assert.equal(previewed, 200, JSON.stringify(preview));
assert.match(preview.digest, /^[a-f0-9]{64}$/);
assert.deepEqual(readFileSync(f.file), before, 'preview writes nothing');
const [stale, staleBody] = await call(f, `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: '0'.repeat(64) });
assert.deepEqual([stale, staleBody.error], [409, 'pack-plan-stale']);
assert.deepEqual(readFileSync(f.file), before);
const [applied, receipt] = await call(f, `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: preview.digest });
assert.equal(applied, 200, JSON.stringify(receipt));
assert.equal(receipt.pack, 'coding');
assert.match(receipt.revision, /^[a-f0-9]{64}$/);
assert.deepEqual(f.onDisk().packs.map(p => [p.id, p.origin]), [['coding', 'shipped']]);
// The two reads go over the read call; only apply over the fenced writer, and only the .console verbs.
assert.deepEqual(f.calls.map(c => [c[0], c[1]]), [['read', 'packs.console'], ['read', 'pack.preview.console'], ['write', 'pack.apply.console'], ['write', 'pack.apply.console']]);
});
test('SP4 DELETE of a workflow removes only that workflow; board POST changes only the board', async t => {
const f = await fixture(t);
const rev = async () => (await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' }))[1].revision;
for (const name of ['one', 'two']) {
const [status, body] = await call(f, `${ORG}/workflows/${name}`, 'POST', { revision: await rev(), workflow: FLOW(name) });
assert.equal(status, 200, JSON.stringify(body));
}
const [boarded, boardBody] = await call(f, `${ORG}/board`, 'POST', { revision: await rev(), board: BOARD });
assert.equal(boarded, 200, JSON.stringify(boardBody));
const before = f.onDisk();
assert.deepEqual(Object.keys(before.workflows).sort(), ['one', 'two']);
assert.deepEqual(before.board, BOARD);
const [removed, receipt] = await call(f, `${ORG}/workflows/one`, 'DELETE', { revision: await rev() });
assert.equal(removed, 200, JSON.stringify(receipt));
const after = f.onDisk();
assert.deepEqual(after.workflows, { two: before.workflows.two });
const { workflows: _a, ...restBefore } = before;
const { workflows: _b, ...restAfter } = after;
assert.deepEqual(restAfter, restBefore, 'nothing but the one workflow changed');
assert.deepEqual(f.calls.at(-1), ['write', 'workflow.set.console', ['name', 'revision', 'workflow']]);
// A stale revision is refused and writes nothing.
const [stale, staleBody] = await call(f, `${ORG}/workflows/two`, 'DELETE', { revision: receipt.revision.replace(/^./, c => c === 'a' ? 'b' : 'a') });
assert.deepEqual([stale, staleBody.error], [409, 'revision-conflict']);
assert.deepEqual(f.onDisk(), after);
});
test('SP4 the admission checks refuse before any bus call; a read-only Console still lists and previews', async t => {
const f = await fixture(t);
const revisionNow = (await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' }))[1].revision;
const asked = f.calls.length;
for (const [label, path, method, body, headers, expected] of [
['business field', `${ORG}/packs/preview`, 'POST', { pack: 'coding', business: 'other' }, {}, [400, 'invalid-request']],
['origin field', `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: 'a'.repeat(64), origin: 'shipped' }, {}, [400, 'invalid-request']],
['apply without digest', `${ORG}/packs/apply`, 'POST', { pack: 'coding' }, {}, [400, 'invalid-request']],
['bad pack name', `${ORG}/packs/preview`, 'POST', { pack: '../x' }, {}, [400, 'invalid-request']],
['bad workflow name', `${ORG}/workflows/Bad%20Name`, 'POST', { revision: revisionNow, workflow: FLOW('x') }, {}, [400, 'invalid-request']],
['workflow not an object', `${ORG}/workflows/three`, 'POST', { revision: revisionNow, workflow: 'x' }, {}, [400, 'invalid-request']],
['delete with a workflow', `${ORG}/workflows/three`, 'DELETE', { revision: revisionNow, workflow: FLOW('x') }, {}, [400, 'invalid-request']],
['no revision', `${ORG}/board`, 'POST', { board: BOARD }, {}, [400, 'invalid-request']],
['not JSON', `${ORG}/board`, 'POST', '{', {}, [400, 'invalid-request']],
['too large', `${ORG}/board`, 'POST', JSON.stringify({ revision: revisionNow, board: { pad: 'x'.repeat(70000) } }), {}, [413, 'invalid-request']],
['form post', `${ORG}/board`, 'POST', { revision: revisionNow, board: BOARD }, { 'content-type': 'text/plain' }, [400, 'invalid-request']],
['cross site', `${ORG}/board`, 'POST', { revision: revisionNow, board: BOARD }, { 'sec-fetch-site': 'cross-site' }, [403, 'cross-origin']],
['preview by GET', `${ORG}/packs/preview`, 'GET', undefined, {}, [405, 'method-not-allowed']],
['board by DELETE', `${ORG}/board`, 'DELETE', { revision: revisionNow }, {}, [405, 'method-not-allowed']],
['board by PUT', `${ORG}/board`, 'PUT', { revision: revisionNow }, {}, [405, 'method-not-allowed']],
]) {
const [status, result] = await call(f, path, method, body, headers);
assert.deepEqual([status, result.error], expected, label);
}
assert.equal(f.calls.length, asked, 'no refused request reached the bus');
// The bus's own refusals arrive as their codes: an unknown pack, and a workflow naming no role.
assert.deepEqual((await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'no-such-pack' })).map((x, i) => i ? x.error : x), [404, 'starter-pack-not-found']);
const [badRole, badRoleBody] = await call(f, `${ORG}/workflows/three`, 'POST', { revision: revisionNow, workflow: { description: 'x.', steps: [{ title: 'x', state: 'todo', roles: ['ghost'] }] } });
assert.deepEqual([badRole, badRoleBody.error], [400, 'invalid-request']);
// The fenced writer calls nothing but the three write verbs.
assert.deepEqual(PACK_WRITE_VERBS, ['pack.apply.console', 'board.set.console', 'workflow.set.console']);
await assert.rejects(packsCaller(async () => ({}))('packs.console', {}, {}), /does not call packs\.console/);
f.signOut();
assert.deepEqual((await call(f, `${ORG}/packs`))[0], 401);
});
test('SP4 without --send the writes are read-only; the list and preview still answer', async t => {
const f = await fixture(t, { send: false });
const [listed, list] = await call(f, `${ORG}/packs`);
assert.equal(listed, 200, JSON.stringify(list));
assert.equal(list.writable, false);
const [previewed, preview] = await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' });
assert.equal(previewed, 200, JSON.stringify(preview));
const before = readFileSync(f.file);
for (const [path, method, body] of [[`${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: preview.digest }], [`${ORG}/board`, 'POST', { revision: preview.revision, board: BOARD }], [`${ORG}/workflows/one`, 'DELETE', { revision: preview.revision }]]) {
const [status, result] = await call(f, path, method, body);
assert.deepEqual([status, result.error], [403, 'read-only'], `${method} ${path}`);
}
assert.deepEqual(readFileSync(f.file), before);
});
test('SP4 a session that is not the root owner reaches no pack route', async t => {
const f = await fixture(t);
const [, preview] = await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' });
const asked = f.calls.length;
const before = readFileSync(f.file);
f.revokeOwner();
for (const [path, method, body] of [[`${ORG}/packs`, 'GET'], [`${ORG}/packs/preview`, 'POST', { pack: 'coding' }], [`${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: preview.digest }], [`${ORG}/workflows/one`, 'POST', { revision: preview.revision, workflow: FLOW('one') }], [`${ORG}/board`, 'POST', { revision: preview.revision, board: BOARD }]]) {
// revokeOwner leaves a granted user's session: the route refuses it owner-only, POST /api/board included.
const [status, result] = await call(f, path, method, body);
assert.deepEqual([status, result.error], [403, 'owner-only'], `${method} ${path}`);
}
assert.equal(f.calls.length, asked, 'nothing reached the bus');
assert.deepEqual(readFileSync(f.file), before);
});
test('SP4 a sign-out while the body is still arriving dispatches nothing', async t => {
const f = await fixture(t);
const [, preview] = await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' });
const asked = f.calls.length;
const before = readFileSync(f.file);
const data = JSON.stringify({ pack: 'coding', digest: preview.digest });
const answer = new Promise((resolve, reject) => {
const req = request(`${f.base}${ORG}/packs/apply`, { method: 'POST', headers: { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } }, res => {
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)]));
});
req.on('error', reject);
req.write(data.slice(0, 5));
setTimeout(() => { f.signOut(); req.end(data.slice(5)); }, 150);
});
const [status, body] = await answer;
assert.deepEqual([status, body.error], [401, 'sign-in-required']);
assert.equal(f.calls.length, asked);
assert.deepEqual(readFileSync(f.file), before);
});
test('SP4 a Console without sign-in serves no pack route; a method the route never serves stays 405', async t => {
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', log: () => {} });
t.after(() => close(web));
const f = { base: `http://127.0.0.1:${web.address().port}` };
for (const [path, method, body] of [['/api/packs', 'GET'], ['/api/packs/preview', 'POST', { pack: 'coding' }], ['/api/board', 'POST', { revision: '0'.repeat(64), board: BOARD }]]) {
const [status, result] = await call(f, path, method, body);
assert.deepEqual([status, result.error], [403, 'owner-only'], `${method} ${path}`);
}
for (const method of ['OPTIONS', 'PUT', 'DELETE']) assert.equal((await call(f, '/api/board', method))[0], 405, method);
});