diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 333c7b526..538f9e2b6 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -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) { diff --git a/packages/bus/src/refusals.mjs b/packages/bus/src/refusals.mjs index 6592f63fe..e09b9dffd 100644 --- a/packages/bus/src/refusals.mjs +++ b/packages/bus/src/refusals.mjs @@ -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", diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 852dec1ac..5ee6f6596 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -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)); diff --git a/packages/bus/tests/console-decide.test.mjs b/packages/bus/tests/console-decide.test.mjs index ded008212..edc179812 100644 --- a/packages/bus/tests/console-decide.test.mjs +++ b/packages/bus/tests/console-decide.test.mjs @@ -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)); diff --git a/packages/bus/tests/packs.test.mjs b/packages/bus/tests/packs.test.mjs new file mode 100644 index 000000000..eecb90cd5 --- /dev/null +++ b/packages/bus/tests/packs.test.mjs @@ -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 /templates//.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']); +}); diff --git a/packages/bus/tests/request-console.test.mjs b/packages/bus/tests/request-console.test.mjs index a0d4928ce..178e0bff5 100644 --- a/packages/bus/tests/request-console.test.mjs +++ b/packages/bus/tests/request-console.test.mjs @@ -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 => { diff --git a/packages/cli/src/org.mjs b/packages/cli/src/org.mjs index d86138f9b..8f92c6d6b 100644 --- a/packages/cli/src/org.mjs +++ b/packages/cli/src/org.mjs @@ -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]; }; diff --git a/packages/cli/tests/org-packs.test.mjs b/packages/cli/tests/org-packs.test.mjs new file mode 100644 index 000000000..bd309fd14 --- /dev/null +++ b/packages/cli/tests/org-packs.test.mjs @@ -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); +}); diff --git a/packages/organization/src/definitions.mjs b/packages/organization/src/definitions.mjs index 97f97e588..13056173e 100644 --- a/packages/organization/src/definitions.mjs +++ b/packages/organization/src/definitions.mjs @@ -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 +// `.n`, a project agent as `n`, `n.` and `.n.`. 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) { diff --git a/packages/organization/src/org-rights.mjs b/packages/organization/src/org-rights.mjs index 40b777260..be1265566 100644 --- a/packages/organization/src/org-rights.mjs +++ b/packages/organization/src/org-rights.mjs @@ -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) { diff --git a/packages/organization/src/organization-config.mjs b/packages/organization/src/organization-config.mjs index e9e27c71b..ba85536f6 100644 --- a/packages/organization/src/organization-config.mjs +++ b/packages/organization/src/organization-config.mjs @@ -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); diff --git a/packages/organization/src/organization.mjs b/packages/organization/src/organization.mjs index 3cf449fa8..6b3283f88 100644 --- a/packages/organization/src/organization.mjs +++ b/packages/organization/src/organization.mjs @@ -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); diff --git a/packages/organization/tests/org-rights.test.mjs b/packages/organization/tests/org-rights.test.mjs index 66997716c..af80aed95 100644 --- a/packages/organization/tests/org-rights.test.mjs +++ b/packages/organization/tests/org-rights.test.mjs @@ -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', () => { diff --git a/packages/organization/tests/pack-writes.test.mjs b/packages/organization/tests/pack-writes.test.mjs new file mode 100644 index 000000000..0e4f716bc --- /dev/null +++ b/packages/organization/tests/pack-writes.test.mjs @@ -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/); +}); diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 0e6a68b4c..bbd61cff9 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -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 }), }; }, diff --git a/packages/webui/src/packs-http.mjs b/packages/webui/src/packs-http.mjs new file mode 100644 index 000000000..e5369a9e4 --- /dev/null +++ b/packages/webui/src/packs-http.mjs @@ -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//packs {packs, writable}: packs.console, the shipped and installed +// packs (never a saved template). /api/packs on a root Console. +// POST /api/orgs//packs/preview {pack, project?, as?}: pack.preview.console. Writes nothing. +// POST /api/orgs//packs/apply {pack, digest, project?, as?}: pack.apply.console +// POST /api/orgs//board {revision, board, project?}: board.set.console (board null removes it) +// POST /api/orgs//workflows/ {revision, workflow, project?}: workflow.set.console +// DELETE /api/orgs//workflows/ {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 } : {}) }); +} diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index bc772defb..14ef40970 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -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)) diff --git a/packages/webui/tests/packs-http.test.mjs b/packages/webui/tests/packs-http.test.mjs new file mode 100644 index 000000000..6ebdd96ec --- /dev/null +++ b/packages/webui/tests/packs-http.test.mjs @@ -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); +});