diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 3d934ef80..7ba046911 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -1055,6 +1055,12 @@ export class Broker { #publish(ticket) { return (typeof ticket === 'string' && this.#publishes.get(ticket)) || fail('publish-unknown'); } + // Design D5: the only key the git socket's gitea token use gets is the agent checkPublish checked + // (#agent: a run holding its claim, never a tester, reader or human). Trusted host only. + publishSession(ticket) { + const { s } = this.#publish(ticket); + return Object.freeze({ business: s.business, role: s.role }); + } // Step 6: records the push the git socket made as the action.allowed for git.push.remote, which // spends the decision. The checks run again; the run may have ended while git ran, so the holder // check is not repeated (as recordLand). Trusted host only. diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index a0bc51fd9..228e0f92f 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -86,7 +86,9 @@ export async function startBroker({ ...repoRoots, ...Object.values(businesses).flatMap((b) => Object.values(b.projects ?? {}).map((p) => p.root)), ]; - credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot }); + // Row 199 (#1697, design D1): the per-agent store is /secrets/agents. + const storeRoot = organization ? join(organization.dir, 'secrets', 'agents') : null; + credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot }); store = new Store(dataRoot); // vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs // plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused. @@ -212,6 +214,12 @@ export async function startBroker({ // Row 137 (#1631): the git socket's land op. checkLand's refusal is the run's, like checkPush's. // A landing then ends in recordLand or landRefused under its ticket, which outlive the run's cap. const checkLand = (cap, args) => refusing(cap, () => broker.checkLand(cap, args), args?.task_ref); + // Row 199 (#1697, design D9b): the git socket's publish op. checkPublish's refusal is the run's. + // The gitea token is used only under the open ticket, keyed from the agent checkPublish checked, + // and the op table returns no token. A publish then ends in recordPublish or publishRefused, + // which files any refusal once, withGitea's included. + const checkPublish = (cap, args) => refusing(cap, () => broker.checkPublish(cap, args), args?.task_ref); + const withGitea = async (ticket, fn) => credentials.useFor(broker.publishSession(ticket), 'gitea', fn); // Metadata only (service, date, state); the token stays in Credentials. function credentialStatus(business, role) { return credentials @@ -237,6 +245,10 @@ export async function startBroker({ checkLand, recordLand: (ticket, land) => broker.recordLand(ticket, land), landRefused: (ticket, args) => broker.landRefused(ticket, args), + checkPublish, + withGitea, + recordPublish: (ticket, pushed) => broker.recordPublish(ticket, pushed), + publishRefused: (ticket, args) => broker.publishRefused(ticket, args), landHeld: (record) => broker.landHeld(record), // Row 156 (#1651): the launcher's landing pass. landHolders: (business) => broker.landHolders(business), diff --git a/packages/bus/tests/publish.test.mjs b/packages/bus/tests/publish.test.mjs index 9c7dc27fc..77ac0e5cd 100644 --- a/packages/bus/tests/publish.test.mjs +++ b/packages/bus/tests/publish.test.mjs @@ -199,3 +199,46 @@ test('git.push.remote is in neither ROUTED list and no shipped role grants it', for (const name of readdirSync(dir).filter((n) => n.endsWith('.json'))) assert.ok(!readFileSync(new URL(name, dir), 'utf8').includes('git.push.remote'), name); }); + +// D1 and D9b through the real startBroker: the store is /secrets/agents, and the gitea +// token is used only under an open publish ticket, keyed from the agent checkPublish checked. +test('runtime: withGitea keys from the open ticket only, and the store root is the organization dir', async (t) => { + const { startBroker } = await import('../src/runtime.mjs'); + const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs'); + const { chmodSync, mkdirSync } = await import('node:fs'); + const root = mkdtempSync(join(tmpdir(), 'bus-publish-rt-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const rolesDir = rolesCopy(root); + // The runtime reads the harness switch from the organization file as it stands. + 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: 0o700 }); + chmodSync(join(root, 'secrets'), 0o700); + const demo = businesses().demo; + demo.roles.coder.credentials = { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: '2099-01-01' } }; + const organization = { dir: root, rolesDir, system: systemFor(root) }; + // No organization, no store root: a store reference refuses the start. + await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo } }), /credential-reference/); + const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization }); + t.after(() => bus.close()); + assert.deepEqual(bus.credentialStatus('demo', 'coder'), [{ service: 'gitea', date: '2099-01-01', state: 'not-issued' }]); + const coder = bus.broker.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' }); + const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + bus.broker.request(coder, { verb: 'role.claim', args: {} }); + bus.checkPush(coder, TASK); + bus.recordPush(coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2', commit: A, previous: null }); + const d = bus.broker.request(coder, { verb: 'decision.raise', args: { + action: 'git.push.remote', target: TASK, task_ref: TASK, question: 'Publish?', blocking: false, recommendation: 'yes', + options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }] } }); + bus.broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } }); + assert.throws(() => bus.checkPublish(coder, { task_ref: TASK, project: 'stack', branch: 'feat' }), /decision-required/); + const ticket = bus.checkPublish(coder, { task_ref: TASK, project: 'stack', branch: 'feat', decision: d.id }).ticket; + await assert.rejects(bus.withGitea(ticket, async () => 1), /credential-unavailable/); + bus.credentials.issue('demo/coder', 'gitea', 'fake_publish_token_0123456789', '2099-01-01'); + assert.equal(await bus.withGitea(ticket, async (token) => token === 'fake_publish_token_0123456789'), true); + await assert.rejects(bus.withGitea(ticket, async (token) => ({ echo: token })), /credential-leak/); + await assert.rejects(bus.withGitea('not-a-ticket', async () => 1), /publish-unknown/); + await assert.rejects(bus.withGitea(coder, async () => 1), /publish-unknown/); + bus.recordPublish(ticket, { ref: 'refs/heads/acme-coder/feat', commit: A, previous: null }); + await assert.rejects(bus.withGitea(ticket, async () => 1), /publish-unknown/); +});