diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 3baa709f9..512d57519 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -90,0 +91 @@ export const PROJECT_FILE_VERBS = Object.freeze(['project.files', 'project.file' +export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status']); @@ -281,0 +283 @@ export class Broker { + #library; @@ -297 +299 @@ export class Broker { - constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null }) { + constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null }) { @@ -308,0 +311 @@ export class Broker { + this.#library = library; @@ -2189,0 +2193,2 @@ export class Broker { + if (LIBRARY_VERBS.includes(request.verb)) + return this.#requestLibrary(s, { verb: request.verb, args: structuredClone(args) }); @@ -2438,0 +2444,20 @@ export class Broker { + // C2-04 M4: only a verified human reads library text or status. Host Git + // functions stay injected, preserving this module's sandbox import closure. + async #requestLibrary(s, request) { + const { verb, args } = request; + try { + this.#human(s); + const fields = verb === 'library.file' ? ['project', 'path'] : ['project']; + keys(args, fields, fields); + if (!this.#library?.readFile || !this.#library?.status) fail('project-not-configured'); + const context = { business: this.#business(s.business), project: args.project }; + const result = verb === 'library.file' + ? await this.#library.readFile({ ...context, path: args.path }) + : await this.#library.status(context); + this.#secretCheck(result); + return result; + } catch (error) { + const Failure = this.#library?.LibraryError; + throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, request); + } + } diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index f81f956f2..e265ff5d1 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -5,0 +6 @@ import * as projectModule from '../../project/src/index.mjs'; +import * as libraryModule from '../../library/src/index.mjs'; @@ -95 +96 @@ export async function startBroker({ - ...Object.values(businesses).flatMap((b) => Object.values(b.projects ?? {}).map((p) => p.root)), + ...Object.values(businesses).flatMap((b) => Object.values(b.projects ?? {}).filter(p => p.kind !== 'library').map((p) => p.root)), @@ -130,0 +132,5 @@ export async function startBroker({ + // C2-04: mirrors are host state, never session checkouts. An unsafe + // existing mirror refuses startup before a socket can become ready. + for (const business of Object.values(businesses)) + for (const [project, entry] of Object.entries(business.projects ?? {})) + if (entry.kind === 'library') await libraryModule.ensureMirror({ business, project, dataRoot }); @@ -138,0 +145,5 @@ export async function startBroker({ + library: { + readFile: context => libraryModule.readLibraryFile({ ...context, dataRoot }), + status: context => libraryModule.libraryStatus({ ...context, dataRoot }), + LibraryError: libraryModule.LibraryError, + }, diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs index d08a4f52e..e8337ea2b 100644 --- a/packages/business/src/business.mjs +++ b/packages/business/src/business.mjs @@ -12,0 +13 @@ import { checkPlugins } from "../../plugins/src/kinds.mjs"; +import { checkMapping } from "../../library/src/mapping.mjs"; @@ -241,0 +243,4 @@ export function validateBusinessDocument(document, file, { rolesDir }) { + if (entry.kind === 'library') { + projects[id] = checkMapping(entry, `${file} projects.${id}`, { fail: (code, message) => refuse(`${code}: ${message}`) }); + continue; + } @@ -257,0 +263 @@ export function validateBusinessDocument(document, file, { rolesDir }) { + if (projects[project].kind === 'library') refuse(`${file} roles.${instance}.publish names library project ${project}; library repositories cannot be published by agents`); diff --git a/packages/business/src/instance-settings.mjs b/packages/business/src/instance-settings.mjs index ff3a6d9d3..50cc36edc 100644 --- a/packages/business/src/instance-settings.mjs +++ b/packages/business/src/instance-settings.mjs @@ -149,0 +150 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null }) + if (entry.kind === 'library') continue; diff --git a/packages/business/tests/business.test.mjs b/packages/business/tests/business.test.mjs index b64659fa1..0f214a005 100644 --- a/packages/business/tests/business.test.mjs +++ b/packages/business/tests/business.test.mjs @@ -10,0 +11,30 @@ import { launchDoc, businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } f +test('C2-04: library mappings load without roots, remain frozen, and retain mapping refusals', async t => { + const { mappingEntry } = await import('../../library/tests/helpers.mjs'); + const { rmSync } = await import('node:fs'); + const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true })); + const doc = businessDoc(root), file = join(root, 'businesses/acme.json'); + doc.projects = { books: mappingEntry(), ...doc.projects }; + const loaded = validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }); + const { checkMapping } = await import('../../library/src/mapping.mjs'); + assert.deepEqual(loaded.projects.books, checkMapping(mappingEntry())); + assert.equal(loaded.projects.books.root, undefined); + assert.ok(Object.isFrozen(loaded.projects.books.universes.harbor.books)); + const only = structuredClone(doc); delete only.projects.stack; + assert.deepEqual(Object.keys(validateBusinessDocument(only, file, { rolesDir: REPO_ROLES }).projects), ['books']); + doc.projects.books.universes.harbor.playground = ['Elsewhere/Playground/']; + refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-outside-book/); + doc.projects.books = { ...mappingEntry(), root: '/forbidden' }; + refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-mapping-invalid/); +}); + +test('C2-04: an otherwise valid publish grant cannot name a library repo', async t => { + const { mappingEntry } = await import('../../library/tests/helpers.mjs'); + const { rmSync } = await import('node:fs'); + const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true })); + const doc = businessDoc(root); + doc.projects.books = { ...mappingEntry(), remote: 'https://git.example/writer/books' }; + doc.roles.coder.identity = { name: 'Coder', email: 'coder@example.invalid', gitea: { url: 'https://git.example', user: 'acme-coder' } }; + doc.roles.coder.publish = ['books']; + refuses(() => validateBusinessDocument(doc, join(root, 'businesses/acme.json'), { rolesDir: REPO_ROLES }), /publish.*library/); +}); + diff --git a/packages/business/tests/instance-settings.test.mjs b/packages/business/tests/instance-settings.test.mjs index 6b19f9e89..a6c8fe665 100644 --- a/packages/business/tests/instance-settings.test.mjs +++ b/packages/business/tests/instance-settings.test.mjs @@ -22,0 +23,10 @@ const LEVELS = { pi: ["off", "minimal", "low", "medium", "high", "xhigh", "max"] +test('C2-04: instance settings skip a library while checking a later code project', async () => { + const { mappingEntry } = await import('../../library/tests/helpers.mjs'); + const f = fixture(doc => { doc.projects = { books: mappingEntry(), ...doc.projects }; }); + writeJson(projectFilePath(f.doc.projects.stack.root), { projectVersion: 1, id: 'stack', vars: { 'tracker.project': 3 } }); + const before = f.settings.read('acme'); + f.settings.set('acme', { revision: before.revision, instance: 'gate-215', op: 'add', definition: 'merge-gate' }, 'jason'); + assert.deepEqual(JSON.parse(readFileSync(f.file, 'utf8')).projects.books, mappingEntry()); + assert.ok(f.settings.read('acme').instances['gate-215']); +}); + diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs index 604cef203..c721ee9e8 100644 --- a/packages/cli/src/launcher.mjs +++ b/packages/cli/src/launcher.mjs @@ -354 +354 @@ export function createLauncher({ - checkouts = await ensureCheckouts({ workspace, projects: b.projects, env }); + checkouts = await ensureCheckouts({ workspace, projects: Object.fromEntries(Object.entries(b.projects).filter(([, entry]) => entry.kind !== 'library')), env }); diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs index 3b663ba94..4636df3de 100644 --- a/packages/cli/tests/launcher.test.mjs +++ b/packages/cli/tests/launcher.test.mjs @@ -32,0 +33,10 @@ const skip = sandboxProblem() ?? false; +test('C2-04: launcher workspace clones code but never the library', { skip, timeout: 30000 }, async t => { + const { mappingEntry } = await import('../../library/tests/helpers.mjs'); + const f = await setup(t, doc => { doc.projects = { books: mappingEntry(), ...doc.projects }; return doc; }); + await f.launcher.launchPm(); + const workspace = workspaceDir(f.f.dataRoot, 'acme', 'chief'); + assert.equal(existsSync(join(workspace, 'stack', '.git')), true); + assert.equal(existsSync(join(workspace, 'books')), false); + assert.equal(await f.close(), 0); +}); + diff --git a/packages/library/src/index.mjs b/packages/library/src/index.mjs index f64d77860..53ce5cb43 100644 --- a/packages/library/src/index.mjs +++ b/packages/library/src/index.mjs @@ -8,0 +9 @@ export { survey, formatSurvey, surveyMain } from './survey.mjs'; +export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs';