diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index ec5e4d509..01f13c139 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -1,7 +1,7 @@ import { createRequirementsHost } from '../../tasks/src/builtin.mjs'; import { createRequirementCatalog } from './requirement-catalog.mjs'; import { readPrdMaterial } from '../../project/src/prd.mjs'; -import { lstatSync, readdirSync, readFileSync } from 'node:fs'; +import { lstatSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Store } from './store.mjs'; @@ -360,6 +360,11 @@ export async function startBroker({ // Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to // gitea, and `held` names the files memory must not load until reconcile or a revoke. const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] }; + // #1886 (F3 defect l): a store reference with no file yet reads as no key (design D3a), but Credentials + // refuses a missing store root, and only issue() made it. Start makes it the same way issue() does when + // any reference names the store. A root that exists with loose modes or as a symlink still refuses. + if (storeRoot && Object.values(references).some(refs => Object.values(refs ?? {}).some(ref => ref?.store === true))) + mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held, modelAgents: proxy ? Object.entries(businesses).flatMap(([id, b]) => Object.keys(b.roles).map(role => id + '/' + role)) : [] }); if (proxy) { diff --git a/packages/bus/tests/credential-verbs.test.mjs b/packages/bus/tests/credential-verbs.test.mjs index 3adfc3a40..7b30f2368 100644 --- a/packages/bus/tests/credential-verbs.test.mjs +++ b/packages/bus/tests/credential-verbs.test.mjs @@ -4,7 +4,7 @@ // scratch root only. import test from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { startBroker } from '../src/runtime.mjs'; @@ -18,7 +18,7 @@ const OTHER = 'fake_other_token_5555555555'; const DATE = '2099-01-01'; const role = { authority: { withinRole: ['message.send'], crossRole: [] } }; -async function setup(t) { +async function setup(t, { storeRoot = true } = {}) { const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-')); t.after(() => rmSync(root, { recursive: true, force: true })); const rolesDir = rolesCopy(root); @@ -26,7 +26,7 @@ async function setup(t) { doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE }; writeJson(join(root, 'businesses/demo.json'), doc); mkdirSync(join(root, 'data'), { mode: 0o700 }); - mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 }); + if (storeRoot) mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 }); const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service); // The bus's resolved business carries the same references; a store one names its derived file. const demo = { @@ -161,3 +161,29 @@ test('T5: issue, rotate, revoke and delete are live, rewrite the business file a for (const path of walk(c.root)) for (const token of [TOKEN, ROTATED, OTHER]) assert.equal(readFileSync(path).includes(token), false, path); }); + +test('#1886: a store reference with no store root yet starts the bus, makes the root private and issues', async (t) => { + const c = await setup(t, { storeRoot: false }); + const dir = join(c.root, 'secrets', 'agents'); + assert.equal(statSync(dir).mode & 0o777, 0o700); + assert.equal(statSync(join(c.root, 'secrets')).mode & 0o077, 0); + assert.deepEqual(c.status('coder'), { gitea: 'not-issued', vikunja: 'valid' }); + const issued = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN + '\n', date: DATE }); + assert.deepEqual(issued, { agent: 'coder', verb: 'issue', service: 'gitea', date: DATE, state: 'valid' }); + assert.equal(statSync(c.store('coder', 'gitea')).mode & 0o777, 0o600); +}); + +test('#1886: a store root that exists with loose modes still refuses start', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'bus-cred-loose-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const rolesDir = rolesCopy(root); + writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } }); + mkdirSync(join(root, 'data'), { mode: 0o700 }); + mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o750 }); + chmodSync(join(root, 'secrets', 'agents'), 0o750); + const demo = { + id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: {}, + roles: { coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } }, + }; + await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/); +});