// Row 199 (#1697, design D9 steps 1 to 3 and 6, D9b): the broker side of publish. git.push.remote // is a hard wait; its decision binds the task's latest push (pushCommit). checkPublish builds the // agent's fork from the project's upstream and the agent's own Gitea user; recordPublish spends the // decision; publishRefused files the refusal. import test from 'node:test'; import assert from 'node:assert/strict'; import { fork } from 'node:child_process'; import { once } from 'node:events'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Store } from '../src/store.mjs'; import { Broker } from '../src/broker.mjs'; const TASK = 'vikunja:1/2'; const A = 'a'.repeat(40); const B = 'b'.repeat(40); const C = 'c'.repeat(40); const identity = (user) => ({ name: user, email: `${user}@example.com`, gitea: { url: 'https://git.example', user } }); const pusher = { withinRole: ['git.push.working', 'message.send'], crossRole: [] }; function businesses(upstream = 'https://git.example/mosaic/stack') { return { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, projects: { stack: { root: '/nonexistent', remote: upstream }, books: { root: '/nonexistent-books' } }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: pusher, identity: identity('acme-coder'), publish: ['stack'] }, second: { authority: pusher, identity: identity('acme-second'), publish: ['stack'] }, plain: { authority: pusher }, reviewer: { authority: { withinRole: ['message.send'], crossRole: [] } }, }, }, }; } function setup(t, upstream) { const root = mkdtempSync(join(tmpdir(), 'bus-publish-')); const store = new Store(root); const b = new Broker({ store, businesses: businesses(upstream) }); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const caps = {}; for (const role of ['coder', 'second', 'plain', 'reviewer']) { caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' }); b.request(caps[role], { verb: 'role.claim', args: {} }); } const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const call = (cap, verb, args) => b.request(cap, { verb, args }); const push = (commit, previous = null, role = 'coder', project = 'stack') => { b.checkPush(caps[role], TASK); return b.recordPush(caps[role], { task_ref: TASK, project, branch: 'feat', ref: 'work/vikunja-stack-2', commit, previous }); }; const raise = (role = 'coder') => call(caps[role], 'decision.raise', { action: 'git.push.remote', target: TASK, task_ref: TASK, question: 'Publish?', blocking: false, recommendation: 'yes', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], }); const approve = (role = 'coder') => { const d = raise(role); call(human, 'decision.resolve', { id: d.id, choice: 'yes' }); return d; }; const publish = (decision, role = 'coder', extra = {}) => b.checkPublish(caps[role], { task_ref: TASK, project: 'stack', branch: 'feat', ...(decision ? { decision: decision.id } : {}), ...extra }); const events = (kind) => store.all('SELECT * FROM events WHERE kind=? ORDER BY seq', kind).map((e) => ({ ...e, body: JSON.parse(e.body) })); return { b, store, caps, human, call, push, raise, approve, publish, events }; } test('a raise with no recorded push refuses no-push; a raise binds the latest push and shows it', (t) => { const f = setup(t); assert.throws(() => f.raise(), /no-push/); f.push(A); const d = f.raise(); assert.match(d.question, new RegExp(`commit ${A}\\)$`)); const raised = f.events('action.allowed').find((e) => e.body.operation === 'decision.raise'); assert.equal(raised.body.pushCommit, A); assert.deepEqual({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) }, { class: 'gated', route_to: 'human' }); }); test('happy path: the fork is the upstream repo under the agent\'s own user, the destination is host-built', (t) => { const f = setup(t); f.push(A); const d = f.approve(); const r = f.publish(d); assert.deepEqual({ ...r, ticket: typeof r.ticket }, { ticket: 'string', task_ref: TASK, project: 'stack', fork: 'https://git.example/acme-coder/stack', user: 'acme-coder', email: 'acme-coder@example.com', source: A, dst: 'refs/heads/acme-coder/feat', }); const done = f.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null }); assert.deepEqual(done, { task_ref: TASK, fork: r.fork, ref: r.dst, commit: A, previous: null }); const allowed = f.events('action.allowed').filter((e) => e.body.action === 'git.push.remote' && e.body.operation !== 'decision.raise'); assert.equal(allowed.length, 1); assert.equal(allowed[0].subject, TASK); assert.equal(allowed[0].body.decision, d.id); assert.equal(allowed[0].body.fork, r.fork); // Spent once. assert.throws(() => f.publish(d), /decision-consumed/); assert.throws(() => f.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null }), /publish-unknown/); }); test('a hard wait: no decision refuses, and a decision for another task refuses', (t) => { const f = setup(t); f.push(A); assert.throws(() => f.publish(null), /decision-required/); const other = f.call(f.caps.coder, 'decision.raise', { action: 'deploy', target: 'vikunja:1/3', question: 'Ship?', blocking: false, recommendation: 'yes', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], }); f.call(f.human, 'decision.resolve', { id: other.id, choice: 'yes' }); assert.throws(() => f.publish(other), /decision-mismatch/); const d = f.raise(); assert.throws(() => f.publish(d), /decision-not-approved/); }); test('r4 B4: approve while the latest push is A, push B, publish refuses decision-mismatch', (t) => { const f = setup(t); f.push(A); const d = f.approve(); f.push(B, A); assert.throws(() => f.publish(d), /decision-mismatch/); // And a push between checkPublish and recordPublish refuses the record. const f2 = setup(t); f2.push(A); const d2 = f2.approve(); const r = f2.publish(d2); f2.push(C, A); assert.throws(() => f2.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null }), /decision-mismatch/); assert.throws(() => f2.b.recordPublish(r.ticket, { ref: r.dst, commit: C, previous: null }), /decision-mismatch/); }); test('grant and fork refusals', (t) => { const f = setup(t); f.push(A, null, 'plain'); // plain has no identity and no grant; its raise still binds the push, the publish refuses. const d = f.approve('plain'); assert.throws(() => f.publish(d, 'plain'), /remote-not-granted/); assert.throws(() => f.publish(d, 'plain', { project: 'books' }), /remote-not-granted|decision-mismatch/); // work-not-yours: second publishes a push coder made. const g = setup(t); g.push(A); const d2 = g.approve('second'); assert.throws(() => g.publish(d2, 'second'), /work-not-yours/); }); test('remote-not-fork, compared without case, and remote-host-mismatch, at push', (t) => { for (const [upstream, code] of [ ['https://git.example/acme-coder/stack', /remote-not-fork/], ['https://git.example/ACME-Coder/stack', /remote-not-fork/], ['https://other.example/mosaic/stack', /remote-host-mismatch/], ['https://git.example:3000/mosaic/stack', /remote-host-mismatch/], ]) { const f = setup(t, upstream); f.push(A); const d = f.approve(); assert.throws(() => f.publish(d), code, upstream); } }); test('two agents granted one project each get their own fork', (t) => { const f = setup(t); f.push(A); const r1 = f.publish(f.approve()); assert.equal(r1.fork, 'https://git.example/acme-coder/stack'); const g = setup(t); g.push(A, null, 'second'); const r2 = g.publish(g.approve('second'), 'second'); assert.equal(r2.fork, 'https://git.example/acme-second/stack'); assert.equal(r2.dst, 'refs/heads/acme-second/feat'); }); test('the request cannot name the fork, the user or the destination', (t) => { const f = setup(t); f.push(A); const d = f.approve(); for (const extra of [{ fork: 'https://git.example/x/stack' }, { dst: 'refs/heads/main' }, { user: 'x' }, { remote: 'https://x' }]) assert.throws(() => f.publish(d, 'coder', extra), /invalid-request/); }); // Row 237 (#1743, duckworth-0068 N1): the decision is spent at recordPublish, after the push, so // checkPublish refuses a second ticket on a decision that already has one open. test('one decision, one open publish: a second ticket refuses publish-busy, whatever its branch', (t) => { const f = setup(t); f.push(A); const d = f.approve(); const r = f.publish(d); assert.throws(() => f.publish(d), /publish-busy/); assert.throws(() => f.publish(d, 'coder', { branch: 'other' }), /publish-busy/); // A refused publish frees the decision, which is still unspent; the retry gets a ticket. f.b.publishRefused(r.ticket, { code: 'fork-unavailable' }); const retry = f.publish(d, 'coder', { branch: 'other' }); assert.throws(() => f.publish(d), /publish-busy/); f.b.recordPublish(retry.ticket, { ref: retry.dst, commit: A, previous: null }); assert.equal(f.events('action.allowed').filter((e) => e.body.action === 'git.push.remote' && e.body.operation !== 'decision.raise').length, 1); assert.throws(() => f.publish(d), /decision-consumed/); // Another agent's decision on the same task isn't held by this one's ticket. const f2 = setup(t); f2.push(A); const mine = f2.approve(); f2.publish(mine); f2.push(B, A, 'second'); const theirs = f2.approve('second'); assert.equal(typeof f2.publish(theirs, 'second').ticket, 'string'); }); test('publishRefused files the code on the task\'s trail and drops the ticket', (t) => { const f = setup(t); f.push(A); const r = f.publish(f.approve()); assert.equal(f.b.publishRefused(r.ticket, { code: 'remote-unrelated' }), 'remote-unrelated'); const refused = f.events('action.refused').filter((e) => e.subject === TASK); assert.deepEqual(refused.at(-1).body, { code: 'remote-unrelated' }); assert.throws(() => f.b.publishRefused(r.ticket, { code: 'x' }), /publish-unknown/); }); test('git.push.remote is in neither ROUTED list and no shipped role grants it', async () => { const { ACTIONS, HARD_WAITS } = await import('../src/broker.mjs'); const vocabulary = await import('../../organization/src/vocabulary.mjs'); assert.ok(ACTIONS.includes('git.push.remote') && HARD_WAITS.includes('git.push.remote')); assert.ok(!vocabulary.ROUTED.includes('git.push.remote')); const { readdirSync, readFileSync } = await import('node:fs'); const dir = new URL('../../../roles/', import.meta.url); 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('../../organization/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/); }); // Row 199 (D9b, deviation 11): the bus process answers the socket's five publish ops from their own // launchOp lines. A LAUNCH_OPS name without one would get launch-op-refused. A boot config that // carries publishTls is refused: the scratch CA is for an in-process runtime only. test('the bus process answers the publish ops from their own lines, not launch-op-refused', async (t) => { const root = mkdtempSync(join(tmpdir(), 'bus-publish-ops-')); t.after(() => rmSync(root, { recursive: true, force: true })); const child = fork(new URL('../src/process.mjs', import.meta.url), [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }); t.after(() => { if (child.exitCode === null) child.kill('SIGKILL'); }); const ask = async (m) => { const reply = once(child, 'message'); child.send(m); return (await reply)[0]; }; const config = { dataRoot: root, businesses: businesses(), launches: [], readers: [] }; const tlsChild = fork(new URL('../src/process.mjs', import.meta.url), [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }); t.after(() => { if (tlsChild.exitCode === null) tlsChild.kill('SIGKILL'); }); const refused = once(tlsChild, 'message'); const gone = once(tlsChild, 'exit'); tlsChild.send({ op: 'boot', config: { ...config, publishTls: ['-c', 'http.sslCAInfo=/nonexistent'] } }); assert.deepEqual((await refused)[0], { ok: false, error: 'invalid-host-request' }); assert.equal((await gone)[0], 2); assert.equal((await ask({ op: 'boot', config })).ok, true); const unknown = 'not-a-ticket'; assert.deepEqual(await ask({ op: 'publishPush', ticket: unknown, id: 1 }), { ok: false, error: 'publish-unknown', id: 1 }); assert.deepEqual(await ask({ op: 'publishResult', ticket: unknown, id: 2 }), { ok: false, error: 'publish-unknown', id: 2 }); assert.deepEqual(await ask({ op: 'recordPublish', ticket: unknown, pushed: { ref: 'refs/heads/x/y', commit: A, previous: null }, id: 3 }), { ok: false, error: 'publish-unknown', id: 3 }); assert.deepEqual(await ask({ op: 'publishRefused', ticket: unknown, args: { code: 'x' }, id: 4 }), { ok: false, error: 'publish-unknown', id: 4 }); assert.deepEqual(await ask({ op: 'checkPublish', cap: 'not-a-cap', args: { task_ref: TASK, project: 'stack', branch: 'feat' }, id: 5 }), { ok: false, error: 'unauthenticated', id: 5 }); const exit = once(child, 'exit'); child.send({ op: 'close' }); assert.equal((await exit)[0], 0); });