From a2c387dba64f6d40f0f8461178265e8c23189c65 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 20 Oct 2026 14:51:54 -0500 Subject: [PATCH] bus: row 199 broker publish authority, the decision binds the latest push (pushCommit), checkPublish builds the agent's fork, recordPublish spends the decision; the D9c parse moves to vocabulary.mjs to keep the broker's import closure (#1697) git.push.remote is a hard wait. decision.raise records the task's latest push commit and refuses no-push without one; the authority check refuses decision-mismatch when the latest push moved. checkPublish refuses decision-consumed, remote-not-granted, remote-host-mismatch, remote-not-fork, no-push, work-not-yours and work-ref, and returns the fork, user, email, source and host-built destination. pm.md and chief-of-staff.md list git.push.remote among the hard waits. --- packages/bus/src/broker.mjs | 105 ++++++++- packages/bus/tests/hard-waits.test.mjs | 8 +- packages/bus/tests/project-runtime.test.mjs | 4 +- packages/bus/tests/publish.test.mjs | 201 ++++++++++++++++++ packages/business/src/business.mjs | 52 +---- packages/business/src/vocabulary.mjs | 47 ++++ .../business/tests/agent-identity.test.mjs | 2 +- roles/chief-of-staff.md | 3 +- roles/pm.md | 3 +- 9 files changed, 369 insertions(+), 56 deletions(-) create mode 100644 packages/bus/tests/publish.test.mjs diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 33d30f058..3d934ef80 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path'; import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs'; import { loadRequirements } from '../../tasks/src/requirements.mjs'; import { MERGE, LandingRefusal, readLanding } from './landing.mjs'; -import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs'; +import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl } from '../../business/src/vocabulary.mjs'; // Kept closed while S1 lands; its vocabulary is the integration authority. export const ACTIONS = Object.freeze([ @@ -193,6 +193,8 @@ export class Broker { #received = new Set(); // Row 137 (#1631): landings checkLand let start, by ticket, until recordLand or landRefused ends them. #landings = new Map(); + // Row 199: a publish between checkPublish and recordPublish or publishRefused. + #publishes = new Map(); #secretCheck; #credentialStatus; #requirements; @@ -797,7 +799,12 @@ export class Broker { // records `?? null` and the current holder reads undefined. if (action === 'role.revoke' && (context.holderRun ?? null) !== (this.#holder(s.business, target)?.holder_run ?? null)) fail('decision-mismatch'); - return { class: cls, decision: d.id }; + // Row 199 (r4 B4): approve at A, push B, publish refuses. + if (action === 'git.push.remote' && (context.pushCommit ?? null) !== (this.#latestPush(s.business, target)?.commit ?? null)) + fail('decision-mismatch'); + return action === 'git.push.remote' + ? { class: cls, decision: d.id, pushCommit: context.pushCommit ?? null } + : { class: cls, decision: d.id }; } // The raise's context: its target and approval option, from the decision.raise action.allowed. #raised(business, decision) { @@ -860,13 +867,14 @@ export class Broker { if (body.commit !== push.commit) fail('verdict-stale'); } // A push is the action.allowed recordPush wrote, the only one for git.push.working with a commit. + // role is the role that pushed (row 199, work-not-yours). #latestPush(business, task) { const r = this.#store.get( - "SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.action')='git.push.working' AND json_type(body,'$.commit')='text' AND subject=? ORDER BY seq DESC LIMIT 1", + "SELECT body, actor_role FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.action')='git.push.working' AND json_type(body,'$.commit')='text' AND subject=? ORDER BY seq DESC LIMIT 1", business, task, ); - return r ? JSON.parse(r.body) : null; + return r ? { ...JSON.parse(r.body), role: r.actor_role } : null; } // Row 93: the bus host's git socket asks before it takes a push's bytes. Nothing is recorded // unless it refuses: the push is recorded, and its authority consumed, once the ref moved. @@ -1000,6 +1008,86 @@ export class Broker { this.#landings.delete(ticket); return code; } + // Row 199 (#1697, design D9 steps 1 to 3, D9b): the bus host's git socket publishes a task's + // recorded work commit to the agent's own fork. checkPublish runs before anything touches the + // network. The run holds its claim; git.push.remote is a hard wait, so it cites a decision the + // human resolved for this task, bound to the task's latest push (#authorityFor). The project must + // be in the agent's publish grant with an upstream; the fork is built from that upstream and the + // agent's own Gitea user, never from the request. The ticket carries the session and decision to + // recordPublish or publishRefused. No token is in the result. + checkPublish(cap, args) { + const s = this.#session(cap); + return this.#store.transaction(() => { + keys(args, ['task_ref', 'project', 'branch', 'decision'], ['task_ref', 'project', 'branch']); + if (!taskRef(args.task_ref) || (args.decision !== undefined && !id(args.decision))) fail('invalid-request'); + identifier(args.project); + string(args.branch, 200); + this.#agent(s); + const { task_ref, project, branch } = args; + const decision = args.decision ?? null; + const result = this.#checkAuthority(s, 'git.push.remote', { decision, target: task_ref }); + const facts = this.#publishFacts(s, task_ref, project, result); + const ticket = randomUUID(); + this.#publishes.set(ticket, { s, task_ref, project, decision, ...facts }); + return { ticket, task_ref, project, fork: facts.fork, user: facts.user, email: facts.email, source: facts.source, + dst: `refs/heads/${facts.user}/${branch}` }; + }); + } + // Steps 2 and 3, checked again at recordPublish. After routing a within-role publish has no + // decision, so the commit is the latest push's (N9); the git socket still checks the work ref tip. + #publishFacts(s, task_ref, project, result) { + if (result.decision && this.#spent(s.business, result.decision)) fail('decision-consumed'); + const b = this.#business(s.business); + const role = b.roles?.[s.role]; + const upstream = b.projects?.[project]?.remote; + if (!role?.identity || !(role.publish ?? []).includes(project) || !upstream) fail('remote-not-granted'); + const problem = forkProblem(upstream, role.identity); + if (problem) fail(problem); + const push = this.#latestPush(s.business, task_ref); + if (!push) fail('no-push'); + if (push.role !== s.role) fail('work-not-yours'); + if (push.project !== project) fail('work-ref'); + const source = result.pushCommit ?? push.commit; + if (source !== push.commit) fail('decision-mismatch'); + const user = role.identity.gitea.user; + return { fork: forkUrl(upstream, user), user, email: role.identity.email, source }; + } + #publish(ticket) { + return (typeof ticket === 'string' && this.#publishes.get(ticket)) || fail('publish-unknown'); + } + // 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. + recordPublish(ticket, pushed) { + const t = this.#publish(ticket); + const done = this.#store.transaction(() => { + keys(pushed, ['ref', 'commit', 'previous'], ['ref', 'commit', 'previous']); + if (!oid(pushed.commit) || (pushed.previous !== null && !oid(pushed.previous))) fail('invalid-request'); + string(pushed.ref, 260); + const result = this.#authorityFor(t.s, 'git.push.remote', { decision: t.decision, target: t.task_ref }); + const facts = this.#publishFacts(t.s, t.task_ref, t.project, result); + if (facts.source !== t.source || pushed.commit !== t.source || facts.fork !== t.fork) fail('ref-moved'); + const { ref, commit, previous } = pushed; + this.#event( + t.s, + 'action.allowed', + { project: t.project, fork: t.fork, ref, commit, previous, action: 'git.push.remote', ...result, target: t.task_ref }, + t.task_ref, + ); + return { task_ref: t.task_ref, fork: t.fork, ref, commit, previous }; + }); + this.#publishes.delete(ticket); + return done; + } + // Ends a publish that didn't push: action.refused with the code on the task's trail. The git + // socket answers the run with the same code. Trusted host only. + publishRefused(ticket, { code } = {}) { + const t = this.#publish(ticket); + if (!/^[a-z-]{1,64}$/.test(code ?? '')) fail('invalid-request'); + this.#store.transaction(() => this.#event(t.s, 'action.refused', { code }, t.task_ref)); + this.#publishes.delete(ticket); + return code; + } // The outcome as a RESULT from the bus, as #notice sends: the runner takes a turn on it and sends // no automatic reply. It goes to the role in the thread the decision was raised in. #landMessage(s, decision, body) { @@ -1700,6 +1788,12 @@ export class Broker { by, ); } + // Row 199 (#1697, design D9 step 1, r4 B4): a publish decision binds one commit, the task's + // latest recorded push now, read here and never taken from the agent. The human sees it. + const pushCommit = + a.action === 'git.push.remote' ? (this.#latestPush(s.business, a.target)?.commit ?? fail('no-push')) : null; + const question = pushCommit ? `${a.question} (commit ${pushCommit})` : a.question; + string(question); const did = randomUUID(); this.#store.run( 'INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,task_ref,requirement_ref,blocking,supersedes,spec_path,spec_commit) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', @@ -1712,7 +1806,7 @@ export class Broker { cls, a.action, route, - a.question, + question, JSON.stringify(a.options), a.recommendation, a.task_ref ?? null, @@ -1734,6 +1828,7 @@ export class Broker { approvalChoice, holderRun: a.action === 'role.revoke' ? (this.#holder(s.business, a.target)?.holder_run ?? null) : null, + ...(pushCommit ? { pushCommit } : {}), }, a.task_ref ?? (taskRef(a.target) ? a.target : did), ); diff --git a/packages/bus/tests/hard-waits.test.mjs b/packages/bus/tests/hard-waits.test.mjs index 205da8978..830d409dd 100644 --- a/packages/bus/tests/hard-waits.test.mjs +++ b/packages/bus/tests/hard-waits.test.mjs @@ -36,10 +36,14 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th }); test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbiter cannot resolve it', async t => { - const f = await fixture(t); + const f = await fixture(t, { roles: { coder: { definition: 'coder', + authority: { withinRole: ['message.send', 'record.write', 'git.push.working'], crossRole: [] } } } }); + // Row 199: a git.push.remote raise binds the task's latest push, so the task has one. + f.broker.recordPush(f.coder, { task_ref: 'vikunja:1/2', project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2', + commit: 'a'.repeat(40), previous: null }); for (const action of WAITS) { for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }]) { - const d = await f.raise({ action, ...args }); + const d = await f.raise({ action, ...(action === 'git.push.remote' ? { target: 'vikunja:1/2' } : {}), ...args }); assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, `${action} ${JSON.stringify(args)}`); } } diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs index 21c49c81b..6906555a2 100644 --- a/packages/bus/tests/project-runtime.test.mjs +++ b/packages/bus/tests/project-runtime.test.mjs @@ -22,8 +22,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n "import { MERGE, LandingRefusal, readLanding } from './landing.mjs';", // Row 141 (#1635): the business ID_PATTERN for instance names. The vocabulary is already in // RUNNER_FILES and imports nothing. Row 155 (#1649) reads THREAD_DEFINITION and END_PROTECTED - // from it too. - "import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';", + // from it too. Row 199 (#1697) reads the D9c fork parse, forkProblem and forkUrl, from it. + "import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl } from '../../business/src/vocabulary.mjs';", ]); assert.doesNotMatch(source, /\bimport\s*\(/); assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/); diff --git a/packages/bus/tests/publish.test.mjs b/packages/bus/tests/publish.test.mjs new file mode 100644 index 000000000..9c7dc27fc --- /dev/null +++ b/packages/bus/tests/publish.test.mjs @@ -0,0 +1,201 @@ +// 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 { 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: 'reviewer', delivery: 'reviewer' }, + projects: { stack: { root: '/nonexistent', remote: upstream }, books: { root: '/nonexistent-books' } }, + roles: { + 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/); +}); + +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('../../business/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); +}); diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs index b780360da..349d30522 100644 --- a/packages/business/src/business.mjs +++ b/packages/business/src/business.mjs @@ -10,6 +10,7 @@ import { checkVars } from "./vars.mjs"; import { parseCredentialRef } from "./credentials.mjs"; import { loadRole } from "./role.mjs"; import { checkPlugins } from "../../plugins/src/kinds.mjs"; +import { forkProblem, giteaName, parseGiteaUrl } from "./vocabulary.mjs"; import { requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze, } from "./util.mjs"; @@ -54,54 +55,17 @@ function checkCredentialMap(map, services, where, store = null) { // Row 199 (#1697, design D8): the agent's own git identity. The email is the one its Gitea user // has, so Gitea links the commits to that user; gitea.url is the instance its pushes go to. -// Gitea's name rule for users and repositories (design D9c): letters, digits, "_", "." and "-", not "." -// or "..". -const giteaName = (name) => typeof name === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(name) && name !== "." && name !== ".."; const EMAIL = /^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,190}\.[A-Za-z]{2,24}$/; const NAME = /^[^\0\n\r<>]{1,100}$/; -// Row 199 (design D9c): an https URL that is already its own normalized form, so a dot segment, an -// encoded dot, userinfo, a query, a fragment, an upper-case host or an explicit :443 each refuse, and the -// path checked is the path used. With repo, the path is exactly /OWNER/REPO (one ".git" stripped); -// without, it is "/". Returns {url, owner, repo}. +// Row 199 (design D9c): the parse is vocabulary.mjs's, where the broker reads it; this wraps it with +// the loader's refusals. Returns {url, owner, repo}. export function httpsUrl(value, where, { repo = false } = {}) { - let url = null; - try { - url = typeof value === "string" ? new URL(value) : null; - } catch { - url = null; - } - const normal = url && (repo ? [url.origin + url.pathname] : [url.origin, url.origin + "/"]); - if (!url || url.protocol !== "https:" || !normal.includes(value)) { - refuse(`${where} must be a normalized https URL with no user, query or fragment`); - } - if (!repo) { - if (url.pathname !== "/") refuse(`${where} must name the Gitea instance only, https://HOST[:PORT]`); - return { url }; - } - const segments = url.pathname.split("/").slice(1); - const name = segments[1]?.endsWith(".git") ? segments[1].slice(0, -4) : segments[1]; - if (segments.length !== 2 || !giteaName(segments[0]) || !giteaName(name)) { - refuse(`${where} must name a repository, https://HOST/OWNER/REPO`); - } - return { url, owner: segments[0], repo: name }; -} - -// Row 199 (design D9 step 2): the agent's fork of an upstream, https://HOST[:PORT]//REPO. The -// owner is always the agent's own Gitea user; nothing else names it. URL.host drops a default port, -// so 443 counts the same on both sides. -export function forkUrl(upstream, user) { - const { url, repo } = httpsUrl(upstream, "remote", { repo: true }); - return `https://${url.host}/${user}/${repo}`; -} - -// The refusal code for publishing `upstream` as an agent with `identity`, or null. Gitea compares -// names without case (lower_name), so the owner check does too. -export function forkProblem(upstream, identity) { - const { url, owner } = httpsUrl(upstream, "remote", { repo: true }); - if (url.host !== httpsUrl(identity.gitea.url, "identity.gitea.url").url.host) return "remote-host-mismatch"; - if (owner.toLowerCase() === identity.gitea.user.toLowerCase()) return "remote-not-fork"; - return null; + const parsed = parseGiteaUrl(value, { repo }); + if (parsed.problem === "form") refuse(`${where} must be a normalized https URL with no user, query or fragment`); + if (parsed.problem === "instance") refuse(`${where} must name the Gitea instance only, https://HOST[:PORT]`); + if (parsed.problem === "repository") refuse(`${where} must name a repository, https://HOST/OWNER/REPO`); + return parsed; } function checkIdentity(identity, where) { diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs index 4e98468dd..bcd1fb273 100644 --- a/packages/business/src/vocabulary.mjs +++ b/packages/business/src/vocabulary.mjs @@ -136,3 +136,50 @@ export const THREAD_DEFINITION = "assistant"; // the human resolved through the CLI, may end or revoke. Row 155 moves the list here, and no // organization write may give an instance one of them. export const END_PROTECTED = Object.freeze([THREAD_DEFINITION]); + +// Row 199 (#1697, design D9c): the Gitea URL parse. It lives here because the broker reads it and +// this file imports nothing. Gitea's name rule for users and repositories: letters, digits, "_", "." +// and "-", not "." or "..". +export const giteaName = (name) => + typeof name === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(name) && name !== "." && name !== ".."; + +// An https URL that is already its own normalized form, so a dot segment, an encoded dot, userinfo, a +// query, a fragment, an upper-case host or an explicit :443 each fail, and the path checked is the +// path used. With repo, the path is exactly /OWNER/REPO (one ".git" stripped); without, it is "/". +// Returns {url, owner, repo}, or {problem} naming the rule that failed: form, instance or repository. +export function parseGiteaUrl(value, { repo = false } = {}) { + let url = null; + try { + url = typeof value === "string" ? new URL(value) : null; + } catch { + url = null; + } + const normal = url && (repo ? [url.origin + url.pathname] : [url.origin, url.origin + "/"]); + if (!url || url.protocol !== "https:" || !normal.includes(value)) return { problem: "form" }; + if (!repo) return url.pathname === "/" ? { url } : { problem: "instance" }; + const segments = url.pathname.split("/").slice(1); + const name = segments[1]?.endsWith(".git") ? segments[1].slice(0, -4) : segments[1]; + if (segments.length !== 2 || !giteaName(segments[0]) || !giteaName(name)) return { problem: "repository" }; + return { url, owner: segments[0], repo: name }; +} + +// Design D9 step 2: the agent's fork of an upstream, https://HOST[:PORT]//REPO, or null for an +// upstream that doesn't parse. The owner is always the agent's own Gitea user; nothing else names it. +// URL.host drops a default port, so 443 counts the same on both sides. +export function forkUrl(upstream, user) { + const { url, repo } = parseGiteaUrl(upstream, { repo: true }); + return url ? `https://${url.host}/${user}/${repo}` : null; +} + +// The refusal code for publishing `upstream` as an agent with `identity`, or null. Gitea compares +// names without case (lower_name), so the owner check does too. An upstream or gitea.url that doesn't +// parse is remote-not-granted: the business loader refuses both, so only a hand-built business gets +// here. +export function forkProblem(upstream, identity) { + const { url, owner } = parseGiteaUrl(upstream, { repo: true }); + const instance = parseGiteaUrl(identity?.gitea?.url).url; + if (!url || !instance || typeof identity.gitea.user !== "string") return "remote-not-granted"; + if (url.host !== instance.host) return "remote-host-mismatch"; + if (owner.toLowerCase() === identity.gitea.user.toLowerCase()) return "remote-not-fork"; + return null; +} diff --git a/packages/business/tests/agent-identity.test.mjs b/packages/business/tests/agent-identity.test.mjs index bbbb62e1f..3ea5876e0 100644 --- a/packages/business/tests/agent-identity.test.mjs +++ b/packages/business/tests/agent-identity.test.mjs @@ -4,7 +4,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { join } from "node:path"; import { BusinessError, parseCredentialRef, validateBusinessDocument } from "../src/index.mjs"; -import { forkProblem, forkUrl } from "../src/business.mjs"; +import { forkProblem, forkUrl } from "../src/vocabulary.mjs"; import { businessDoc, FAR_DATE, REPO_ROLES, tmp } from "./helpers.mjs"; function refuses(fn, pattern) { diff --git a/roles/chief-of-staff.md b/roles/chief-of-staff.md index 0bf2f6359..6ba31d01c 100644 --- a/roles/chief-of-staff.md +++ b/roles/chief-of-staff.md @@ -73,7 +73,8 @@ business's arbiter for its domain. Pass `domain` to `raise_decision`, gated raise, and every hard wait, goes to the human through the inbox. The hard waits are `spend`, `data.delete`, `network.expose`, `account.create`, `credential.mint`, `message.external`, -`git.push.protected` and `deploy`; a citation never moves them. +`git.push.protected`, `git.push.remote` and `deploy`; a citation never +moves them. A `role.revoke` raise whose target is the assistant is a hard wait too: it goes to the human, cited or not. diff --git a/roles/pm.md b/roles/pm.md index 50a95f951..4bf49ad7b 100644 --- a/roles/pm.md +++ b/roles/pm.md @@ -53,7 +53,8 @@ business's arbiter for its domain. Pass `domain` to `raise_decision`, gated raise, and every hard wait, goes to the human through the inbox. The hard waits are `spend`, `data.delete`, `network.expose`, `account.create`, `credential.mint`, `message.external`, -`git.push.protected` and `deploy`; a citation never moves them. +`git.push.protected`, `git.push.remote` and `deploy`; a citation never +moves them. A `role.revoke` raise whose target is the assistant is a hard wait too: it goes to the human, cited or not.