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:
1 parent
286bf598c0
commit
a2c387dba6
9 files changed
+369
-56
No files matched your search
+100
-5
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user