Candidate diff and manifest against 66ea40580; messaging suite now 12 tests; gate green; 21/21 mutants caught (m16, m17 added). No push. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
521 lines
25 KiB
Diff
521 lines
25 KiB
Diff
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
|
|
index 64689c573..f10e11b37 100644
|
|
--- a/packages/bus/src/broker.mjs
|
|
+++ b/packages/bus/src/broker.mjs
|
|
@@ -149,6 +149,18 @@ export class Broker {
|
|
}
|
|
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
|
|
fail('invalid-arbiter');
|
|
+ // Row 120 (#1614): a role definition's optional messaging lists. A malformed one refuses at boot.
|
|
+ for (const d of Object.values(b.definitions ?? {})) {
|
|
+ const m = d?.messaging;
|
|
+ if (m === undefined || m === null) continue;
|
|
+ if (
|
|
+ !object(m) ||
|
|
+ !['sendTo', 'receiveFrom'].every(
|
|
+ (k) => Array.isArray(m[k]) && m[k].every((r) => id(r) && r !== 'tester'),
|
|
+ )
|
|
+ )
|
|
+ fail('invalid-messaging');
|
|
+ }
|
|
}
|
|
}
|
|
#now() {
|
|
@@ -313,6 +325,21 @@ export class Broker {
|
|
? 'cross-role'
|
|
: 'gated';
|
|
}
|
|
+ // Row 120 (#1614, REQ-ASSIST-2). A role definition may carry messaging { sendTo, receiveFrom },
|
|
+ // naming the role definitions it may send to and receive from; "human" is the user. A definition
|
|
+ // without the lists is unlimited, as before. The sender's lists are checked first, then the
|
|
+ // target's, so a tester or the human sending to a limited role is refused unless listed. Every
|
|
+ // path that stores a message from a role checks it: message.send, review.request, review.verdict.
|
|
+ #checkMessaging(s, to) {
|
|
+ const b = this.#business(s.business);
|
|
+ const definition = (role) => b.definitions?.[b.roles[role]?.definition];
|
|
+ const sender = s.human ? 'human' : s.tester ? 'tester' : b.roles[s.role]?.definition;
|
|
+ const target = to === 'human' || to === 'tester' ? to : b.roles[to]?.definition;
|
|
+ const out = s.human || s.tester ? undefined : definition(s.role)?.messaging;
|
|
+ if (out && !out.sendTo.includes(target)) fail('message-recipient-refused');
|
|
+ const inbound = to === 'human' || to === 'tester' ? undefined : definition(to)?.messaging;
|
|
+ if (inbound && !inbound.receiveFrom.includes(sender)) fail('message-sender-refused');
|
|
+ }
|
|
#decision(s, id) {
|
|
const d = this.#store.get('SELECT * FROM decisions WHERE id=? AND business=?', id, s.business);
|
|
if (!d) fail('decision-not-found');
|
|
@@ -961,6 +988,7 @@ export class Broker {
|
|
if (a.to === 'tester') fail('unknown-role');
|
|
}
|
|
if (a.to !== 'human' && a.to !== 'tester' && !Object.hasOwn(b.roles, a.to)) fail('unknown-role');
|
|
+ this.#checkMessaging(s, a.to);
|
|
string(a.body, 32768);
|
|
if (
|
|
a.class !== undefined &&
|
|
@@ -1064,6 +1092,8 @@ export class Broker {
|
|
);
|
|
fail(older ? 'stale-commit' : 'unknown-commit');
|
|
}
|
|
+ // REQ-ASSIST-2 has no approval exception: a review request is a message to the reviewer.
|
|
+ this.#checkMessaging(s, a.to);
|
|
const mid = randomUUID();
|
|
// Row 105 (#1599): the run holding the reviewer role, or null when nobody holds it and the
|
|
// request waits for a claim. #reviewMessage routes on the same holder.
|
|
@@ -1105,6 +1135,8 @@ export class Broker {
|
|
)
|
|
)
|
|
fail('verdict-exists');
|
|
+ // The verdict is a message to the role that asked, so it meets that role's receive list.
|
|
+ this.#checkMessaging(s, r.from);
|
|
const mid = randomUUID();
|
|
const eid = this.#event(
|
|
s,
|
|
diff --git a/packages/bus/tests/messaging.test.mjs b/packages/bus/tests/messaging.test.mjs
|
|
new file mode 100644
|
|
index 000000000..a1d6f9e53
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/messaging.test.mjs
|
|
@@ -0,0 +1,287 @@
|
|
+import test from 'node:test';
|
|
+import assert from 'node:assert/strict';
|
|
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
+import { tmpdir } from 'node:os';
|
|
+import { join } from 'node:path';
|
|
+import { fileURLToPath } from 'node:url';
|
|
+import { Store } from '../src/store.mjs';
|
|
+import { Broker } from '../src/broker.mjs';
|
|
+
|
|
+// Row 120 (#1614, REQ-ASSIST-2): the broker limits who a role sends to and hears from.
|
|
+// The assistant's lists are read from the shipped roles/assistant.json, so the test pins the file
|
|
+// the stack runs, not a copy of it.
|
|
+const shipped = (name) =>
|
|
+ JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
|
+const assistantLists = shipped('assistant').messaging;
|
|
+
|
|
+const within = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
|
+// Instance names are the business's; the lists name definitions.
|
|
+const roles = {
|
|
+ chief: { definition: 'chief-of-staff', ...within },
|
|
+ assistant: { definition: 'assistant', ...within },
|
|
+ pm: { definition: 'pm', ...within },
|
|
+ coder: {
|
|
+ definition: 'coder',
|
|
+ authority: { withinRole: ['message.send', 'git.push.working', 'review.request'], crossRole: [] },
|
|
+ },
|
|
+ scribe: { definition: 'scribe', ...within },
|
|
+ warden: { definition: 'warden', ...within },
|
|
+ reviewer: {
|
|
+ definition: 'reviewer',
|
|
+ authority: { withinRole: ['message.send', 'review.verdict'], crossRole: [] },
|
|
+ },
|
|
+};
|
|
+const definitions = {
|
|
+ 'chief-of-staff': {},
|
|
+ assistant: { messaging: assistantLists },
|
|
+ pm: {},
|
|
+ coder: {},
|
|
+ reviewer: {},
|
|
+ // Only a send limit: tells a dropped sender-side check apart from the target-side check.
|
|
+ scribe: { messaging: { sendTo: ['pm'], receiveFrom: ['human', 'pm', 'coder', 'warden', 'chief-of-staff'] } },
|
|
+ // Only a receive limit: the same for the target side.
|
|
+ warden: { messaging: { sendTo: ['human', 'pm', 'coder', 'scribe'], receiveFrom: ['pm'] } },
|
|
+};
|
|
+const demo = (extra = {}) => ({
|
|
+ demo: {
|
|
+ id: 'demo',
|
|
+ human: 'jason',
|
|
+ arbiters: { technical: 'pm', delivery: 'chief' },
|
|
+ roles,
|
|
+ definitions,
|
|
+ projects: { stack: { root: '/nonexistent' } },
|
|
+ vars: { 'bus.tester': true },
|
|
+ ...extra,
|
|
+ },
|
|
+});
|
|
+
|
|
+function setup(t, businesses = demo()) {
|
|
+ const root = mkdtempSync(join(tmpdir(), 'bus-messaging-'));
|
|
+ const store = new Store(root);
|
|
+ t.after(() => {
|
|
+ store.close();
|
|
+ rmSync(root, { recursive: true, force: true });
|
|
+ });
|
|
+ const b = new Broker({ store, businesses });
|
|
+ const caps = {};
|
|
+ for (const role of Object.keys(roles)) {
|
|
+ caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
|
|
+ b.request(caps[role], { verb: 'role.claim' });
|
|
+ }
|
|
+ caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
+ caps.tester = b.bindTester({ business: 'demo' });
|
|
+ const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
|
+ const send = (from, to, extra = {}) =>
|
|
+ call(from, 'message.send', { to, body: from === 'tester' ? '[mos test] hello' : 'hello', ...extra });
|
|
+ const count = (sql) => store.get(sql).n;
|
|
+ const refusals = () =>
|
|
+ store
|
|
+ .all("SELECT actor_role, body FROM events WHERE kind='action.refused' ORDER BY seq")
|
|
+ .map((e) => [e.actor_role, JSON.parse(e.body).code]);
|
|
+ return { b, store, caps, call, send, count, refusals };
|
|
+}
|
|
+
|
|
+const RECIPIENT = 'message-recipient-refused';
|
|
+const SENDER = 'message-sender-refused';
|
|
+
|
|
+test('the assistant sends to the user and to the chief of staff only', (t) => {
|
|
+ const { send, store } = setup(t);
|
|
+ assert.deepEqual(assistantLists, { sendTo: ['human', 'chief-of-staff'], receiveFrom: ['human', 'chief-of-staff'] });
|
|
+ for (const to of ['human', 'chief']) {
|
|
+ const sent = send('assistant', to);
|
|
+ const row = store.get('SELECT from_role, to_role FROM messages WHERE id=?', sent.id);
|
|
+ assert.deepEqual({ ...row }, { from_role: 'assistant', to_role: to });
|
|
+ }
|
|
+});
|
|
+
|
|
+test('an assistant send to any other role, or to the tester, is refused and logged', (t) => {
|
|
+ const { send, store, count, refusals } = setup(t);
|
|
+ const asked = send('tester', 'pm', { class: 'REQUEST' });
|
|
+ const messages = count('SELECT count(*) n FROM messages');
|
|
+ const allowed = count("SELECT count(*) n FROM events WHERE kind='action.allowed'");
|
|
+ const others = ['pm', 'coder', 'scribe', 'warden'];
|
|
+ for (const to of others) assert.throws(() => send('assistant', to), { code: RECIPIENT }, to);
|
|
+ // A reply to a real tester message is the only way past the tester gate; the list still refuses it.
|
|
+ assert.throws(() => send('assistant', 'tester', { in_reply_to: asked.id }), { code: RECIPIENT }, 'tester');
|
|
+ assert.deepEqual(
|
|
+ refusals(),
|
|
+ [...others, 'tester'].map(() => ['assistant', RECIPIENT]),
|
|
+ );
|
|
+ assert.equal(count('SELECT count(*) n FROM messages'), messages, 'no message stored');
|
|
+ assert.equal(
|
|
+ count("SELECT count(*) n FROM events WHERE kind='action.allowed'"),
|
|
+ allowed,
|
|
+ 'a refused send consumes no authority',
|
|
+ );
|
|
+ assert.equal(store.get("SELECT count(*) n FROM messages WHERE from_role='assistant'").n, 0);
|
|
+});
|
|
+
|
|
+test('only the user and the chief of staff may send to the assistant; the tester is refused by default', (t) => {
|
|
+ const { send, store, count, refusals } = setup(t);
|
|
+ for (const from of ['human', 'chief']) {
|
|
+ const sent = send(from, 'assistant');
|
|
+ assert.equal(store.get('SELECT to_role FROM messages WHERE id=?', sent.id).to_role, 'assistant');
|
|
+ }
|
|
+ const messages = count('SELECT count(*) n FROM messages');
|
|
+ // Senders without a list of their own, so only the assistant's receive list can refuse them.
|
|
+ const others = ['pm', 'coder', 'tester'];
|
|
+ for (const from of others) assert.throws(() => send(from, 'assistant'), { code: SENDER }, from);
|
|
+ assert.deepEqual(refusals(), others.map((from) => [from, SENDER]));
|
|
+ assert.equal(count('SELECT count(*) n FROM messages'), messages, 'no message stored');
|
|
+ assert.equal(count("SELECT count(*) n FROM messages WHERE to_role='assistant' AND from_role='tester'"), 0);
|
|
+});
|
|
+
|
|
+test('a sender-side list alone refuses a send to a role that has no list', (t) => {
|
|
+ const { send, count, refusals } = setup(t);
|
|
+ send('scribe', 'pm');
|
|
+ assert.throws(() => send('scribe', 'coder'), { code: RECIPIENT });
|
|
+ assert.throws(() => send('scribe', 'human'), { code: RECIPIENT });
|
|
+ assert.deepEqual(refusals(), [['scribe', RECIPIENT], ['scribe', RECIPIENT]]);
|
|
+ assert.equal(count("SELECT count(*) n FROM messages WHERE from_role='scribe'"), 1);
|
|
+});
|
|
+
|
|
+test('a target-side list alone refuses a send from a role that has no list', (t) => {
|
|
+ const { send, count, refusals } = setup(t);
|
|
+ send('pm', 'warden');
|
|
+ for (const from of ['coder', 'chief', 'human', 'tester'])
|
|
+ assert.throws(() => send(from, 'warden'), { code: SENDER }, from);
|
|
+ assert.deepEqual(refusals(), ['coder', 'chief', 'human', 'tester'].map((from) => [from === 'human' ? null : from, SENDER]));
|
|
+ assert.equal(count("SELECT count(*) n FROM messages WHERE to_role='warden'"), 1);
|
|
+});
|
|
+
|
|
+test('the sender list is checked before the target list', (t) => {
|
|
+ const { send, refusals } = setup(t);
|
|
+ // The scribe may not send to the warden, and the warden may not hear from the scribe: one code.
|
|
+ assert.throws(() => send('scribe', 'warden'), { code: RECIPIENT });
|
|
+ assert.deepEqual(refusals(), [['scribe', RECIPIENT]]);
|
|
+});
|
|
+
|
|
+test('roles without the field behave exactly as before', (t) => {
|
|
+ const { send, call, count, refusals } = setup(t);
|
|
+ for (const [from, to] of [
|
|
+ ['pm', 'coder'],
|
|
+ ['coder', 'pm'],
|
|
+ ['chief', 'pm'],
|
|
+ ['pm', 'chief'],
|
|
+ ['pm', 'human'],
|
|
+ ['human', 'coder'],
|
|
+ ['tester', 'pm'],
|
|
+ ['tester', 'coder'],
|
|
+ ])
|
|
+ send(from, to);
|
|
+ assert.deepEqual(refusals(), []);
|
|
+ assert.equal(count('SELECT count(*) n FROM messages'), 8);
|
|
+ // A role still answers a tester only in reply, whatever its lists.
|
|
+ assert.throws(() => send('pm', 'tester'), { code: 'tester-reply-required' });
|
|
+ const asked = send('tester', 'coder', { class: 'REQUEST' });
|
|
+ send('coder', 'tester', { in_reply_to: asked.id });
|
|
+ assert.equal(call('pm', 'message.receive').length > 0, true);
|
|
+});
|
|
+
|
|
+test('a business with no role definitions at all is unlimited', (t) => {
|
|
+ const businesses = demo();
|
|
+ delete businesses.demo.definitions;
|
|
+ const { send, refusals } = setup(t, businesses);
|
|
+ for (const [from, to] of [['assistant', 'pm'], ['pm', 'assistant'], ['tester', 'assistant']]) send(from, to);
|
|
+ assert.deepEqual(refusals(), []);
|
|
+});
|
|
+
|
|
+test('a malformed messaging field refuses at boot', (t) => {
|
|
+ const root = mkdtempSync(join(tmpdir(), 'bus-messaging-'));
|
|
+ const store = new Store(root);
|
|
+ t.after(() => {
|
|
+ store.close();
|
|
+ rmSync(root, { recursive: true, force: true });
|
|
+ });
|
|
+ const boot = (messaging) => () =>
|
|
+ new Broker({ store, businesses: demo({ definitions: { ...definitions, pm: { messaging } } }) });
|
|
+ const ok = { sendTo: [], receiveFrom: ['human'] };
|
|
+ boot(ok)();
|
|
+ for (const bad of [
|
|
+ 'human',
|
|
+ [],
|
|
+ { sendTo: [] },
|
|
+ { sendTo: 'human', receiveFrom: [] },
|
|
+ { sendTo: ['tester'], receiveFrom: [] },
|
|
+ { sendTo: [], receiveFrom: ['tester'] },
|
|
+ { sendTo: [''], receiveFrom: [] },
|
|
+ { sendTo: [7], receiveFrom: [] },
|
|
+ ])
|
|
+ assert.throws(boot(bad), { code: 'invalid-messaging' }, JSON.stringify(bad));
|
|
+});
|
|
+
|
|
+// Round 1 F1: review.request and review.verdict store a message too, so the same lists bind them.
|
|
+// REQ-ASSIST-2 has no approval exception, so an approved decision doesn't open a path.
|
|
+const TASK = 'vikunja:1/2';
|
|
+const COMMIT = 'a'.repeat(40);
|
|
+const options = [
|
|
+ { key: 'yes', text: 'Allow' },
|
|
+ { key: 'no', text: 'Decline' },
|
|
+];
|
|
+const pushed = (b, caps) => {
|
|
+ b.checkPush(caps.coder, TASK);
|
|
+ b.recordPush(caps.coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-1-2', commit: COMMIT, previous: null });
|
|
+};
|
|
+// The assistant holds no review.request, so it raises a decision for it, and the human approves.
|
|
+const approved = (call) => {
|
|
+ const d = call('assistant', 'decision.raise', {
|
|
+ action: 'review.request',
|
|
+ target: TASK,
|
|
+ question: 'review?',
|
|
+ options,
|
|
+ recommendation: 'yes',
|
|
+ blocking: false,
|
|
+ });
|
|
+ call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
+ return d.id;
|
|
+};
|
|
+
|
|
+test("the assistant's review.request to a reviewer is refused and logged, even with an approved decision", (t) => {
|
|
+ const { b, caps, call, count, refusals } = setup(t);
|
|
+ pushed(b, caps);
|
|
+ const decision = approved(call);
|
|
+ const messages = count('SELECT count(*) n FROM messages');
|
|
+ const used = "SELECT count(*) n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='review.request'";
|
|
+ const before = count(used);
|
|
+ const ask = () => call('assistant', 'review.request', { task_ref: TASK, commit: COMMIT, to: 'reviewer', decision });
|
|
+ // The refusal leaves the decision unconsumed, so a retry meets the same list, not decision-consumed.
|
|
+ assert.throws(ask, { code: RECIPIENT });
|
|
+ assert.throws(ask, { code: RECIPIENT });
|
|
+ assert.deepEqual(refusals(), [['assistant', RECIPIENT], ['assistant', RECIPIENT]]);
|
|
+ assert.equal(count('SELECT count(*) n FROM messages'), messages, 'no REVIEW-REQUEST stored');
|
|
+ assert.equal(count("SELECT count(*) n FROM events WHERE kind='review.requested'"), 0);
|
|
+ assert.equal(count(used), before, 'a refused request consumes no authority');
|
|
+});
|
|
+
|
|
+test('a review.verdict to the assistant from a role outside its receiveFrom is refused and logged', (t) => {
|
|
+ // A request from before the lists existed: a first broker without the assistant's lists lets it through.
|
|
+ const first = setup(t, demo({ definitions: { ...definitions, assistant: {} } }));
|
|
+ pushed(first.b, first.caps);
|
|
+ const decision = approved(first.call);
|
|
+ const asked = first.call('assistant', 'review.request', { task_ref: TASK, commit: COMMIT, to: 'reviewer', decision });
|
|
+ // A second broker on the same store enforces the shipped lists.
|
|
+ first.call('reviewer', 'role.release');
|
|
+ const b2 = new Broker({ store: first.store, businesses: demo() });
|
|
+ const cap = b2.bindLaunch({ business: 'demo', role: 'reviewer', run: 'reviewer-run-2', harness: 'pi' });
|
|
+ b2.request(cap, { verb: 'role.claim' });
|
|
+ const args = { task_ref: TASK, request: asked.request, verdict: 'approve', body: 'looks fine' };
|
|
+ assert.throws(() => b2.request(cap, { verb: 'review.verdict', args }), { code: SENDER });
|
|
+ assert.deepEqual(first.refusals(), [['reviewer', SENDER]]);
|
|
+ assert.equal(first.count("SELECT count(*) n FROM messages WHERE class='REVIEW-RESULT'"), 0, 'no REVIEW-RESULT stored');
|
|
+ assert.equal(first.count("SELECT count(*) n FROM events WHERE kind='review.verdict'"), 0);
|
|
+ assert.equal(
|
|
+ first.count("SELECT count(*) n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='review.verdict'"),
|
|
+ 0,
|
|
+ 'a refused verdict consumes no authority',
|
|
+ );
|
|
+});
|
|
+
|
|
+test('review requests and verdicts between roles without the field are unchanged', (t) => {
|
|
+ const { b, caps, call, store, refusals } = setup(t);
|
|
+ pushed(b, caps);
|
|
+ const asked = call('coder', 'review.request', { task_ref: TASK, commit: COMMIT, to: 'reviewer' });
|
|
+ const verdict = call('reviewer', 'review.verdict', { task_ref: TASK, request: asked.request, verdict: 'approve', body: 'ok' });
|
|
+ assert.equal(store.get('SELECT to_role FROM messages WHERE id=?', asked.message).to_role, 'reviewer');
|
|
+ assert.equal(store.get('SELECT to_role FROM messages WHERE id=?', verdict.message).to_role, 'coder');
|
|
+ assert.deepEqual(refusals(), []);
|
|
+});
|
|
diff --git a/packages/business/src/role.mjs b/packages/business/src/role.mjs
|
|
index 37f99ce14..b990e8e12 100644
|
|
--- a/packages/business/src/role.mjs
|
|
+++ b/packages/business/src/role.mjs
|
|
@@ -16,7 +16,8 @@ import {
|
|
|
|
const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
|
|
const V1_KEYS = ["roleVersion", "name", "tools", "network"];
|
|
-const V2_KEYS = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
|
|
+const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
|
|
+const V2_KEYS = [...V2_REQUIRED, "messaging"];
|
|
|
|
function checkTools(tools) {
|
|
if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names');
|
|
@@ -45,6 +46,24 @@ function checkAuthority(authority) {
|
|
return lists;
|
|
}
|
|
|
|
+// REQ-ASSIST-2. Optional: a role with no "messaging" key may message anyone and be messaged by
|
|
+// anyone the broker's other rules allow. With it, the broker refuses a send to a role outside
|
|
+// sendTo and a send from a role outside receiveFrom. Entries name role definitions, since a role
|
|
+// file can't know a business's instance names; "human" names the user. The tester is never a
|
|
+// role, so it can't be listed.
|
|
+function checkMessaging(messaging) {
|
|
+ requireObject(messaging, 'role "messaging"');
|
|
+ rejectUnknownKeys(messaging, ["sendTo", "receiveFrom"], 'role "messaging"');
|
|
+ const lists = {};
|
|
+ for (const key of ["sendTo", "receiveFrom"]) {
|
|
+ lists[key] = requireDistinctList(messaging[key], `role messaging.${key}`, (entry) => {
|
|
+ requireId(entry, `role messaging.${key} entry`);
|
|
+ if (entry === "tester") refuse(`messaging.${key} lists tester, which is never a role`);
|
|
+ });
|
|
+ }
|
|
+ return lists;
|
|
+}
|
|
+
|
|
function checkGiteaScopes(scopes) {
|
|
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
|
|
const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
|
|
@@ -103,7 +122,8 @@ function checkContract(contract, file) {
|
|
|
|
// Validate a parsed role document read from `file`. Returns a frozen role:
|
|
// { roleVersion, name, title, contract, contractPath, tools, network,
|
|
-// authority: { withinRole, crossRole }, credentials: [{ service, scopes }] }.
|
|
+// authority: { withinRole, crossRole }, credentials: [{ service, scopes }],
|
|
+// messaging: { sendTo, receiveFrom } | null }.
|
|
export function validateRoleDocument(document, file) {
|
|
requireObject(document, "role");
|
|
if (document.roleVersion !== 1 && document.roleVersion !== 2) refuse('role "roleVersion" must be 1 or 2');
|
|
@@ -116,10 +136,10 @@ export function validateRoleDocument(document, file) {
|
|
const network = document.network === undefined ? "none" : checkNetwork(document.network);
|
|
return deepFreeze({
|
|
roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network,
|
|
- authority: { withinRole: [], crossRole: [] }, credentials: [],
|
|
+ authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null,
|
|
});
|
|
}
|
|
- for (const key of V2_KEYS) {
|
|
+ for (const key of V2_REQUIRED) {
|
|
if (document[key] === undefined) refuse(`role version 2 requires "${key}"`);
|
|
}
|
|
return deepFreeze({
|
|
@@ -132,6 +152,7 @@ export function validateRoleDocument(document, file) {
|
|
network: checkNetwork(document.network),
|
|
authority: checkAuthority(document.authority),
|
|
credentials: checkCredentials(document.credentials),
|
|
+ messaging: document.messaging === undefined ? null : checkMessaging(document.messaging),
|
|
});
|
|
}
|
|
|
|
diff --git a/packages/business/tests/assistant.test.mjs b/packages/business/tests/assistant.test.mjs
|
|
index ce2648fab..9cdbff49a 100644
|
|
--- a/packages/business/tests/assistant.test.mjs
|
|
+++ b/packages/business/tests/assistant.test.mjs
|
|
@@ -14,6 +14,11 @@ const APPROVED = Object.freeze({
|
|
network: "api-only",
|
|
authority: Object.freeze({ withinRole: Object.freeze(["message.send"]), crossRole: Object.freeze([]) }),
|
|
credentials: Object.freeze([]),
|
|
+ // REQ-ASSIST-2: the user and the chief of staff, both ways. The broker enforces these lists (row 120).
|
|
+ messaging: Object.freeze({
|
|
+ sendTo: Object.freeze(["human", "chief-of-staff"]),
|
|
+ receiveFrom: Object.freeze(["human", "chief-of-staff"]),
|
|
+ }),
|
|
});
|
|
|
|
const INSTANCE = "assistant";
|
|
@@ -75,6 +80,16 @@ test("the assistant's authority is message.send within role and nothing else", (
|
|
for (const action of ["task.create", "task.assign", "role.launch"]) assert.equal(classify(record, action), "gated", action);
|
|
});
|
|
|
|
+test("the assistant talks to the user and the chief of staff only, and no other shipped role is limited", () => {
|
|
+ const role = loadRole(REPO_ROLES, "assistant");
|
|
+ assert.deepEqual(role.messaging, { sendTo: ["human", "chief-of-staff"], receiveFrom: ["human", "chief-of-staff"] });
|
|
+ assert.ok(Object.isFrozen(role.messaging.sendTo));
|
|
+ // Limiting another role is a policy change of its own; the shipped chief of staff and PM stay open.
|
|
+ for (const name of ["chief-of-staff", "pm", "cto", "coder", "reviewer", "researcher"]) {
|
|
+ assert.equal(loadRole(REPO_ROLES, name).messaging, null, name);
|
|
+ }
|
|
+});
|
|
+
|
|
test("a business grant through limits does not widen the role", () => {
|
|
const record = resolved(loadFixture((doc) => {
|
|
doc.roles[INSTANCE].vars = { "limits.authority": ["message.send", "task.create", "role.launch"] };
|
|
diff --git a/packages/business/tests/role.test.mjs b/packages/business/tests/role.test.mjs
|
|
index 150be7ec2..606a82636 100644
|
|
--- a/packages/business/tests/role.test.mjs
|
|
+++ b/packages/business/tests/role.test.mjs
|
|
@@ -87,6 +87,7 @@ test("version 1 files keep loading with no authority", () => {
|
|
assert.equal(role.roleVersion, 1);
|
|
assert.equal(role.contractPath, null);
|
|
assert.deepEqual(role.authority, { withinRole: [], crossRole: [] });
|
|
+ assert.equal(role.messaging, null);
|
|
assert.deepEqual(role.tools, ["read", "grep", "find", "ls", "bash"]);
|
|
const dir = tmp();
|
|
const file = writeJson(join(dir, "plain.json"), { roleVersion: 1, name: "plain", tools: ["read"] });
|
|
@@ -131,6 +132,25 @@ test("authority: closed vocabulary, no gated-only action, no overlap", () => {
|
|
refuses(() => check({ authority: { withinRole: [], crossRole: [], gated: [] } }), /unsupported role "authority" key/);
|
|
});
|
|
|
|
+test("messaging: optional, role definitions and human, never the tester", () => {
|
|
+ assert.equal(check({}).messaging, null);
|
|
+ const role = check({ messaging: { sendTo: ["human", "chief-of-staff"], receiveFrom: [] } });
|
|
+ assert.deepEqual(role.messaging, { sendTo: ["human", "chief-of-staff"], receiveFrom: [] });
|
|
+ assert.ok(Object.isFrozen(role.messaging.sendTo));
|
|
+ refuses(() => check({ messaging: "human" }), /role "messaging" must be a JSON object/);
|
|
+ refuses(() => check({ messaging: { sendTo: [] } }), /messaging\.receiveFrom must be an array/);
|
|
+ refuses(() => check({ messaging: { receiveFrom: [] } }), /messaging\.sendTo must be an array/);
|
|
+ refuses(() => check({ messaging: { sendTo: [], receiveFrom: [], extra: [] } }), /unsupported role "messaging" key/);
|
|
+ refuses(() => check({ messaging: { sendTo: ["pm", "pm"], receiveFrom: [] } }), /duplicate entry in role messaging\.sendTo/);
|
|
+ refuses(() => check({ messaging: { sendTo: [], receiveFrom: ["not a role"] } }), /messaging\.receiveFrom entry/);
|
|
+ refuses(() => check({ messaging: { sendTo: [7], receiveFrom: [] } }), /messaging\.sendTo entry/);
|
|
+ refuses(() => check({ messaging: { sendTo: ["tester"], receiveFrom: [] } }), /messaging\.sendTo lists tester/);
|
|
+ refuses(() => check({ messaging: { sendTo: [], receiveFrom: ["tester"] } }), /messaging\.receiveFrom lists tester/);
|
|
+ // Version 1 has no messaging key.
|
|
+ const file = writeJson(join(tmp(), "v1m.json"), { roleVersion: 1, name: "v1m", tools: ["read"], messaging: { sendTo: [], receiveFrom: [] } });
|
|
+ refuses(() => loadRoleFile(file), /unsupported role key: "messaging"/);
|
|
+});
|
|
+
|
|
test("credentials: Gitea scopes", () => {
|
|
const gitea = (scopes) => check({ credentials: [{ service: "gitea", scopes }] });
|
|
assert.deepEqual(gitea(["write:repository", "read:user"]).credentials[0].scopes, ["write:repository", "read:user"]);
|
|
diff --git a/roles/assistant.json b/roles/assistant.json
|
|
index 286b879f0..1208051d5 100644
|
|
--- a/roles/assistant.json
|
|
+++ b/roles/assistant.json
|
|
@@ -9,5 +9,9 @@
|
|
"withinRole": ["message.send"],
|
|
"crossRole": []
|
|
},
|
|
- "credentials": []
|
|
+ "credentials": [],
|
|
+ "messaging": {
|
|
+ "sendTo": ["human", "chief-of-staff"],
|
|
+ "receiveFrom": ["human", "chief-of-staff"]
|
|
+ }
|
|
}
|