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.
This commit is contained in:
jason.woltje committed 2026-10-20 14:51:54 -05:00
1 parent 286bf598c0
commit a2c387dba6
9 files changed
+369 -56

No files matched your search

+100 -5
View File
@@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path';
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs'; import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs'; import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.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. // Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([ export const ACTIONS = Object.freeze([
@@ -193,6 +193,8 @@ export class Broker {
#received = new Set(); #received = new Set();
// Row 137 (#1631): landings checkLand let start, by ticket, until recordLand or landRefused ends them. // Row 137 (#1631): landings checkLand let start, by ticket, until recordLand or landRefused ends them.
#landings = new Map(); #landings = new Map();
// Row 199: a publish between checkPublish and recordPublish or publishRefused.
#publishes = new Map();
#secretCheck; #secretCheck;
#credentialStatus; #credentialStatus;
#requirements; #requirements;
@@ -797,7 +799,12 @@ export class Broker {
// records `?? null` and the current holder reads undefined. // records `?? null` and the current holder reads undefined.
if (action === 'role.revoke' && (context.holderRun ?? null) !== (this.#holder(s.business, target)?.holder_run ?? null)) if (action === 'role.revoke' && (context.holderRun ?? null) !== (this.#holder(s.business, target)?.holder_run ?? null))
fail('decision-mismatch'); 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. // The raise's context: its target and approval option, from the decision.raise action.allowed.
#raised(business, decision) { #raised(business, decision) {
@@ -860,13 +867,14 @@ export class Broker {
if (body.commit !== push.commit) fail('verdict-stale'); 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. // 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) { #latestPush(business, task) {
const r = this.#store.get( 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, business,
task, 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 // 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. // 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); this.#landings.delete(ticket);
return code; 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 // 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. // no automatic reply. It goes to the role in the thread the decision was raised in.
#landMessage(s, decision, body) { #landMessage(s, decision, body) {
@@ -1700,6 +1788,12 @@ export class Broker {
by, 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(); const did = randomUUID();
this.#store.run( 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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', '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, cls,
a.action, a.action,
route, route,
a.question, question,
JSON.stringify(a.options), JSON.stringify(a.options),
a.recommendation, a.recommendation,
a.task_ref ?? null, a.task_ref ?? null,
@@ -1734,6 +1828,7 @@ export class Broker {
approvalChoice, approvalChoice,
holderRun: holderRun:
a.action === 'role.revoke' ? (this.#holder(s.business, a.target)?.holder_run ?? null) : null, 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), a.task_ref ?? (taskRef(a.target) ? a.target : did),
); );
+6 -2
View File
@@ -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 => { 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 action of WAITS) {
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }]) { 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)}`); assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, `${action} ${JSON.stringify(args)}`);
} }
} }
+2 -2
View File
@@ -22,8 +22,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
"import { MERGE, LandingRefusal, readLanding } from './landing.mjs';", "import { MERGE, LandingRefusal, readLanding } from './landing.mjs';",
// Row 141 (#1635): the business ID_PATTERN for instance names. The vocabulary is already in // 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 // RUNNER_FILES and imports nothing. Row 155 (#1649) reads THREAD_DEFINITION and END_PROTECTED
// from it too. // from it too. Row 199 (#1697) reads the D9c fork parse, forkProblem and forkUrl, from it.
"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';",
]); ]);
assert.doesNotMatch(source, /\bimport\s*\(/); assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/); assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
+201
View File
@@ -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: '[email protected]', 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);
});
+8 -44
View File
@@ -10,6 +10,7 @@ import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs"; import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs"; import { loadRole } from "./role.mjs";
import { checkPlugins } from "../../plugins/src/kinds.mjs"; import { checkPlugins } from "../../plugins/src/kinds.mjs";
import { forkProblem, giteaName, parseGiteaUrl } from "./vocabulary.mjs";
import { import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs"; } 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 // 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. // 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 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}$/; 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 // Row 199 (design D9c): the parse is vocabulary.mjs's, where the broker reads it; this wraps it with
// encoded dot, userinfo, a query, a fragment, an upper-case host or an explicit :443 each refuse, and the // the loader's refusals. Returns {url, owner, repo}.
// path checked is the path used. With repo, the path is exactly /OWNER/REPO (one ".git" stripped);
// without, it is "/". Returns {url, owner, repo}.
export function httpsUrl(value, where, { repo = false } = {}) { export function httpsUrl(value, where, { repo = false } = {}) {
let url = null; const parsed = parseGiteaUrl(value, { repo });
try { if (parsed.problem === "form") refuse(`${where} must be a normalized https URL with no user, query or fragment`);
url = typeof value === "string" ? new URL(value) : null; if (parsed.problem === "instance") refuse(`${where} must name the Gitea instance only, https://HOST[:PORT]`);
} catch { if (parsed.problem === "repository") refuse(`${where} must name a repository, https://HOST/OWNER/REPO`);
url = null; return parsed;
}
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]/<user>/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;
} }
function checkIdentity(identity, where) { function checkIdentity(identity, where) {
+47
View File
@@ -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 // 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. // organization write may give an instance one of them.
export const END_PROTECTED = Object.freeze([THREAD_DEFINITION]); 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]/<user>/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;
}
@@ -4,7 +4,7 @@ import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { join } from "node:path"; import { join } from "node:path";
import { BusinessError, parseCredentialRef, validateBusinessDocument } from "../src/index.mjs"; 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"; import { businessDoc, FAR_DATE, REPO_ROLES, tmp } from "./helpers.mjs";
function refuses(fn, pattern) { function refuses(fn, pattern) {
+2 -1
View File
@@ -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. gated raise, and every hard wait, goes to the human through the inbox.
The hard waits are `spend`, `data.delete`, `network.expose`, The hard waits are `spend`, `data.delete`, `network.expose`,
`account.create`, `credential.mint`, `message.external`, `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: A `role.revoke` raise whose target is the assistant is a hard wait too:
it goes to the human, cited or not. it goes to the human, cited or not.
+2 -1
View File
@@ -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. gated raise, and every hard wait, goes to the human through the inbox.
The hard waits are `spend`, `data.delete`, `network.expose`, The hard waits are `spend`, `data.delete`, `network.expose`,
`account.create`, `credential.mint`, `message.external`, `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: A `role.revoke` raise whose target is the assistant is a hard wait too:
it goes to the human, cited or not. it goes to the human, cited or not.