diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 538f9e2b6..0272092f2 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -12,6 +12,8 @@ import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs'; import { loadRequirements } from '../../tasks/src/requirements.mjs'; import { MERGE, LandingRefusal, readLanding } from './landing.mjs'; import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs'; +import { canonicalJson } from '../../organization/src/util.mjs'; +import { compositionOf } from '../../organization/src/definitions.mjs'; // Kept closed while S1 lands; its vocabulary is the integration authority. export const ACTIONS = Object.freeze([ @@ -6287,6 +6289,9 @@ export class Broker { if (request.verb === 'hire.raise' || request.verb === 'hire.start.console' || (request.verb === 'decision.raise' && ['role.hire','role.fire','role.change'].includes(args.action))) { const input=request.verb==='decision.raise' ? (()=>{ keys(args,['action','hire'],['action','hire']); if(args.action!==`role.${args.hire?.op}`)fail('invalid-request'); return args.hire; })() : args; + // #1873 SP5: a raise from a pack's recorded proposal; the pin, not the caller, names the role. + if(request.verb==='hire.raise' && object(args) && Object.hasOwn(args,'proposal')) + return Promise.resolve().then(()=>this.#raiseHire(s,this.#proposalHire(s,args),false)).catch(e=>{throw this.#refused(s,new BusError(e.code??'invalid-request'),request);}); return this.#raiseHire(s,input,request.verb==='hire.start.console').catch(e=>{throw this.#refused(s,new BusError(e.code??'invalid-request'),request);}); } if(request.verb==='decision.preview.console' && ['role.hire','role.fire','role.change'].includes(this.#decision(s,args.id).action))return this.#hireConsole(s,'hire.preview.console',args).catch(e=>{throw this.#refused(s,new BusError(e.code??'outcome-unknown'),request);}); @@ -6301,20 +6306,43 @@ export class Broker { 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]); + // #1873 SP5: the unsuffixed verbs also serve an agent whose role file lists + // organization.change, the test organization.set uses; any other role gets decision-required. + // The packs read checks the grant and never spends it, as organization.revision; an agent + // without it gets human-required. A write checks before the writer, again inside it, and + // spends the grant once the writer has committed. The actor is the definition and instance. + const agent = !request.verb.endsWith('.console') && !s.human && !s.console; + let actor = s.human, authorityError = null; + if (agent) { + keys(args, ...PACK_BUS_KEYS[request.verb]); + if (request.verb === 'packs') { + this.#processingWork(); this.#agent(s); + if (this.#classification(s, 'organization.change') !== 'within-role') fail('human-required'); + } else this.#checkAuthority(s, 'organization.change'); + actor = this.#business(s.business).roles[s.role].definition + '.' + s.role; + } + else { + 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 spends = agent && verb !== 'packs' && verb !== 'pack.preview'; + const agentContext = agent ? { agent: true, beforeWrite: () => { try { this.#checkAuthority(s, 'organization.change'); } catch (e) { authorityError = e; throw e; } } } : {}; + const packFail = e => { if (authorityError && e === authorityError) throw e; this.#packFail(e); }; 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); + try { result = this.#organization(s.business, verb, structuredClone(args), actor, { openHires, ...agentContext }); } + catch (e) { packFail(e); } + const done = (value) => { + if (spends) this.#store.transaction(() => this.#consumeAuthority(s, 'organization.change')); + this.#secretCheck(value); return value; + }; + return result?.then ? result.then(done, packFail) : 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 @@ -6664,6 +6692,44 @@ export class Broker { if(s.human!==this.#business(s.business).human)fail('human-required'); return {actor:s.human,agent:false,rights:['org.hire'],owner:true,instanceOwner:true,subject:s.console}; } + // #1873 SP5 (design section 3, "The chief applying a pack"): hire.raise {proposal: {pack, project?, + // hire}, route, reason?}. Only the chief gets this far. The proposal is the latest packs entry of + // that pack id and project, in the organization as loaded, whose proposedHires names the hire. Its + // pin fills op, name, level and role; a caller's own role, name, level, op, credentials, + // personality or binding refuses. A template pin and a definition pin go to prepare as recorded, + // which refuses decision-stale and definition-rev-conflict. An inline pin is checked here: the + // stored entry must still hash to the pin (starter-pack-proposal-stale), and its from template + // must not be in launch.noHire (definition-not-hireable), since prepare's own noHire check + // reads only a template or definition ref. The returned input carries no proposal key, so + // prepare's request keys are unchanged. reason defaults to the one the pack recorded. + #proposalHire(s, args) { + this.#agent(s); + const b = this.#business(s.business); + if (s.role !== b.launch?.by) fail('role-not-chief'); + keys(args, ['proposal', 'route', 'reason'], ['proposal', 'route']); + const proposal = args.proposal; + keys(proposal, ['pack', 'project', 'hire'], ['pack', 'hire']); + for (const value of [proposal.pack, proposal.hire, ...(Object.hasOwn(proposal, 'project') ? [proposal.project] : [])]) + if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(value)) fail('invalid-request'); + const project = proposal.project ?? null; + const entry = (Array.isArray(b.packs) ? b.packs : []) + .filter(e => e?.id === proposal.pack && (e.project ?? null) === project && Array.isArray(e.proposedHires) && e.proposedHires.some(h => h?.name === proposal.hire)) + .at(-1); + if (!entry) fail('invalid-request'); + const hire = entry.proposedHires.find(h => h.name === proposal.hire); + const pin = hire.pin; + if (!object(pin)) fail('invalid-request'); + let role; + if (Object.hasOwn(pin, 'inline')) { + if (!object(hire.entry) || typeof pin.sha256 !== 'string') fail('invalid-request'); + if (sha256(canonicalJson(hire.entry)) !== pin.sha256) fail('starter-pack-proposal-stale'); + const from = hire.entry.from?.template; + if (from !== undefined && (b.launch?.noHire ?? []).includes(from)) fail('definition-not-hireable'); + role = { inline: compositionOf(hire.entry) }; + } else role = structuredClone(pin); + const reason = Object.hasOwn(args, 'reason') ? args.reason : hire.reason; + return { op: 'hire', level: project === null ? 'org' : 'project', ...(project === null ? {} : { project }), name: hire.name, role, route: structuredClone(args.route), reason }; + } async #raiseHire(s, input, immediate) { const b=this.#business(s.business); // Change is a replacement composition and uses the existing role.hire grant. diff --git a/packages/bus/src/refusals.mjs b/packages/bus/src/refusals.mjs index e09b9dffd..c985e0cdf 100644 --- a/packages/bus/src/refusals.mjs +++ b/packages/bus/src/refusals.mjs @@ -211,6 +211,13 @@ group( "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." ); +// #1873 SP5: a hire raised from a pack proposal is the role the proposal pinned. An inline pin is the +// canonical hash of the entry stored beside it; a stored entry that no longer hashes to it refuses. +group( + "starter-pack-proposal-stale", + "R8: a hire raised from a pack proposal matches its recorded pin", + "The role entry stored with this proposal no longer matches the hash it was recorded with. Preview and apply the pack again for a fresh proposal, then raise the hire from that one." +); group( "not-resolver", "Role resolve right and decision route", diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 5ee6f6596..e92e20bb0 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -351,7 +351,18 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean, // 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 ?? [])); + // #1873 SP5 (design section 3, "The chief applying a pack"): context.agent is an + // organization.change holder's call. It names no saved template and no definitions rename + // (human-required), and its plan writes no definition: a definition a human apply would add is + // marked not-written, and its hire proposal pins the template or the inline entry, which the chief + // raises as a hire decision. A workflow step naming a pack definition the organization does not + // hold and that is not a V2 template is left out and listed; a workflow left with no step is + // marked left-out and not written. applyPack refuses an agent's definitions or saved origin + // whatever this builds. async function build(id, args, actor, context) { + const agent = context?.agent === true; + if (agent && typeof args.pack === 'string' && args.pack.startsWith(SAVED_PREFIX)) throw new BusError('human-required'); + if (agent && Array.isArray(args.as) && args.as.some(v => typeof v === 'string' && v.startsWith('definitions.'))) throw new BusError('human-required'); const { document: doc, revision } = writer.read(id); const found = await loadPack(args.pack, actor); const pack = found.pack; @@ -379,6 +390,16 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean, const chief = chiefOf(doc, `${id}.json`); const registry = registryFor(doc); const noHire = doc.launch?.noHire ?? []; + const templates = new Map(); + const isTemplate = name => { + if (!templates.has(name)) { + try { readTemplate(rolesDir, name); templates.set(name, true); } catch (error) { + if (error?.name !== 'BusinessError') throw error; + templates.set(name, false); + } + } + return templates.get(name); + }; const items = []; const plan = {}; // Definitions are organization-wide in either scope: a project's agents name org definitions. @@ -400,6 +421,7 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean, 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 (agent && mark === 'add') item.mark = mark = 'not-written'; if (mark === 'add') definitions[target] = entry; checked[name].added = mark === 'add'; items.push(item); @@ -423,11 +445,21 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean, 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 leftOut = []; + if (agent) { + const unwritable = r => r !== 'human' && Object.hasOwn(pack.definitions, r) && !Object.hasOwn(doc.definitions ?? {}, role(r)) && !isTemplate(role(r)); + workflow.steps = workflow.steps.filter((s, i) => { + const missing = value.steps[i].roles.filter(unwritable).map(role); + if (missing.length) leftOut.push({ step: i + 1, title: s.title, roles: missing }); + return missing.length === 0; + }); + } const current = scope.workflows?.[target]; let mark; - if (current === undefined) mark = gone.has(path) ? 'removed' : 'add'; + if (agent && workflow.steps.length === 0) mark = 'left-out'; + else 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 }); + items.push({ item: 'workflow', name, target, path, mark, ...(leftOut.length ? { leftOut } : {}) }); if (mark === 'add') workflows[target] = workflow; } if (Object.keys(workflows).length) plan[`${prefix}workflows`] = workflows; @@ -453,7 +485,10 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean, items, hires, skills: { notEnabled }, chief: chief !== null, plan, digest, }; } - const write = (id, plan, revision, actor, detail) => { + // context.beforeWrite (SP5) is the broker's recheck of an agent's organization.change grant, + // run once more before the writer commits. + const write = (id, plan, revision, actor, detail, context) => { + context?.beforeWrite?.(); const out = writer.applyPack(id, plan, { revision, actor, assertClean, detail }); return { revision: out.revision, added: out.added }; }; @@ -479,15 +514,15 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean, 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 }; + return { ...write(id, plan, preview.revision, actor, {}, context), pack: preview.pack.id }; }, - 'board.set': (id, args, actor) => { + 'board.set': (id, args, actor, context) => { if (args.board !== null && !isObject(args.board)) bad(); - return write(id, { [scoped(args, 'board')]: args.board }, revisionOf(args), actor, {}); + return write(id, { [scoped(args, 'board')]: args.board }, revisionOf(args), actor, {}, context); }, - 'workflow.set': (id, args, actor) => { + 'workflow.set': (id, args, actor, context) => { 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, {}); + return write(id, { [scoped(args, 'workflows')]: { [args.name]: args.workflow } }, revisionOf(args), actor, {}, context); }, }; } @@ -1140,6 +1175,7 @@ export async function startBroker({ } // #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. + // SP5: or an organization.change holder's actor, with context.agent and context.beforeWrite. 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 }); diff --git a/packages/bus/tests/packs-agent.test.mjs b/packages/bus/tests/packs-agent.test.mjs new file mode 100644 index 000000000..3cb8e20d7 --- /dev/null +++ b/packages/bus/tests/packs-agent.test.mjs @@ -0,0 +1,304 @@ +// #1873 SP5: the pack, board and workflow verbs for an agent whose role file lists +// organization.change, and hire.raise from a pack's recorded proposal (design section 3, "The +// chief applying a pack", and section 8, SP5). The chief's authority is read from the shipped +// roles/chief-of-staff.json, so the test pins the grant the stack runs. +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 { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { DatabaseSync } from 'node:sqlite'; +import { Broker } from '../src/broker.mjs'; +import { Store } from '../src/store.mjs'; +import { REFUSALS } from '../src/refusals.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'; +import { hireDecisions } from '../../organization/src/hire-decisions.mjs'; +import { organizationWriter } from '../../organization/src/organization.mjs'; +import { loadBusiness } from '../../organization/src/organization-config.mjs'; +import { compositionOf, entryFromTemplate, readTemplate, templateHash } from '../../organization/src/definitions.mjs'; +import { canonicalJson } from '../../organization/src/util.mjs'; +import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs'; + +const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8')); +const chiefAuthority = shipped('chief-of-staff').authority; +const sha = (text) => createHash('sha256').update(text).digest('hex'); +const ROUTE = { harness: 'pi', model: 'claude-opus-5-5' }; + +// The SP4 harness (packs.test.mjs) with the shipped chief grant and a coder without it. +async function harness(t, { mutate = () => {} } = {}) { + const { startBroker } = await import('../src/runtime.mjs'); + const root = mkdtempSync(join(tmpdir(), 'packs5-')); + const rolesDir = rolesCopy(root); + const doc = businessDoc(root, 'demo'); + doc.plugins = { harness: ['pi'] }; + mutate(doc); + writeJson(join(root, 'businesses/demo.json'), doc); + const data = join(root, 'data'); + mkdirSync(data, { mode: 0o700 }); + const roles = { + chief: { definition: 'chief-of-staff', authority: chiefAuthority }, + coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } }, + }; + const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles } }; + const bus = await startBroker({ dataRoot: data, businesses, organization: { dir: root, rolesDir, system: systemFor(root) } }); + 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 human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief-run' }); + const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' }); + b.request(chief, { verb: 'role.claim' }); + b.request(coder, { verb: 'role.claim' }); + const call = async (cap, verb, args = {}) => 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; + 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 }); + }; + // The broker's own database, read-only: grants spent and decisions raised. + const query = (sql) => { const db = new DatabaseSync(join(data, 'bus/bus.sqlite'), { readOnly: true }); try { return db.prepare(sql).all(); } finally { db.close(); } }; + const spent = () => query("SELECT body FROM events WHERE kind='action.allowed' ORDER BY seq").map(e => JSON.parse(e.body)).filter(e => e.action === 'organization.change').length; + const decisions = () => query('SELECT count(*) n FROM decisions')[0].n; + return { b, root, rolesDir, path, human, chief, coder, call, file, revision, install, spent, decisions }; +} +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('SP5 the chief applies the coding pack: board and workflows written, no definitions, template pins, one grant spent', async t => { + const { call, chief, file, path, rolesDir, spent, decisions } = await harness(t); + const before = file(); + const preview = await call(chief, 'pack.preview', { pack: 'coding' }); + assert.equal(spent(), 0); + assert.deepEqual(readFileSync(path, 'utf8'), JSON.stringify(before, null, 2) + '\n'); + // A definition a human apply would add is not written for an agent; its hire pins the template. + for (const name of ['coder', 'cto', 'merge-gate', 'pm', 'reviewer', 'security-reviewer']) assert.equal(marks(preview)[`definitions.${name}`], 'not-written'); + assert.equal(marks(preview).board, 'add'); + assert.equal(marks(preview)['workflows.change'], 'add'); + assert.equal(preview.plan.definitions, undefined); + for (const h of preview.hires) { + assert.equal(h.mark, 'propose'); + assert.deepEqual(h.pin, { template: h.definition, sha256: templateHash(rolesDir, h.definition) }); + } + await call(chief, 'pack.apply', { pack: 'coding', digest: preview.digest }); + const after = file(); + assert.equal(after.board.layout, 'kanban'); + assert.deepEqual(after.board, codingPack().board); + assert.deepEqual(after.workflows.change.steps.map(s => s.roles), [['pm'], ['coder'], ['reviewer'], ['human', 'merge-gate']]); + assert.deepEqual(after.definitions ?? null, before.definitions ?? null); + assert.equal(after.packs.length, 1); + assert.equal(after.packs[0].by, 'chief-of-staff.chief'); + assert.deepEqual(after.packs[0].added.sort(), ['board', 'workflows.change']); + assert.deepEqual(after.packs[0].proposedHires.map(h => [h.name, h.pin.template]), [['pm-1', 'pm'], ['coder-1', 'coder'], ['reviewer-1', 'reviewer']]); + assert.equal(spent(), 1); + assert.equal(decisions(), 0); +}); + +test('SP5 a role without organization.change gets decision-required for the writes and human-required for the packs read', async t => { + const { call, coder, chief, path, revision, install, spent } = await harness(t); + const before = readFileSync(path); + for (const [verb, args] of [['pack.apply', { pack: 'coding', digest: 'a'.repeat(64) }], ['board.set', { revision: revision(), board: null }], + ['workflow.set', { revision: revision(), name: 'x', workflow: null }], ['pack.preview', { pack: 'coding' }]]) + await assert.rejects(call(coder, verb, args), /decision-required/, verb); + await assert.rejects(call(coder, 'packs'), /human-required/); + // The console names stay the owner's. + await assert.rejects(call(chief, 'pack.preview.console', { pack: 'coding' }), /human-required/); + await assert.rejects(call(chief, 'packs.console'), /human-required/); + assert.deepEqual(readFileSync(path), before); + // The chief's packs read lists shipped and installed packs and spends nothing. + install(variant('teamkit')); + const listed = (await call(chief, 'packs')).packs; + assert.ok(listed.some(p => p.id === 'coding' && p.origin === 'shipped')); + assert.ok(listed.some(p => p.id === 'teamkit' && p.origin === 'installed')); + assert.equal(spent(), 0); + // The agent request carries no origin, actor or unknown key. + await assert.rejects(call(chief, 'pack.preview', { pack: 'coding', actor: 'jason' }), /invalid-request/); + await assert.rejects(call(chief, 'packs', { all: true }), /invalid-request/); +}); + +test('SP5 the chief writes a board and a workflow, each spending the grant once, with its own actor', async t => { + const { call, chief, file, revision, spent } = await harness(t); + await call(chief, 'board.set', { revision: revision(), board: codingPack().board }); + assert.deepEqual(file().board, codingPack().board); + assert.equal(spent(), 1); + const workflow = { description: 'Intake.', steps: [{ title: 'Take it', state: 'todo', roles: ['human'] }, { title: 'Build it', state: 'in-progress', roles: ['coder'] }] }; + await call(chief, 'workflow.set', { revision: revision(), name: 'intake', workflow }); + assert.deepEqual(file().workflows.intake, workflow); + assert.equal(spent(), 2); + await call(chief, 'workflow.set', { revision: revision(), name: 'intake', workflow: null }); + assert.equal(file().workflows, undefined); + assert.equal(spent(), 3); + // A refused write spends nothing. + await assert.rejects(call(chief, 'board.set', { revision: 'b'.repeat(64), board: null }), /organization-|invalid-request|stale|conflict/); + await assert.rejects(call(chief, 'workflow.set', { revision: revision(), name: 'x', workflow: { description: 'x', steps: [{ title: 'Do', state: 'todo', roles: ['nobody-at-all'] }] } }), /invalid-request/); + assert.equal(spent(), 3); +}); + +test('SP5 an agent apply never writes a definition or a saved template: human-required', async t => { + const { call, chief, path, root, rolesDir } = await harness(t); + const before = readFileSync(path); + // A definitions rename names a definition to write. + await assert.rejects(call(chief, 'pack.preview', { pack: 'coding', as: ['definitions.coder=dev'] }), /human-required/); + await assert.rejects(call(chief, 'pack.apply', { pack: 'coding', as: ['definitions.coder=dev'], digest: 'a'.repeat(64) }), /human-required/); + // A saved template refuses before anything is read; the name need not exist. + await assert.rejects(call(chief, 'pack.preview', { pack: 'saved:x' }), /human-required/); + await assert.rejects(call(chief, 'pack.apply', { pack: 'saved:x', digest: 'a'.repeat(64) }), /human-required/); + // A workflows rename is allowed. + const renamed = await call(chief, 'pack.preview', { pack: 'coding', as: ['workflows.change=ship'] }); + assert.equal(marks(renamed)['workflows.ship'], 'add'); + assert.deepEqual(readFileSync(path), before); + // The writer's own fence: the chief's actor with a definition in the plan, or a saved origin. + const writer = organizationWriter({ dir: root, rolesDir }); + const { rev: _rev, ...entry } = entryFromTemplate(readTemplate(rolesDir, 'coder')); + const pack = { id: 'kit', version: '1.0.0', sha256: 'a'.repeat(64), origin: 'installed', scope: 'org', project: null, proposedHires: [] }; + const opts = () => ({ revision: writer.read('demo').revision, actor: 'chief-of-staff.chief', assertClean: () => {}, detail: {} }); + assert.throws(() => writer.applyPack('demo', { definitions: { dev: entry }, packs: pack }, opts()), /human-required/); + assert.throws(() => writer.applyPack('demo', { board: codingPack().board, packs: { ...pack, origin: 'saved' } }, opts()), /human-required/); + assert.deepEqual(readFileSync(path), before); +}); + +test('SP5 a workflow step naming a definition the agent apply cannot write is left out and marked', async t => { + const { call, chief, file, install, rolesDir } = await harness(t); + const { rev: _rev, ...scribe } = entryFromTemplate(readTemplate(rolesDir, 'coder')); + install(variant('scribekit', p => { + p.definitions = { scribe, pm: p.definitions.pm }; + p.hires = [{ name: 'scribe-1', definition: 'scribe', reason: 'Take notes.' }]; + p.workflows = { + notes: { description: 'Notes.', steps: [{ id: 'plan', title: 'Plan it', state: 'todo', roles: ['pm'] }, { id: 'write', title: 'Write it', state: 'in-progress', roles: ['scribe', 'human'] }] }, + solo: { description: 'Solo.', steps: [{ id: 'write', title: 'Write it', state: 'todo', roles: ['scribe'] }] }, + }; + })); + const preview = await call(chief, 'pack.preview', { pack: 'scribekit' }); + assert.equal(marks(preview)['definitions.scribe'], 'not-written'); + const items = Object.fromEntries(preview.items.map(i => [i.path, i])); + assert.equal(items['workflows.notes'].mark, 'add'); + assert.deepEqual(items['workflows.notes'].leftOut, [{ step: 2, title: 'Write it', roles: ['scribe'] }]); + assert.equal(items['workflows.solo'].mark, 'left-out'); + assert.deepEqual(items['workflows.solo'].leftOut, [{ step: 1, title: 'Write it', roles: ['scribe'] }]); + assert.deepEqual(Object.keys(preview.plan.workflows), ['notes']); + // The full-entry definition's hire pins the inline entry. + const hire = preview.hires.find(h => h.name === 'scribe-1'); + assert.deepEqual(hire.pin, { inline: 'scribe', sha256: sha(canonicalJson(scribe)) }); + await call(chief, 'pack.apply', { pack: 'scribekit', digest: preview.digest }); + assert.deepEqual(file().workflows, { notes: { description: 'Notes.', steps: [{ title: 'Plan it', state: 'todo', roles: ['pm'] }] } }); + assert.equal(file().definitions?.scribe, undefined); + assert.deepEqual(file().packs.at(-1).proposedHires[0].entry, scribe); +}); + +// The hire-decisions.test.mjs fixture: a Broker on the loaded organization and the hire service. +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), 'sp5-bus-')), rolesDir = rolesCopy(root); + const file = writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } }); + const storeRoot = join(root, 'data'); mkdirSync(storeRoot, { mode: 0o700 }); + const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(storeRoot); + const service = hireDecisions({ writer, rolesDir, dataRoot: storeRoot, configDir: root, assertClean: () => {} }); + const loaded = () => { const doc = loadBusiness('demo', { dir: root, rolesDir }); return { ...doc, roles: Object.fromEntries(Object.entries(doc.roles).map(([n, e]) => [n, { ...doc.definitions[e.definition], ...e }])) }; }; + const b = new Broker({ store, businesses: { demo: loaded() }, hires: service }); + const caps = { chief: b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }), coder: b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' }) }; + b.request(caps.chief, { verb: 'role.claim' }); b.request(caps.coder, { verb: 'role.claim' }); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const call = (cap, verb, args = {}) => b.request(cap, { verb, args }); + // A packs entry as an owner's apply records it, then the broker's organization reloads. + const record = (plan) => { + writer.applyPack('demo', plan, { revision: writer.read('demo').revision, actor: 'jason', assertClean: () => {}, detail: {} }); + b.reloadBusiness('demo', loaded()); + }; + const pack = (proposedHires, extra = {}) => ({ id: 'kit', version: '1.0.0', sha256: 'a'.repeat(64), origin: 'installed', scope: 'org', project: null, proposedHires, ...extra }); + // Edit the organization file directly, as a later change would, then reload. + const edit = (change) => { const doc = JSON.parse(readFileSync(file, 'utf8')); change(doc); writeJson(file, doc); b.reloadBusiness('demo', loaded()); }; + const raise = (proposal, extra = {}) => call(caps.chief, 'hire.raise', { proposal, route: ROUTE, ...extra }); + const count = () => store.get('SELECT count(*) n FROM decisions').n; + return { root, rolesDir, b, store, file, writer, caps, call, record, pack, edit, raise, count, loaded }; +} + +test('SP5 a hire raised from a template proposal carries the broker-computed role and lines, as a direct raise does', async t => { + const { rolesDir, record, pack, raise, call, caps, count } = fixture(t); + const pin = { template: 'reviewer', sha256: templateHash(rolesDir, 'reviewer') }; + record({ packs: pack([{ name: 'scout', reason: 'Review the pack work.', pin }]) }); + const d = await raise({ pack: 'kit', hire: 'scout' }); + assert.equal(d.hire.name, 'scout'); + assert.equal(d.hire.level, 'org'); + // The lines are the broker's, the same as a direct raise of the same role computes. + const direct = await call(caps.chief, 'hire.raise', { op: 'hire', level: 'org', name: 'scout-b', role: pin, route: ROUTE, reason: 'Review.' }); + assert.deepEqual(d.hire.role, direct.hire.role); + assert.deepEqual(d.hire.flagged, direct.hire.flagged); + assert.deepEqual(d.hire.credentialLines, direct.hire.credentialLines); + assert.ok(Array.isArray(d.hire.credentialLines)); + assert.equal(count(), 2); + // hire.raise requires a reason and stores none. The raise above sent none, so the recorded one + // filled it; a caller's own reason replaces it, and an empty one refuses as a direct raise does. + await assert.rejects(raise({ pack: 'kit', hire: 'scout' }, { reason: '' }), { code: 'invalid-request' }); + assert.equal((await raise({ pack: 'kit', hire: 'scout' }, { reason: 'Mine.' })).hire.name, 'scout'); + assert.equal(count(), 3); +}); + +test('SP5 a proposal raise: role-not-chief, decision-stale, definition-rev-conflict, starter-pack-proposal-stale, definition-not-hireable', async t => { + const { rolesDir, record, pack, raise, call, caps, edit, count } = fixture(t); + const { rev: _rev, ...scribe } = entryFromTemplate(readTemplate(rolesDir, 'coder')); + const inline = { name: 'inl', reason: 'Inline.', pin: { inline: 'scribe', sha256: sha(canonicalJson(scribe)) }, entry: scribe }; + record({ + definitions: { helper: scribe }, + packs: pack([ + { name: 'moved', reason: 'Moved.', pin: { template: 'reviewer', sha256: 'b'.repeat(64) } }, + { name: 'def', reason: 'Def.', pin: { definition: 'helper', rev: 1 } }, + inline, + ]), + }); + // Only the chief raises, with or without a proposal. + await assert.rejects(call(caps.coder, 'hire.raise', { proposal: { pack: 'kit', hire: 'def' }, route: ROUTE }), { code: 'role-not-chief' }); + await assert.rejects(raise({ pack: 'kit', hire: 'moved' }), { code: 'decision-stale' }); + edit(doc => { doc.definitions.helper = { ...doc.definitions.helper, rev: 2, instructions: doc.definitions.helper.instructions + '\nAlso this.' }; }); + await assert.rejects(raise({ pack: 'kit', hire: 'def' }), { code: 'definition-rev-conflict' }); + // The inline entry stored with the proposal still hashes to its pin, so it raises. + const ok = await raise({ pack: 'kit', hire: 'inl' }); + assert.equal(ok.hire.name, 'inl'); + const direct = await call(caps.chief, 'hire.raise', { op: 'hire', level: 'org', name: 'inl-b', role: { inline: compositionOf(scribe) }, route: ROUTE, reason: 'Inline.' }); + assert.deepEqual(ok.hire.role, direct.hire.role); + assert.equal(count(), 2); + // A later edit to the stored entry is caught at the raise. + edit(doc => { doc.packs[0].proposedHires[2].entry.instructions += '\nAnd more.'; }); + await assert.rejects(raise({ pack: 'kit', hire: 'inl' }), { code: 'starter-pack-proposal-stale' }); + // An inline entry from a template launch.noHire names refuses, though prepare reads no ref for it. + edit(doc => { doc.packs[0].proposedHires[2].entry = scribe; doc.launch.noHire = ['coder']; }); + await assert.rejects(raise({ pack: 'kit', hire: 'inl' }), { code: 'definition-not-hireable' }); + assert.equal(count(), 2); +}); + +test('SP5 a proposal raise names no role, name, level or other hire key; unknown proposals refuse; a plain raise is unchanged', async t => { + const { rolesDir, record, pack, raise, call, caps, count } = fixture(t); + const pin = { template: 'reviewer', sha256: templateHash(rolesDir, 'reviewer') }; + record({ packs: pack([{ name: 'scout', reason: 'Review.', pin }]) }); + for (const extra of [{ role: pin }, { name: 'other' }, { level: 'org' }, { op: 'hire' }, { credentials: {} }, { personality: 'x' }, { binding: {} }]) + await assert.rejects(raise({ pack: 'kit', hire: 'scout' }, extra), { code: 'invalid-request' }, JSON.stringify(extra)); + for (const proposal of [{ pack: 'kit' }, { pack: 'kit', hire: 'nobody' }, { pack: 'other', hire: 'scout' }, { pack: 'kit', hire: 'scout', project: 'stack' }, + { pack: 'kit', hire: 'scout', role: 'x' }, { pack: 'kit', hire: 7 }, 'kit']) + await assert.rejects(raise(proposal), { code: 'invalid-request' }, JSON.stringify(proposal)); + await assert.rejects(call(caps.chief, 'hire.raise', { proposal: { pack: 'kit', hire: 'scout' } }), { code: 'invalid-request' }); + assert.equal(count(), 0); + // Without proposal, hire.raise is what it was. + const INLINE = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: 'Read and report.' }; + const d = await call(caps.chief, 'hire.raise', { op: 'hire', level: 'org', name: 'plain', role: { inline: INLINE }, route: ROUTE, reason: 'Need help' }); + assert.equal(d.hire.name, 'plain'); + assert.equal(count(), 1); +}); + +test('SP5 starter-pack-proposal-stale is in the refusal table with a rail and a next step that names no path', () => { + const c = 'starter-pack-proposal-stale'; + assert.ok(Object.hasOwn(REFUSALS, c)); + assert.ok(REFUSALS[c].rail?.length > 5); + assert.ok(REFUSALS[c].nextStep?.length > 20); + assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[c].nextStep)); +}); diff --git a/packages/bus/tests/packs.test.mjs b/packages/bus/tests/packs.test.mjs index eecb90cd5..307587498 100644 --- a/packages/bus/tests/packs.test.mjs +++ b/packages/bus/tests/packs.test.mjs @@ -318,7 +318,10 @@ test('SP4 who may call: the owner only; the tester, a member, an agent and a con ['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/); + // SP5: an agent reaches the unsuffixed names through its role's organization.change; the coder + // lists none, so its write needs a decision (packs-agent.test.mjs covers the chief). + b.request(agent, { verb: 'role.claim' }); + assert.throws(() => b.request(agent, { verb: 'pack.apply', args: { pack: 'coding', digest: 'a'.repeat(64) } }), /decision-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/);