diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 135a1699a..f0eb4ae65 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -88,6 +88,7 @@ export const TASK_PAGE_MAX = 200; // Row 148 (#1642): the human-only verbs behind the Console's document editor. The save is the one // request allowed past the ordinary 64 KiB envelope (server.mjs and client.mjs), up to its content cap. export const PROJECT_FILE_VERBS = Object.freeze(['project.files', 'project.file', 'project.file.save']); +export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status']); export const PROJECT_SAVE_VERB = 'project.file.save'; export const PROJECT_SAVE_REQUEST_MAX = 1024 * 1024 + 64 * 1024; export const WEB_VERBS =Object.freeze(['web.search', 'web.fetch']); @@ -279,6 +280,7 @@ export class Broker { #organization; #credentialVerbs; #project; + #library; #usage; #limits; #harnessOn; @@ -294,7 +296,7 @@ export class Broker { #stops = new Map(); #clock = 0; // credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use(). - 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 }) { if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business'); this.#businesses = structuredClone(businesses); this.#store = store; @@ -306,6 +308,7 @@ export class Broker { this.#hostGrant = hostGrant; this.#hostKeys = hostKeys; this.#project = project; + this.#library = library; this.#usage = usage; this.#limits = limits; // Row 65: harnessOn(business, name) is true when the organization file's plugins.harness switches @@ -2192,6 +2195,8 @@ export class Broker { return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) }); if (PROJECT_FILE_VERBS.includes(request.verb)) return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) }); + if (LIBRARY_VERBS.includes(request.verb)) + return this.#requestLibrary(s, { verb: request.verb, args: structuredClone(args) }); if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args)); if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set"].includes(request.verb)) { // Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose @@ -2441,6 +2446,26 @@ export class Broker { throw this.#refused(s, ProjectFailure && e instanceof ProjectFailure ? new BusError(e.code) : e, request); } } + // 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); + } + } // The eight task verbs reach the trusted S3 handler here. It runs outside any transaction, gets the // cap to authorize and record through authorize() and recordTask(); refusals are recorded as in request(). async requestTask(cap, request, handler) { diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index 7398247ce..0e316fd74 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -1779,7 +1779,7 @@ export class Store { const RCOPIES=join(base,'rcopies');mkdirSync(RCOPIES); for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],[C1,'3g',['3f']]]){ const d=join(RCOPIES,id); - for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/bus/src/business.mjs','packages/plugins/src/kinds.mjs','packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true}); + for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/bus/src/business.mjs','packages/plugins/src/kinds.mjs','packages/business/src','packages/library/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true}); if(id===C1)writeFileSync(join(d,'scripts','mosaic'),`#!/usr/bin/env bash\nexec node "${join(RCOPIES,B,'packages','cli','src','cli.mjs')}" "$@"\n`,{mode:0o755}); for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)} execFileSync('chmod',['-R','a-w',d]);