From 08e50e513f74fafff81811b7501061bad1c879dd Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 12 Nov 2026 19:37:33 -0600 Subject: [PATCH] feat(packs): the owner saves an organization's shape as a template (#1873 SP8) template-save.mjs captures the org (or one project) as a starter-pack JSON under /templates//.json: copy material and instance credentials stripped, role credential request lines kept, owner/org/project/agent/root names replaced by placeholders in free text, at most 8 hires, packs entries {id, version}. A held credential value in kept text refuses template-secret-suspected naming the field path. Preview then save with the preview's digest; O_EXCL 0600 write, a second save refuses. Bus: template.save/template.list for the owner's CLI cap, .console names for the owner's Console cap, null in VERB_RIGHTS. CLI: mosaic org template save|list. Console: GET /api/templates, POST /api/templates/preview, POST /api/templates. --- packages/bus/src/broker.mjs | 38 +- packages/bus/src/refusals.mjs | 7 + packages/bus/src/runtime.mjs | 13 + packages/bus/tests/templates.test.mjs | 152 ++++++ packages/cli/src/org.mjs | 46 +- packages/cli/tests/org-templates.test.mjs | 73 +++ packages/organization/src/org-rights.mjs | 4 + packages/organization/src/template-save.mjs | 468 ++++++++++++++++++ .../organization/tests/org-rights.test.mjs | 3 + .../organization/tests/template-save.test.mjs | 286 +++++++++++ packages/webui/src/cli.mjs | 4 + packages/webui/src/serve.mjs | 23 +- packages/webui/src/template-http.mjs | 99 ++++ packages/webui/tests/template-http.test.mjs | 173 +++++++ 14 files changed, 1380 insertions(+), 9 deletions(-) create mode 100644 packages/bus/tests/templates.test.mjs create mode 100644 packages/cli/tests/org-templates.test.mjs create mode 100644 packages/organization/src/template-save.mjs create mode 100644 packages/organization/tests/template-save.test.mjs create mode 100644 packages/webui/src/template-http.mjs create mode 100644 packages/webui/tests/template-http.test.mjs diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 83c8a5691..a351d1fbc 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -184,7 +184,9 @@ export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.rem // 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', // #1873 SP4: the owner's starter packs, board and workflows. - 'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console']); + 'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console', + // #1873 SP8: and saving a template, with the list of the owner's own. + 'template.save.console', 'template.list.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', @@ -221,6 +223,17 @@ const PACK_KEYS = Object.freeze({ '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]]))); +// #1873 SP8: saving a template and listing the caller's own, the owner's alone. The CLI calls the +// unsuffixed name as the owner's human cap, the Console the .console one; no agent, member or tester +// reaches either. template.save previews with preview: true and writes with the preview's digest. +const TEMPLATE_KEYS = Object.freeze({ + 'template.save': [['name', 'project', 'preview', 'digest'], ['name']], + 'template.list': [[], []], +}); +export const TEMPLATE_BUS_KEYS = Object.freeze(Object.fromEntries(Object.entries(TEMPLATE_KEYS).flatMap(([verb, k]) => [[verb, k], [`${verb}.console`, k]]))); +// The codes a template refusal keeps, each with its reason: a field path or a checked name, never a +// value from the organization file and never a host path. +const TEMPLATE_CODES = Object.freeze(['template-secret-suspected', 'invalid-request', 'human-required', 'not-configured', 'pack-plan-stale', 'starter-pack-invalid', 'starter-pack-template-changed']); // 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']); @@ -6348,6 +6361,23 @@ export class Broker { }; return result?.then ? result.then(done, packFail) : done(result); } + // #1873 SP8: saving a template. Only the organization's owner: the human cap from the CLI or the + // owner's console cap. An agent, a member's cap and the tester are refused before any argument + // check. The account the template is saved under is the caller's own s.human. + if (Object.hasOwn(TEMPLATE_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, ...TEMPLATE_BUS_KEYS[request.verb]); + if (!this.#organization) fail('not-configured'); + const verb = request.verb.replace(/\.console$/, ''); + let result; + try { result = this.#organization(s.business, verb, structuredClone(args), s.human, {}); } + catch (e) { this.#templateFail(e); } + const done = (value) => { this.#secretCheck(value); return value; }; + return result?.then ? result.then(done, (e) => this.#templateFail(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 @@ -7013,6 +7043,12 @@ export class Broker { if (e?.name === 'BusinessError') fail('invalid-request'); throw new BusError('outcome-unknown'); } + // #1873 SP8: a template refusal keeps its code and its reason (a path, never the text); anything + // else is read as a pack refusal. + #templateFail(e) { + if (e?.name === 'TemplateError' && TEMPLATE_CODES.includes(e.code)) throw new BusError(e.code, typeof e.reason === 'string' ? e.reason.slice(0, 300) : undefined); + this.#packFail(e); + } // 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 c985e0cdf..7574799df 100644 --- a/packages/bus/src/refusals.mjs +++ b/packages/bus/src/refusals.mjs @@ -218,6 +218,13 @@ group( "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." ); +// #1873 SP8: a saved template carries no credential the bus holds. Save checks every kept string and +// key against the credential store; a hit names the field, never the text. +group( + "template-secret-suspected", + "R1/R10: a saved template carries no credential value the bus holds", + "A field the template would keep holds a credential value. Remove it from the named definition, board or workflow text, then preview and save again." +); group( "not-resolver", "Role resolve right and decision route", diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index e3e0d7938..7224e7ff9 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -42,6 +42,7 @@ import { organizationWriter, OrganizationError, setReplyLimits } from '../../org import { definitionVerbs, DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.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 { TemplateError, templateList, templatePreview, templateSave } from '../../organization/src/template-save.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'; @@ -1187,6 +1188,18 @@ export async function startBroker({ 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); } + // #1873 SP8: saving a template and listing the owner's own. The broker passes only the owner's + // human; both read the organization file and neither writes it, so nothing reloads. + if (verb === 'template.save' || verb === 'template.list') { + const base = { rolesDir: organization.rolesDir, configDir: organization.dir, account: actor }; + if (verb === 'template.list') return templateList(base); + const { name, project, preview, digest } = args; + const document = organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}).read(id).document; + const assertClean = value => credentials.assertClean(value); + if (preview === true && digest === undefined) return templatePreview({ document, name, project, rolesDir: organization.rolesDir, assertClean }); + if (preview === undefined && typeof digest === 'string') return templateSave({ ...base, document, name, project, digest, assertClean }); + throw new TemplateError('invalid-request', 'template.save takes preview: true or the preview digest'); + } 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/templates.test.mjs b/packages/bus/tests/templates.test.mjs new file mode 100644 index 000000000..a372ce825 --- /dev/null +++ b/packages/bus/tests/templates.test.mjs @@ -0,0 +1,152 @@ +// #1873 SP8: saving a template at the bus. Only the organization's owner saves or lists: the CLI's +// human cap on the unsuffixed names, the owner's console cap on the .console ones. The saved file +// applies through SP4 as saved: for its saver. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CONSOLE_VERBS, TEMPLATE_BUS_KEYS, TESTER_ORGANIZATION_VERBS } from '../src/broker.mjs'; +import { REFUSALS } from '../src/refusals.mjs'; +import { VERB_RIGHTS } from '../../organization/src/org-rights.mjs'; + +const TOKEN = 'fixture-held-token-sp8-bus-3a9d'; +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 { entryFromTemplate, readTemplate } = await import('../../organization/src/definitions.mjs'); + const root = mkdtempSync(join(tmpdir(), 'tmpl-')); + const rolesDir = rolesCopy(root); + const doc = businessDoc(root, 'demo'); + doc.plugins = { harness: ['pi'] }; + const writer = entryFromTemplate(readTemplate(rolesDir, 'coder')); + writer.instructions = 'Write code for demo. Ask jason.'; + doc.definitions = { writer }; + // An instance of the org definition, with its own token files and tracker bot. + doc.roles.ana = { ...structuredClone(doc.roles.coder), definition: 'org:writer', tracker: { bot: 'bot-demo-ana', botId: 11 } }; + mutate(doc, root); + writeJson(join(root, 'businesses/demo.json'), doc); + 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 } }; + const bus = await startBroker({ dataRoot: data, businesses, organization: { dir: root, rolesDir, system: systemFor(root) } }); + t.after(async () => { await bus.close(); 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 saved = (name, account = 'jason') => join(root, 'templates', account, `${name}.json`); + return { bus, b, root, rolesDir, path, consoleOwner, human, call, saved }; +} + +// Preview, then save with the preview's digest: the console names by default, the cli names for cap. +const save = async (call, name, extra = {}, cap) => { + const verb = cap ? 'template.save' : 'template.save.console'; + const preview = await call(verb, { name, preview: true, ...extra }, cap); + return { preview, out: await call(verb, { name, digest: preview.digest, ...extra }, cap) }; +}; + +test('SP8 red before: the owner saves an organization whose instance holds credentials; neither credentials nor copy material reach the file', async t => { + const { call, saved, root } = await harness(t); + const { preview, out } = await save(call, 'demo-shape'); + assert.deepEqual(out, { name: 'demo-shape', scope: 'org', digest: preview.digest }); + const text = readFileSync(saved('demo-shape'), 'utf8'); + assert.equal(text, preview.json); + for (const k of ['copyBindings', 'copyId', 'orgIncarnation', 'generation', 'tracker', 'holder']) assert.ok(!text.includes(`"${k}"`), k); + assert.ok(!text.includes(join(root, 'secrets')), 'a token file path reached the template'); + assert.ok(!text.includes('"file"')); + // The role's credential request lines are kept. + assert.ok(JSON.parse(text).definitions.writer.role.credentials.length > 0); +}); + +test('SP8 a saved template applies through SP4 as saved: for its saver', async t => { + const { call, path } = await harness(t); + await save(call, 'demo-shape'); + const listed = await call('template.list.console'); + assert.deepEqual(listed.templates.map(x => [x.name, x.scope]), [['demo-shape', 'org']]); + const preview = await call('pack.preview.console', { pack: 'saved:demo-shape' }); + assert.equal(preview.pack.origin, 'saved'); + assert.ok(preview.hires.some(h => h.name === 'coder-1'), JSON.stringify(preview.hires.map(h => h.name))); + const before = JSON.parse(readFileSync(path, 'utf8')); + const out = await call('pack.apply.console', { pack: 'saved:demo-shape', digest: preview.digest }); + const after = JSON.parse(readFileSync(path, 'utf8')); + assert.equal(after.packs.length, (before.packs ?? []).length + 1); + assert.equal(after.packs.at(-1).origin, 'saved'); + assert.equal(after.packs.at(-1).id, 'demo-shape'); + // writer is already the organization's own (same); the template references the coder template. + assert.ok(out.added.includes('definitions.coder'), JSON.stringify(out.added)); + assert.equal(preview.hires.find(h => h.name === 'coder-1').definition, 'writer'); +}); + +test('SP8 the owner\'s CLI cap saves on the unsuffixed name and writes the file', async t => { + const { call, human, saved } = await harness(t); + const { out } = await save(call, 'cli-shape', {}, human); + assert.equal(out.name, 'cli-shape'); + assert.ok(existsSync(saved('cli-shape'))); + assert.deepEqual((await call('template.list', {}, human)).templates.map(x => x.name), ['cli-shape']); +}); + +test('SP8 who may call: a member\'s session, an agent and the tester refuse; the owner\'s console cap cannot call the cli name', async t => { + const { b, call, human, root } = await harness(t); + assert.deepEqual(TESTER_ORGANIZATION_VERBS, ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']); + const member = b.bindHireConsole({ business: 'demo', actor: 'alice', subject: { provider: 'test', iss: 'test', sub: 'alice' }, rights: ['org.hire', 'board.read', 'org.setup'] }); + for (const verb of ['template.save', 'template.save.console']) assert.throws(() => b.request(member, { verb, args: { name: 'x', preview: true } }), /human-required/); + for (const verb of ['template.list', 'template.list.console']) assert.throws(() => b.request(member, { verb, args: {} }), /human-required/); + const tester = b.bindTester({ business: 'demo' }); + assert.throws(() => b.request(tester, { verb: 'template.save', args: { name: 'x', preview: true } }), /tester-refused/); + const agent = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' }); + b.request(agent, { verb: 'role.claim' }); + for (const verb of ['template.save', 'template.save.console']) assert.throws(() => b.request(agent, { verb, args: { name: 'x', preview: true } }), /human-required/); + await assert.rejects(call('template.save', { name: 'x', preview: true }), /human-required/); + await assert.rejects(call('template.save.console', { name: 'x', preview: true }, human), /human-required/); + assert.ok(!existsSync(join(root, 'templates'))); +}); + +test('SP8 the real credential store: a held value in kept text refuses template-secret-suspected with the field path as its reason', async t => { + // A definition holding one never loads (definition-holds-credential); workflow text is what save + // must catch itself. + const workflow = { description: `Ship it, key ${TOKEN}`, steps: [{ title: 'Write', state: 'todo', roles: ['writer'] }] }; + const { call, root } = await harness(t, { mutate: doc => { doc.workflows = { change: workflow }; } }); + await assert.rejects(call('template.save.console', { name: 'leaky', preview: true }), (e) => { + assert.equal(e.code, 'template-secret-suspected'); + assert.equal(e.reason, 'workflows.change.description'); + assert.ok(!String(e.message).includes(TOKEN) && !JSON.stringify(e).includes(TOKEN)); + return true; + }); + assert.ok(!existsSync(join(root, 'templates'))); +}); + +test('SP8 argument checks: a second save, a .. name, an unknown key, preview with a digest, and a stale digest', async t => { + const { call, saved } = await harness(t); + const { preview } = await save(call, 'demo-shape'); + const first = readFileSync(saved('demo-shape')); + await assert.rejects(call('template.save.console', { name: 'demo-shape', digest: preview.digest }), (e) => e.code === 'invalid-request' && e.reason === 'a template of this name is already saved'); + assert.deepEqual(readFileSync(saved('demo-shape')), first); + for (const name of ['..', '../x', 'a/b']) await assert.rejects(call('template.save.console', { name, preview: true }), { code: 'invalid-request' }); + for (const args of [{ name: 'x', preview: true, account: 'alex' }, { name: 'x', preview: true, digest: preview.digest }, { name: 'x' }, { preview: true }, { name: 'x', preview: false }]) + await assert.rejects(call('template.save.console', args), { code: 'invalid-request' }); + await assert.rejects(call('template.list.console', { account: 'alex' }), { code: 'invalid-request' }); + await assert.rejects(call('template.save.console', { name: 'other', digest: preview.digest }), { code: 'pack-plan-stale' }); +}); + +test('SP8 the schema pins: both .console verbs are console verbs, owner-only in VERB_RIGHTS, and template-secret-suspected has a rail', () => { + for (const verb of ['template.save.console', 'template.list.console']) { + assert.ok(CONSOLE_VERBS.includes(verb), verb); + assert.ok(Object.hasOwn(VERB_RIGHTS, verb) && VERB_RIGHTS[verb] === null, verb); + assert.ok(Object.hasOwn(TEMPLATE_BUS_KEYS, verb)); + } + assert.ok(!CONSOLE_VERBS.includes('template.save') && !CONSOLE_VERBS.includes('template.list')); + const c = 'template-secret-suspected'; + 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/cli/src/org.mjs b/packages/cli/src/org.mjs index 8f92c6d6b..95821b0ba 100644 --- a/packages/cli/src/org.mjs +++ b/packages/cli/src/org.mjs @@ -17,7 +17,9 @@ import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.mjs'; import { orgOwnerCommand, ORG_OWNER_USAGE, ORG_OWNER_VERBS } from './org-owner.mjs'; // 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]'; +// #1873 SP8: saving the organization, or one project, as a template; the owner's alone. +export const TEMPLATE_USAGE = 'mosaic org template save ID NAME [--project P] (--preview | --digest D) | template list ID'; +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) + ' | ' + TEMPLATE_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'; @@ -114,6 +116,47 @@ async function packsCommand(args, io, deps) { } io.stdout.write(JSON.stringify(result, null, 2) + '\n'); } +// #1873 SP8: template save previews (--preview) or writes the previewed JSON (--digest); template +// list prints the owner's saved templates. Both call the bus as the owner's human; an agent run is +// refused before the bus is called. +const TEMPLATE_REFUSALS = { + 'template-secret-suspected': 'a field the template would keep holds a credential the organization\'s bus holds; remove it and preview again (nothing was written)', + 'pack-plan-stale': 'the organization or a template changed since the preview; preview again and save its digest', + 'invalid-request': 'the bus refused the request: a bad name, a name already saved, an unknown project, or an organization the template cannot hold', + 'human-required': 'only the organization\'s owner, from a human shell, may do this', + 'not-configured': 'the templates directory is missing or not private to this instance\'s user', + 'starter-pack-invalid': 'the template would not be a valid pack: more than 8 hires, or a definition wider than its template', +}; +async function templateCommand(args, io, deps) { + const pos = [], values = {}, flags = new Set(); + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (['--project', '--digest'].includes(a)) { + if (!args[i + 1] || args[i + 1].startsWith('--') || values[a] !== undefined) throw new CliError(`${a} needs one value`, 4); + values[a] = args[++i]; + } else if (a === '--preview') { if (flags.has(a)) throw new CliError('--preview is given twice', 4); flags.add(a); } + else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); + else pos.push(a); + } + const [, sub, id, name] = pos; + refuseAgentRun(io); + let verb, request; + if (sub === 'save' && pos.length === 4 && flags.has('--preview') !== (values['--digest'] !== undefined)) { + verb = 'template.save'; + request = { name, ...(values['--project'] === undefined ? {} : { project: values['--project'] }), ...(flags.has('--preview') ? { preview: true } : { digest: values['--digest'] }) }; + } else if (sub === 'list' && pos.length === 3 && !flags.size && !Object.keys(values).length) { verb = 'template.list'; request = {}; } + else throw new CliError(TEMPLATE_USAGE, 4); + let result; + try { result = await packCall(id, io, deps)(verb, request); } + catch (e) { + if (typeof e?.code !== 'string' || e.code === '' || e instanceof CliError) throw e; + const why = TEMPLATE_REFUSALS[e.code] ?? 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 { @@ -122,6 +165,7 @@ export async function orgCommand(args, 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); + if (args[0] === 'template' && ['save', 'list'].includes(args[1])) return await templateCommand(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-templates.test.mjs b/packages/cli/tests/org-templates.test.mjs new file mode 100644 index 000000000..fd4a94f07 --- /dev/null +++ b/packages/cli/tests/org-templates.test.mjs @@ -0,0 +1,73 @@ +// Starter packs SP8: `mosaic org template save|list`. Each word goes through the organization's bus as +// its human; save --preview writes nothing, save --digest writes the previewed JSON under the owner's +// account, and template render keeps its own words. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, lstatSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { main } from '../src/cli.mjs'; +import { ORG_USAGE, TEMPLATE_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'; + +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 = []; + 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 saved = (name) => join(boot.organization.dir, 'templates', f.doc.human, `${name}.json`); + return { root, f, file, calls, run, saved }; +} + +test('SP8 mosaic org template save: --preview writes nothing, --digest writes the previewed JSON, a second save refuses, list shows it', async (t) => { + const s = await setup(t); + assert.ok(ORG_USAGE.includes(TEMPLATE_USAGE.slice('mosaic org '.length))); + const before = readFileSync(s.file); + const preview = await s.run(['template', 'save', 'acme', 'acme-shape', '--preview']); + assert.match(preview.digest, /^[0-9a-f]{64}$/); + assert.ok(Array.isArray(preview.freeText) && Array.isArray(preview.replacements)); + assert.ok(!existsSync(s.saved('acme-shape')), 'preview writes no file'); + + const out = await s.run(['template', 'save', 'acme', 'acme-shape', '--digest', preview.digest]); + assert.deepEqual(out, { name: 'acme-shape', scope: 'org', digest: preview.digest }); + assert.equal(readFileSync(s.saved('acme-shape'), 'utf8'), preview.json); + assert.equal(lstatSync(s.saved('acme-shape')).mode & 0o777, 0o600); + assert.deepEqual(readFileSync(s.file), before, 'save writes nothing to the organization file'); + + await assert.rejects(s.run(['template', 'save', 'acme', 'acme-shape', '--digest', preview.digest]), (e) => e.code === 'invalid-request' && /already saved/.test(e.message)); + await assert.rejects(s.run(['template', 'save', 'acme', 'other', '--digest', preview.digest]), (e) => e.code === 'pack-plan-stale' && /preview again/.test(e.message)); + const listed = await s.run(['template', 'list', 'acme']); + assert.deepEqual(listed.templates.map((x) => x.name), ['acme-shape']); + assert.deepEqual(s.calls.map(([verb, keys]) => [verb, keys.join(',')]), [ + ['template.save', 'name,preview'], ['template.save', 'digest,name'], ['template.save', 'digest,name'], ['template.save', 'digest,name'], ['template.list', ''], + ]); +}); + +test('SP8 template words: a bad shape prints the usage; an agent shell refuses before the bus is called; template render keeps its words', async (t) => { + const s = await setup(t); + for (const args of [['template', 'save', 'acme', 'x'], ['template', 'save', 'acme', 'x', '--preview', '--digest', 'a'.repeat(64)], ['template', 'save', 'acme'], ['template', 'list'], ['template', 'list', 'acme', '--preview']]) { + await assert.rejects(s.run(args), (e) => e.exitCode === 4 && e.message === TEMPLATE_USAGE, args.join(' ')); + } + await assert.rejects(s.run(['template', 'save', 'acme', 'x', '--force']), (e) => e.exitCode === 4 && /unknown option --force/.test(e.message)); + await assert.rejects(s.run(['template', 'save', 'acme', '..', '--preview']), (e) => e.code === 'invalid-request'); + const agent = { ...s.f.env, MOSAIC_RUN_ID: 'run-1' }; + const calls = s.calls.length; + for (const args of [['template', 'save', 'acme', 'x', '--preview'], ['template', 'save', 'acme', 'x', '--digest', 'a'.repeat(64)], ['template', 'list', 'acme']]) { + await assert.rejects(s.run(args, agent), (e) => e.exitCode === 3 && /human shell/.test(e.message), args.join(' ')); + } + assert.equal(s.calls.length, calls); + // template render is still routed to its own words (it needs --output). + await assert.rejects(s.run(['template', 'render', 'acme']), (e) => e.exitCode === 4 && /--output/.test(e.message)); +}); diff --git a/packages/organization/src/org-rights.mjs b/packages/organization/src/org-rights.mjs index be1265566..a562db495 100644 --- a/packages/organization/src/org-rights.mjs +++ b/packages/organization/src/org-rights.mjs @@ -97,6 +97,10 @@ export const VERB_RIGHTS = record({ 'pack.apply.console': null, 'board.set.console': null, 'workflow.set.console': null, + // #1873 SP8: saving a template, and listing the saved ones, are the owner's alone: a template is a + // copy of the organization's roles, and the files live under the owner's account. + 'template.save.console': null, + 'template.list.console': null, }); function refuse(code) { diff --git a/packages/organization/src/template-save.mjs b/packages/organization/src/template-save.mjs new file mode 100644 index 000000000..712cfcbd2 --- /dev/null +++ b/packages/organization/src/template-save.mjs @@ -0,0 +1,468 @@ +// Starter packs SP8 (#1873): saving an organization, or one of its projects, as a template. Design: +// starter-packs-design section 5 and SP8 in section 8. +// +// A saved template is a pack document (packVersion 1) written to +// /templates//.json and read later with origin `saved` +// (plugins/src/starter-pack.mjs readSavedTemplate). Save copies what section 5 captures and nothing +// else: definitions (role, instructions, personality, from), one hire proposal per agent, the board, +// the workflows, the union of the captured roles' skills and {id, version} for each pack applied +// before. Everything else stays in the organization: members, identities, credential material +// (instance credentials, copyBindings, copyId, orgIncarnation, generation), launch, plugins, vars, +// project roots and git state, definition revs and hire decisions, and everything on the bus. +// +// Free text is kept, with the organization id, its project ids, its agent names, the owner's +// account and the project roots replaced by {org}, {project}, {agent}, {owner} and {root}. The +// preview lists each replacement and every kept free-text field by path, and the exact JSON save +// writes; save takes that JSON's digest. Every string and key is passed to the credential store's +// assertClean; a hit refuses template-secret-suspected naming the path, never the text. +// +// A refusal is a TemplateError: a bus code and a reason that names a path or a checked name only, +// never a value from the document and never a host path. + +import { closeSync, constants, fchmodSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, writeSync } from "node:fs"; +import { join } from "node:path"; +import { ORG_PREFIX, checkDefinition, readTemplate } from "./definitions.mjs"; +import { sha256 } from "./util.mjs"; +import { ID_PATTERN } from "./vocabulary.mjs"; +import { NAME } from "../../plugins/src/kinds.mjs"; +import { + PACK_BOUNDS, PACK_VERSION, SAVED_MAX_BYTES, checkPack, narrowsTemplate, readSavedTemplate, +} from "../../plugins/src/starter-pack.mjs"; + +// A template name is a pack id (NAME), at most 64 characters, the length of every other pack name. +export const TEMPLATE_NAME_MAX = 64; +// list reads at most this many files; a longer directory refuses rather than truncating silently. +export const TEMPLATE_LIST_MAX = 256; +const CHIEF = "chief-of-staff"; +const ASSISTANT = "assistant"; +const HIRE_DEFINITION = /^hire-/; +// The board a template carries when the organization (or project) has none: the five task states in +// store order, with plain labels, as a kanban. A pack must carry a board (SP1 checkPack). +export const DEFAULT_BOARD = Object.freeze({ + columns: Object.freeze([ + Object.freeze({ state: "todo", label: "To do" }), + Object.freeze({ state: "in-progress", label: "In progress" }), + Object.freeze({ state: "in-review", label: "In review" }), + Object.freeze({ state: "blocked", label: "Blocked" }), + Object.freeze({ state: "done", label: "Done" }), + ]), + layout: "kanban", +}); +const HIRE_REASON = "The organization this template was saved from ran this agent."; +// A word boundary for placeholder replacement: an identifier character on either side means the +// literal is part of a longer name, which stays as it is. +const WORD = "A-Za-z0-9_-"; + +export class TemplateError extends Error { + constructor(code, reason) { + super(reason ? `${code}: ${reason}` : code); + this.name = "TemplateError"; + this.code = code; + this.reason = reason; + } +} + +const refuse = (code, reason) => { + throw new TemplateError(code, reason); +}; + +const plain = (value) => value !== null && typeof value === "object" && !Array.isArray(value) + && [Object.prototype, null].includes(Object.getPrototypeOf(value)); + +const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +// The account (the caller's s.human) and the name are checked before any path is built from them. +export function checkAccount(account) { + if (typeof account !== "string" || !ID_PATTERN.test(account)) refuse("human-required", "the saving account is not a valid account name"); + return account; +} + +export function checkTemplateName(name) { + if (typeof name !== "string" || name.length > TEMPLATE_NAME_MAX || !NAME.test(name)) { + refuse("invalid-request", `a template name must match ${NAME}, at most ${TEMPLATE_NAME_MAX} characters`); + } + return name; +} + +// One template, read now, or null when the name is no usable version 2 template. +function templateOrNull(rolesDir, name, cache) { + if (!cache.has(name)) { + let value = null; + if (typeof name === "string" && ID_PATTERN.test(name)) { + try { + value = readTemplate(rolesDir, name); + } catch (error) { + if (error?.name !== "BusinessError") throw error; + } + } + cache.set(name, value); + } + return cache.get(name); +} + +// ---- Placeholders ---- + +// The literals section 5 replaces, longest first so a root path is replaced before the ids inside +// it. A name that is also a template or a definition name is a role's name, not a person's or a +// client's, and stays: replacing "coder" would rewrite every role text that says coder. +function literals(document, { rolesDir, cache }) { + const out = []; + const add = (text, placeholder) => { + if (typeof text === "string" && text.length > 0 && !out.some((l) => l.text === text)) out.push({ text, placeholder }); + }; + for (const project of Object.values(document.projects ?? {})) { + add(project?.root, "{root}"); + add(project?.repo, "{root}"); + } + add(document.id, "{org}"); + for (const id of Object.keys(document.projects ?? {})) add(id, "{project}"); + add(document.human, "{owner}"); + const role = (name) => Object.hasOwn(document.definitions ?? {}, name) || templateOrNull(rolesDir, name, cache) !== null; + const agents = new Set([...Object.keys(document.roles ?? {}), ...(Array.isArray(document.retired) ? document.retired : [])]); + for (const project of Object.values(document.projects ?? {})) for (const name of Object.keys(project?.agents ?? {})) agents.add(name); + for (const name of agents) if (!role(name)) add(name, "{agent}"); + out.sort((a, b) => b.text.length - a.text.length); + return out.map((l) => ({ ...l, pattern: new RegExp(`(? { + counts[placeholder] = (counts[placeholder] ?? 0) + 1; + return placeholder; + }); + } + return out; +} + +// ---- Capture ---- + +// Builds the template document. With list null, free text is kept as it is (the scan's second +// copy); otherwise each replacement and each kept free-text field is recorded by path. +function capture(document, { name, project, rolesDir, list, record }) { + const cache = new Map(); + const text = (value, path) => { + if (list === null) return value; + const counts = {}; + const out = replace(value, list, counts); + for (const [placeholder, count] of Object.entries(counts)) record.replacements.push({ path, placeholder, count }); + record.freeText.push({ path, text: out }); + return out; + }; + const orgDefinitions = plain(document.definitions) ? document.definitions : {}; + const scope = project === undefined ? "org" : "project"; + let source; + if (scope === "project") { + if (!plain(document.projects) || !Object.hasOwn(document.projects, project)) refuse("invalid-request", "the project is not declared in this organization"); + source = document.projects[project]; + if (source?.kind === "library") refuse("invalid-request", "a library project has no agents, board or workflows to save"); + } else { + source = document; + } + + // The agents: one hire per org instance, or per project agent that defines one. Never the chief + // or the assistant, which every organization already has. + const agentEntries = scope === "org" + ? Object.entries(plain(document.roles) ? document.roles : {}) + : Object.entries(plain(source.agents) ? source.agents : {}).filter(([, e]) => typeof e?.definition === "string"); + agentEntries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + const agents = []; + for (const [, entry] of agentEntries) { + const definition = entry?.definition; + if (typeof definition !== "string") continue; + const org = definition.startsWith(ORG_PREFIX) ? definition.slice(ORG_PREFIX.length) : null; + const base = org === null ? definition : orgDefinitions[org]?.from?.template; + if (base === CHIEF || base === ASSISTANT) continue; + if (org !== null && !Object.hasOwn(orgDefinitions, org)) refuse("invalid-request", "an agent names an organization definition the file does not hold"); + agents.push({ org, template: org === null ? definition : null }); + } + + // Which org definitions are copied: every one for an org template; for a project template the + // ones its agents and workflows name. + const sourceWorkflows = plain(source.workflows) ? source.workflows : {}; + let copied; + if (scope === "org") { + copied = Object.keys(orgDefinitions); + } else { + const named = new Set(agents.filter((a) => a.org !== null).map((a) => a.org)); + for (const workflow of Object.values(sourceWorkflows)) { + for (const step of Array.isArray(workflow?.steps) ? workflow.steps : []) { + for (const role of Array.isArray(step?.roles) ? step.roles : []) if (Object.hasOwn(orgDefinitions, role)) named.add(role); + } + } + copied = Object.keys(orgDefinitions).filter((n) => named.has(n)); + } + + // Names. A definition named hire- becomes -, so no decision id leaves the + // organization; every other copied definition keeps its name. + const taken = new Set(copied.filter((n) => !HIRE_DEFINITION.test(n))); + const free = (base) => { + if (!taken.has(base)) { taken.add(base); return base; } + for (let n = 1; ; n++) if (!taken.has(`${base}-${n}`)) { taken.add(`${base}-${n}`); return `${base}-${n}`; } + }; + const numbered = (base) => { + for (let n = 1; ; n++) if (!taken.has(`${base}-${n}`)) { taken.add(`${base}-${n}`); return `${base}-${n}`; } + }; + const renamed = new Map(); + for (const n of copied) { + if (!HIRE_DEFINITION.test(n)) { renamed.set(n, n); continue; } + renamed.set(n, numbered(orgDefinitions[n]?.from?.template ?? "role")); + } + + // Definitions: role, instructions, personality and from. No rev. + const definitions = {}; + const skills = []; + const addSkills = (list) => { for (const s of Array.isArray(list) ? list : []) if (!skills.includes(s)) skills.push(s); }; + for (const n of copied) { + const entry = orgDefinitions[n]; + const key = renamed.get(n); + const at = `definitions.${key}`; + if (!plain(entry) || !plain(entry.role)) refuse("invalid-request", `${at} is not a definition entry`); + const out = {}; + if (entry.from !== undefined) { + const template = templateOrNull(rolesDir, entry.from?.template, cache); + if (!template) refuse("starter-pack-invalid", `${at}.from names a template that is not a version 2 role`); + // A template that changed since the definition copied it: the saved from names its current + // hash, and the role must still narrow the current template. The preview lists the re-pin. + if (template.sha256 !== entry.from.sha256 && list !== null) record.repinned.push(`${at}.from`); + out.from = { template: template.name, sha256: template.sha256 }; + let composition; + try { + ({ composition } = checkDefinition(key, { ...entry, rev: 1, from: out.from }, { where: "definitions" })); + } catch (error) { + if (error?.name === "BusinessError") refuse("starter-pack-invalid", `${at} fails the composition check`); + throw error; + } + if (!narrowsTemplate(composition, template.role)) refuse("starter-pack-invalid", `${at}.role is wider than template ${template.name}`); + } + out.role = structuredClone(entry.role); + if (typeof entry.instructions === "string") out.instructions = text(entry.instructions, `${at}.instructions`); + if (entry.personality !== undefined) { + const p = structuredClone(entry.personality); + if (plain(p)) { + if (typeof p.summary === "string") p.summary = text(p.summary, `${at}.personality.summary`); + if (typeof p.voice === "string") p.voice = text(p.voice, `${at}.personality.voice`); + if (Array.isArray(p.traits)) p.traits = p.traits.map((t, i) => (typeof t === "string" ? text(t, `${at}.personality.traits[${i}]`) : t)); + } + out.personality = p; + } + definitions[key] = out; + addSkills(entry.role.skills); + } + + // Hires: - and the definition. A template agent hires a template reference; an org + // definition agent hires the copied definition. + const refs = new Map(); + const hireNames = new Set(); + const hires = []; + for (const agent of agents) { + let definition; + let base; + if (agent.template !== null) { + const template = templateOrNull(rolesDir, agent.template, cache); + if (!template) refuse("starter-pack-invalid", `an agent uses template ${agent.template}, which is not a version 2 role`); + if (!refs.has(agent.template)) { + const key = free(agent.template); + definitions[key] = { template: template.name, sha256: template.sha256 }; + refs.set(agent.template, key); + addSkills(template.role.skills); + } + definition = refs.get(agent.template); + base = agent.template; + } else { + definition = renamed.get(agent.org); + base = orgDefinitions[agent.org]?.from?.template ?? definition; + } + let hireName; + for (let n = 1; ; n++) if (!hireNames.has(`${base}-${n}`)) { hireName = `${base}-${n}`; break; } + hireNames.add(hireName); + hires.push({ name: hireName, definition, reason: HIRE_REASON }); + } + if (hires.length > PACK_BOUNDS.hires) refuse("starter-pack-invalid", `a template holds at most ${PACK_BOUNDS.hires} hires; this one would hold ${hires.length}`); + + // The board, or the default when there is none. + const sourceBoard = plain(source.board) ? source.board : DEFAULT_BOARD; + const board = { + columns: sourceBoard.columns.map((c, i) => ({ state: c.state, label: text(c.label, `board.columns[${i}].label`) })), + layout: sourceBoard.layout, + }; + + // Workflows. A pack step carries an id; the organization's steps have none, so save numbers them. + // A pack workflow has no title: a workflow without a description takes its title as one. + const workflows = {}; + for (const [wname, workflow] of Object.entries(sourceWorkflows)) { + const at = `workflows.${wname}`; + const description = typeof workflow?.description === "string" ? text(workflow.description, `${at}.description`) + : typeof workflow?.title === "string" ? text(workflow.title, `${at}.description`) + : `The ${wname} workflow.`; + const steps = (Array.isArray(workflow?.steps) ? workflow.steps : []).map((step, i) => ({ + id: `step-${i + 1}`, + title: text(step.title, `${at}.steps[${i}].title`), + roles: (Array.isArray(step.roles) ? step.roles : []).map((r) => (renamed.has(r) ? renamed.get(r) : r)), + state: step.state, + })); + workflows[wname] = { description, steps }; + } + + // packs: {id, version} for each pack applied before at this scope, once each, the latest 32. + const seen = new Set(); + const packs = []; + for (const entry of Array.isArray(document.packs) ? document.packs : []) { + if (scope === "org" ? entry?.scope !== "org" : entry?.project !== project) continue; + const k = `${entry.id}\n${entry.version}`; + if (seen.has(k)) continue; + seen.add(k); + packs.push({ id: entry.id, version: entry.version }); + } + if (list !== null) record.packsDropped = Math.max(0, packs.length - PACK_BOUNDS.packs); + const keptPacks = packs.slice(Math.max(0, packs.length - PACK_BOUNDS.packs)); + + const label = name.slice(0, PACK_BOUNDS.label); + const descriptionText = scope === "org" ? "Saved from {org}." : "Saved from {project} in {org}."; + return { + packVersion: PACK_VERSION, id: name, version: "1.0.0", label, description: descriptionText, scope, + definitions, hires, board, workflows, skills, packs: keptPacks, + }; +} + +// Every string and every key, by path, through the credential store's check. A hit refuses +// template-secret-suspected and names the path; the text never leaves. +function scan(value, path, assertClean) { + const check = (v, where) => { + try { + assertClean(v); + } catch (error) { + if (error?.code === "credential-leak") refuse("template-secret-suspected", where); + throw error; + } + }; + if (typeof value === "string") return check(value, path); + if (Array.isArray(value)) return value.forEach((v, i) => scan(v, `${path}[${i}]`, assertClean)); + if (value !== null && typeof value === "object") { + for (const [k, v] of Object.entries(value)) { + const where = path ? `${path}.${k}` : k; + check(k, `${path || "template"}[key]`); + scan(v, where, assertClean); + } + } +} + +// The preview: the exact JSON save writes, its digest, each replacement and each kept free-text +// field by path. Refuses before returning when any string or key holds a held credential value. +export function templatePreview({ document, name, project, rolesDir, assertClean }) { + checkTemplateName(name); + if (project !== undefined && (typeof project !== "string" || !ID_PATTERN.test(project))) refuse("invalid-request", "the project id is invalid"); + if (typeof assertClean !== "function") throw new TypeError("templatePreview needs the credential store's assertClean"); + if (!plain(document)) refuse("invalid-request", "the organization file is not readable"); + const scope = { name, project, rolesDir }; + // The scan reads both copies: the kept text before replacement (a held value a placeholder would + // break up still refuses) and the document save writes. + const raw = capture(document, { ...scope, list: null, record: null }); + scan(raw, "", assertClean); + const record = { replacements: [], freeText: [], repinned: [], packsDropped: 0 }; + const template = capture(document, { ...scope, list: literals(document, { rolesDir, cache: new Map() }), record }); + scan(template, "", assertClean); + let checked; + try { + checked = checkPack(template, { origin: "saved", rolesDir }); + } catch (error) { + if (error?.name === "PluginError") refuse(/^starter-pack-[a-z-]+$/.test(error.code ?? "") ? error.code : "starter-pack-invalid"); + throw error; + } + const json = `${JSON.stringify(checked, null, 2)}\n`; + if (Buffer.byteLength(json) > SAVED_MAX_BYTES) refuse("starter-pack-invalid", `a saved template is at most ${SAVED_MAX_BYTES} bytes`); + const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + return { + name, scope: checked.scope, ...(project === undefined ? {} : { project }), + template: checked, json, digest: sha256(json), + replacements: record.replacements.sort(byPath), freeText: record.freeText.sort(byPath), + repinned: record.repinned.sort(), packsDropped: record.packsDropped, + }; +} + +// ---- The file ---- + +// A directory save writes under: real, the uid's own, writable by nobody else. The account's own +// directory is private (0700 or narrower), as readSavedTemplate requires. +function directory(path, uid, { private: closed }) { + try { + mkdirSync(path, { mode: 0o700 }); + } catch (error) { + if (error?.code !== "EEXIST") refuse("not-configured", `the ${closed ? "account" : "templates"} directory cannot be created`); + } + let st; + try { + st = lstatSync(path); + } catch { + refuse("not-configured", `the ${closed ? "account" : "templates"} directory is unreadable`); + } + if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== uid || (st.mode & 0o022) !== 0 || (closed && (st.mode & 0o077) !== 0)) { + refuse("not-configured", `the ${closed ? "account" : "templates"} directory is not private to this instance's user`); + } +} + +// Save: the preview again, its digest compared, then the file created exclusively (wx, 0600). A +// name already saved refuses; nothing is replaced. +export function templateSave({ document, name, project, digest, rolesDir, assertClean, configDir, account, uid = process.getuid() }) { + checkAccount(account); + checkTemplateName(name); + if (typeof digest !== "string" || !/^[0-9a-f]{64}$/.test(digest)) refuse("invalid-request", "save takes the preview's digest"); + const preview = templatePreview({ document, name, project, rolesDir, assertClean }); + if (preview.digest !== digest) refuse("pack-plan-stale", "the organization or a template changed since the preview"); + const templates = join(configDir, "templates"); + const home = join(templates, account); + directory(templates, uid, { private: false }); + directory(home, uid, { private: true }); + const file = join(home, `${name}.json`); + let fd; + try { + fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + } catch (error) { + if (error?.code === "EEXIST") refuse("invalid-request", "a template of this name is already saved"); + refuse("not-configured", "the template file cannot be created"); + } + try { + fchmodSync(fd, 0o600); + const bytes = Buffer.from(preview.json); + let offset = 0; + while (offset < bytes.length) offset += writeSync(fd, bytes, offset); + fsyncSync(fd); + } finally { + closeSync(fd); + } + return { name, scope: preview.scope, ...(project === undefined ? {} : { project }), digest }; +} + +// List: the caller's own saved templates, each read through readSavedTemplate. A file that does not +// read is listed as unreadable, by name only. No account directory yet is an empty list. +export function templateList({ configDir, account, rolesDir, uid = process.getuid() }) { + checkAccount(account); + const home = join(configDir, "templates", account); + let names; + try { + const st = lstatSync(home); + if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== uid || (st.mode & 0o077) !== 0) { + refuse("not-configured", "the account directory is not private to this instance's user"); + } + names = readdirSync(home); + } catch (error) { + if (error instanceof TemplateError) throw error; + if (error?.code === "ENOENT") return { templates: [] }; + refuse("not-configured", "the account directory is unreadable"); + } + const files = names.filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -".json".length)) + .filter((n) => n.length <= TEMPLATE_NAME_MAX && NAME.test(n)).sort(); + if (files.length > TEMPLATE_LIST_MAX) refuse("invalid-request", `the account holds more than ${TEMPLATE_LIST_MAX} saved templates`); + const templates = files.map((n) => { + try { + const { pack } = readSavedTemplate({ configDir, account, name: n, rolesDir, uid }); + return { name: n, version: pack.version, label: pack.label, description: pack.description, scope: pack.scope, hires: pack.hires.length }; + } catch (error) { + if (error?.name === "PluginError") return { name: n, readable: false, code: error.code }; + throw error; + } + }); + return { templates }; +} diff --git a/packages/organization/tests/org-rights.test.mjs b/packages/organization/tests/org-rights.test.mjs index af80aed95..82a750705 100644 --- a/packages/organization/tests/org-rights.test.mjs +++ b/packages/organization/tests/org-rights.test.mjs @@ -89,6 +89,9 @@ const VERBS = { 'pack.apply.console': null, 'board.set.console': null, 'workflow.set.console': null, + // Starter packs SP8 (#1873): saving a template and listing the saved ones are the owner's alone. + 'template.save.console': null, + 'template.list.console': null, }; test('M1/R1 the eleven rights pin the grantable flags, including compulsory org.view', () => { diff --git a/packages/organization/tests/template-save.test.mjs b/packages/organization/tests/template-save.test.mjs new file mode 100644 index 000000000..6fd82103f --- /dev/null +++ b/packages/organization/tests/template-save.test.mjs @@ -0,0 +1,286 @@ +// Starter packs SP8 (#1873): saving an organization or one of its projects as a template. The saved +// file carries no credential material and no member, keeps each role's credential request lines, +// replaces the organization's own names with placeholders, and reads back as an origin-saved pack. +import test from "node:test"; +import assert from "node:assert/strict"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { businessDoc, rolesCopy } from "./helpers.mjs"; +import { entryFromTemplate, readTemplate } from "../src/definitions.mjs"; +import { + DEFAULT_BOARD, TemplateError, templateList, templatePreview, templateSave, +} from "../src/template-save.mjs"; +import { checkPack, readSavedTemplate } from "../../plugins/src/starter-pack.mjs"; + +const TOKEN = "fixture-held-token-sp8-91c4e7"; +// The credential store's check, as the bus passes it: a held value anywhere in the text refuses. +const assertClean = (value) => { + if (JSON.stringify(value).includes(TOKEN)) throw Object.assign(new Error("credential-leak"), { name: "BusError", code: "credential-leak" }); +}; +const STRIPPED = ["copyBindings", "copyId", "orgIncarnation", "generation", "holder", "tracker", "vars", "launch", "members", "rev", "hireDecision"]; + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "tsave-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const rolesDir = rolesCopy(root); + const doc = businessDoc(root, "acme"); + const coder = readTemplate(rolesDir, "coder"); + const writer = entryFromTemplate(coder); + writer.instructions = `Write code for acme in ${doc.projects.stack.root}/src. Ask jason; pair with ana on stack.`; + writer.personality = { summary: "Careful acme engineer.", traits: ["works with ana", "plain"], voice: "short" }; + const hired = entryFromTemplate(readTemplate(rolesDir, "reviewer")); + hired.instructions = "Review what ana writes."; + doc.definitions = { writer, "hire-0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0": hired }; + doc.roles.ana = { definition: "org:writer", credentials: { gitea: { file: join(root, "secrets", "ana.token"), rotateBy: "2099-01-01" } } }; + doc.roles.bo = { definition: "org:hire-0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0" }; + doc.retired = ["cy"]; + // Credential material the save must never copy. + doc.copyBindings = { ana: { copyId: "copy-1", orgIncarnation: "inc-1", generation: 3, state: "bound", source: { level: "root", name: "ana", project: null } } }; + doc.board = { columns: [ + { state: "todo", label: "acme backlog" }, { state: "in-progress", label: "Doing" }, { state: "in-review", label: "Review" }, + { state: "blocked", label: "Stuck" }, { state: "done", label: "Done" }, + ], layout: "list" }; + doc.workflows = { change: { title: "Change for stack", steps: [ + { title: "ana writes it", state: "in-progress", roles: ["writer"] }, + { title: "Review it", state: "in-review", roles: ["hire-0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0", "human"] }, + ] } }; + doc.packs = [ + { id: "coding", version: "1.0.0", origin: "shipped", scope: "org", appliedAt: "2026-11-01T00:00:00Z", revision: "r1", added: ["board"], proposedHires: [] }, + { id: "coding", version: "1.0.0", origin: "shipped", scope: "org", appliedAt: "2026-11-02T00:00:00Z", revision: "r2", added: [], proposedHires: [] }, + { id: "intake", version: "2.1.0", origin: "installed", project: "stack", appliedAt: "2026-11-03T00:00:00Z", revision: "r3", added: [], proposedHires: [] }, + ]; + const configDir = join(root, "config"); + mkdirSync(configDir, { mode: 0o700 }); + return { root, rolesDir, doc, configDir }; +} + +const keysDeep = (value, out = []) => { + if (Array.isArray(value)) value.forEach((v) => keysDeep(v, out)); + else if (value && typeof value === "object") for (const [k, v] of Object.entries(value)) { out.push(k); keysDeep(v, out); } + return out; +}; +const refused = (fn, code, reason) => assert.throws(fn, (e) => { + assert.ok(e instanceof TemplateError, `${e?.name}: ${e?.message}`); + assert.equal(e.code, code); + if (reason !== undefined) assert.equal(e.reason, reason); + return true; +}); + +test("SP8 red before: an instance's credentials and copyBindings never reach the saved file", (t) => { + const { doc, rolesDir, configDir, root } = fixture(t); + const preview = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }); + templateSave({ document: doc, name: "acme-shape", digest: preview.digest, rolesDir, assertClean, configDir, account: "jason" }); + const file = join(configDir, "templates", "jason", "acme-shape.json"); + const text = readFileSync(file, "utf8"); + const saved = JSON.parse(text); + const keys = keysDeep(saved); + for (const k of STRIPPED) assert.ok(!keys.includes(k), `the saved file holds ${k}`); + assert.ok(!text.includes(join(root, "secrets")), "the saved file names a token file"); + assert.ok(!text.includes("copy-1") && !text.includes("inc-1")); + // credentials appears only as a definition's request lines: {service, scopes?}, never a file. + for (const [name, entry] of Object.entries(saved.definitions)) { + if (!entry.role) continue; + assert.ok(Array.isArray(entry.role.credentials), name); + for (const line of entry.role.credentials) assert.deepEqual(Object.keys(line).filter((k) => k !== "scopes"), ["service"]); + } + assert.equal(text, preview.json); +}); + +test("SP8 the saved file keeps each role's credential request lines", (t) => { + const { doc, rolesDir } = fixture(t); + const { template } = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }); + assert.deepEqual(template.definitions.writer.role.credentials, doc.definitions.writer.role.credentials); + assert.ok(template.definitions.writer.role.credentials.length > 0); +}); + +test("SP8 placeholders replace the org and project ids, the owner, agent names and project roots, in free text only", (t) => { + const { doc, rolesDir } = fixture(t); + const preview = templatePreview({ document: doc, name: "team-shape", rolesDir, assertClean }); + const { template } = preview; + assert.equal(template.definitions.writer.instructions, "Write code for {org} in {root}/src. Ask {owner}; pair with {agent} on {project}."); + assert.equal(template.definitions.writer.personality.summary, "Careful {org} engineer."); + assert.deepEqual(template.definitions.writer.personality.traits, ["works with {agent}", "plain"]); + assert.equal(template.board.columns[0].label, "{org} backlog"); + assert.equal(template.workflows.change.description, "Change for {project}"); + assert.equal(template.workflows.change.steps[0].title, "{agent} writes it"); + // Agent names that are role names (pm, coder) stay; role text says "coder" on purpose. + const all = JSON.stringify(template); + for (const literal of ["acme", "jason", "ana", doc.projects.stack.root]) assert.ok(!all.includes(literal), literal); + assert.equal(template.description, "Saved from {org}."); + // Each replacement is listed by path. + const at = (path) => preview.replacements.filter((r) => r.path === path).map((r) => r.placeholder).sort(); + assert.deepEqual(at("definitions.writer.instructions"), ["{agent}", "{org}", "{owner}", "{project}", "{root}"]); +}); + +test("SP8 the preview lists every kept free-text field with its saved text", (t) => { + const { doc, rolesDir } = fixture(t); + const preview = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }); + const { template } = preview; + const expected = []; + for (const [name, entry] of Object.entries(template.definitions)) { + if (typeof entry.instructions === "string") expected.push([`definitions.${name}.instructions`, entry.instructions]); + if (entry.personality) { + if (entry.personality.summary !== undefined) expected.push([`definitions.${name}.personality.summary`, entry.personality.summary]); + if (entry.personality.voice !== undefined) expected.push([`definitions.${name}.personality.voice`, entry.personality.voice]); + (entry.personality.traits ?? []).forEach((v, i) => expected.push([`definitions.${name}.personality.traits[${i}]`, v])); + } + } + template.board.columns.forEach((c, i) => expected.push([`board.columns[${i}].label`, c.label])); + for (const [w, wf] of Object.entries(template.workflows)) { + expected.push([`workflows.${w}.description`, wf.description]); + wf.steps.forEach((s, i) => expected.push([`workflows.${w}.steps[${i}].title`, s.title])); + } + expected.sort(([a], [b]) => (a < b ? -1 : 1)); + assert.deepEqual(preview.freeText.map((f) => [f.path, f.text]), expected); +}); + +test("SP8 a held credential in kept text refuses template-secret-suspected naming the field path, never the text", (t) => { + const { doc, rolesDir } = fixture(t); + doc.definitions.writer.personality.voice = `short, key ${TOKEN}`; + refused(() => templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }), "template-secret-suspected", "definitions.writer.personality.voice"); + try { templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }); } catch (e) { assert.ok(!e.message.includes(TOKEN)); } + // A held value a placeholder would split still refuses: the scan reads the text before replacement. + const second = fixture(t); + second.doc.workflows.change.steps[1].title = `acme${TOKEN}`; + refused(() => templatePreview({ document: second.doc, name: "acme-shape", rolesDir: second.rolesDir, assertClean }), "template-secret-suspected", "workflows.change.steps[1].title"); + // A workflow key that holds one is named by its parent and [key]. + const third = fixture(t); + third.doc.workflows[TOKEN.replace(/-/g, "")] = third.doc.workflows.change; + const keyed = (v) => { if (JSON.stringify(v).includes(TOKEN.replace(/-/g, ""))) throw Object.assign(new Error("x"), { code: "credential-leak" }); }; + refused(() => templatePreview({ document: third.doc, name: "acme-shape", rolesDir: third.rolesDir, assertClean: keyed }), "template-secret-suspected", "workflows[key]"); +}); + +test("SP8 a second save to the same name refuses and leaves the first file", (t) => { + const { doc, rolesDir, configDir } = fixture(t); + const { digest } = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }); + templateSave({ document: doc, name: "acme-shape", digest, rolesDir, assertClean, configDir, account: "jason" }); + const file = join(configDir, "templates", "jason", "acme-shape.json"); + const first = readFileSync(file); + doc.definitions.writer.instructions = "Changed."; + const next = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }); + refused(() => templateSave({ document: doc, name: "acme-shape", digest: next.digest, rolesDir, assertClean, configDir, account: "jason" }), "invalid-request", "a template of this name is already saved"); + assert.deepEqual(readFileSync(file), first); + assert.equal(lstatSync(file).mode & 0o777, 0o600); + assert.equal(lstatSync(join(configDir, "templates", "jason")).mode & 0o777, 0o700); +}); + +test("SP8 a name that is no pack id refuses before any path is built", (t) => { + const { doc, rolesDir, configDir } = fixture(t); + for (const name of ["..", ".", "a/b", "../x", ".hidden", "", "A", "x".repeat(65), 7]) { + refused(() => templatePreview({ document: doc, name, rolesDir, assertClean }), "invalid-request"); + refused(() => templateSave({ document: doc, name, digest: "0".repeat(64), rolesDir, assertClean, configDir, account: "jason" }), "invalid-request"); + } + for (const account of ["..", "a/b", ""]) refused(() => templateList({ configDir, account, rolesDir }), "human-required"); + assert.ok(!existsSync(join(configDir, "templates"))); +}); + +test("SP8 a ninth hire refuses", (t) => { + const { doc, rolesDir } = fixture(t); + // pm, cto, coder, reviewer, ana and bo are six; three more make nine. + doc.roles.dee = { definition: "coder" }; + doc.roles.eve = { definition: "coder" }; + assert.equal(templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }).template.hires.length, 8); + doc.roles.fay = { definition: "reviewer" }; + refused(() => templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }), "starter-pack-invalid", "a template holds at most 8 hires; this one would hold 9"); +}); + +test("SP8 each packs entry holds only id and version, once, at the saved scope", (t) => { + const { doc, rolesDir } = fixture(t); + const org = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }).template; + assert.deepEqual(org.packs, [{ id: "coding", version: "1.0.0" }]); + const project = templatePreview({ document: doc, name: "stack-shape", project: "stack", rolesDir, assertClean }).template; + assert.deepEqual(project.packs, [{ id: "intake", version: "2.1.0" }]); + for (const entry of [...org.packs, ...project.packs]) assert.deepEqual(Object.keys(entry).sort(), ["id", "version"]); +}); + +test("SP8 hires and names: no chief, no assistant, no decision id; hire- becomes