From 079ced93f33c86ba418d767b29f6a31ed21c3760 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 12 Nov 2026 16:51:29 -0600 Subject: [PATCH 1/3] feat(packs): pack, board and workflow verbs, routes and CLI words (#1873 SP4) Design section 8. The bus answers packs, pack.preview, pack.apply, board.set and workflow.set to the organization's human, and their .console forms to the owner's Console cap. The broker runs the section 3 merge, the plan digest (pack-plan-stale on a mismatch) and the held credential check. The organization writer keeps packs, board and workflows out of the generic write and refuses definition writes to every actor name an agent carries, project agents included. mosaic org pack list|preview|apply, board set and workflow set|remove go over the organization's bus as its human and refuse inside an agent shell before any file is read. The Console serves GET /api/orgs//packs, packs/preview, packs/apply, board and workflows/ (POST and DELETE) to the root owner's session only, writes under --send only. --- packages/bus/src/broker.mjs | 46 ++- packages/bus/src/refusals.mjs | 7 + packages/bus/src/runtime.mjs | 201 ++++++++++- packages/bus/tests/packs.test.mjs | 313 ++++++++++++++++++ packages/cli/src/org.mjs | 95 +++++- packages/cli/tests/org-packs.test.mjs | 99 ++++++ packages/organization/src/definitions.mjs | 12 + packages/organization/src/org-rights.mjs | 7 + .../organization/src/organization-config.mjs | 6 + packages/organization/src/organization.mjs | 12 +- .../organization/tests/org-rights.test.mjs | 7 + .../organization/tests/pack-writes.test.mjs | 84 +++++ packages/webui/src/cli.mjs | 4 + packages/webui/src/packs-http.mjs | 124 +++++++ packages/webui/src/serve.mjs | 23 +- packages/webui/tests/packs-http.test.mjs | 209 ++++++++++++ 16 files changed, 1230 insertions(+), 19 deletions(-) create mode 100644 packages/bus/tests/packs.test.mjs create mode 100644 packages/cli/tests/org-packs.test.mjs create mode 100644 packages/organization/tests/pack-writes.test.mjs create mode 100644 packages/webui/src/packs-http.mjs create mode 100644 packages/webui/tests/packs-http.test.mjs 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..bc1dab5fa 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,188 @@ 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; + if (!m || out[m[1]].has(m[2]) || [...out[m[1]].values()].includes(m[3])) 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, 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. + const tombstones = (doc, id, project) => new Set((doc.packs ?? []).filter(e => e.id === id && (e.project ?? null) === 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(); + scan(pack); + const gone = tombstones(doc, pack.id, 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. + const workflow = { description: value.description, steps: value.steps.map(s => ({ title: s.title, state: s.state, roles: [...s.roles] })) }; + 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'); + return { ...write(id, preview.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 +1117,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/packs.test.mjs b/packages/bus/tests/packs.test.mjs new file mode 100644 index 000000000..7ab8a2240 --- /dev/null +++ b/packages/bus/tests/packs.test.mjs @@ -0,0 +1,313 @@ +// #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 } = 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)); } + 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); +}); + +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 } = 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'); +}); + +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); + 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']) + await assert.rejects(call('pack.preview.console', { pack: 'coding', as }), /invalid-request/); +}); + +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', {}]]) + 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/cli/src/org.mjs b/packages/cli/src/org.mjs index d86138f9b..67c04d6fe 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 but pack list is a bus call as the human: an agent run refuses before a file is read. + if (!(word === 'pack' && sub === 'list')) refuseInsideAgent(io.env); + 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..866dfe854 --- /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 write 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', '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..5bdc826ba --- /dev/null +++ b/packages/webui/src/packs-http.mjs @@ -0,0 +1,124 @@ +// 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, 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'); + 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..bb70dd86d 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,7 @@ 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), packsPath = PACKS_ROUTE.test(path); 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 +945,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 +958,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 +966,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 +997,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 +1073,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) { + if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' }); + return packsHTTP(req, res, { path, 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..5e25d2ee3 --- /dev/null +++ b/packages/webui/tests/packs-http.test.mjs @@ -0,0 +1,209 @@ +// 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']], + ]) { + 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 }]]) { + const [status] = await call(f, path, method, body); + assert.ok([401, 403, 404].includes(status), `${method} ${path} answered ${status}`); + } + 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); +}); -- 2.54.0 From 1ba3288bec540d4ff18e0974617e88c4e5d47939 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 12 Nov 2026 17:03:56 -0600 Subject: [PATCH 2/3] fix(packs): SP4 review round, --as collisions, step roles, the board route and repeat applies (#1873) - An --as target that lands on another pack item's name refuses invalid-request (definitions and workflows); the preview no longer shows two adds for one path. - A workflow step role naming one of the pack's definitions follows its --as rename, so an inline definition renamed at preview applies. - GET /api/board stays the Board read; only POST /api/board is the pack board write. - Tombstones key on pack id, origin and project: a saved template reusing a shipped id is another pack. - An apply that adds nothing and proposes what the last apply proposed writes nothing, so retries do not fill the 256 packs entries. - mosaic org pack list refuses inside an agent run like the other words. - Tests pin coder-1's credential lines, skills.notEnabled and the member's unsuffixed pack.apply and board.set refusals; the CONSOLE_VERBS pins in two bus tests name the five pack verbs. --- packages/bus/src/runtime.mjs | 28 ++++++++--- packages/bus/tests/console-decide.test.mjs | 4 +- packages/bus/tests/packs.test.mjs | 55 +++++++++++++++++++-- packages/bus/tests/request-console.test.mjs | 4 +- packages/cli/src/org.mjs | 4 +- packages/cli/tests/org-packs.test.mjs | 4 +- packages/webui/src/serve.mjs | 4 +- 7 files changed, 86 insertions(+), 17 deletions(-) diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index bc1dab5fa..c81035198 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -345,9 +345,11 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean, if (!chief) return null; return { ...chief, ...resolveInstance({ system, business: loaded, instance: doc.launch.by }).limits }; } - // Each earlier packs entry of this id (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. - const tombstones = (doc, id, project) => new Set((doc.packs ?? []).filter(e => e.id === id && (e.project ?? null) === project).flatMap(e => e.added ?? [])); + // 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); @@ -363,8 +365,14 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean, 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, project); + 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`); @@ -408,7 +416,9 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean, 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. - const workflow = { description: value.description, steps: value.steps.map(s => ({ title: s.title, state: s.state, roles: [...s.roles] })) }; + // A role naming one of this pack's definitions follows that definition's --as rename. + const role = r => 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) })) }; const path = `${prefix}workflows.${target}`; const current = scope.workflows?.[target]; let mark; @@ -458,7 +468,13 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean, 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'); - return { ...write(id, preview.plan, preview.revision, actor, {}), pack: preview.pack.id }; + // An apply that adds nothing and proposes what the last apply of this pack proposed writes + // nothing, so a retry loop does not fill the packs list to its cap. 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 && 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(); 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 index 7ab8a2240..afe37667e 100644 --- a/packages/bus/tests/packs.test.mjs +++ b/packages/bus/tests/packs.test.mjs @@ -78,7 +78,7 @@ const variant = (id, patch = () => {}) => { const p = structuredClone(codingPack 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 } = await harness(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' }); @@ -89,6 +89,16 @@ test('SP4 preview writes nothing; apply writes the add items and one packs entry // 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 }); @@ -113,6 +123,14 @@ test('SP4 preview writes nothing; apply writes the add items and one packs entry 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 => { @@ -126,7 +144,7 @@ test('SP4 a stale digest refuses pack-plan-stale and writes nothing', async t => }); 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 } = await harness(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 }); @@ -165,6 +183,12 @@ test('SP4 an edited definition is kept with its diff; a removed item stays remov 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 => { @@ -178,10 +202,32 @@ test('SP4 --as renames a definition or workflow and pins the hire to the new nam assert.ok(file().definitions.builder); assert.equal(file().definitions.coder, undefined); assert.ok(file().workflows.ship); - 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']) + // 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']]) 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']); +}); + 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 } }; @@ -229,7 +275,8 @@ test('SP4 who may call: the owner only; the tester, a member, an agent and a con 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', {}]]) + 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/); 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 67c04d6fe..8f92c6d6b 100644 --- a/packages/cli/src/org.mjs +++ b/packages/cli/src/org.mjs @@ -76,8 +76,8 @@ async function packsCommand(args, io, deps) { 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 but pack list is a bus call as the human: an agent run refuses before a file is read. - if (!(word === 'pack' && sub === 'list')) refuseInsideAgent(io.env); + // 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) { diff --git a/packages/cli/tests/org-packs.test.mjs b/packages/cli/tests/org-packs.test.mjs index 866dfe854..bd309fd14 100644 --- a/packages/cli/tests/org-packs.test.mjs +++ b/packages/cli/tests/org-packs.test.mjs @@ -85,13 +85,13 @@ test('SP4 mosaic org workflow remove takes out only that workflow; board set cha 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 write before it reads a file or calls the bus', async (t) => { +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', 'preview', 'acme', 'coding'], ['pack', 'apply', 'acme', 'coding', '--digest', 'a'.repeat(64)], ['board', 'set', 'acme', spec], ['workflow', 'set', 'acme', 'one', spec], ['workflow', 'remove', 'acme', 'one']]) { + 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, []); diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index bb70dd86d..bb23c7e9e 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -933,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), packsPath = PACKS_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 /api/board stays the Board read; only POST /api/board is the pack route's board write. + packsPath = PACKS_ROUTE.test(path) && (path !== '/api/board' || req.method === 'POST'); 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; -- 2.54.0 From 9a649abf85baca4823c1d335d6016fcbcc833794 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 12 Nov 2026 17:17:19 -0600 Subject: [PATCH 3/3] fix(packs): SP4 re-check, the board route method split, human role reserved, distinct step roles (#1873) - serve.mjs: only GET and HEAD /api/board stay the Board read; every other method goes to the pack route, so DELETE and PUT answer its 405 again. The owner-only check moves inside packsHTTP after the method check, so OPTIONS on a root Console stays 405 as before SP4. - runtime.mjs: a --as rename cannot target the definition name human, and a human step role never follows a rename. A step whose mapped roles collide refuses invalid-request at preview, not only at apply. - The no-op apply skip also needs the same pack sha256, so a changed pack with nothing to add still records its entry. - Tests: the --as refusals for human, a step role collision, a pack defining human, a changed saved pack recording its entry, board by PUT, a granted session refused owner-only on every pack route, and a Console without sign-in refused owner-only. --- packages/bus/src/runtime.mjs | 17 ++++++---- packages/bus/tests/packs.test.mjs | 41 +++++++++++++++++++++++- packages/webui/src/packs-http.mjs | 4 ++- packages/webui/src/serve.mjs | 10 +++--- packages/webui/tests/packs-http.test.mjs | 17 ++++++++-- 5 files changed, 74 insertions(+), 15 deletions(-) diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index c81035198..5ee6f6596 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -323,7 +323,8 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean, if (!Array.isArray(as) || as.length > 32) bad(); for (const value of as) { const m = typeof value === 'string' ? PACK_AS.exec(value) : null; - if (!m || out[m[1]].has(m[2]) || [...out[m[1]].values()].includes(m[3])) bad(); + // `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; @@ -416,9 +417,11 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean, 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. - const role = r => Object.hasOwn(pack.definitions, r) ? as.definitions.get(r) ?? r : r; + // 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; @@ -468,11 +471,13 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean, 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 that adds nothing and proposes what the last apply of this pack proposed writes - // nothing, so a retry loop does not fill the packs list to its cap. The digest pins the revision. + // 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 && canonicalJson(last.proposedHires ?? []) === canonicalJson(plan.packs.proposedHires)) + 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 }; }, diff --git a/packages/bus/tests/packs.test.mjs b/packages/bus/tests/packs.test.mjs index afe37667e..eecb90cd5 100644 --- a/packages/bus/tests/packs.test.mjs +++ b/packages/bus/tests/packs.test.mjs @@ -203,7 +203,7 @@ test('SP4 --as renames a definition or workflow and pins the hire to the new nam 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']]) + 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/); }); @@ -226,6 +226,45 @@ test('SP4 --as onto another workflow of the pack refuses; a renamed inline defin 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 => { diff --git a/packages/webui/src/packs-http.mjs b/packages/webui/src/packs-http.mjs index 5bdc826ba..e5369a9e4 100644 --- a/packages/webui/src/packs-http.mjs +++ b/packages/webui/src/packs-http.mjs @@ -51,7 +51,7 @@ async function body(req, required, optional) { return value; } -export async function packsHTTP(req, res, { path, read, write, session, live, origin }) { +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); @@ -64,6 +64,8 @@ export async function packsHTTP(req, res, { path, read, write, session, live, or 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'; diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index bb23c7e9e..14ef40970 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -934,8 +934,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF 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), - // GET /api/board stays the Board read; only POST /api/board is the pack route's board write. - packsPath = PACKS_ROUTE.test(path) && (path !== '/api/board' || req.method === 'POST'); + // 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; @@ -1077,9 +1077,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF } // Starter packs SP4 (#1873): the owner's pack, board and workflow routes on the selected organization. if (packsPath) { - if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' }); - return packsHTTP(req, res, { path, read: requestRead, write: send ? packsWrite : null, - session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin }); + 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. diff --git a/packages/webui/tests/packs-http.test.mjs b/packages/webui/tests/packs-http.test.mjs index 5e25d2ee3..6ebdd96ec 100644 --- a/packages/webui/tests/packs-http.test.mjs +++ b/packages/webui/tests/packs-http.test.mjs @@ -143,6 +143,7 @@ test('SP4 the admission checks refuse before any bus call; a read-only Console s ['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); @@ -181,8 +182,9 @@ test('SP4 a session that is not the root owner reaches no pack route', async t = 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 }]]) { - const [status] = await call(f, path, method, body); - assert.ok([401, 403, 404].includes(status), `${method} ${path} answered ${status}`); + // 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); @@ -207,3 +209,14 @@ test('SP4 a sign-out while the body is still arriving dispatches nothing', async 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); +}); -- 2.54.0