Compare commits
15
Commits
org-redact2
...
org-a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4eed1e219 | ||
|
|
1e1bbaa6dd | ||
|
|
78bda9d6a4 | ||
|
|
6b39af7888 | ||
|
|
96c900d210 | ||
|
|
307fe928ac | ||
|
|
e9a079fcc4 | ||
|
|
7de9cfccc3 | ||
|
|
c3c192677d | ||
|
|
95ee19cc71 | ||
|
|
07dba7f2f5 | ||
|
|
ceee80d5b7 | ||
|
|
fa9bedb09a | ||
|
|
67d6c9ed84 | ||
|
|
c8dbd3f6ec |
No files matched your search
@@ -50,6 +50,7 @@ export const ACTIONS = Object.freeze([
|
||||
'policy.change',
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
'decision.resolve.delivery',
|
||||
// Row 285 (C2-08b, #1790): routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6, below.
|
||||
'library.write',
|
||||
]);
|
||||
@@ -78,6 +79,7 @@ const ROUTED = new Set([
|
||||
'policy.change',
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
'decision.resolve.delivery',
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
'role.hire',
|
||||
'role.fire',
|
||||
@@ -88,6 +90,8 @@ const ROUTED = new Set([
|
||||
export const HARD_WAITS = Object.freeze(ACTIONS.filter((action) => !ROUTED.has(action)));
|
||||
const GATED = new Set(HARD_WAITS);
|
||||
const CLOSED = "('resolved','withdrawn','expired')";
|
||||
// RR (#1873): these actions never take their domain from the raising agent.
|
||||
const DECISION_DOMAINS = Object.freeze({ 'git.merge.protected': 'technical', 'policy.change': 'delivery' });
|
||||
// How many closed decisions `decisions` with list 'recent' returns, newest close first.
|
||||
export const RECENT = 50;
|
||||
// How many replies `replies` returns, oldest first.
|
||||
@@ -5061,6 +5065,20 @@ export class Broker {
|
||||
);
|
||||
return evidence ? JSON.parse(evidence.body) : null;
|
||||
}
|
||||
#checkResolver(s, d) {
|
||||
const b = this.#business(s.business), role = b.roles[s.role];
|
||||
const domain = d.action === 'prd.approve' ? 'technical' : DECISION_DOMAINS[d.action] ?? this.#raised(s.business, d.id)?.domain;
|
||||
// Pre-RR evidence has no domain. Assignments may have changed since raise,
|
||||
// so require both possible rights rather than infer one from today's file.
|
||||
const domains = domain === undefined ? ['technical', 'delivery'] : [domain];
|
||||
for (const kind of domains) {
|
||||
if (!['technical', 'delivery'].includes(kind)) fail('not-resolver');
|
||||
const right = `decision.resolve.${kind}`;
|
||||
if (!role?.authority?.withinRole.includes(right)) {
|
||||
throw new BusError('not-resolver', `instance ${s.role}, role ${role?.definition ?? s.role}, lacks ${right}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Whether an executor already used the decision. decision.raise records context, not permission
|
||||
// consumed by an executor.
|
||||
#spent(business, decision) {
|
||||
@@ -7064,8 +7082,8 @@ export class Broker {
|
||||
if (!choices.includes(a.recommendation)) fail('invalid-recommendation');
|
||||
const approvalChoice = a.approvalChoice ?? 'yes';
|
||||
if (!choices.includes(approvalChoice)) fail('invalid-approval-choice');
|
||||
const domain = a.domain ?? 'delivery';
|
||||
if (!['technical', 'delivery'].includes(domain)) fail('invalid-domain');
|
||||
if (a.domain !== undefined && !['technical', 'delivery'].includes(a.domain)) fail('invalid-domain');
|
||||
const domain = DECISION_DOMAINS[a.action] ?? a.domain ?? 'delivery';
|
||||
// PRD 1.5 REQ-DEC-2 (row 139, #1633): a gated raise citing the project's current spec commit
|
||||
// goes to the arbiter for its domain, like a cross-role one. An uncited gated raise and any
|
||||
// hard wait go to the human. readSpec already refused a stale or unconfirmed citation.
|
||||
@@ -7147,6 +7165,7 @@ export class Broker {
|
||||
decision: did,
|
||||
action: a.action,
|
||||
operation: 'decision.raise',
|
||||
domain,
|
||||
task_ref: a.task_ref ?? null,
|
||||
target: a.target ?? null,
|
||||
approvalChoice,
|
||||
@@ -7213,6 +7232,7 @@ export class Broker {
|
||||
else {
|
||||
this.#agent(s);
|
||||
if (s.role !== d.route_to) fail('not-resolver');
|
||||
if (verb === 'decision.resolve') this.#checkResolver(s, d);
|
||||
}
|
||||
const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
|
||||
if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice))
|
||||
|
||||
@@ -282,7 +282,7 @@ test('REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set
|
||||
const outside = [
|
||||
{ launch: { by: 'coder' } },
|
||||
{ human: 'mallory' },
|
||||
{ arbiters: { technical: 'coder', delivery: 'coder' } },
|
||||
{ arbiters: { technical: 'chief', delivery: 'chief' } },
|
||||
{ roles: {} },
|
||||
{ actor: 'jason' },
|
||||
{ 'launch.by': 'coder' },
|
||||
@@ -1026,7 +1026,7 @@ test('row 194 T8, T9, T18: through the runtime the chief hires and fires with th
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
// A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED.
|
||||
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
|
||||
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
|
||||
writeJson(join(rolesDir, 'aide.json'), {
|
||||
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
|
||||
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
|
||||
|
||||
@@ -18,8 +18,8 @@ export function citationWorld(t, { definition = 'coder', library = true, guard:
|
||||
writeFileSync(settings, JSON.stringify({ replyLimits: { requireCitation: false, citationsPerReply: 20 } }));
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
...(library ? { projects: { books: map } } : {}),
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
|
||||
const limits = () => {
|
||||
try { return resolveReplyLimits({ readable: true, document: JSON.parse(readFileSync(settings, 'utf8')) }); }
|
||||
catch { return resolveReplyLimits({ readable: false }); }
|
||||
|
||||
@@ -18,8 +18,8 @@ test('C2-06 runtime reads changed and unreadable reply limits without restarting
|
||||
// Runtime takes the host's broker projection, not the business validator's unresolved role entries.
|
||||
// No agent action is needed here: use the same empty-authority shape as limits-runtime.test.mjs.
|
||||
const business = { id: 'acme', human: 'jason', projects: checked.projects,
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business }, organization: { dir, rolesDir, system: systemFor(root) } });
|
||||
t.after(() => runtime.close());
|
||||
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -48,8 +48,8 @@ function setup(t) {
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
},
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
@@ -227,7 +227,7 @@ test('ordinary PRD approval and nine non-yes/no choices use the same protocol wi
|
||||
test('ordinary role routes refuse Console resolution and agent answers retain the actual role/run', (t) => {
|
||||
const f = setup(t);
|
||||
for (const route of ['pm', 'chief']) {
|
||||
const id = grantDecision(f.store, { action: 'prd.approve', route, close: null });
|
||||
const id = grantDecision(f.store, { action: 'policy.change', route, close: null });
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
|
||||
f.call(route, 'decision.resolve', { id, choice: 'yes' });
|
||||
|
||||
@@ -30,8 +30,8 @@ async function setup(t, { storeRoot = true } = {}) {
|
||||
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
|
||||
// The bus's resolved business carries the same references; a store one names its derived file.
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
|
||||
roles: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { ...role, credentials: { gitea: { store: true, file: store('coder', 'gitea'), rotateBy: DATE }, vikunja: doc.roles.coder.credentials.vikunja } },
|
||||
reviewer: { ...role, credentials: doc.roles.reviewer.credentials },
|
||||
},
|
||||
@@ -58,7 +58,7 @@ test('row 199: the lifecycle verbs refuse every session but the human, and the t
|
||||
const calls = [];
|
||||
const b = new Broker({
|
||||
store: s,
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, roles: { coder: role } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } },
|
||||
credentialVerbs: (id, verb, args, actor) => (calls.push([verb, actor]), { ok: true }),
|
||||
});
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
@@ -97,7 +97,7 @@ test('row 199: the lifecycle verbs refuse every session but the human, and the t
|
||||
b.request(tester, { verb: 'credentials.revoke', args: args['credentials.revoke'] });
|
||||
assert.deepEqual(calls, [['credentials.issue', 'jason'], ['credentials.revoke', 'jason'], ['credentials.delete', 'jason'], ['credentials.revoke', 'tester']]);
|
||||
// No lifecycle without the runtime's store.
|
||||
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } } } });
|
||||
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } } });
|
||||
const h2 = bare.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
assert.throws(() => bare.request(h2, { verb: 'credentials.revoke', args: args['credentials.revoke'] }), /not-configured/);
|
||||
});
|
||||
@@ -182,8 +182,8 @@ test('#1886: a store root that exists with loose modes still refuses start', asy
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o750 });
|
||||
chmodSync(join(root, 'secrets', 'agents'), 0o750);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: {},
|
||||
roles: { coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: {},
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
|
||||
};
|
||||
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/);
|
||||
});
|
||||
@@ -24,8 +24,8 @@ async function setup(t, limit = 0) {
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'),
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...ROLES }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
|
||||
@@ -76,8 +76,8 @@ test('REQ-LIMIT-4 bindLaunch keeps the account off session.launched, refuses one
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-bind-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } });
|
||||
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...ROLES }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } });
|
||||
const base = { business: 'demo', role: 'coder', harness: 'claude-code', provider: 'anthropic', model: 'opus' };
|
||||
for (const [i, account] of ['Jason Max', '', 5, {}, '-x'].entries())
|
||||
assert.throws(() => broker.bindLaunch({ ...base, run: `bad${i}`, account }), { code: 'invalid-request' }, JSON.stringify(account));
|
||||
|
||||
@@ -13,8 +13,8 @@ const options = [
|
||||
{ key: 'no', text: 'Decline' },
|
||||
];
|
||||
const roles = {
|
||||
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['decision.resolve.delivery', 'message.send'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
};
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles } };
|
||||
|
||||
@@ -22,8 +22,8 @@ const options = [
|
||||
];
|
||||
const pusher = { authority: { withinRole: ['message.send'], crossRole: ['git.push.working'] } };
|
||||
const roles = {
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
// Hears from pm, the technical arbiter, but not from the chief of staff, the delivery arbiter.
|
||||
coder: { definition: 'coder', ...pusher },
|
||||
|
||||
@@ -13,8 +13,8 @@ const options = [
|
||||
{ key: 'no', text: 'Decline' },
|
||||
];
|
||||
const roles = {
|
||||
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['decision.resolve.delivery', 'message.send', 'task.create'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
};
|
||||
const businesses = {
|
||||
|
||||
@@ -48,7 +48,7 @@ function setup(t, { keygen, check, hang = false } = {}) {
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
},
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
|
||||
@@ -17,8 +17,8 @@ test('row 298 runtime: human scope approval writes the file and organization his
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
runtime = await startBroker({ dataRoot: join(root, 'data'),
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
});
|
||||
const human = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -27,8 +27,8 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th
|
||||
const root = mkdtempSync(join(tmpdir(), 'hard-waits-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = authority => ({ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority } } } });
|
||||
const business = authority => ({ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority } } } });
|
||||
for (const action of WAITS) {
|
||||
assert.throws(() => new Broker({ store, businesses: business({ withinRole: [action], crossRole: [] }) }), /invalid-authority/, action);
|
||||
assert.throws(() => new Broker({ store, businesses: business({ withinRole: [], crossRole: [action] }) }), /invalid-authority/, action);
|
||||
@@ -54,19 +54,19 @@ test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbite
|
||||
}
|
||||
}
|
||||
const d = await f.raise({ action: 'data.delete', ...cite(f) });
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
});
|
||||
|
||||
test('REQ-DEC-2: a gated raise citing the current spec goes to the arbiter for its domain; uncited, to the human', async t => {
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'reviewer' },
|
||||
roles: { cto: { definition: 'cto', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, cto: { definition: 'cto', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } } } });
|
||||
f.agent('cto');
|
||||
for (const action of LEFT) {
|
||||
const raise = { action, ...(action === 'role.revoke' ? { target: 'reviewer' } : {}) };
|
||||
assert.deepEqual(routed(f, f.raise(raise)), { class: 'gated', route_to: 'human' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: 'cto' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: 'reviewer' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: action === 'policy.change' ? 'chief' : 'cto' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: action === 'git.merge.protected' ? 'cto' : 'chief' }, action);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -74,15 +74,15 @@ test('REQ-DEC-2: an arbiter-approved cited gated decision authorizes the raiser,
|
||||
const f = await fixture(t, { roles: { pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
f.agent('pm');
|
||||
const d = await f.raise({ action: 'role.revoke', target: 'pm', ...cite(f) });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
|
||||
assert.throws(() => f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' }), /agent-required/);
|
||||
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true });
|
||||
});
|
||||
|
||||
test('REQ-DEC-2: a cited merge raised by merge-gate goes to the technical arbiter; a merge raised without the grant routes by citation', async t => {
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'reviewer' }, roles: {
|
||||
cto: { definition: 'cto', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
cto: { definition: 'cto', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
|
||||
'merge-gate': { definition: 'merge-gate', authority: { withinRole: [], crossRole: ['git.merge.protected'] } },
|
||||
} });
|
||||
const cto = f.agent('cto'), gate = f.agent('merge-gate');
|
||||
@@ -106,7 +106,7 @@ test('REQ-DEC-2: a stale or unconfirmed citation refuses without a decision; an
|
||||
await assert.rejects(f.raise({ action: 'policy.change', ...old, spec_commit: edit }), { code: 'spec-unconfirmed' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
const current = f.save('# Current\n');
|
||||
const own = await f.call(f.reviewer, 'decision.raise', { action: 'policy.change', question: 'Change?',
|
||||
const own = await f.call(f.chief, 'decision.raise', { action: 'policy.change', question: 'Change?',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
|
||||
project: 'stack', spec_path: 'spec/north-star.md', spec_commit: current });
|
||||
assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' });
|
||||
@@ -136,7 +136,7 @@ const allowedCount = f => f.store.get("SELECT count(*) n FROM events WHERE kind=
|
||||
test('Q169: the chief of staff\'s end of the assistant stops every thread run; its own run and another ender\'s are refused', async t => {
|
||||
const f = await fixture(t, { roles: { ...seats,
|
||||
chief: { definition: 'chief-of-staff', ...grants(['role.end']) }, lead: { definition: 'lead', ...grants(['role.end']) } } });
|
||||
const chief = f.agent('chief'), lead = f.agent('lead');
|
||||
const chief = f.chief, lead = f.agent('lead');
|
||||
// No thread is held yet, so there is nothing to end.
|
||||
assert.throws(() => f.call(chief, 'role.revoke', { role: 'assistant', reason: 'Q169' }), /not-held/);
|
||||
const a = held(f, 'a-run'), b = held(f, 'b-run');
|
||||
@@ -158,11 +158,11 @@ test('Q169: the chief of staff\'s end of the assistant stops every thread run; i
|
||||
|
||||
test('Q169: a revoke of the assistant the chief of staff raises goes to the human, cited or not, and no arbiter resolves it', async t => {
|
||||
const f = await fixture(t, { roles: { ...seats, chief: { definition: 'chief-of-staff', ...grants(['role.end']) } } });
|
||||
const chief = f.agent('chief');
|
||||
const chief = f.chief;
|
||||
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }, { ...cite(f), domain: 'delivery' }]) {
|
||||
const d = await revoke(f, chief, 'assistant', args);
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, JSON.stringify(args));
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
}
|
||||
// Resolved by the human, the chief's revoke citing it stops the thread run, once.
|
||||
const a = held(f, 'a-run');
|
||||
@@ -181,8 +181,8 @@ test('Q169: another role\'s cited revoke of the assistant, resolved by an arbite
|
||||
// One raised while the instance's definition wasn't protected went to the arbiter. The same
|
||||
// store under a business that now makes it the assistant still refuses it.
|
||||
const d = await revoke(f, f.coder, 'pm', cite(f));
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
|
||||
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
const now = { ...f.business, roles: { ...f.business.roles, pm: { definition: 'assistant', ...grants([]) } } };
|
||||
const later = new Broker({ store: f.store, businesses: { demo: now } });
|
||||
const coder = later.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder' });
|
||||
@@ -192,7 +192,7 @@ test('Q169: another role\'s cited revoke of the assistant, resolved by an arbite
|
||||
test('Q169: a revoke of the assistant citing a decision the human resolved stops its thread run', async t => {
|
||||
const f = await fixture(t, { roles: seats });
|
||||
const d = await revoke(f, f.coder, 'assistant', cite(f));
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
// Row 155: the recorded holder run (null) and the role_claims one (none) compare equal, so the
|
||||
// revoke reaches the thread claims and stops the held one.
|
||||
@@ -205,8 +205,8 @@ test('Q169: a cited revoke of a role not in END_PROTECTED still goes to the arbi
|
||||
const f = await fixture(t, { roles: seats });
|
||||
f.agent('pm');
|
||||
const d = await revoke(f, f.coder, 'pm', cite(f));
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
|
||||
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true });
|
||||
});
|
||||
|
||||
@@ -216,7 +216,7 @@ test('Q169: a role granted role.revoke at either class still sends a revoke of t
|
||||
boss: { definition: 'boss', ...grants(['role.revoke']) } } });
|
||||
const lead = f.agent('lead'), boss = f.agent('boss');
|
||||
assert.deepEqual(routed(f, await revoke(f, lead, 'assistant', cite(f))), { class: 'gated', route_to: 'human' });
|
||||
assert.deepEqual(routed(f, await revoke(f, lead, 'pm', cite(f))), { class: 'cross-role', route_to: 'reviewer' });
|
||||
assert.deepEqual(routed(f, await revoke(f, lead, 'pm', cite(f))), { class: 'cross-role', route_to: 'chief' });
|
||||
const own = await revoke(f, boss, 'assistant', { choice: 'yes' });
|
||||
assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', own.id).n, 0);
|
||||
|
||||
@@ -12,7 +12,7 @@ export const HUMAN_YES = { op: 'resolved', by: 'jason', choice: 'yes', via: 'cli
|
||||
|
||||
// A credential.mint decision with `grant` in its raise body. `close` is the decision_events row, or null
|
||||
// for an open decision.
|
||||
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', grant, close = HUMAN_YES }) {
|
||||
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', domain = 'delivery', grant, close = HUMAN_YES }) {
|
||||
const id = randomUUID();
|
||||
store.run(
|
||||
'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
@@ -21,7 +21,7 @@ export function grantDecision(store, { business = 'demo', action = 'credential.m
|
||||
store.run(
|
||||
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)",
|
||||
randomUUID(), AT, business, 'chief', 'chief-run', id,
|
||||
JSON.stringify({ decision: id, action, operation: 'decision.raise', approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
|
||||
JSON.stringify({ decision: id, action, operation: 'decision.raise', ...(domain === null ? {} : { domain }), approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
|
||||
);
|
||||
if (close)
|
||||
store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', id, AT, close.op, close.by, close.choice ?? null, close.via ?? null);
|
||||
|
||||
@@ -572,8 +572,8 @@ test('T27: the bus start sweeps <configDir>/secrets/run before it listens, and a
|
||||
await new Promise((r) => dead.on('close', r));
|
||||
writeFileSync(join(run, 'h-dddddd', 'pids'), `${dead.pid} x\n`);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
};
|
||||
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await start();
|
||||
@@ -640,8 +640,8 @@ test('M3: a host_grants record gives Credentials a store reference at start, and
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
};
|
||||
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const hostKeys = (bus) => bus.credentials.status().filter((c) => c.service.startsWith('host.'));
|
||||
|
||||
@@ -65,7 +65,7 @@ test('row 165 runtime: an add and a change reach the organization file with the
|
||||
const rolesDir = rolesCopy(root);
|
||||
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
|
||||
@@ -26,7 +26,7 @@ const A = 'a'.repeat(40);
|
||||
const B = 'b'.repeat(40);
|
||||
const C = 'c'.repeat(40);
|
||||
const roles = {
|
||||
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['message.send', 'decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['git.push.working', 'review.request', 'message.send'], crossRole: [] } },
|
||||
reviewer: { authority: { withinRole: ['review.verdict', 'message.send'], crossRole: [] } },
|
||||
'merge-gate': { authority: { withinRole: [], crossRole: [MERGE] } },
|
||||
@@ -221,15 +221,15 @@ test('REQ-LAND-1: a within-role merge grant refuses not-authorized at the land',
|
||||
assert.deepEqual([...w.merges(), ...w.events('action.refused')], []);
|
||||
});
|
||||
|
||||
test('a cross-role merge that routes to the human, because merge-gate is its own arbiter, lands on the human\'s CLI answer', (t) => {
|
||||
const w = setup(t, { arbiters: { technical: 'merge-gate', delivery: 'merge-gate' } });
|
||||
test('a cross-role merge that routes to the human, because the cto lander is its own arbiter, lands on the human\'s CLI answer', (t) => {
|
||||
const w = setup(t, { arbiters: { technical: 'cto', delivery: 'pm' }, roles: { ...roles, cto: { authority: { withinRole: ['decision.resolve.technical'], crossRole: [MERGE] } } } });
|
||||
w.push(A);
|
||||
w.approve(A);
|
||||
const d = w.raise();
|
||||
const d = w.raise('cto');
|
||||
assert.deepEqual(w.routed(d), { class: 'cross-role', route_to: 'human' });
|
||||
assert.throws(() => w.resolve(d), /human-required/);
|
||||
w.resolve(d, 'yes', 'human');
|
||||
const { ticket, by, via } = w.check({ task_ref: TASK, decision: d.id });
|
||||
const { ticket, by, via } = w.b.checkLand(w.caps.cto, { task_ref: TASK, decision: d.id });
|
||||
assert.deepEqual([by, via], ['jason', 'cli']);
|
||||
w.b.recordLand(ticket, w.land);
|
||||
assert.equal(w.merges()[0].body.class, 'cross-role');
|
||||
|
||||
@@ -119,7 +119,7 @@ const TASK = 'vikunja:1/2';
|
||||
|
||||
test('row 302 a run landRelease released answers launcher-released, and claims nothing again', (t) => {
|
||||
const w = setup(t, {
|
||||
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['git.push.working', 'review.request', 'message.send'], crossRole: [] } },
|
||||
reviewer: { authority: { withinRole: ['review.verdict', 'message.send'], crossRole: [] } },
|
||||
'merge-gate': { authority: { withinRole: [], crossRole: [MERGE] } },
|
||||
|
||||
@@ -43,9 +43,9 @@ async function until(fn, ms = 10000) {
|
||||
}
|
||||
|
||||
const makeBusiness = ({ root, chief = true, instances = null } = {}) => ({
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
// sage-4097 / mos-1584: the chief's declared read credential; no-chief negative fixture stays absent.
|
||||
roles: { ...(chief ? { chief: { definition: 'chief-of-staff', ...role, credentials: { gitea: giteaReference(join(root, 'secrets', 'agents'), 'acme', 'chief', '2099-01-01') } } } : {}), coder: { definition: 'coder', ...role } },
|
||||
roles: { cto: { definition: 'cto', authority: { withinRole: ['decision.resolve.technical'], crossRole: [] } }, pm: { definition: 'pm', authority: { withinRole: ['decision.resolve.delivery'], crossRole: [] } }, ...(chief ? { chief: { definition: 'chief-of-staff', ...role, credentials: { gitea: giteaReference(join(root, 'secrets', 'agents'), 'acme', 'chief', '2099-01-01') } } } : {}), coder: { definition: 'coder', ...role } },
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' } },
|
||||
...(instances ? { launch: { instances } } : {}),
|
||||
});
|
||||
@@ -77,7 +77,7 @@ function world(t, options) {
|
||||
// chief-of-staff instance, fired by boss. The real read grant targets the library project books.
|
||||
function verbDoc(url, root) {
|
||||
return {
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'boss', delivery: 'boss' },
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {
|
||||
boss: { definition: 'pm', authority: chiefAuthority },
|
||||
// sage-4097 / mos-1584: fixture projection carries the chief's own Gitea store reference.
|
||||
|
||||
@@ -31,8 +31,8 @@ async function world(t, { projects, adapter, claimed = true, now = () => NOW, fi
|
||||
const f = scratchRepo(t, files), dataRoot = join(f.root, 'data');
|
||||
mkdirSync(dataRoot);
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const context = { business, project: 'books', dataRoot };
|
||||
let mirror = null, state = null, fetches = 0;
|
||||
if (business.projects.books?.kind === 'library') {
|
||||
@@ -222,8 +222,8 @@ test('real socket awaits lookup results and rechecks rejected query arguments',
|
||||
test('runtime actually injects the lookup adapter, without exposing human library.file', { timeout: 30_000 }, async t => {
|
||||
const f = scratchRepo(t, TEXTS), dataRoot = join(f.root, 'runtime'); mkdirSync(dataRoot);
|
||||
const business = { id: 'acme', human: 'jason', plugins: { harness: ['pi'] }, projects: { books: mappingEntry() },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business } }); t.after(() => runtime.close());
|
||||
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
|
||||
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
|
||||
|
||||
@@ -21,7 +21,7 @@ function world(t, adapter) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-library-sync-')), store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' } }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' } }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const broker = new Broker({ store, businesses: { acme: business }, library: adapter });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'library-sync-agent', harness: 'pi' });
|
||||
@@ -81,7 +81,7 @@ test('C2-03a runtime: the timer syncs each entry with a user at start and on eac
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
mkdirSync(join(configDir, 'secrets', 'acme'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(configDir, 'secrets', 'acme', library.SLOT), TOKEN + '\n', { mode: 0o600 });
|
||||
const business = { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
const business = { id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' }, quiet: mappingEntry() } };
|
||||
const calls = [];
|
||||
// A git that succeeds without fetching: the remote-tracking ref stays absent, so each pass fails.
|
||||
|
||||
@@ -234,8 +234,8 @@ test('C2-02 F2/E6 runtime: human saves/reverts use editor.user; no login never u
|
||||
const pushes = [];
|
||||
const command = (kind, url, options) => { if (kind === 'push') pushes.push(options.user); return bare.command(kind, url, options); };
|
||||
const initial = (await bare.state()).lastFetch;
|
||||
const business = { ...bare.context.business, human: 'jason', roles: { coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' } };
|
||||
const business = { ...bare.context.business, human: 'jason', roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' } };
|
||||
const runtime = await startBroker({ dataRoot: bare.context.dataRoot, businesses: { acme: business },
|
||||
librarySync: { configDir, command, intervalMs: 60000 } });
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,7 @@ function world(t, adapter = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-library-')), store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const broker = new Broker({ store, businesses: { acme: business }, library: adapter });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'library-agent', harness: 'pi' });
|
||||
@@ -68,7 +68,7 @@ test('C2-04 runtime: protectedRoots skips library but retains real code-root cre
|
||||
const f = repo(t), dataRoot = join(f.root, 'data'), token = join(f.root, 'fixture-token');
|
||||
mkdirSync(dataRoot, { mode: 0o700 }); writeFileSync(token, 'made-up-fixture-token-123456', { mode: 0o600 });
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry(), stack: { root: f.repo } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business } });
|
||||
await runtime.close();
|
||||
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
|
||||
@@ -81,7 +81,7 @@ test('C2-04 runtime: protectedRoots skips library but retains real code-root cre
|
||||
test('C2-04 runtime: unsafe existing mirror refuses boot and never publishes a socket', async t => {
|
||||
const f = repo(t), dataRoot = join(f.root, 'data');
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const mirror = join(dataRoot, 'projects/acme/books'); mkdirSync(mirror, { recursive: true });
|
||||
writeFileSync(join(mirror, 'keep'), 'made-up note');
|
||||
await assert.rejects(startBroker({ dataRoot, businesses: { acme: business } }), { code: 'library-git' });
|
||||
@@ -92,7 +92,7 @@ test('C2-04 runtime: unsafe existing mirror refuses boot and never publishes a s
|
||||
test('C2-04 runtime: real library adapters reach the socket and lock waits leave other bus reads responsive', { timeout: 15000 }, async t => {
|
||||
const f = repo(t), dataRoot = join(f.root, 'data');
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business } });
|
||||
t.after(() => runtime.close());
|
||||
const cap = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -26,7 +26,7 @@ const doc = (extra = {}) => ({
|
||||
retired: ['old'],
|
||||
...extra,
|
||||
});
|
||||
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'only-there', delivery: 'only-there' }, roles: { 'only-there': none } };
|
||||
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, 'only-there': none } };
|
||||
|
||||
function setup(t, { organization = null, testerOn = undefined, processing = undefined } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'lifecycle-verbs-'));
|
||||
@@ -349,7 +349,7 @@ test('row 194 V5 retry: a retired name deletes again only after workspace-rename
|
||||
const { transaction } = f.b.lifecycleDelete({ business: 'demo', request });
|
||||
f.done(request, { transaction, refusal: 'workspace-rename-failed' });
|
||||
// The bus reloads after the commit, as the runtime does.
|
||||
const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'coder', delivery: 'chief' } });
|
||||
const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'chief', delivery: 'chief' } });
|
||||
delete next.roles.pm;
|
||||
f.b.reloadBusiness('demo', next);
|
||||
const writes = w.calls.length;
|
||||
|
||||
@@ -13,8 +13,8 @@ import { Broker } from '../src/broker.mjs';
|
||||
|
||||
const shipped = name => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const businesses = () => ({ demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
|
||||
@@ -20,8 +20,8 @@ async function setup(t, plugins, limit = 0) {
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'),
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
|
||||
|
||||
@@ -8,7 +8,7 @@ import {Broker} from '../src/broker.mjs';
|
||||
import {Store} from '../src/store.mjs';
|
||||
test('limits binds actor from human proof and rejects role, reader, a tester set and forged actor',t=>{
|
||||
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-bus-')),store=new Store(root);t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});const calls=[];
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}});
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},vars:{'bus.tester':true},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}});
|
||||
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),role=b.bindLaunch({business:'demo',role:'coder',run:'coder-run',harness:'pi',address:'coder-run'}),reader=b.bindReader({business:'demo'}),tester=b.bindTester({business:'demo'}),patch={revision:'a'.repeat(64),limits:{total:0}};
|
||||
for(const cap of [role,reader])for(const verb of ['limits','limits.set'])assert.throws(()=>b.request(cap,{verb,args:verb==='limits'?{}:patch}),/human-required/);
|
||||
// Row 175 (#1673): the tester reads limits; limits.set stays the human's.
|
||||
@@ -19,7 +19,7 @@ test('limits binds actor from human proof and rejects role, reader, a tester set
|
||||
test('runtime wires verified human settings to the actual shared file writer and actor receipt',async t=>{
|
||||
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');const {readFileSync}=await import('node:fs');
|
||||
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-runtime-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo');writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});
|
||||
t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const view=bus.broker.request(human,{verb:'limits'});const done=bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:0}}});assert.equal(done.limits.total,0);assert.equal(JSON.parse(readFileSync(join(root,'businesses/demo.json'),'utf8')).launch.limits.total,0);const receipt=JSON.parse(readFileSync(join(root,'businesses/.history/demo/changes.jsonl'),'utf8').trim().split('\n').at(-1));assert.equal(receipt.actor,'jason');assert.equal(receipt.state,'committed');
|
||||
});
|
||||
@@ -28,7 +28,7 @@ test('uncertain organization receipts leave bus startup and reads alone but refu
|
||||
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-recovery-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo'),file=writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const writer=organizationWriter({dir:root,rolesDir}),original=readFileSync(file,'utf8'),revision=writer.read('demo').revision,next=structuredClone(doc);next.launch.limits.total=7;
|
||||
const interrupted=organizationWriter({dir:root,rolesDir,barrier:()=>{throw Error('fixture interruption');}});assert.throws(()=>interrupted.write('demo',next,{revision,actor:'jason'}));const external=structuredClone(doc);external.launch.limits.total=12;writeJson(file,external);const bytes=readFileSync(file,'utf8');
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const view=bus.broker.request(human,{verb:'limits'});assert.equal(view.limits.total,12);assert.throws(()=>bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:1}}}),/organization-uncertain/);assert.equal(readFileSync(file,'utf8'),bytes);
|
||||
// The operator reconciles the fixture to the recorded old whole document.
|
||||
writeJson(file,original);const reconciled=bus.broker.request(human,{verb:'limits'});const result=bus.broker.request(human,{verb:'limits.set',args:{revision:reconciled.revision,limits:{total:1}}});assert.equal(result.limits.total,1);
|
||||
|
||||
@@ -15,7 +15,7 @@ function fixture(t, cross = false) {
|
||||
arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
pm: role(cross ? [] : ['message.send'], cross ? ['message.send'] : []),
|
||||
cto: role(),
|
||||
cto: role(['decision.resolve.technical']),
|
||||
coder: role(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ test('launch admission refreshes selection and refuses a removed model and incom
|
||||
writeJson(join(root,'businesses/demo.json'),doc); mkdirSync(join(root,'data'),{mode:0o700});
|
||||
let present=true, reads=0;
|
||||
const service={invalidate(){},read:async(_,{fresh})=>{assert.equal(fresh,true);reads++;return {status:'ready',models:present?[{module:'cliproxy',upstream:'zai',model:'fixture-id',available:true,harnesses:['pi']}]:[]};}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:service});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:service});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const revision=bus.broker.request(human,{verb:'limits'}).revision;
|
||||
const {checkedTransport}=await import('../../harness/src/bundle.mjs');
|
||||
@@ -78,7 +78,7 @@ test('a different enabled fixture provider supplies legacy mapping and admission
|
||||
const makeProviderLoader=()=>({load:async()=>({refused:[]}),snapshot:()=>[{name:'second-fixture',module}]});
|
||||
const offered={key:'fixture-model',module:'second-fixture',upstream:'fixture',provider:'fixture',model:'fixture-model',label:'Fixture',available:true,reason:null,harnesses:['pi']};
|
||||
const modelCatalogue={read:async()=>({status:'ready',models:[offered],notices:[]}),invalidate(){}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue,makeProviderLoader});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue,makeProviderLoader});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const view=await bus.broker.request(human,{verb:'routes'});
|
||||
assert.equal(view.catalogue.legacy['fixture-legacy'].module,'second-fixture');
|
||||
const answer=await bus.modelAdmission({business:'demo',instance:'coder',modelKey:'fixture-legacy',harness:'pi',revision:view.revision,entry:doc.launch.models['fixture-legacy'],provider:'fixture',model:'fixture-model',account:null,route:'primary',transport});
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
|
||||
t.after(async()=>{const failures=[];for(const g of gates)g.release();try{await waitFor(()=>active.size===0,'owned operations did not settle',5000);}catch{failures.push('owned-operation-close');}for(const close of [...closers].reverse()){try{await close();}catch{failures.push('owned-close');}}try{server?.closeAllConnections();if(server?.listening)await new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve()));}catch{failures.push('owned-server-close');}try{checkHistory({logs,proxyReceipts,trace});}catch{failures.push('value-free-receipts');}try{checkHistory({launch:launchLog()});}catch{failures.push('owned-launch-log-guard');}if(stderr)process.stderr.write=stderr;try{fs.rmSync(root,{recursive:true,force:true});}catch{failures.push('owned-root-close');}if(failures.length)throw new AggregateError(failures.map(code=>Error(code)),'owned managed fixture cleanup failed');});
|
||||
const f=fixture(root,'acme',doc=>{doc.plugins={...doc.plugins,harness:['pi']};for(const role of Object.values(doc.roles))role.vars={...role.vars,harness:'pi'};doc.roles.chief.vars.model='claude-opus-5-5';doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};if(listed)doc.launch.instances.push('aide');if(keep)doc.launch.keep=['aide'];if(retired)doc.retired=['alpha'];if(noHire)doc.launch.noHire=['aide'];return doc;});
|
||||
// The established scratch aide pattern only; repository roles/policy stay untouched.
|
||||
for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});}
|
||||
for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n\n## Authority\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});}
|
||||
const chiefFile=join(f.roles,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide','helper']});
|
||||
const env={PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,MOSAIC_CONFIG:f.config,MOSAIC_ROLES_DIR:f.roles};
|
||||
const configDir=join(root,'config'),prefix=`msk-${id(configDir)}-`,sentinel=prefix+'sentinel-'+randomBytes(32).toString('base64url');history.add(sentinel);
|
||||
|
||||
@@ -176,7 +176,7 @@ test('proxy-stop-failed: old proxy key and retiring record survive a failed grac
|
||||
async function bootBusiness(f, binding = {}) {
|
||||
const rolesDir = rolesCopy(f.root), system = systemFor(f.root), doc = businessDoc(f.root, 'demo');
|
||||
// Aide is needed because shipped chief hireable:[] and security-reviewer has review.verdict outside HIRE_ALLOWED.
|
||||
fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
|
||||
fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
|
||||
writeJson(join(rolesDir, 'aide.json'), {
|
||||
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
|
||||
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
|
||||
|
||||
@@ -15,7 +15,7 @@ async function hireFixture(t,{revoking=false}={}){
|
||||
if(revoking){const r=old.credentials.modelRecord('demo/orphan');old.credentials.writeModel('demo/orphan',{...r,state:'revoking',revokingAt:'2026-10-25T00:00:00.000Z'});}old.credentials.close();
|
||||
const rolesDir=rolesCopy(f.root),system=systemFor(f.root),doc=businessDoc(f.root,'demo');
|
||||
// Scratch aide: shipped chief hireable:[] and review.verdict is outside HIRE_ALLOWED.
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
|
||||
const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});doc.plugins={...doc.plugins,harness:['pi']};
|
||||
const file=writeJson(join(f.configDir,'businesses','demo.json'),doc);fs.writeFileSync(join(f.configDir,'secrets','proxy-management.key'),'fx651-manage',{mode:0o600});
|
||||
const business=loadBusiness('demo',{dir:f.configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){
|
||||
const configDir=join(root,'config'),dataRoot=join(root,'data'),rolesDir=rolesCopy(root),system=systemFor(root);fs.mkdirSync(dataRoot,{mode:0o700});
|
||||
const doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};
|
||||
// Existing row331 scratch aide pattern only; repository role authority unchanged.
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
|
||||
const ids=twoBusinesses?['demo','other']:['demo'],files={},businesses={};for(const name of ids){const document={...structuredClone(doc),id:name};files[name]=writeJson(join(configDir,'businesses',name+'.json'),document);const business=loadBusiness(name,{dir:configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});businesses[name]=busOrganization(business,resolved);}
|
||||
fs.mkdirSync(join(configDir,'secrets'),{recursive:true,mode:0o700});fs.writeFileSync(join(configDir,'secrets','proxy-management.key'),'fx654-manage',{mode:0o600});fs.mkdirSync(join(dataRoot,'bus-host'),{recursive:true,mode:0o700});writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:[]});
|
||||
const sentinel=`msk-${hash(configDir).slice(0,12)}-sentinel-${randomBytes(32).toString('base64url')}`;state.keys.push(sentinel);history.add(sentinel);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { join } from 'node:path';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
const role={authority:{withinRole:['message.send'],crossRole:[]}};
|
||||
function fixture(t,proxyInit){const store=new Store(mkdtempSync(join(tmpdir(),'p1-bus-')));t.after(()=>store.close());const b=new Broker({store,proxyInit,businesses:{demo:{id:'demo',human:'owner',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:role}}}});return {store,b,human:b.bindHuman({business:'demo',human:'owner',via:'cli',outsideAgent:true}),tester:b.bindTester({business:'demo'}),reader:b.bindReader({business:'demo'}),agent:b.bindLaunch({business:'demo',role:'coder',run:'p1-run',harness:'pi'})};}
|
||||
function fixture(t,proxyInit){const store=new Store(mkdtempSync(join(tmpdir(),'p1-bus-')));t.after(()=>store.close());const b=new Broker({store,proxyInit,businesses:{demo:{id:'demo',human:'owner',arbiters:{technical:'chief',delivery:'chief'},vars:{'bus.tester':true},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:role}}}});return {store,b,human:b.bindHuman({business:'demo',human:'owner',via:'cli',outsideAgent:true}),tester:b.bindTester({business:'demo'}),reader:b.bindReader({business:'demo'}),agent:b.bindLaunch({business:'demo',role:'coder',run:'p1-run',harness:'pi'})};}
|
||||
test('proxy.init only accepts path-bound outside-agent human; refuses agent/tester/reader',async t=>{const calls=[];const f=fixture(t,async a=>{calls.push(a);return {port:a.port,binary:'/fixture'};});for(const cap of [f.tester,f.agent])assert.throws(()=>f.b.request(cap,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='human-required');assert.throws(()=>f.b.request(f.reader,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='read-only');assert.equal(calls.length,0);assert.deepEqual(await f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management',port:18432}}),{port:18432,binary:'/fixture'});assert.equal(JSON.stringify(f.store.all('SELECT body FROM events')).includes('p1-management'),false);});
|
||||
for(const [code,args]of [['proxy-key-invalid',{key:'a\nb'}],['proxy-config-invalid',{key:'p1-management',port:8317}],['invalid-request',{key:'p1-management',binary:'/forged'}]])test(`proxy.init ${code}`,t=>{const f=fixture(t,()=>{throw Error('must not call');});assert.throws(()=>f.b.request(f.human,{verb:'proxy.init',args}),e=>e.code===code);});
|
||||
test('proxy.init not-configured',t=>{const f=fixture(t,null);assert.throws(()=>f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='not-configured');});
|
||||
|
||||
@@ -51,7 +51,7 @@ export async function revocationFixture(t,{runtime=false,onRoot=()=>{},missingBo
|
||||
let boot,writer,chief,human,busBoots=0;
|
||||
if(runtime){
|
||||
const rolesDir=rolesCopy(root),system=systemFor(root),doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};doc.launch.instances.push('aide');
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});writeJson(join(configDir,'businesses','demo.json'),doc);
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});writeJson(join(configDir,'businesses','demo.json'),doc);
|
||||
const business=loadBusiness('demo',{dir:configDir,rolesDir}),resolved={};for(const name of Object.keys(business.roles))resolved[name]=resolveInstance({system,business,instance:name});
|
||||
boot={dataRoot,businesses:{demo:busOrganization(business,resolved)},organization:{dir:configDir,rolesDir,system},proxy:{port,binary:'/fixture-unused'}};writer=organizationWriter({dir:configDir,rolesDir});
|
||||
stderr=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){receipts.push(String(chunk));args.find(a=>typeof a==='function')?.();return true;}return stderr.call(this,chunk,...args);};
|
||||
|
||||
@@ -24,7 +24,7 @@ const demo = () => ({
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'lead', delivery: 'chief' },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles,
|
||||
definitions: { reviewer: {} },
|
||||
vars: { 'bus.tester': true },
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createLimits, modelUsageKey } from '../src/limits.mjs';
|
||||
const key='012345abcdef';
|
||||
test('P5 broker preserves original plugin subject and derives internal run context from authenticated native caller',async t=>{
|
||||
const root=mkdtempSync(join(tmpdir(),'p5-context-')),store=new Store(root),asked=[];t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},
|
||||
limits:{mustStop:async(...args)=>{asked.push(args);return {stop:false};}},usage:{collect:async()=>({recorded:true}),view:()=>({})}});
|
||||
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const cap=b.bindLaunch({business:'demo',role:'coder',run:'native-run',harness:'pi',provider:'fixture',model:'fake'});b.request(cap,{verb:'role.claim'});
|
||||
|
||||
@@ -30,7 +30,7 @@ test('P5 real runtime binds stack usage to issued key, daily stops at stack coun
|
||||
});
|
||||
await new Promise(r=>server.listen(0,'127.0.0.1',r));let bus;
|
||||
t.after(async()=>{await bus?.close();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});});
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}});
|
||||
assert.equal(calls.some(p=>p.includes('usage/queue')),false);
|
||||
const model=await bus.modelPreflight({business:'demo',role:'coder'}),keyId=hash(model.key);
|
||||
@@ -77,7 +77,7 @@ test('R4 real runtime: a quota spent on A and moved to B with no reset still ref
|
||||
});
|
||||
await new Promise(r=>server.listen(0,'127.0.0.1',r));let bus;
|
||||
t.after(async()=>{await bus?.close();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});});
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}});
|
||||
const stored=()=>JSON.parse(readFileSync(join(config,'businesses/demo.json'),'utf8')).launch.limits.tokens.keys;
|
||||
const may=async()=>(await bus.mayLaunch({business:'demo',instance:'coder',provider:'fixture',model:'fake'})).allow;
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function telemetryFixture(t, { foreign = false, hold = false, waitM
|
||||
});
|
||||
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
||||
let bus=null,startPromise=null,closePromise=null;
|
||||
const start=()=>startPromise??=(startBroker({dataRoot:data,...(orgMode ? {instanceRoot:root} : {}),businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
const start=()=>startPromise??=(startBroker({dataRoot:data,...(orgMode ? {instanceRoot:root} : {}),businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}}).then(value=>(bus=value)));
|
||||
const release=(fail=false)=>{state.hold=false;for(const res of state.held.splice(0)){if(res.destroyed)continue;if(fail){res.writeHead(503);res.end('{}');}else answerQueue(res);}};
|
||||
const close=()=>bus?(closePromise??=bus.close()):Promise.resolve();
|
||||
|
||||
@@ -37,7 +37,7 @@ test('runtime: the switch changes the organization file, the receipt carries the
|
||||
const rolesDir = rolesCopy(root);
|
||||
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
@@ -72,7 +72,7 @@ test('vikunja:19/309 through the broker, a patch switches the kinds it names and
|
||||
const rolesDir = rolesCopy(root);
|
||||
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { 'usage-source': ['meter'] } });
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
||||
@@ -97,7 +97,7 @@ test('plugins read advertises loadable harnesses even when switched off', async
|
||||
const root=mkdtempSync(join(tmpdir(),'plugins-available-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
const rolesDir=rolesCopy(root);const doc=businessDoc(root,'demo');doc.plugins={harness:['claude-code']};
|
||||
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const host=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>host.close());
|
||||
const host=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>host.close());
|
||||
const human=host.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const view=host.broker.request(human,{verb:'plugins'});
|
||||
assert.deepEqual(view.plugins.harness,['claude-code']);assert.ok(view.available.harness.includes('pi'));assert.ok(view.available.harness.includes('claude-code'));
|
||||
|
||||
@@ -10,8 +10,8 @@ import { Client } from '../src/client.mjs';
|
||||
import { registerRuntime, updateRuntime, processIdentity } from '../../processing-control/src/runtimes.mjs';
|
||||
import { readControl } from '../../processing-control/src/state.mjs';
|
||||
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
async function broker(root) {
|
||||
const r = await startBroker({ dataRoot: root, businesses, readers: ['demo'] });
|
||||
const cap = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -13,7 +13,7 @@ const state = await import('../../processing-control/src/state.mjs').catch(e =>
|
||||
throw e;
|
||||
});
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', vars: { 'bus.tester': true },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: [] } },
|
||||
} } };
|
||||
function fixture(t, extra = {}) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Broker, TESTER_VERBS, USER_VERBS, VIEWER_VERBS } from '../src/broker.mj
|
||||
const shipped = (name) => JSON.parse(readFileSync(new URL(`../../../roles/${name}.json`, import.meta.url), 'utf8'));
|
||||
const AT = '2026-11-05T00:00:00.000Z';
|
||||
const business = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'continuity', delivery: 'continuity' },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {
|
||||
continuity: { definition: 'continuity', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
|
||||
@@ -10,7 +10,7 @@ function fixture(t, project, secretCheck = () => {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'project-await-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] } },
|
||||
} };
|
||||
const broker = new Broker({ store, businesses: { demo: business }, project, secretCheck });
|
||||
|
||||
@@ -64,7 +64,7 @@ test('real startBroker injects the project adapter for socket reads and bound-au
|
||||
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
// Row 65: the bound run's harness is switched on.
|
||||
const business = { id: 'demo', human: 'jason', plugins: { harness: ['pi'] }, projects: { stack: { root: join(root, 'code') } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: {
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: {
|
||||
definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] },
|
||||
} } };
|
||||
const initialized = await initProject({ business, project: 'stack', dataRoot: root });
|
||||
|
||||
@@ -15,7 +15,7 @@ test('role must not materialize a new catalogue key by submitting legacy entry s
|
||||
const offered={key:'zai-fixture-new',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-new',label:'Fixture',available:true,reason:null,harnesses:['pi']};
|
||||
let status='ready';
|
||||
const modelCatalogue={invalidate(){},read:async()=>({status,models:[offered],notices:[]})};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const db=new DatabaseSync(join(root,'data/bus/bus.sqlite'),{readOnly:true});t.after(()=>db.close());
|
||||
const allowed=()=>db.prepare("SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='organization.change'").get().n;
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
|
||||
@@ -20,7 +20,7 @@ async function fixture(t, options={}) {
|
||||
if(options.fallback)Object.assign(doc.roles.coder.vars,{'fallback.harness':'pi','fallback.model':'existing-alias','fallback.thinking':'low'});
|
||||
writeJson(file,doc);fs.mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const modelCatalogue={invalidate(){},read:async()=>({status:'ready',models:[offer],notices:[]})};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{...Object.fromEntries(Object.keys(doc.roles).map(key=>[key,{authority:{withinRole:[],crossRole:[]}}])),chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{...Object.fromEntries(Object.keys(doc.roles).map(key=>[key,{authority:{withinRole:[],crossRole:[]}}])),chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const db=new DatabaseSync(join(root,'data/bus/bus.sqlite'),{readOnly:true});t.after(()=>db.close());
|
||||
const count=()=>db.prepare("SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='organization.change'").get().n;
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
|
||||
@@ -18,8 +18,8 @@ function setup(t, proxyPage = async () => ({ ok: true })) {
|
||||
access: () => ({ accessVersion: 1, revision: 1, users: [], grants: [
|
||||
{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['coder'], level: 'collaborator' },
|
||||
] }),
|
||||
businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
vars: { 'bus.tester': true }, roles: { coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
vars: { 'bus.tester': true }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } } },
|
||||
});
|
||||
const caps = {
|
||||
human: broker.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true }),
|
||||
|
||||
@@ -24,9 +24,9 @@ function businesses(upstream = 'https://git.example/mosaic/stack') {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'reviewer', delivery: 'reviewer' },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
projects: { stack: { root: '/nonexistent', remote: upstream }, books: { root: '/nonexistent-books' } },
|
||||
roles: {
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { authority: pusher, identity: identity('acme-coder'), publish: ['stack'] },
|
||||
second: { authority: pusher, identity: identity('acme-second'), publish: ['stack'] },
|
||||
plain: { authority: pusher },
|
||||
|
||||
@@ -211,13 +211,13 @@ test('T14 reloadBusiness refuses an unknown business, a changed human and a docu
|
||||
const root = mkdtempSync(join(tmpdir(), 'reload-unit-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const role = { authority: { withinRole: [], crossRole: [] } };
|
||||
const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } };
|
||||
const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } };
|
||||
const b = new Broker({ store: new Store(root), businesses: { demo } });
|
||||
assert.equal(code(() => b.reloadBusiness('other', demo)), 'unknown-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, human: 'mos' })), 'invalid-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: role, tester: role } })), 'invalid-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: { authority: { withinRole: ['spend'], crossRole: [] } } } })), 'invalid-authority');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', null)), 'invalid-business');
|
||||
b.reloadBusiness('demo', { ...demo, roles: { coder: role, 'sec-1': role } });
|
||||
b.reloadBusiness('demo', { ...demo, roles: { ...demo.roles, 'sec-1': role } });
|
||||
assert.ok(b.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' }));
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// RR (#1873): resolution checks current role rights, and callers cannot choose a merge/policy arbiter.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadRole } from '../../organization/src/index.mjs';
|
||||
import { REPO_ROLES } from '../../organization/tests/helpers.mjs';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
import { grantDecision } from './host-fixtures.mjs';
|
||||
|
||||
async function setup(t, { shared = false } = {}) {
|
||||
const roles = Object.fromEntries(['cto', 'pm', 'chief-of-staff'].map(definition => {
|
||||
const role = loadRole(REPO_ROLES, definition);
|
||||
return [definition === 'chief-of-staff' ? 'chief' : definition, { definition, authority: structuredClone(role.authority) }];
|
||||
}));
|
||||
roles.coder = { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: ['task.scope.change', 'git.merge.protected', 'policy.change'] } };
|
||||
const f = await fixture(t, { roles, arbiters: { technical: 'cto', delivery: shared ? 'cto' : 'pm' } });
|
||||
return { ...f, roles, cto: f.agent('cto'), pm: f.agent('pm'), chief: f.chief };
|
||||
}
|
||||
|
||||
for (const [domain, resolver] of [['technical', 'cto'], ['delivery', 'pm']]) {
|
||||
test(`RR: ${domain} arbiter that loses its right refuses not-resolver`, async t => {
|
||||
const f = await setup(t);
|
||||
const d = f.raise({ action: 'task.scope.change', domain });
|
||||
f.business.roles[resolver].authority.withinRole = ['message.send'];
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
assert.throws(() => f.call(f[resolver], 'decision.resolve', { id: d.id, choice: 'yes' }), error => {
|
||||
assert.equal(error.code, 'not-resolver');
|
||||
assert.match(error.reason, new RegExp(`${resolver}.*${resolver}.*decision\\.resolve\\.${domain}`));
|
||||
return true;
|
||||
});
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', d.id).n, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('RR: a shared arbiter cannot resolve a delivery decision using only its technical right', async t => {
|
||||
const f = await setup(t, { shared: true });
|
||||
const d = f.raise({ action: 'task.scope.change', domain: 'delivery' });
|
||||
assert.equal(d.route_to, 'cto');
|
||||
assert.throws(() => f.call(f.cto, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'not-resolver' });
|
||||
});
|
||||
|
||||
test('RR: merge raised with delivery still routes to CTO and resolves (acceptance 314 step 5)', async t => {
|
||||
const f = await setup(t);
|
||||
const d = await f.raise({ action: 'git.merge.protected', domain: 'delivery', project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
|
||||
assert.equal(d.route_to, 'cto');
|
||||
assert.throws(() => f.call(f.pm, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'not-resolver' });
|
||||
f.call(f.cto, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=?', d.id).op, 'resolved');
|
||||
});
|
||||
|
||||
for (const resolver of ['chief', 'pm']) {
|
||||
test(`RR: ${resolver} named delivery arbiter resolves policy.change even when raised technical`, async t => {
|
||||
const f = await setup(t); f.business.arbiters.delivery = resolver;
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
const d = f.raise({ action: 'policy.change', domain: 'technical' });
|
||||
assert.equal(d.route_to, resolver);
|
||||
f.call(f[resolver], 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.equal(f.store.get('SELECT by FROM decision_events WHERE decision=?', d.id).by, `${resolver}-run`);
|
||||
});
|
||||
}
|
||||
|
||||
test('RR: the chief holds both rights when named for both domains', async t => {
|
||||
const f = await setup(t); f.business.arbiters = { technical: 'chief', delivery: 'chief' };
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
for (const action of ['git.merge.protected', 'policy.change']) {
|
||||
const d = f.raise({ action }); assert.equal(d.route_to, 'chief');
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
}
|
||||
});
|
||||
|
||||
// Old raise evidence did not preserve domain. Current assignments cannot prove the old domain.
|
||||
test('RR: remapping arbiters cannot let a legacy decision resolve with only the other domain right', async t => {
|
||||
const f = await setup(t);
|
||||
const id = grantDecision(f.store, { action: 'task.scope.change', route: 'pm', domain: null, close: null });
|
||||
f.business.arbiters = { technical: 'pm', delivery: 'cto' };
|
||||
f.business.roles.pm.authority.withinRole = ['message.send', 'decision.resolve.technical'];
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
assert.throws(() => f.call(f.pm, 'decision.resolve', { id, choice: 'yes' }), { code: 'not-resolver' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', id).n, 0);
|
||||
// Human recovery closes the old route by withdrawing it, before the raiser creates a new decision.
|
||||
f.call(f.human, 'decision.withdraw', { id });
|
||||
assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=?', id).op, 'withdrawn');
|
||||
});
|
||||
@@ -28,7 +28,7 @@ test('C2-10c check hydration preserves unchecked rows and one broadcast with two
|
||||
const messaging = JSON.parse(readFileSync(new URL('../../../roles/companion.json', import.meta.url))).messaging;
|
||||
const role = { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', vars: {},
|
||||
arbiters: { technical: 'bean', delivery: 'bean' }, roles: { bean: role, chet: role }, definitions: { companion: { messaging } } } } });
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, bean: role, chet: role }, definitions: { companion: { messaging } } } } });
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (verb, args) => broker.request(human, { verb, args });
|
||||
const project = call('project.create', { name: 'Room' }).id;
|
||||
|
||||
@@ -110,7 +110,7 @@ test('row 65 runtime: routes and routes.set reach the organization file with the
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
writeJson(file, doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(() => bus.close());
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
||||
@@ -157,7 +157,7 @@ test('fixture provider catalogue appears in actual routes and materializes the s
|
||||
const file = join(root, 'businesses/demo.json'); writeJson(file, doc); mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const offered={ key:'zai-fixture-id',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-id',label:'fixture-id',available:true,reason:null,harnesses:['pi'] };
|
||||
const catalogues={ read:async()=>({status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[]}),invalidate:()=>{} };
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}} } }, organization:{dir:root,rolesDir,system:systemFor(root)}, modelCatalogue:catalogues });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}} } }, organization:{dir:root,rolesDir,system:systemFor(root)}, modelCatalogue:catalogues });
|
||||
t.after(() => bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const before=await bus.broker.request(human,{verb:'routes'});
|
||||
@@ -179,7 +179,7 @@ for (const writer of ['limits.set','organization.set','instances.set']) test(`${
|
||||
let mode='absent';
|
||||
const offered={key:'fixture-persisted',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-id',label:'fixture',available:true,reason:null,harnesses:['pi']};
|
||||
const catalogue={read:async()=>({status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:mode==='absent'?[]:[{...offered,harnesses:mode==='incompatible'?['claude-code']:['pi']}],notices:[]}),invalidate(){}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority:chiefAuthority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority:chiefAuthority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const cap=writer==='organization.set'?bus.broker.bindLaunch({business:'demo',role:'chief',run:'provider-chief',harness:'pi'}):human;
|
||||
if(writer==='organization.set')bus.broker.request(cap,{verb:'role.claim'});
|
||||
@@ -201,7 +201,7 @@ test('legacy routes follow enabled module mappings while unchanged unavailable s
|
||||
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
let state='ready';const offered={key:'zai-fixture-two',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-two',label:'fixture',available:true,reason:null,harnesses:['pi']};
|
||||
const catalogue={read:async()=>({status:state,observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[]}),invalidate(){}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const request=(verb,args)=>bus.broker.request(human,{verb,args});
|
||||
let view=await request('routes');assert.deepEqual(view.catalogue.legacy['legacy-one'],{module:'cliproxy',upstream:'claude',model:'fixture-one'});
|
||||
const pick=(harness,model,fallback=null)=>({harness,model,thinking:'low',fallback});
|
||||
|
||||
@@ -12,8 +12,8 @@ const businesses = {
|
||||
arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
cto: { authority: { withinRole: [], crossRole: [] } },
|
||||
pm: { authority: { withinRole: [], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['decision.resolve.technical'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['decision.resolve.delivery'], crossRole: [] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Broker } from '../src/broker.mjs';
|
||||
import { initProject, readSpec, writeRecord, listProjectFiles, readProjectFile, writeHumanFile, ProjectError } from '../../project/src/index.mjs';
|
||||
export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8' }).trim();
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'reviewer', delivery: 'reviewer' }, access = null } = {}) {
|
||||
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'chief', delivery: 'chief' }, access = null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-spec-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
@@ -16,6 +16,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
|
||||
projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } }, roles: {
|
||||
coder: { definition: 'coder', authority: authority(['message.send', 'record.write']) },
|
||||
reviewer: { definition: 'reviewer', authority: authority(['message.send']) },
|
||||
chief: { definition: 'chief-of-staff', authority: authority(['message.send', 'decision.resolve.technical', 'decision.resolve.delivery']) },
|
||||
...roles,
|
||||
} };
|
||||
const { repo, commit } = await initProject({ business, project: 'stack', dataRoot: root });
|
||||
@@ -31,7 +32,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
|
||||
const cap = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
|
||||
broker.request(cap, { verb: 'role.claim' }); return cap;
|
||||
};
|
||||
const coder = agent('coder'), reviewer = agent('reviewer');
|
||||
const coder = agent('coder'), reviewer = agent('reviewer'), chief = agent('chief');
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (cap, verb, args = {}) => broker.request(cap, { verb, args });
|
||||
const raise = args => call(coder, 'decision.raise', { action: 'deploy', question: 'Ship?', options,
|
||||
@@ -42,5 +43,5 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
|
||||
git(repo, '-c', `user.name=${author}`, '-c', '[email protected]', 'commit', '-m', 'Spec');
|
||||
return git(repo, 'rev-parse', 'HEAD');
|
||||
};
|
||||
return { root, store, business, broker, repo, commit, agent, coder, reviewer, human, call, raise, save };
|
||||
return { root, store, business, broker, repo, commit, agent, coder, reviewer, chief, human, call, raise, save };
|
||||
}
|
||||
@@ -19,7 +19,7 @@ async function setup(t, { running = ['chief', 'pm', 'cto'], thread = true } = {}
|
||||
cto: { definition: 'cto', ...within }, assistant: { definition: 'assistant', ...within },
|
||||
} });
|
||||
f.business.definitions = { assistant: { messaging: shipped('assistant').messaging } };
|
||||
f.cap = Object.fromEntries(running.map(role => [role, f.agent(role)]));
|
||||
f.cap = Object.fromEntries(running.map(role => [role, role === 'chief' ? f.chief : f.agent(role)]));
|
||||
if (thread) {
|
||||
const project = f.call(f.human, 'project.create', { name: 'stack work', repo: 'stack' }).id;
|
||||
f.threadId = f.call(f.human, 'thread.create', { project, name: 'alpha' }).id;
|
||||
|
||||
@@ -139,7 +139,7 @@ test('REQ-COS-1: the request goes to the business launcher, and only when that i
|
||||
// launcher `chief`, so a check on the name passes there; these use names that differ from the definitions.
|
||||
test('REQ-COS-1: the sender is judged by definition: an instance `lead` of the pm definition can ask, an instance `pm` of the coder definition can\'t', (t) => {
|
||||
const lead = setup(t, {
|
||||
arbiters: { technical: 'lead', delivery: 'chief' },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: authority.chief },
|
||||
lead: { definition: 'pm', authority: authority.pm },
|
||||
@@ -172,9 +172,9 @@ test('REQ-COS-1: the sender is judged by definition: an instance `lead` of the p
|
||||
|
||||
test('REQ-COS-1: the launcher is judged by definition: an instance `boss` of the chief-of-staff definition receives, a `chief` of the coder definition gives no-chief-of-staff', (t) => {
|
||||
const boss = setup(t, {
|
||||
arbiters: { technical: 'pm', delivery: 'boss' },
|
||||
arbiters: { technical: 'pm', delivery: 'chief' },
|
||||
launch: { by: 'boss', instances: ['coder'], max: { opus: 2, sonnet: 2 } },
|
||||
roles: {
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
boss: { definition: 'chief-of-staff', authority: authority.chief },
|
||||
pm: { definition: 'pm', authority: authority.pm },
|
||||
coder: { definition: 'coder', ...none },
|
||||
|
||||
@@ -91,7 +91,7 @@ test('runtime reads the current organization file for project selections and con
|
||||
doc.plugins = { 'system-type': ['coding'] };
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: business }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -46,8 +46,8 @@ async function runtime(t) {
|
||||
dataRoot: join(root, 'data'),
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
|
||||
roles: { coder: { definition: 'coder', ...none }, assistant: { definition: 'assistant', ...none } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', ...none }, assistant: { definition: 'assistant', ...none } },
|
||||
},
|
||||
},
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
@@ -166,8 +166,8 @@ function broker(t, vars = { 'bus.tester': true }) {
|
||||
store,
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'lead', delivery: 'lead' }, ...(vars ? { vars } : {}),
|
||||
roles: { dev: { definition: 'coder', ...none }, lead: { definition: 'pm', ...none } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, ...(vars ? { vars } : {}),
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, dev: { definition: 'coder', ...none }, lead: { definition: 'pm', ...none } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Broker } from '../src/broker.mjs';
|
||||
test('REQ-LIMIT-4 usage protocol binds run identity, requires a delivered turn and isolates businesses', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'usage-broker-'));
|
||||
const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } } };
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'chief', delivery: 'chief' } } };
|
||||
const recorded = [];
|
||||
const broker = new Broker({ store, businesses, usage: {
|
||||
collect: async (...args) => { recorded.push(args); return { recorded: true }; },
|
||||
|
||||
@@ -12,7 +12,7 @@ test('REQ-PLUG-1 runtime reaches transcript only through loader, over the real b
|
||||
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const dir = join(root, 'launches/demo/run1/session'); mkdirSync(dir, { recursive: true });
|
||||
const business = { id: 'demo', human: 'jason', plugins: { 'usage-source': ['transcript'], harness: ['pi'] },
|
||||
roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } };
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'chief', delivery: 'chief' } };
|
||||
runtime = await startBroker({ dataRoot: root, businesses: { demo: business }, launches: [{
|
||||
// Row 65: the launch names where its harness keeps the session files.
|
||||
business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'other-provider', model: 'other-model', transcript: dir,
|
||||
|
||||
@@ -65,10 +65,10 @@ test("merge-gate's technical raise is cross-role, goes to the cto, and only merg
|
||||
assert.throws(() => b.authorize(caps['merge-gate'], 'git.merge.protected', { decision: d.id, target: TASK }), /decision-consumed/);
|
||||
});
|
||||
|
||||
test("merge-gate has no message.send, and without a domain its raise goes to the delivery arbiter", (t) => {
|
||||
test("merge-gate has no message.send, and without a domain its merge raise still goes to the technical arbiter", (t) => {
|
||||
const { call, raise } = setup(t);
|
||||
assert.throws(() => call('merge-gate', 'message.send', { to: 'cto', body: 'landed' }), /decision-required/);
|
||||
assert.equal(raise('merge-gate', 'git.merge.protected', { target: TASK }).route_to, 'pm');
|
||||
assert.equal(raise('merge-gate', 'git.merge.protected', { target: TASK }).route_to, 'cto');
|
||||
// Every other action is gated for merge-gate, so an uncited raise goes to the human.
|
||||
const push = raise('merge-gate', 'git.push.working', { domain: 'technical', target: TASK });
|
||||
assert.deepEqual([push.class, push.route_to], ['gated', 'human']);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/agent-levels-write.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/agent-levels-write.mjs";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/composition.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/composition.mjs";
|
||||
@@ -0,0 +1,195 @@
|
||||
// #1873 Phase A slice A2: the owner's terminal verbs for the root and project agent levels (design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
|
||||
//
|
||||
// mosaic agents root list [--json]
|
||||
// mosaic agents root set NAME [--definition ROLE] [--var KEY=VALUE]... [--var-json KEY=JSON]...
|
||||
// [--unset-var KEY]... [--exclude-orgs ID,ID | --exclude-orgs none] [--revision R]
|
||||
// mosaic agents root remove NAME [--revision R]
|
||||
// mosaic project agents PROJECT --org ID list [--json]
|
||||
// mosaic project agents PROJECT --org ID set NAME --definition ROLE [--var KEY=VALUE]... [--var-json KEY=JSON]... [--revision R]
|
||||
// mosaic project agents PROJECT --org ID narrow NAME --var KEY=VALUE... [--var-json KEY=JSON]... [--revision R]
|
||||
// mosaic project agents PROJECT --org ID exclude NAME [--revision R]
|
||||
// mosaic project agents PROJECT --org ID remove NAME [--revision R]
|
||||
//
|
||||
// Every write is the owner's: an agent run is refused before anything is read (exit 3), and the
|
||||
// verbs run only on a named dev instance, as `mosaic org` does, with the local dev owner as actor.
|
||||
// Without --revision a write reads the revision just before it writes, so a concurrent change
|
||||
// between that read and the write still refuses, but one made before the command started does
|
||||
// not. Pass --revision from a `list` to make the write conditional on what was listed.
|
||||
// A write prints its receipt; refusals name keys, never values.
|
||||
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { OrgError } from '../../organization/src/org-registry.mjs';
|
||||
import { OrganizationError, organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { changeRootAgent, patchRootEntry, projectAgentSummary, readRootAgentsFile, rootAgentSummary } from '../../organization/src/agent-levels-write.mjs';
|
||||
import { loadSystem, rolesDir } from './config.mjs';
|
||||
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const AGENTS_ROOT_USAGE = 'mosaic agents root list [--json] | set NAME [--definition ROLE] [--var KEY=VALUE]... [--var-json KEY=JSON]... [--unset-var KEY]... [--exclude-orgs ID,ID|none] [--revision R] | remove NAME [--revision R]';
|
||||
export const PROJECT_AGENTS_USAGE = 'mosaic project agents PROJECT --org ID list [--json] | set NAME --definition ROLE [--var KEY=VALUE]... [--revision R] | narrow NAME --var KEY=VALUE... [--revision R] | exclude NAME [--revision R] | remove NAME [--revision R]';
|
||||
|
||||
const usage = (text, line) => new CliError(`${text}\nusage: ${line}`, 4);
|
||||
const REPEATED = ['--var', '--var-json', '--unset-var'];
|
||||
const SINGLE = ['--definition', '--exclude-orgs', '--revision', '--org'];
|
||||
|
||||
// Option values are never echoed back: a refusal names the option only.
|
||||
function parse(args, line) {
|
||||
const positional = [], single = {}, repeated = { '--var': [], '--var-json': [], '--unset-var': [] }, flags = new Set();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--json') { if (flags.has(a)) throw usage('duplicate --json', line); flags.add(a); }
|
||||
else if (REPEATED.includes(a) || SINGLE.includes(a)) {
|
||||
if (i + 1 >= args.length) throw usage(`${a} needs one value`, line);
|
||||
const value = args[++i];
|
||||
if (REPEATED.includes(a)) repeated[a].push(value);
|
||||
else if (Object.hasOwn(single, a)) throw usage(`duplicate ${a}`, line);
|
||||
else single[a] = value;
|
||||
} else if (a.startsWith('--')) throw usage('unknown option', line);
|
||||
else positional.push(a);
|
||||
}
|
||||
return { positional, single, repeated, flags };
|
||||
}
|
||||
|
||||
function only(parsed, allowed, line) {
|
||||
for (const key of [...Object.keys(parsed.single), ...REPEATED.filter(k => parsed.repeated[k].length), ...parsed.flags])
|
||||
if (!allowed.includes(key)) throw usage(`unexpected ${key}`, line);
|
||||
}
|
||||
|
||||
// KEY=VALUE pairs; a --var-json value is parsed as JSON. A malformed pair names its key at most.
|
||||
function vars(parsed, line) {
|
||||
const out = {};
|
||||
const put = (key, value) => {
|
||||
if (!key) throw usage('a var needs KEY=VALUE', line);
|
||||
if (Object.hasOwn(out, key)) throw usage(`var ${key} is given twice`, line);
|
||||
out[key] = value;
|
||||
};
|
||||
for (const pair of parsed.repeated['--var']) {
|
||||
const at = pair.indexOf('=');
|
||||
if (at < 1) throw usage('a var needs KEY=VALUE', line);
|
||||
put(pair.slice(0, at), pair.slice(at + 1));
|
||||
}
|
||||
for (const pair of parsed.repeated['--var-json']) {
|
||||
const at = pair.indexOf('=');
|
||||
if (at < 1) throw usage('a var needs KEY=JSON', line);
|
||||
const key = pair.slice(0, at);
|
||||
try { put(key, JSON.parse(pair.slice(at + 1))); }
|
||||
catch (e) { if (e instanceof CliError) throw e; throw usage(`var ${key} is not valid JSON`, line); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const revisionOf = (parsed, read) => parsed.single['--revision'] ?? read();
|
||||
function owner(io) {
|
||||
refuseInsideAgent(io.env);
|
||||
// Its refusal is one fixed line naming no path.
|
||||
let instance;
|
||||
try { instance = devOwnerInstance(io.env); } catch (error) { throw error instanceof CliError ? error : new CliError(String(error?.message), 2); }
|
||||
// The instance's system config must exist and validate, marker included, as `mosaic org` checks:
|
||||
// a missing or broken config refuses (exit 3) before anything is read or written.
|
||||
loadSystem({ env: io.env, instance });
|
||||
return { dir: configDir(io.env), roles: rolesDir(io.env) };
|
||||
}
|
||||
// Only the writers' own coded refusals print their text (it names keys and ids, never a value).
|
||||
// Anything else, a filesystem error above all, prints its code and a fixed line: its message can
|
||||
// hold a host path. So does a coded refusal whose text happens to hold an absolute path.
|
||||
const ABSOLUTE_PATH = /(?:^|[\s('"=:])\/[^\s/]/;
|
||||
function refusal(error) {
|
||||
if (error instanceof CliError) return error;
|
||||
const code = typeof error?.code === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(error.code) ? error.code : null;
|
||||
const ours = (error instanceof OrgError || error instanceof OrganizationError || error?.name === 'BusinessError') && code !== null;
|
||||
const text = ours && typeof error.message === 'string' && !ABSOLUTE_PATH.test(error.message) ? error.message : 'the operation failed; the error text is not shown';
|
||||
// A refusal after the replace says the change landed; the receipt may be missing.
|
||||
const landed = error?.committed ? ' (the change was written; its receipt may be missing)' : '';
|
||||
return new CliError(`${code ? `${code}: ` : ''}${text}${landed}`, 2);
|
||||
}
|
||||
|
||||
export async function agentsRootCommand(args, io) {
|
||||
const line = AGENTS_ROOT_USAGE;
|
||||
const parsed = parse(args, line);
|
||||
const [op, name, ...extra] = parsed.positional;
|
||||
try {
|
||||
const { dir, roles } = owner(io);
|
||||
if (op === 'list') {
|
||||
if (name !== undefined) throw usage('list takes no name', line);
|
||||
only(parsed, ['--json'], line);
|
||||
const read = await readRootAgentsFile(dir);
|
||||
const listing = { revision: read.revision, state: read.exists ? 'present' : 'absent', agents: rootAgentSummary(read.document) };
|
||||
io.stdout.write(parsed.flags.has('--json') ? `${JSON.stringify(listing, null, 2)}\n` : formatRoot(listing));
|
||||
return;
|
||||
}
|
||||
if (!['set', 'remove'].includes(op) || !name || extra.length) throw usage('root takes list, set NAME or remove NAME', line);
|
||||
only(parsed, op === 'set' ? ['--definition', '--var', '--var-json', '--unset-var', '--exclude-orgs', '--revision'] : ['--revision'], line);
|
||||
let entry = null;
|
||||
if (op === 'set') {
|
||||
const patch = {};
|
||||
if (Object.hasOwn(parsed.single, '--definition')) patch.definition = parsed.single['--definition'];
|
||||
const set = vars(parsed, line);
|
||||
for (const key of parsed.repeated['--unset-var']) {
|
||||
if (Object.hasOwn(set, key)) throw usage(`var ${key} is both set and unset`, line);
|
||||
set[key] = null;
|
||||
}
|
||||
if (Object.keys(set).length) patch.vars = set;
|
||||
if (Object.hasOwn(parsed.single, '--exclude-orgs')) {
|
||||
const text = parsed.single['--exclude-orgs'];
|
||||
patch.excludeOrgs = text === 'none' ? [] : text.split(',');
|
||||
}
|
||||
if (!Object.keys(patch).length) throw usage('set needs --definition, a var or --exclude-orgs', line);
|
||||
entry = old => patchRootEntry(old, patch);
|
||||
}
|
||||
const revision = await revisionOf(parsed, async () => (await readRootAgentsFile(dir)).revision);
|
||||
const receipt = await changeRootAgent(dir, { revision, name, entry }, { actor: DEV_OWNER, owner: DEV_OWNER, rolesDir: roles });
|
||||
io.stdout.write(`${JSON.stringify(receipt)}\n`);
|
||||
} catch (error) { throw refusal(error); }
|
||||
}
|
||||
|
||||
export async function projectAgentsCommand(args, io) {
|
||||
const line = PROJECT_AGENTS_USAGE;
|
||||
const parsed = parse(args, line);
|
||||
const [project, op, name, ...extra] = parsed.positional;
|
||||
try {
|
||||
const { dir, roles } = owner(io);
|
||||
const org = parsed.single['--org'];
|
||||
if (!project || !org) throw usage('project agents needs PROJECT and --org ID', line);
|
||||
const writer = organizationWriter({ dir, rolesDir: roles });
|
||||
if (op === 'list') {
|
||||
if (name !== undefined) throw usage('list takes no name', line);
|
||||
only(parsed, ['--org', '--json'], line);
|
||||
const { document, revision } = writer.read(org);
|
||||
if (!Object.hasOwn(document.projects ?? {}, project)) throw new OrgError('project-not-found', `project ${project} is not in organization ${org}`);
|
||||
const listing = { organization: org, project, revision, agents: projectAgentSummary(document.projects[project].agents ?? {}) };
|
||||
io.stdout.write(parsed.flags.has('--json') ? `${JSON.stringify(listing, null, 2)}\n` : formatProject(listing));
|
||||
return;
|
||||
}
|
||||
if (!['set', 'narrow', 'exclude', 'remove'].includes(op) || !name || extra.length) throw usage('project agents takes list, set, narrow, exclude or remove NAME', line);
|
||||
const allowed = { set: ['--definition', '--var', '--var-json'], narrow: ['--var', '--var-json'], exclude: [], remove: [] }[op];
|
||||
only(parsed, ['--org', '--revision', ...allowed], line);
|
||||
let entry = null;
|
||||
if (op === 'set') {
|
||||
if (!Object.hasOwn(parsed.single, '--definition')) throw usage('set needs --definition', line);
|
||||
const v = vars(parsed, line);
|
||||
entry = { definition: parsed.single['--definition'], ...(Object.keys(v).length ? { vars: v } : {}) };
|
||||
} else if (op === 'narrow') {
|
||||
const v = vars(parsed, line);
|
||||
if (!Object.keys(v).length) throw usage('narrow needs at least one --var', line);
|
||||
entry = { vars: v };
|
||||
} else if (op === 'exclude') entry = { exclude: true };
|
||||
const revisionBefore = revisionOf(parsed, () => writer.read(org).revision);
|
||||
const result = writer.setProjectAgent(org, { revision: revisionBefore, project, name, entry }, DEV_OWNER.sub);
|
||||
// The receipt: names and revisions only. The writer's `previous` is a host path; it isn't printed.
|
||||
const receipt = { verb: 'project.agents.set', organization: org, project, agent: name, form: op === 'set' ? 'define' : op, revisionBefore, revisionAfter: result.revision, transaction: result.transaction };
|
||||
io.stdout.write(`${JSON.stringify(receipt)}\n`);
|
||||
} catch (error) { throw refusal(error); }
|
||||
}
|
||||
|
||||
function formatRoot({ revision, state, agents }) {
|
||||
if (!agents.length) return `root agents: none (${state === 'absent' ? 'no file' : 'empty'}), revision ${revision}\n`;
|
||||
return `root agents (revision ${revision}):\n` + agents.map(a =>
|
||||
` ${a.name}: ${a.definition}${a.model ? `, model ${a.model}` : ''}${a.varKeys.length ? `, vars ${a.varKeys.join(', ')}` : ''}${a.excludeOrgs.length ? `, not in ${a.excludeOrgs.join(', ')}` : ''}\n`).join('');
|
||||
}
|
||||
function formatProject({ organization, project, revision, agents }) {
|
||||
if (!agents.length) return `project ${project} in ${organization}: no project agent entries, revision ${revision}\n`;
|
||||
return `project ${project} in ${organization} (revision ${revision}):\n` + agents.map(a =>
|
||||
` ${a.name}: ${a.form}${a.definition ? ` ${a.definition}` : ''}${a.varKeys?.length ? `, vars ${a.varKeys.join(', ')}` : ''}\n`).join('');
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import { consoleRestart, consoleStart, consoleStatus, consoleStop } from "./org-
|
||||
|
||||
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
|
||||
import { orgCommand, ORG_USAGE } from "./org.mjs";
|
||||
import { agentsRootCommand, AGENTS_ROOT_USAGE, projectAgentsCommand, PROJECT_AGENTS_USAGE } from "./agent-levels.mjs";
|
||||
import { ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs";
|
||||
import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from "./dev-fixture.mjs";
|
||||
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
|
||||
@@ -65,6 +66,8 @@ export const USAGE = `usage:
|
||||
${ORG_INSTANCES_USAGE}
|
||||
mosaic business validate|resolve|instances|instance|veto: the old spelling of mosaic org config validate|resolve and mosaic org instances|instance|veto, same arguments
|
||||
${PROJECT_USAGE}
|
||||
${PROJECT_AGENTS_USAGE}
|
||||
${AGENTS_ROOT_USAGE}
|
||||
${PROJECT_BUS_USAGE}
|
||||
${NORTH_STAR_USAGE}
|
||||
${INTERVIEW_USAGE}
|
||||
@@ -731,6 +734,9 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
|
||||
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (verb === 'org') return orgCommand(rest, io, deps);
|
||||
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
|
||||
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
|
||||
if (verb === "project" && rest[0] === "agents") return projectAgentsCommand(rest.slice(1), io);
|
||||
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
||||
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
|
||||
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
|
||||
|
||||
@@ -290,7 +290,7 @@ test("the real human-cli, run from agent ancestry, refuses and writes nothing",
|
||||
const config = join(root, "config");
|
||||
const bus = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir: config, rolesDir: f.roles, system: systemFor(root) },
|
||||
});
|
||||
t.after(() => bus.close());
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// #1873 A2: `mosaic agents root` and `mosaic project agents`, end to end through scripts/mosaic on a
|
||||
// bootstrapped named instance (the org CLI's own setup, org.test.mjs).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const repo = resolve(import.meta.dirname, '../../..');
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
const ABSENT = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
||||
|
||||
function instance() {
|
||||
const home = mkdtempSync(join(tmpdir(), 'a2cli-'));
|
||||
const config = join(home, '.config/mosaic-atwo/config.json');
|
||||
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home, MOSAIC_CONFIG: config };
|
||||
const boot = spawnSync(join(repo, 'scripts/bootstrap.sh'), ['--instance', 'atwo'], { cwd: repo, env, encoding: 'utf8' });
|
||||
assert.equal(boot.status, 0, boot.stderr);
|
||||
const run = (args, extra = {}) => spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env: { ...env, ...extra }, encoding: 'utf8' });
|
||||
const ok = (...args) => { const r = run(args); assert.equal(r.status, 0, `${args.join(' ')}: ${r.stderr}`); return r.stdout; };
|
||||
const file = join(home, 'alpha.json');
|
||||
ok('org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file);
|
||||
ok('org', 'create', 'alpha', '--from', file);
|
||||
const project = Object.keys(JSON.parse(readFileSync(join(home, '.config/mosaic-atwo/businesses/alpha.json'), 'utf8')).projects)[0];
|
||||
return { home, env, run, ok, project, configDir: join(home, '.config/mosaic-atwo') };
|
||||
}
|
||||
|
||||
test('agents root list, set, remove: each write prints its receipt, values stay out of receipts and refusals', () => {
|
||||
const { run, ok, configDir } = instance();
|
||||
assert.deepEqual(JSON.parse(ok('agents', 'root', 'list', '--json')), { revision: ABSENT, state: 'absent', agents: [] });
|
||||
assert.match(ok('agents', 'root', 'list'), /^root agents: none \(no file\)/);
|
||||
const added = JSON.parse(ok('agents', 'root', 'set', 'researcher', '--definition', 'research', '--var', 'model=claude-opus-5-5'));
|
||||
assert.equal(added.verb, 'root.agent.set'); assert.equal(added.agent, 'researcher'); assert.deepEqual(added.changed, ['added']);
|
||||
assert.equal(added.revisionBefore, ABSENT);
|
||||
const patched = run(['agents', 'root', 'set', 'researcher', '--var', 'limits.network=none', '--exclude-orgs', 'home']);
|
||||
assert.equal(patched.status, 0, patched.stderr);
|
||||
assert.deepEqual(JSON.parse(patched.stdout).changed, ['vars.limits.network', 'excludeOrgs']);
|
||||
assert.ok(!patched.stdout.includes('none') && !patched.stdout.includes('claude-opus'));
|
||||
const listed = JSON.parse(ok('agents', 'root', 'list', '--json'));
|
||||
assert.equal(listed.state, 'present');
|
||||
assert.deepEqual(listed.agents, [{ name: 'researcher', definition: 'research', model: 'claude-opus-5-5', varKeys: ['limits.network'], excludeOrgs: ['home'] }]);
|
||||
// A stale --revision refuses and the file is unchanged.
|
||||
const before = readFileSync(join(configDir, 'agents.json'), 'utf8');
|
||||
const stale = run(['agents', 'root', 'set', 'researcher', '--var', 'limits.network=api-only', '--revision', added.revisionAfter]);
|
||||
assert.equal(stale.status, 2); assert.match(stale.stderr, /revision-conflict/);
|
||||
// An invalid value refuses with the key named and the value absent.
|
||||
const bad = run(['agents', 'root', 'set', 'researcher', '--var', `model=${SECRET.repeat(10)}`]);
|
||||
assert.equal(bad.status, 2); assert.match(bad.stderr, /agents-invalid: .*model/); assert.ok(!bad.stderr.includes(SECRET), bad.stderr);
|
||||
const badJson = run(['agents', 'root', 'set', 'researcher', '--var-json', `limits.tools=${SECRET}`]);
|
||||
assert.equal(badJson.status, 4); assert.match(badJson.stderr, /var limits\.tools is not valid JSON/); assert.ok(!badJson.stderr.includes(SECRET));
|
||||
const chief = run(['agents', 'root', 'set', 'boss', '--definition', 'chief-of-staff']);
|
||||
assert.equal(chief.status, 2); assert.match(chief.stderr, /chief-of-staff can't be a root agent/);
|
||||
assert.equal(readFileSync(join(configDir, 'agents.json'), 'utf8'), before);
|
||||
// An agent run is refused before anything is read.
|
||||
for (const marker of ['MOSAIC_RUN_ID', 'MOSAIC_BUS_CAP', 'MOSAIC_AGENT_RUN']) {
|
||||
const inside = run(['agents', 'root', 'remove', 'researcher'], { [marker]: '1' });
|
||||
assert.equal(inside.status, 3, `${marker}: ${inside.stderr}`);
|
||||
}
|
||||
assert.equal(readFileSync(join(configDir, 'agents.json'), 'utf8'), before);
|
||||
const removed = JSON.parse(ok('agents', 'root', 'remove', 'researcher'));
|
||||
assert.equal(removed.verb, 'root.agent.remove'); assert.deepEqual(removed.changed, ['removed']);
|
||||
const receipts = readFileSync(join(configDir, 'agents-receipts.jsonl'), 'utf8');
|
||||
assert.equal(receipts.trim().split('\n').length, 3);
|
||||
assert.ok(!receipts.includes('claude-opus') && !receipts.includes(SECRET));
|
||||
// Usage refusals never echo option values.
|
||||
const unknown = run(['agents', 'root', 'set', 'researcher', `--${SECRET}`]);
|
||||
assert.equal(unknown.status, 4); assert.ok(!unknown.stderr.includes(SECRET));
|
||||
});
|
||||
|
||||
test('a filesystem failure prints its code and a fixed line, never a host path', () => {
|
||||
const { run, configDir, home } = instance();
|
||||
// A directory where the receipt file goes: opening it fails EISDIR, whose message holds the path.
|
||||
mkdirSync(join(configDir, 'agents-receipts.jsonl'));
|
||||
const r = run(['agents', 'root', 'set', 'researcher', '--definition', 'research']);
|
||||
assert.equal(r.status, 2, r.stderr);
|
||||
assert.match(r.stderr, /EISDIR: the operation failed; the error text is not shown/);
|
||||
assert.ok(!r.stderr.includes(home), r.stderr);
|
||||
assert.equal(existsSync(join(configDir, 'agents.json')), false);
|
||||
});
|
||||
|
||||
test('a missing, malformed or unclaimed instance config refuses before anything is read or written', () => {
|
||||
const verbs = [['agents', 'root', 'set', 'researcher', '--definition', 'research'], ['agents', 'root', 'list'], ['project', 'agents', 'work', '--org', 'alpha', 'exclude', 'coder']];
|
||||
const check = (env, dir, why) => {
|
||||
for (const args of verbs) {
|
||||
const r = spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env, encoding: 'utf8' });
|
||||
assert.equal(r.status, 3, `${why} ${args.join(' ')}: ${r.stderr}`); assert.match(r.stderr, /system config problem/);
|
||||
}
|
||||
assert.equal(existsSync(join(dir, 'agents.json')), false, why);
|
||||
assert.equal(existsSync(join(dir, 'agents-receipts.jsonl')), false, why);
|
||||
};
|
||||
// Missing: the instance's config directory exists, its config.json does not.
|
||||
const home = mkdtempSync(join(tmpdir(), 'a2cfg-'));
|
||||
const dir = join(home, '.config/mosaic-atwo');
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home, MOSAIC_CONFIG: join(dir, 'config.json') };
|
||||
check(env, dir, 'missing');
|
||||
writeFileSync(join(dir, 'config.json'), '{', { mode: 0o600 });
|
||||
check(env, dir, 'malformed');
|
||||
// Unclaimed: a bootstrapped instance whose data root marker names another instance.
|
||||
const booted = instance();
|
||||
const { dataRoot } = JSON.parse(readFileSync(booted.env.MOSAIC_CONFIG, 'utf8'));
|
||||
writeFileSync(join(dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'other' }));
|
||||
check(booted.env, booted.configDir, 'marker');
|
||||
});
|
||||
|
||||
test('the default instance refuses the agent level verbs', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'a2def-'));
|
||||
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home };
|
||||
for (const args of [['agents', 'root', 'list'], ['project', 'agents', 'work', '--org', 'alpha', 'list']]) {
|
||||
const r = spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env, encoding: 'utf8' });
|
||||
assert.equal(r.status, 2, r.stderr); assert.match(r.stderr, /named instance/);
|
||||
}
|
||||
assert.equal(existsSync(join(home, '.config/mosaic-dev/agents.json')), false);
|
||||
});
|
||||
|
||||
test('project agents: set, narrow, exclude and remove print receipts; a widening narrow and the chief refuse', () => {
|
||||
const { run, ok, project, home } = instance();
|
||||
ok('agents', 'root', 'set', 'researcher', '--definition', 'research');
|
||||
ok('agents', 'root', 'set', 'scout', '--definition', 'research');
|
||||
const p = (...args) => ['project', 'agents', project, '--org', 'alpha', ...args];
|
||||
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, []);
|
||||
const defined = JSON.parse(ok(...p('set', 'checker', '--definition', 'fact-checker', '--var', 'model=claude-sonnet-5-5')));
|
||||
assert.deepEqual({ ...defined, revisionBefore: undefined, revisionAfter: undefined, transaction: undefined },
|
||||
{ verb: 'project.agents.set', organization: 'alpha', project, agent: 'checker', form: 'define', revisionBefore: undefined, revisionAfter: undefined, transaction: undefined });
|
||||
assert.notEqual(defined.revisionBefore, defined.revisionAfter);
|
||||
assert.ok(!JSON.stringify(defined).includes(home), 'no host path in the receipt');
|
||||
assert.equal(JSON.parse(ok(...p('narrow', 'researcher', '--var', 'limits.network=none'))).form, 'narrow');
|
||||
assert.equal(JSON.parse(ok(...p('exclude', 'scout'))).form, 'exclude');
|
||||
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, [
|
||||
{ name: 'checker', form: 'define', definition: 'fact-checker', varKeys: ['model'] },
|
||||
{ name: 'researcher', form: 'narrow', varKeys: ['limits.network'] },
|
||||
{ name: 'scout', form: 'exclude' },
|
||||
]);
|
||||
const file = join(home, '.config/mosaic-atwo/businesses/alpha.json');
|
||||
const before = readFileSync(file, 'utf8');
|
||||
const widen = run(p('narrow', 'scout', '--var', `model=${SECRET}`));
|
||||
assert.equal(widen.status, 2); assert.match(widen.stderr, /can only narrow limits\.tools, limits\.network, limits\.authority, not "model"/); assert.ok(!widen.stderr.includes(SECRET));
|
||||
const chief = run(p('exclude', 'chief'));
|
||||
assert.equal(chief.status, 2); assert.match(chief.stderr, /project-agents-invalid/);
|
||||
const stale = run(p('remove', 'checker', '--revision', defined.revisionBefore));
|
||||
assert.equal(stale.status, 2); assert.match(stale.stderr, /revision-conflict/);
|
||||
const inside = run(p('remove', 'checker'), { MOSAIC_RUN_ID: 'r1' });
|
||||
assert.equal(inside.status, 3);
|
||||
assert.equal(readFileSync(file, 'utf8'), before);
|
||||
for (const name of ['checker', 'researcher', 'scout']) assert.equal(JSON.parse(ok(...p('remove', name))).form, 'remove');
|
||||
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, []);
|
||||
const missing = run(['project', 'agents', 'nope', '--org', 'alpha', 'exclude', 'scout']);
|
||||
assert.equal(missing.status, 2); assert.match(missing.stderr, /project-not-found/);
|
||||
});
|
||||
@@ -100,9 +100,15 @@ function sessionPairs(pairs) {
|
||||
// Safety net for a removed-outer-bound change. It is fixture cleanup, never
|
||||
// product supervision. The guard reads only fixture records and checks sid
|
||||
// immediately before every kill. Normal outer-bound cases return before it.
|
||||
export function fixtureGuard(t,files,milliseconds=9500) {
|
||||
const program=`const fs=require('node:fs');setTimeout(()=>{for(const file of JSON.parse(process.argv[1])){let rows=[];try{rows=fs.readFileSync(file,'utf8').trim().split(String.fromCharCode(10))}catch{}for(const row of rows){const [pid,sid]=row.trim().split(' ').map(Number);if(!(pid>0&&sid>0))continue;try{const raw=fs.readFileSync('/proc/'+pid+'/stat','utf8'),f=raw.slice(raw.lastIndexOf(') ')+2).split(' ');if(Number(f[3])===sid&&f[0]!=='Z')process.kill(pid,'SIGKILL')}catch{}}}},Number(process.argv[2]));`;
|
||||
const child=spawn(process.execPath,['-e',program,JSON.stringify(files),String(milliseconds)],{stdio:'ignore'});
|
||||
export function fixtureGuard(t,files,milliseconds=9500,startFile) {
|
||||
// P10 already records the instant its observed probe starts. Arm its safety
|
||||
// net there, so unrelated host-fixture setup cannot consume the probe budget.
|
||||
// Callers without an observer retain the original immediate-start behavior.
|
||||
const program=`const fs=require('node:fs');
|
||||
function cleanup(){for(const file of JSON.parse(process.argv[1])){let rows=[];try{rows=fs.readFileSync(file,'utf8').trim().split(String.fromCharCode(10))}catch{}for(const row of rows){const [pid,sid]=row.trim().split(' ').map(Number);if(!(pid>0&&sid>0))continue;try{const raw=fs.readFileSync('/proc/'+pid+'/stat','utf8'),f=raw.slice(raw.lastIndexOf(') ')+2).split(' ');if(Number(f[3])===sid&&f[0]!=='Z')process.kill(pid,'SIGKILL')}catch{}}}}
|
||||
function arm(){setTimeout(cleanup,Number(process.argv[2]));}
|
||||
if(!process.argv[3])arm();else{const poll=setInterval(()=>{let text='';try{text=fs.readFileSync(process.argv[3],'utf8')}catch{}if(/^begin [0-9]+[.][0-9]{2}\\n/.test(text)){clearInterval(poll);arm();}},10);}`;
|
||||
const child=spawn(process.execPath,['-e',program,JSON.stringify(files),String(milliseconds),...(startFile?[startFile]:[])],{stdio:'ignore'});
|
||||
const stop=async()=>{
|
||||
if(child.exitCode!==null||child.signalCode!==null)return;
|
||||
const exited=new Promise(resolve=>child.once('exit',resolve));
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fixtureGuard } from './case-sudo-fixture.mjs';
|
||||
|
||||
test('fixture cleanup guard starts its budget at the probe marker', async t => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'guard-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const records = join(dir, 'pids'), startFile = join(dir, 'probe');
|
||||
writeFileSync(startFile, '');
|
||||
const child = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { stdio: 'ignore' });
|
||||
const exited = once(child, 'exit');
|
||||
t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); });
|
||||
const raw = readFileSync(`/proc/${child.pid}/stat`, 'utf8');
|
||||
const sid = Number(raw.slice(raw.lastIndexOf(') ') + 2).split(' ')[3]);
|
||||
writeFileSync(records, `${child.pid} ${sid}\n`);
|
||||
const stop = fixtureGuard(t, [records], 100, startFile);
|
||||
// An empty marker is setup, not the start of the observed probe. This wait
|
||||
// exceeds the cleanup budget deliberately; no production bound is changed.
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
assert.equal(child.signalCode, null, 'setup must not consume the probe cleanup budget');
|
||||
writeFileSync(startFile, 'begin 100.00\n');
|
||||
const terminal = await Promise.race([exited, new Promise((_, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('armed fixture guard did not clean up')), 3000);
|
||||
timer.unref();
|
||||
})]);
|
||||
assert.deepEqual(terminal, [null, 'SIGKILL']);
|
||||
await stop();
|
||||
});
|
||||
@@ -429,7 +429,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
|
||||
assert.equal(baseline.status,0,baseline.out+baseline.err);
|
||||
assert.deepEqual(await pollFixtureGone(files),[],'baseline leaves no session');
|
||||
}
|
||||
const capture=cleanupFixture(t,files);const stop=fixtureGuard(t,files,7000);
|
||||
const capture=cleanupFixture(t,files);
|
||||
if(fault==='inner')put(join(h.hc,'peer-hang'),'');
|
||||
if(fault==='outer'){put(join(h.hc,'stall-probe'),'');put(join(h.hc,'stall-output'),'200 997 997\n');}
|
||||
if(fault==='overflow'){put(join(h.hc,'peer-output'),'200 997 997\n'+'X'.repeat(256));put(join(h.hc,'peer-status'),'0');}
|
||||
@@ -440,6 +440,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
|
||||
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
|
||||
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
|
||||
}
|
||||
const stop=fixtureGuard(t,files,7000,timingFile);
|
||||
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
|
||||
assert.equal(r.status,0,r.out+r.err);
|
||||
const reason=fault==='inner'?'inner-timeout':fault==='outer'?'outer-timeout':'output-limit';
|
||||
|
||||
@@ -33,7 +33,7 @@ async function setup(t, { stub = false, hook = null } = {}) {
|
||||
const bus = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
businesses: stub
|
||||
? { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }
|
||||
? { acme: { id: "acme", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }
|
||||
: boot.businesses,
|
||||
organization: stub ? { dir, rolesDir: f.roles, system: systemFor(root) } : boot.organization,
|
||||
});
|
||||
|
||||
@@ -445,7 +445,7 @@ test("each launch makes sure the workspace has a clone of the project: cloned, f
|
||||
test("a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none", { skip, timeout: 30000 }, async (t) => {
|
||||
const asked = [];
|
||||
const held = [{ instance: "chief", decision: "d1", task_ref: "vikunja:1/2" }, { instance: "coder", decision: "d2", task_ref: "vikunja:1/3" }];
|
||||
const answering = (holders) => ({ wrap: (host) => Object.create(host, { op: { value: async (m) => (m.op === "landHolders" ? (asked.push(m), holders) : host.op(m)) } }) });
|
||||
const answering = (holders) => ({ threadTickMs: 3600000, wrap: (host) => Object.create(host, { op: { value: async (m) => (m.op === "landHolders" ? (asked.push(m), holders) : host.op(m)) } }) });
|
||||
const landing = "- Landing: once your merge decision is approved, land_work it with the task.";
|
||||
const read = (f, run, file) => readFileSync(join(f.dataRoot, "launches", "acme", run, file), "utf8");
|
||||
|
||||
|
||||
@@ -90,9 +90,9 @@ async function setup(t, { publishDeadlineMs } = {}) {
|
||||
credentials: { gitea: { store: true, file: join(root, "secrets", "agents", "demo", user, "gitea"), rotateBy: "2099-01-01" } },
|
||||
});
|
||||
const demo = {
|
||||
id: "demo", human: "jason", arbiters: { technical: "reviewer", delivery: "reviewer" },
|
||||
id: "demo", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
projects: { stack: { root: project, remote: `${gitea.origin}/mosaic/stack` } },
|
||||
roles: {
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: agent("coder"),
|
||||
second: agent("second"),
|
||||
reviewer: { authority: { withinRole: ["message.send"], crossRole: [] } },
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fixture } from './helpers.mjs';
|
||||
const repo = join(import.meta.dirname, '../../..');
|
||||
test('task file JSON refusal preserves exit 2 and file without parser bytes', t => {
|
||||
const file = write(join(scratch(t), 'task.json'));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2, r.stderr);
|
||||
assert.ok(r.stderr.includes(file), r.stderr);
|
||||
assert.match(r.stderr, /task file is not valid JSON/);
|
||||
assert.doesNotMatch(r.stdout + r.stderr, /sk-test|Unexpected token/);
|
||||
});
|
||||
test('task file read refusal retains exit 2, file and filesystem code', t => {
|
||||
const file = write(join(scratch(t), 'task.json'), '{}');
|
||||
// A directory is refused by lstat before the read. Inject a read failure
|
||||
// after that unchanged regular-file gate, including on root-run CI hosts.
|
||||
const preload = `import fs from 'node:fs';
|
||||
const read = fs.readFileSync;
|
||||
fs.readFileSync = function(path, ...args) {
|
||||
if (path === ${JSON.stringify(file)}) throw Object.assign(new Error(${JSON.stringify(secret)}), { code: 'EIO' });
|
||||
return read.call(this, path, ...args);
|
||||
};`;
|
||||
const r = spawnSync(process.execPath, ['--import', 'data:text/javascript,' + encodeURIComponent(preload), join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2, r.stderr);
|
||||
assert.ok(r.stderr.includes(file), r.stderr);
|
||||
assert.match(r.stderr, /task file is not valid JSON.*\(EIO\)/);
|
||||
assert.doesNotMatch(r.stderr, /sk-test/);
|
||||
});
|
||||
test('case2 precheck business helper omits parser bytes in its BUSINESS fact', t => {
|
||||
const root = scratch(t), f = fixture(root);
|
||||
const file = write(join(root, 'config/businesses/acme.json'));
|
||||
// Execute the production facts helper, without host or credential checks.
|
||||
const shell = readFileSync(join(repo, 'scripts/case2-precheck.sh'), 'utf8');
|
||||
const start = shell.indexOf('facts="$(cd "$REPO" && node --input-type=module -e \'') + 'facts="$(cd "$REPO" && node --input-type=module -e \''.length;
|
||||
assert.ok(start > 100);
|
||||
const end = shell.indexOf("' \"$REPO\" \"$BUSINESS\"", start);
|
||||
assert.ok(end > start);
|
||||
const r = spawnSync(process.execPath, ['--input-type=module', '-e', shell.slice(start, end), repo, 'acme'], { encoding: 'utf8', env: { ...f.env, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, PATH: process.env.PATH } });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.ok(r.stdout.includes(`BUSINESS\tbad\tcannot read ${file}:`), r.stdout);
|
||||
assert.doesNotMatch(r.stdout + r.stderr, /sk-test|Unexpected token/);
|
||||
});
|
||||
@@ -18,8 +18,8 @@ const none = { authority: { withinRole: ["message.send"], crossRole: [] } };
|
||||
const inAgent = (env) => io({ ...env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" });
|
||||
const bus = (vars = { "bus.tester": true }) => ({
|
||||
acme: {
|
||||
id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, ...(vars ? { vars } : {}),
|
||||
roles: { coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } },
|
||||
id: "acme", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, ...(vars ? { vars } : {}),
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -281,8 +281,8 @@ function mosaic(args, env) {
|
||||
test("row 184: scripts/mosaic runs thread create, threads, talk --thread and thread read as the tester through tester-cli.mjs", async (t) => {
|
||||
const { root, f, org } = await orgFixture(t);
|
||||
const none = { authority: { withinRole: ["message.send"], crossRole: [] } };
|
||||
const businesses = { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, vars: { "bus.tester": true },
|
||||
roles: { coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } } } };
|
||||
const businesses = { acme: { id: "acme", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, vars: { "bus.tester": true },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } } } };
|
||||
const rt = await startBroker({ dataRoot: org.root, businesses, organization: { dir: join(root, ".config", "mosaic-test"), rolesDir: f.roles, system: systemFor(root) } });
|
||||
t.after(() => rt.close());
|
||||
const human = rt.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// exception is <boardDir>/seen.json, which holds Jason's "seen" marks and is
|
||||
// only changed when he clicks; a scan reads it and never rewrites it.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { join, basename, isAbsolute, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -35,9 +36,9 @@ export function loadConfig(path = defaultConfigPath()) {
|
||||
if (!existsSync(path)) throw new ConfigError(`config not found: ${path}`);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new ConfigError(`config is not valid JSON: ${path} (${err.message})`);
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new ConfigError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new ConfigError(`config is not an object: ${path}`);
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) {
|
||||
@@ -227,9 +228,9 @@ export function loadSeen(boardDir) {
|
||||
if (!existsSync(path)) return {};
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new ConfigError(`seen marks file is not valid JSON: ${path} (${err.message})`);
|
||||
parsed = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new ConfigError(`seen marks file is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError(`seen marks file must be a JSON object: ${path}`);
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
function safe(label, ErrorType, properties = {}) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
import { ConfigError, loadConfig, loadSeen } from '../src/scan.mjs';
|
||||
for (const kind of ['config', 'seen']) test(`${kind} JSON refusal names only the file`, t => {
|
||||
const root = scratch(t), file = write(join(root, kind === 'config' ? 'config.json' : 'seen.json'));
|
||||
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), safe(file, ConfigError));
|
||||
});
|
||||
for (const kind of ['config', 'seen']) test(`${kind} read refusal retains the filesystem code`, t => {
|
||||
const root = scratch(t), file = join(root, kind === 'config' ? 'config.json' : 'seen.json');
|
||||
mkdirSync(file);
|
||||
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), error => {
|
||||
safe(file, ConfigError)(error);
|
||||
assert.match(error.message, /\(EISDIR\)$/);
|
||||
assert.doesNotMatch(error.message, /illegal operation/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
// The per-message budget resets on agent_start, the event pi emits once per
|
||||
// prompt run, so a follow-up message gets a fresh budget.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { Type } from "typebox";
|
||||
import { TOOLS_ENV, TOOL_DESCRIPTIONS, READ_MAX_LINES, loadToolsConfig, createToolSet, enabledToolNames } from "../src/tools.mjs";
|
||||
import { COMMIT_MESSAGE_MAX, COMMIT_PATHS_MAX, VAULT_PREFIXES } from "../src/git.mjs";
|
||||
@@ -141,9 +142,9 @@ export default function (pi) {
|
||||
if (typeof raw !== "string" || raw.length === 0) throw new Error(`${TOOLS_ENV} is not set; the connector sets it from the binding's tools key`);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`${TOOLS_ENV} is not valid JSON: ${err.message}`);
|
||||
parsed = parseJsonFileText(raw, TOOLS_ENV);
|
||||
} catch {
|
||||
throw new Error(`${TOOLS_ENV} is not valid JSON`);
|
||||
}
|
||||
const config = loadToolsConfig(parsed);
|
||||
const tools = createToolSet(config);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
// Ids in this file are private to the binding's data directory (0600),
|
||||
// like every other journal.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
@@ -104,9 +105,9 @@ export function readApprovals(dir) {
|
||||
for (const [i, line] of readFileSync(path, "utf8").split("\n").entries()) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
out.push(JSON.parse(line));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON (${err.message})`);
|
||||
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
|
||||
} catch {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
// Absent means no tools and a launch exactly as before. It is a fixed
|
||||
// key: the extension reads it at pi start.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import { loadWebConfig } from "./web.mjs";
|
||||
import { loadSetsparkConfig } from "./setspark.mjs";
|
||||
@@ -59,9 +60,9 @@ export function loadDataRoot(path = defaultConfigPath()) {
|
||||
if (!existsSync(path)) throw new DiscordError(`config not found: ${path}`);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`config is not valid JSON: ${path} (${err.message})`);
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new DiscordError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DiscordError(`config is not an object: ${path}`);
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new DiscordError(`config.dataRoot must be an absolute path: ${path}`);
|
||||
@@ -292,9 +293,9 @@ export function loadBinding(path) {
|
||||
checkPrivateFile(path, "binding");
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`binding is not valid JSON: ${path} (${err.message})`);
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new DiscordError(`binding is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
return validateBinding(raw, `binding ${path}`);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
// supervised form for a service unit's pre-start
|
||||
// Directories are 0700, files 0600. Lines are appended, never rewritten.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import {
|
||||
appendFileSync, closeSync, constants, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmdirSync, rmSync,
|
||||
unlinkSync, writeFileSync, writeSync,
|
||||
@@ -47,9 +48,9 @@ function readLines(path) {
|
||||
for (const [i, line] of text.split("\n").entries()) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
out.push(JSON.parse(line));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON (${err.message})`);
|
||||
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
|
||||
} catch {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
function safe(label, ErrorType, properties = {}) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
import { DiscordError } from '../src/errors.mjs';
|
||||
import { loadDataRoot, loadBinding } from '../src/binding.mjs';
|
||||
import { readApprovals } from '../src/approvals.mjs';
|
||||
import { readInboxIds } from '../src/journal.mjs';
|
||||
import extension from '../extension/tools.mjs';
|
||||
import { TOOLS_ENV } from '../src/tools.mjs';
|
||||
for (const [name, read, relative] of [
|
||||
['config', (file) => loadDataRoot(file), 'config.json'],
|
||||
['binding', (file) => loadBinding(file), 'binding.json'],
|
||||
['approvals', (_file, root) => readApprovals(root), 'approvals.jsonl'],
|
||||
['journal', (_file, root) => readInboxIds(root), 'inbox.jsonl'],
|
||||
]) test(`${name} JSON refusal omits parser bytes`, t => {
|
||||
const root = scratch(t), ledger = name === 'approvals' || name === 'journal';
|
||||
const file = write(join(root, relative), ledger ? '{}\n' + malformed : malformed);
|
||||
assert.throws(() => read(file, root), safe(ledger ? `${file}:2` : file, DiscordError, { exitCode: 2 }));
|
||||
});
|
||||
test('tools environment JSON refusal names the config key without its bytes', () => {
|
||||
const previous = process.env[TOOLS_ENV];
|
||||
process.env[TOOLS_ENV] = malformed;
|
||||
try { assert.throws(() => extension({}), safe(TOOLS_ENV, Error)); }
|
||||
finally { if (previous === undefined) delete process.env[TOOLS_ENV]; else process.env[TOOLS_ENV] = previous; }
|
||||
});
|
||||
test('config read refusal retains the filesystem code', t => {
|
||||
const file = scratch(t);
|
||||
assert.throws(() => loadDataRoot(file), error => {
|
||||
safe(file, DiscordError, { exitCode: 2 })(error);
|
||||
assert.match(error.message, /\(EISDIR\)$/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ export function webPlan(resolved, harness, builtinWeb) {
|
||||
throw new BundleError(`instance ${resolved.instance}: a ${resolved.definition} role fetches only through the broker, not search-source "harness-builtin"`, "web-source-not-allowed");
|
||||
}
|
||||
if (granted && source === "harness-builtin" && !builtinWeb) {
|
||||
throw new BundleError(`instance ${resolved.instance}: search-source "harness-builtin" has no built-in web tool on ${harness}`);
|
||||
throw new BundleError(`instance ${resolved.instance}: search-source "harness-builtin" has no built-in web tool on the configured harness`);
|
||||
}
|
||||
return { source, builtin: granted && source === "harness-builtin" };
|
||||
}
|
||||
|
||||
@@ -193,17 +193,17 @@ test("a chief-of-staff bundle lists launch among the bus tools and the manifest'
|
||||
|
||||
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1): the 3d re-pin window checks this manifest: both tools typed.
|
||||
// Row 141 (#1635, REQ-MSG-2) granted instance.name, and row 155 (#1649) gives it set_name, so nothing
|
||||
// the chief holds is untyped and the prompt has no untyped line. Row 194 (#1692, PRD 1.7 REQ-COS-3)
|
||||
// granted role.hire and role.fire, with hire and fire.
|
||||
test("a chief-of-staff bundle lists end_run, change_organization and set_name as typed and nothing untyped, for either harness", (t) => {
|
||||
// the chief held then was untyped. Row 194 (#1692, PRD 1.7 REQ-COS-3)
|
||||
// granted role.hire and role.fire, with hire and fire. RR adds two resolve rights; TI owns their tools.
|
||||
test("a chief-of-staff bundle lists end_run, change_organization and set_name as typed and resolve rights as untyped, for either harness", (t) => {
|
||||
for (const harness of ["pi", "claude-code"]) {
|
||||
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" }));
|
||||
const b = build(t, r);
|
||||
const prefix = harness === "pi" ? "" : "mcp__mosaic__";
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.untyped, [], harness);
|
||||
assert.deepEqual(b.manifest.untyped, ["decision.resolve.technical", "decision.resolve.delivery"], harness);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
|
||||
assert.doesNotMatch(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -440,7 +440,7 @@ test("search-source harness-builtin: claude-code gets WebSearch and WebFetch and
|
||||
|
||||
test("search-source harness-builtin is refused for a granted pi role, which has no built-in web tool", (t) => {
|
||||
const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "pi", model: "claude-sonnet-5-5" }));
|
||||
assert.throws(() => build(t, withSource(granted(r), "harness-builtin")), (e) => e instanceof BundleError && /no built-in web tool on pi/.test(e.message));
|
||||
assert.throws(() => build(t, withSource(granted(r), "harness-builtin")), (e) => e instanceof BundleError && /no built-in web tool on the configured harness/.test(e.message));
|
||||
// Without the grant the source doesn't matter on pi.
|
||||
assert.ok(!build(t, withSource(r, "harness-builtin")).manifest.builtins.some((x) => /web/i.test(x)));
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
function safe(label, ErrorType, properties = {}) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
import { BundleError, webPlan } from '../src/bundle.mjs';
|
||||
test('harness web refusal names the harness field without the configured value', () => {
|
||||
assert.throws(() => webPlan({ instance: 'builder', definition: 'coder', webSource: 'harness-builtin', limits: { tools: ['web'] } }, secret, false), safe('harness', BundleError, { code: 'bundle-refused' }));
|
||||
});
|
||||
@@ -55,7 +55,7 @@ for (const harness of ['pi', 'claude-code']) {
|
||||
}
|
||||
|
||||
test('PM intake writes and staff request follow the existing message.send grant', t => {
|
||||
const muted = bundle(t, 'pm', 'pi', doc => { doc.roles.pm.vars['limits.authority'] = ['task.assign']; });
|
||||
const muted = bundle(t, 'pm', 'pi', doc => { doc.roles.pm.vars['limits.authority'] = ['task.assign', 'decision.resolve.delivery']; });
|
||||
assert.deepEqual(muted.tools.filter(intake).map(t => t.name).sort(), reads.toSorted());
|
||||
assert.ok(!muted.tools.some(t => t.name === 'request_staff'));
|
||||
});
|
||||
|
||||
@@ -23,15 +23,15 @@ const businesses = {
|
||||
demo: {
|
||||
id: "demo",
|
||||
human: "jason",
|
||||
arbiters: { technical: "pm", delivery: "pm" },
|
||||
arbiters: { technical: "chief", delivery: "pm" },
|
||||
launch: { by: "pm", instances: ["coder"], models:{}, limits:{} },
|
||||
roles: {
|
||||
pm: { authority: { withinRole: ["message.send", "role.launch"], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ["message.send", "role.launch", "decision.resolve.delivery"], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ["message.send"], crossRole: [] } },
|
||||
// Row 132 (#1628): the chief of staff's grants and the assistant. Since row 139 (Q169) the
|
||||
// chief may end the assistant; with no thread run holding a claim the end refuses not-held.
|
||||
// Row 155 (#1649) adds row 141's instance.name for set_name.
|
||||
chief: { definition: "chief-of-staff", authority: { withinRole: ["message.send", "role.end", "organization.change", "instance.name"], crossRole: [] } },
|
||||
chief: { definition: "chief-of-staff", authority: { withinRole: ["message.send", "role.end", "organization.change", "instance.name", "decision.resolve.technical", "decision.resolve.delivery"], crossRole: [] } },
|
||||
assistant: { definition: "assistant", authority: { withinRole: ["message.send"], crossRole: [] } },
|
||||
},
|
||||
},
|
||||
@@ -829,8 +829,10 @@ test("a run in a landing turn isn't released for a second held decision until it
|
||||
const earlier = ctx.broker.bindLaunch({ business: "demo", role: "coder", run: "coder-0", harness: "pi" });
|
||||
ctx.call(earlier, "role.claim");
|
||||
const d1 = ctx.call(earlier, "decision.raise", merge("vikunja:1/2"));
|
||||
assert.equal(d1.route_to, "pm");
|
||||
ctx.call(ctx.pm, "decision.resolve", { id: d1.id, choice: "yes" });
|
||||
const chief = ctx.broker.bindLaunch({ business: "demo", role: "chief", run: "chief-run", harness: "pi" });
|
||||
ctx.call(chief, "role.claim");
|
||||
assert.equal(d1.route_to, "chief");
|
||||
ctx.call(chief, "decision.resolve", { id: d1.id, choice: "yes" });
|
||||
ctx.broker.endLaunch({ business: "demo", run: "coder-0", reason: "exited" });
|
||||
const file = join(ctx.runDir, "session.json");
|
||||
writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file, "utf8")), landing: [{ decision: d1.id, task_ref: "vikunja:1/2" }] }));
|
||||
@@ -843,7 +845,7 @@ test("a run in a landing turn isn't released for a second held decision until it
|
||||
await until(() => existsSync(started));
|
||||
// d2 is resolved during the landing turn, so the landing pass lists it for the running holder.
|
||||
const d2 = ctx.call(ctx.cap, "decision.raise", merge("vikunja:1/3"));
|
||||
ctx.call(ctx.pm, "decision.resolve", { id: d2.id, choice: "yes" });
|
||||
ctx.call(chief, "decision.resolve", { id: d2.id, choice: "yes" });
|
||||
assert.deepEqual(ctx.broker.landHolders("demo").map((h) => [h.instance, h.decision]), [["coder", d1.id], ["coder", d2.id]]);
|
||||
const release = () => ctx.broker.landRelease({ business: "demo", run: "coder-run", decision: d2.id });
|
||||
assert.deepEqual(release(), { released: false, landing: true });
|
||||
|
||||
@@ -58,7 +58,8 @@ test("the chief of staff gets launch, send_message, end_run, hire, fire, change_
|
||||
// Row 132 (#1628): role.end and organization.change have their tools. Row 141 (#1635) granted
|
||||
// instance.name, and row 155 (#1649) gives it set_name, so no held action is untyped. Row 194 (#1692,
|
||||
// PRD 1.7 REQ-COS-3) granted role.hire and role.fire, with hire and fire.
|
||||
assert.deepEqual(untypedActions(chief, tools), []);
|
||||
// RR grants resolve rights; TI owns their typed tool exposure.
|
||||
assert.deepEqual(untypedActions(chief, tools), ["decision.resolve.technical", "decision.resolve.delivery"]);
|
||||
});
|
||||
|
||||
// Row 194 (#1692, PRD 1.7 REQ-COS-3): hire and fire send the broker's argument keys, reason required,
|
||||
@@ -104,7 +105,7 @@ test("request_staff is the pm's alone: staff.request over message.send, roles an
|
||||
const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" }));
|
||||
assert.ok(!names(typedTools({ resolved: assistant })).includes("request_staff"));
|
||||
// A pm without message.send is offered no request_staff, as for send_message.
|
||||
const mute = resolvedFor(t, "pm", (d) => (d.roles.pm.vars["limits.authority"] = ["task.assign"]));
|
||||
const mute = resolvedFor(t, "pm", (d) => (d.roles.pm.vars["limits.authority"] = ["task.assign", "decision.resolve.delivery"]));
|
||||
assert.ok(!names(typedTools({ resolved: mute, tracker: true })).includes("request_staff"));
|
||||
// The pm still has no launch tool, and the chief of staff's launch offer is its limits, not a request.
|
||||
assert.ok(!names(tools).includes("launch"));
|
||||
@@ -240,7 +241,7 @@ test("list_requirements reads the requirement ids, for a task.create holder with
|
||||
assert.match(list.description, /task_create takes only these/);
|
||||
assert.match(list.description, /Product intake always needs an approved product snapshot/);
|
||||
assert.ok(!names(typedTools({ resolved: pm, arbiter: true })).includes("list_requirements"));
|
||||
const noCreate = resolvedFor(t, "pm", (d) => (d.roles.pm.vars["limits.authority"] = ["message.send", "task.assign"]));
|
||||
const noCreate = resolvedFor(t, "pm", (d) => (d.roles.pm.vars["limits.authority"] = ["message.send", "task.assign", "decision.resolve.delivery"]));
|
||||
const without = names(typedTools({ resolved: noCreate, tracker: true }));
|
||||
assert.ok(without.includes("task_assign") && !without.includes("task_create") && !without.includes("list_requirements"));
|
||||
const create = tools.find((x) => x.name === "task_create").parameters.properties;
|
||||
|
||||
@@ -65,6 +65,9 @@ function fixture(t) {
|
||||
cpSync(source, path.join(root, 'packages/ledger/src'), { recursive: true });
|
||||
// The queue section imports the queue validator and the seat registry.
|
||||
for (const pkg of ['queue', 'seat']) cpSync(path.join(source, '../..', pkg, 'src'), path.join(root, 'packages', pkg, 'src'), { recursive: true });
|
||||
mkdirSync(path.join(root, 'packages/organization/src'), { recursive: true });
|
||||
for (const file of ['util.mjs', 'errors.mjs', 'vocabulary.mjs'])
|
||||
cpSync(path.join(source, '../../organization/src', file), path.join(root, 'packages/organization/src', file));
|
||||
// The fake serves ISSUES for every page, or page N of the ISSUE_PAGES array
|
||||
// (an empty page past its end). API_FAIL fails every call, or only FAIL_PAGE.
|
||||
const api = put('bin/gitea-api.sh', `#!/usr/bin/env node
|
||||
|
||||
@@ -337,6 +337,9 @@ function cliFixture(t, rows = MAP) {
|
||||
const repo = scratchRepo(t, { map: mapText(rows, { retired: [], highWater: Math.max(...rows.map(r => r.id)) }) });
|
||||
genesisCommitted(repo);
|
||||
for (const pkg of ['ledger', 'seat']) cpSync(path.join(PACKAGES, pkg, 'src'), path.join(repo.root, 'packages', pkg, 'src'), { recursive: true });
|
||||
mkdirSync(path.join(repo.root, 'packages/organization/src'), { recursive: true });
|
||||
for (const file of ['util.mjs', 'errors.mjs', 'vocabulary.mjs'])
|
||||
cpSync(path.join(PACKAGES, 'organization/src', file), path.join(repo.root, 'packages/organization/src', file));
|
||||
const dataRoot = path.join(repo.base, 'data');
|
||||
const config = path.join(repo.base, 'config.json');
|
||||
writeFileSync(config, JSON.stringify({ dataRoot }));
|
||||
|
||||
@@ -112,10 +112,10 @@ function checkScope(value, where, check) {
|
||||
|
||||
function checkExpires(value, where) {
|
||||
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
|
||||
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
|
||||
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z`);
|
||||
}
|
||||
const iso = new Date(value).toISOString();
|
||||
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
|
||||
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time`);
|
||||
}
|
||||
|
||||
function checkGrant(grant, at, ctx, users, seenGrants) {
|
||||
@@ -133,7 +133,7 @@ function checkGrant(grant, at, ctx, users, seenGrants) {
|
||||
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
|
||||
});
|
||||
checkScope(grant.instances, `${at}.instances`, (x, where) => {
|
||||
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
|
||||
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of the grant's business`);
|
||||
});
|
||||
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
|
||||
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
// #1873 Phase A slice A2: the root agent file's writer (design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
|
||||
//
|
||||
// One instance-wide file, <configDir>/agents.json. Only the signed-in owner writes it: the caller
|
||||
// passes the actor and the owner principal its own trusted context established (the CLI's local
|
||||
// owner, the Console's owner session), and anything else refuses `human-required`. The write
|
||||
// follows M2's locked store (accounts.mjs): a lock file, a re-read and revision check under it, the
|
||||
// whole new document checked with A1's checkRootAgents, a 0600 temp file, a re-check, then rename
|
||||
// (or link for a new file), a directory sync and one receipt line. Nothing here launches an agent.
|
||||
//
|
||||
// No refusal and no receipt carries a value from the file or the request: a var value can be a
|
||||
// secret typed into the wrong place. Messages name the file (agents.json) and the key, never a
|
||||
// host path.
|
||||
import { constants, lstatSync } from 'node:fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { join, parse, resolve } from 'node:path';
|
||||
import { OrgError } from './org-registry.mjs';
|
||||
import { ROOT_FILE, checkProjectAgents, checkRootAgents, loadRootAgents, projectUnion, rootAgentsPath } from './agent-levels.mjs';
|
||||
import { canonicalJson, isPlainObject } from './util.mjs';
|
||||
import { ID_PATTERN } from './vocabulary.mjs';
|
||||
import { REGISTRY } from './vars.mjs';
|
||||
|
||||
export const ROOT_LOCK = 'agents.lock';
|
||||
export const ROOT_RECEIPTS = 'agents-receipts.jsonl';
|
||||
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
||||
// The revision of a root file that does not exist yet: the digest of no bytes.
|
||||
export const ABSENT_REVISION = sha256('');
|
||||
const REVISION = /^[a-f0-9]{64}$/;
|
||||
const PRINCIPAL = ['provider', 'iss', 'sub'];
|
||||
const fail = (code, message = code) => { throw new OrgError(code, message); };
|
||||
|
||||
// The only actor that writes: the owner principal the caller's trusted context names. The tester
|
||||
// identity never counts as the owner, whatever a context says.
|
||||
export function requireRootOwner(actor, owner) {
|
||||
const ok = p => isPlainObject(p) && PRINCIPAL.every(k => typeof p[k] === 'string' && p[k] !== '');
|
||||
if (!ok(actor) || !ok(owner) || PRINCIPAL.some(k => actor[k] !== owner[k]) || actor.sub === 'tester' || actor.provider === 'tester')
|
||||
fail('human-required', 'only the signed-in owner writes the root agent file');
|
||||
}
|
||||
|
||||
// Every ancestor a real directory that only root or this uid can change: owned by one of them, and
|
||||
// group or other writable only with the sticky bit (as /tmp is), so no other user can rename or
|
||||
// replace configDir between these checks and the path-based lock, temp file and rename. configDir
|
||||
// itself is this uid's and not group or other writable.
|
||||
function assertConfigDir(configDir, uid) {
|
||||
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('agents-unsafe', 'config directory must be an absolute normalized path');
|
||||
let current = parse(configDir).root;
|
||||
const trusted = st => (st.uid === 0 || st.uid === uid) && (!(st.mode & 0o022) || (st.mode & 0o1000));
|
||||
try { if (!trusted(lstatSync(current))) fail('agents-unsafe', `a config directory ancestor must belong to root or uid ${uid} and not be writable by others without the sticky bit`); }
|
||||
catch (error) { if (error instanceof OrgError) throw error; fail('agents-unsafe', 'config directory is missing'); }
|
||||
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
|
||||
current = join(current, part);
|
||||
let st;
|
||||
try { st = lstatSync(current); } catch { fail('agents-unsafe', 'config directory is missing'); }
|
||||
if (!st.isDirectory() || st.isSymbolicLink()) fail('agents-unsafe', 'config path contains a symbolic link or non-directory');
|
||||
if (current !== configDir && !trusted(st)) fail('agents-unsafe', `a config directory ancestor must belong to root or uid ${uid} and not be writable by others without the sticky bit`);
|
||||
}
|
||||
const st = lstatSync(configDir);
|
||||
if (st.uid !== uid) fail('agents-unsafe', `config directory must belong to uid ${uid}`);
|
||||
if (st.mode & 0o022) fail('agents-unsafe', 'config directory must not be writable by group or other');
|
||||
}
|
||||
|
||||
// The reader's descriptor checks (loadRootAgents), plus one link: a regular file, no symbolic link,
|
||||
// this uid, no group or other write. Missing is an empty root level at ABSENT_REVISION.
|
||||
async function readCurrent(configDir, uid, statFile = fd => fd.stat()) {
|
||||
const file = rootAgentsPath(configDir);
|
||||
let fd;
|
||||
try { fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
||||
catch (error) {
|
||||
if (error.code === 'ENOENT') return { exists: false, revision: ABSENT_REVISION, document: { agentsVersion: 1, agents: {} } };
|
||||
fail('agents-unsafe', `root agent file must be a regular file, not a symbolic link (${ROOT_FILE})`);
|
||||
}
|
||||
let bytes;
|
||||
try {
|
||||
const st = await statFile(fd);
|
||||
if (!st.isFile()) fail('agents-unsafe', `root agent file must be a regular file, not a symbolic link (${ROOT_FILE})`);
|
||||
if (st.uid !== uid) fail('agents-unsafe', `root agent file must belong to uid ${uid} (${ROOT_FILE})`);
|
||||
if (st.mode & 0o022) fail('agents-unsafe', `root agent file must not be writable by group or other (${ROOT_FILE})`);
|
||||
if (st.nlink !== 1) fail('agents-unsafe', `root agent file must have exactly one link (${ROOT_FILE})`);
|
||||
bytes = await fd.readFile();
|
||||
} finally { await fd.close(); }
|
||||
let document;
|
||||
// The parser's text can quote the file's bytes: say only that it is not JSON.
|
||||
try { document = JSON.parse(bytes.toString('utf8')); } catch { fail('agents-invalid', `root agent file is not valid JSON (${ROOT_FILE})`); }
|
||||
return { exists: true, revision: sha256(bytes), document };
|
||||
}
|
||||
|
||||
// The file's document and revision, for a form or a CLI list. A file that is not a root agent
|
||||
// document at all refuses; one with a bad entry reads, so the owner can fix or remove that entry.
|
||||
// statFile is a test seam for the descriptor stat; it grants nothing a caller's uid does not.
|
||||
export async function readRootAgentsFile(configDir, { uid = process.geteuid(), statFile } = {}) {
|
||||
assertConfigDir(configDir, uid);
|
||||
const current = await readCurrent(configDir, uid, statFile);
|
||||
shape(current.document);
|
||||
return current;
|
||||
}
|
||||
|
||||
function shape(document) {
|
||||
if (!isPlainObject(document) || document.agentsVersion !== 1 || !isPlainObject(document.agents))
|
||||
fail('agents-invalid', `root agent file must be {"agentsVersion": 1, "agents": {...}} (${ROOT_FILE})`);
|
||||
}
|
||||
|
||||
// Every string a request put in an entry, as a message could quote it bare or as JSON.
|
||||
// What a refusal must not show. Field names, var names, role ids and org ids may appear; every
|
||||
// var value, every unknown key and any other string that isn't an id is caller text.
|
||||
const FIELDS = new Set(['definition', 'vars', 'excludeOrgs', 'exclude', 'credentials', 'holder', 'tracker', 'identity', 'publish', 'hosts']);
|
||||
function all(value, out) {
|
||||
if (typeof value === 'string') { if (value.length >= 3) out.push(value, JSON.stringify(value)); }
|
||||
else if (Array.isArray(value)) value.forEach(v => all(v, out));
|
||||
else if (isPlainObject(value)) for (const [key, v] of Object.entries(value)) { all(key, out); all(v, out); }
|
||||
return out;
|
||||
}
|
||||
const unlessId = (value, out) => (typeof value === 'string' && ID_PATTERN.test(value) ? out : all(value, out));
|
||||
function strings(entry, out = []) {
|
||||
if (!isPlainObject(entry)) return all(entry, out);
|
||||
for (const [key, value] of Object.entries(entry)) {
|
||||
if (!FIELDS.has(key)) all(key, out);
|
||||
if (key === 'definition') unlessId(value, out);
|
||||
else if (key === 'excludeOrgs' && Array.isArray(value)) value.forEach(v => unlessId(v, out));
|
||||
else if (key === 'exclude' && typeof value === 'boolean') continue;
|
||||
else if (key === 'vars' && isPlainObject(value)) for (const [name, v] of Object.entries(value)) { if (!Object.hasOwn(REGISTRY, name)) all(name, out); all(v, out); }
|
||||
else all(value, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A refusal's message with its key path and without any value: an id check's "(got ...)" is cut,
|
||||
// and a message that still holds a string from the entry is replaced by one naming the key only.
|
||||
export function withoutValues(message, entry, prefix) {
|
||||
const cut = String(message).replace(/ \(got [\s\S]*\)$/, '');
|
||||
if (!strings(entry).some(text => cut.includes(text))) return cut;
|
||||
const at = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\.[^\\s:]+)*`).exec(cut)?.[0] ?? prefix;
|
||||
return `${at}: refused (the value is not shown)`;
|
||||
}
|
||||
|
||||
// A1's checks on the whole new document.
|
||||
function validate(document, rolesDir, entry) {
|
||||
try { return checkRootAgents(document, { rolesDir }); }
|
||||
catch (error) {
|
||||
if (error?.name !== 'BusinessError') throw error;
|
||||
fail('agents-invalid', withoutValues(error.message, entry, 'root agents'));
|
||||
}
|
||||
}
|
||||
|
||||
// What changed in one entry, by key name only.
|
||||
function changedKeys(before, after) {
|
||||
if (!before && !after) return [];
|
||||
if (!before) return ['added'];
|
||||
if (!after) return ['removed'];
|
||||
const out = [];
|
||||
if (before.definition !== after.definition) out.push('definition');
|
||||
const bv = isPlainObject(before.vars) ? before.vars : {}, av = isPlainObject(after.vars) ? after.vars : {};
|
||||
for (const key of [...new Set([...Object.keys(bv), ...Object.keys(av)])].sort()) {
|
||||
if (canonicalJson(bv[key]) !== canonicalJson(av[key])) out.push(`vars.${key}`);
|
||||
}
|
||||
if (canonicalJson(before.excludeOrgs ?? []) !== canonicalJson(after.excludeOrgs ?? [])) out.push('excludeOrgs');
|
||||
return out;
|
||||
}
|
||||
|
||||
// One entry changed by a patch: `definition` replaces, `vars` sets each key it names (null removes
|
||||
// that key), `excludeOrgs` replaces the list ([] clears it). Keys the patch leaves out are kept.
|
||||
export function patchRootEntry(old, { definition, vars, excludeOrgs } = {}) {
|
||||
const next = old ? structuredClone(old) : {};
|
||||
if (definition !== undefined) next.definition = definition;
|
||||
if (vars !== undefined) {
|
||||
if (!isPlainObject(vars)) fail('agents-invalid', 'vars must be an object of keys');
|
||||
const merged = isPlainObject(next.vars) ? { ...next.vars } : {};
|
||||
for (const [key, value] of Object.entries(vars)) { if (value === null) delete merged[key]; else merged[key] = value; }
|
||||
if (Object.keys(merged).length) next.vars = merged; else delete next.vars;
|
||||
}
|
||||
if (excludeOrgs !== undefined) { if (Array.isArray(excludeOrgs) && excludeOrgs.length === 0) delete next.excludeOrgs; else next.excludeOrgs = excludeOrgs; }
|
||||
return next;
|
||||
}
|
||||
|
||||
async function syncDirectory(dir) {
|
||||
const fd = await fs.open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
try { await fd.sync(); } finally { await fd.close(); }
|
||||
}
|
||||
|
||||
// Change one root agent. `entry` is the new entry, null to remove it, or a function of the old
|
||||
// entry (undefined when there is none) returning either. Returns the receipt. An error after the
|
||||
// rename carries committed: true, so a caller never retries a write that landed.
|
||||
export async function changeRootAgent(configDir, { revision, name, entry }, { actor, owner, rolesDir, uid = process.geteuid(), beforeReplace, statFile, syncDirectory: syncDir = syncDirectory } = {}) {
|
||||
requireRootOwner(actor, owner);
|
||||
if (typeof name !== 'string' || !ID_PATTERN.test(name)) fail('agents-invalid', 'agent name must be an id: lowercase letters, digits, dot, dash or underscore, at most 64');
|
||||
if (typeof revision !== 'string' || !REVISION.test(revision)) fail('revision-conflict', 'a root agent change needs the revision it was read at');
|
||||
assertConfigDir(configDir, uid);
|
||||
const lock = join(configDir, ROOT_LOCK);
|
||||
let lockFd;
|
||||
for (let n = 0; n < 200; n++) {
|
||||
try { lockFd = await fs.open(lock, 'wx', 0o600); break; }
|
||||
catch (error) { if (error.code !== 'EEXIST') throw error; await new Promise(r => setTimeout(r, 10)); }
|
||||
}
|
||||
if (!lockFd) fail('agents-locked', 'the root agent file is locked by another write; inspect agents.lock before retrying');
|
||||
const file = rootAgentsPath(configDir);
|
||||
let temp, receiptFd, committed = false;
|
||||
try {
|
||||
assertConfigDir(configDir, uid);
|
||||
const before = await readCurrent(configDir, uid, statFile);
|
||||
if (before.revision !== revision) fail('revision-conflict', 'the root agent file changed since it was read; read it again');
|
||||
shape(before.document);
|
||||
const old = Object.hasOwn(before.document.agents, name) ? before.document.agents[name] : undefined;
|
||||
const value = typeof entry === 'function' ? entry(old === undefined ? undefined : structuredClone(old)) : entry;
|
||||
if (value === null && old === undefined) fail('agent-not-found', `root agents.${name} does not exist`);
|
||||
if (value !== null && !isPlainObject(value)) fail('agents-invalid', `root agents.${name} must be an object`);
|
||||
const next = structuredClone(before.document);
|
||||
if (value === null) delete next.agents[name]; else next.agents[name] = value;
|
||||
const changed = changedKeys(old, value ?? undefined);
|
||||
if (changed.length === 0) fail('no-change', `root agents.${name} is already that`);
|
||||
validate(next, rolesDir, value);
|
||||
const bytes = JSON.stringify(next, null, 2) + '\n';
|
||||
// Check what lands on disk, not only the object built: JSON can drop or reshape a value.
|
||||
validate(JSON.parse(bytes), rolesDir, value);
|
||||
const after = sha256(bytes);
|
||||
const line = {
|
||||
at: new Date().toISOString(), uid: process.geteuid(), actor: 'owner',
|
||||
verb: value === null ? 'root.agent.remove' : 'root.agent.set', agent: name,
|
||||
...(value === null ? {} : { definition: value.definition }),
|
||||
revisionBefore: before.revision, revisionAfter: after, changed,
|
||||
};
|
||||
// Refuse an unsafe receipt destination before publishing the change.
|
||||
receiptFd = await fs.open(join(configDir, ROOT_RECEIPTS), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
||||
const st = await receiptFd.stat();
|
||||
if (!st.isFile() || st.uid !== uid || (st.mode & 0o022) || st.nlink !== 1) fail('agents-unsafe', `unsafe root agent receipt file (${ROOT_RECEIPTS})`);
|
||||
temp = join(configDir, `.agents-${randomBytes(12).toString('hex')}`);
|
||||
const fd = await fs.open(temp, 'wx', 0o600);
|
||||
try { await fd.writeFile(bytes); await fd.sync(); } finally { await fd.close(); }
|
||||
await beforeReplace?.();
|
||||
// A writer that ignores the lock still loses: the bytes must be the ones read.
|
||||
const again = await readCurrent(configDir, uid, statFile);
|
||||
if (again.exists !== before.exists || again.revision !== before.revision) fail('revision-conflict', 'the root agent file changed before the write; read it again');
|
||||
// A new file is published with link, which fails rather than replace a file made meanwhile.
|
||||
if (before.exists) await fs.rename(temp, file);
|
||||
else await fs.link(temp, file);
|
||||
committed = true;
|
||||
if (!before.exists) await fs.unlink(temp);
|
||||
await syncDir(configDir);
|
||||
await receiptFd.writeFile(JSON.stringify(line) + '\n'); await receiptFd.sync();
|
||||
return line;
|
||||
} catch (error) { if (committed && error instanceof Error) error.committed = true; throw error; }
|
||||
finally {
|
||||
if (temp) await fs.rm(temp, { force: true });
|
||||
if (receiptFd) await receiptFd.close();
|
||||
await lockFd.close(); await fs.unlink(lock);
|
||||
}
|
||||
}
|
||||
|
||||
// The entries as a form or a list shows them: names, definition, the model, the other var keys and
|
||||
// the opted-out organizations. No var value other than the model.
|
||||
export function rootAgentSummary(document) {
|
||||
return Object.entries(isPlainObject(document?.agents) ? document.agents : {}).map(([name, entry]) => {
|
||||
const vars = isPlainObject(entry?.vars) ? entry.vars : {};
|
||||
return {
|
||||
name, definition: typeof entry?.definition === 'string' ? entry.definition : null,
|
||||
model: typeof vars.model === 'string' ? vars.model : null,
|
||||
varKeys: Object.keys(vars).filter(k => k !== 'model').sort(),
|
||||
excludeOrgs: Array.isArray(entry?.excludeOrgs) ? entry.excludeOrgs.filter(x => typeof x === 'string') : [],
|
||||
};
|
||||
}).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// One row per entry: its form and what it names, never a var value.
|
||||
export function projectAgentSummary(agents) {
|
||||
if (!isPlainObject(agents)) return [];
|
||||
return Object.keys(agents).sort().map(name => {
|
||||
const entry = isPlainObject(agents[name]) ? agents[name] : {};
|
||||
if (entry.exclude === true) return { name, form: 'exclude' };
|
||||
if (entry.definition !== undefined) return { name, form: 'define', definition: typeof entry.definition === 'string' ? entry.definition : null, varKeys: Object.keys(isPlainObject(entry.vars) ? entry.vars : {}).sort() };
|
||||
return { name, form: 'narrow', varKeys: Object.keys(isPlainObject(entry.vars) ? entry.vars : {}).sort() };
|
||||
});
|
||||
}
|
||||
|
||||
// The project-agent verb's guard (design section 7, the projects fence's one exception for agents):
|
||||
// `next` may differ from `old` only in projects.<project>.agents.<name>, the project must exist and
|
||||
// not be a library, and the project's entries and its union must pass A1's checks. Returns
|
||||
// [code, message] or null. `root` is the root level as loadRootAgents reads it.
|
||||
export function projectAgentProblem(old, next, { orgId, project, name, entry, rolesDir, root }) {
|
||||
const target = old?.projects?.[project];
|
||||
if (!isPlainObject(target)) return ['project-not-found', `projects.${project} does not exist`];
|
||||
if (target.kind === 'library') return ['project-agents-library', `projects.${project} is a library project; it takes no agents`];
|
||||
const without = doc => {
|
||||
const copy = structuredClone(doc);
|
||||
const p = copy?.projects?.[project];
|
||||
if (isPlainObject(p) && isPlainObject(p.agents)) {
|
||||
delete p.agents[name];
|
||||
if (Object.keys(p.agents).length === 0) delete p.agents;
|
||||
}
|
||||
return canonicalJson(copy);
|
||||
};
|
||||
if (without(old) !== without(next)) return ['organization-projects-protected', `only projects.${project}.agents.${name} may change`];
|
||||
const after = next.projects[project].agents?.[name];
|
||||
if (canonicalJson(after ?? null) !== canonicalJson(entry ?? null)) return ['organization-projects-protected', `projects.${project}.agents.${name} is not the entry asked for`];
|
||||
if (canonicalJson(after ?? null) === canonicalJson(target.agents?.[name] ?? null)) return ['no-change', `projects.${project}.agents.${name} is already that`];
|
||||
const where = `projects.${project}.agents`;
|
||||
let entries;
|
||||
try { entries = checkProjectAgents(next.projects[project].agents ?? {}, { rolesDir, where }); }
|
||||
catch (error) { if (error?.name !== 'BusinessError') throw error; return ['project-agents-invalid', withoutValues(error.message, entry, where)]; }
|
||||
const org = Object.fromEntries(Object.entries(old.roles ?? {}).map(([n, r]) => [n, { definition: r?.definition, vars: r?.vars ?? {} }]));
|
||||
try { projectUnion({ orgId, root, org, project: entries, projectId: project }); }
|
||||
catch (error) { if (error?.name !== 'BusinessError') throw error; return ['project-agents-invalid', withoutValues(error.message, entry, where)]; }
|
||||
return null;
|
||||
}
|
||||
|
||||
// The root level a project union is checked against. A root file that refuses refuses the project
|
||||
// write too: an exclusion or narrowing checked against a root level that did not parse could be wrong.
|
||||
export function rootForProjectCheck(configDir, rolesDir) {
|
||||
try { return loadRootAgents(configDir, { rolesDir }); }
|
||||
catch (error) { if (error?.name !== 'BusinessError') throw error; fail('project-agents-invalid', 'the root agent file refuses; fix it before changing project agents'); }
|
||||
}
|
||||
|
||||
// A project entry's form, for the change log and the receipt.
|
||||
export const entryForm = entry => entry === null ? 'remove' : Object.hasOwn(entry, 'exclude') ? 'exclude' : Object.hasOwn(entry, 'definition') ? 'define' : 'narrow';
|
||||
@@ -0,0 +1,518 @@
|
||||
// Roles round 2, slice P1 (#1873): the primitives registry and the composition validator.
|
||||
// Design: docs/plans/2026-11-10_roles-and-rails-design.md sections 3, 4 and 7, and the P1
|
||||
// paragraph of section 13.
|
||||
//
|
||||
// A role is a composition of the primitives in section 4 and nothing else: tools, network,
|
||||
// authority, credentials, messaging, skills, instructions and personality. checkComposition
|
||||
// checks one against a registry (the core set from vocabulary.mjs plus a plugin-declared set) in
|
||||
// the seven steps section 4 lists, in order, and returns the lines a human must see flagged.
|
||||
//
|
||||
// Nothing calls checkComposition yet. HIRE_ALLOWED and unhireableAction (vocabulary.mjs) still
|
||||
// bound a hire, and role.mjs and the broker still use them. Slice A4 switches hire over to this
|
||||
// validator and removes them. In P1 the plugin set is whatever the caller passes, empty by
|
||||
// default; nothing here reads a plugin file (PL1 builds install and the manifest).
|
||||
//
|
||||
// A refusal names the field and the position at fault. It quotes a value only when that value is
|
||||
// a registered name (a core or plugin action, tool, network, service or scope), so no role text,
|
||||
// and no secret placed where a name belongs, reaches an error. Free text (instructions,
|
||||
// personality) is never quoted.
|
||||
|
||||
import { refuse } from "./errors.mjs";
|
||||
import {
|
||||
ACTIONS, ROUTED, GATED_ONLY, TOOLS, NETWORKS, SERVICES, HOST_SERVICE, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
ID_PATTERN,
|
||||
} from "./vocabulary.mjs";
|
||||
import { NAME as PLUGIN_NAME } from "../../plugins/src/kinds.mjs";
|
||||
import { deepFreeze } from "./util.mjs";
|
||||
|
||||
// The primitives (section 4). Every other key refuses (step 1).
|
||||
export const COMPOSITION_KEYS = Object.freeze([
|
||||
"tools", "network", "authority", "credentials", "messaging", "skills", "instructions", "personality",
|
||||
]);
|
||||
const REQUIRED = Object.freeze(["tools", "network", "authority", "credentials", "instructions"]);
|
||||
const AUTHORITY_KEYS = Object.freeze(["withinRole", "crossRole"]);
|
||||
const MESSAGING_KEYS = Object.freeze(["sendTo", "receiveFrom"]);
|
||||
const PERSONALITY_KEYS = Object.freeze(["summary", "traits", "voice"]);
|
||||
const CREDENTIAL_KEYS = Object.freeze(["service", "scopes"]);
|
||||
const PLUGIN_KEYS = Object.freeze(["name", "tools", "actions", "services"]);
|
||||
const PLUGIN_SERVICE_KEYS = Object.freeze(["name", "scopes"]);
|
||||
|
||||
// Step 6's length bounds, in characters or entries. The design gives none, so P1 sets them. The
|
||||
// largest shipped contract is about 8 KB; instructions get twice that. T1 stores personality
|
||||
// with these same bounds.
|
||||
export const COMPOSITION_BOUNDS = Object.freeze({
|
||||
instructions: 16384,
|
||||
summary: 280,
|
||||
traits: 12,
|
||||
trait: 40,
|
||||
voice: 280,
|
||||
messaging: 64,
|
||||
skills: 64,
|
||||
});
|
||||
|
||||
// Ruling 8 and R9: a hired agent can't hire or fire, whatever HIRE_ALLOWED says later.
|
||||
export const NEVER_HIRED = Object.freeze(["role.hire", "role.fire"]);
|
||||
// The role rails (section 3): an action that lets its holder decide for other agents.
|
||||
export const RESOLVE_PREFIX = "decision.resolve.";
|
||||
|
||||
export const FLAG_RESOLVE = "resolve-right";
|
||||
export const FLAG_ABOVE_CHIEF = "above-chief";
|
||||
|
||||
const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
|
||||
const GITEA_SCOPE = /^(read|write):([a-z]+)$/;
|
||||
// A plugin's declared bare name: lower-case segments joined by dots. With the prefix it is
|
||||
// <plugin>.<name>.
|
||||
const DECLARED_NAME = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
||||
const DECLARED_SCOPE = /^[a-z][a-z0-9_.:-]*$/;
|
||||
const NAME_MAX = 64;
|
||||
|
||||
const isPlain = (value) => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The registry.
|
||||
|
||||
const BUILT = new WeakSet();
|
||||
|
||||
// The first segment of every core action, tool and service. A plugin may not take one as its
|
||||
// name, so a prefixed plugin name can never read as a core name (git + push.protected).
|
||||
const RESERVED_PLUGIN_NAMES = Object.freeze([...new Set([
|
||||
...ACTIONS.map((a) => a.split(".")[0]), ...TOOLS, ...SERVICES, HOST_SERVICE, ...NETWORKS,
|
||||
])]);
|
||||
|
||||
function declaredList(value, where, pattern) {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) refuse(`${where} must be an array`);
|
||||
const seen = new Set();
|
||||
value.forEach((entry, i) => {
|
||||
if (typeof entry !== "string" || entry.length > NAME_MAX || !pattern.test(entry)) {
|
||||
refuse(`${where}[${i}] must be a name matching ${pattern}`);
|
||||
}
|
||||
if (seen.has(entry)) refuse(`${where}[${i}] repeats an earlier entry`);
|
||||
seen.add(entry);
|
||||
});
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
// One plugin's declared set: { name, tools?: [name], actions?: [name], services?: [{ name, scopes:
|
||||
// [scope] }] }. Names are declared bare and registered with the plugin's prefix. A plugin action is
|
||||
// always routed (section 4), so it may not name a hard wait, and no declared name may reuse a core
|
||||
// name, bare or prefixed.
|
||||
function checkPlugin(plugin, i, core) {
|
||||
const where = `registry plugins[${i}]`;
|
||||
if (!isPlain(plugin)) refuse(`${where} must be a JSON object`);
|
||||
for (const key of Object.keys(plugin)) {
|
||||
if (!PLUGIN_KEYS.includes(key)) refuse(`${where} has a key outside ${PLUGIN_KEYS.join(", ")}`);
|
||||
}
|
||||
const { name } = plugin;
|
||||
if (typeof name !== "string" || name.length > 32 || !PLUGIN_NAME.test(name)) refuse(`${where}.name must match ${PLUGIN_NAME}`);
|
||||
if (RESERVED_PLUGIN_NAMES.includes(name)) refuse(`${where}.name ${JSON.stringify(name)} is a core name`);
|
||||
const full = (bare) => `${name}.${bare}`;
|
||||
const coreName = (bare) => core.has(bare) || core.has(full(bare));
|
||||
const tools = declaredList(plugin.tools, `${where}.tools`, DECLARED_NAME);
|
||||
tools.forEach((bare, j) => {
|
||||
if (coreName(bare)) refuse(`${where}.tools[${j}] reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`);
|
||||
});
|
||||
const actions = declaredList(plugin.actions, `${where}.actions`, DECLARED_NAME);
|
||||
actions.forEach((bare, j) => {
|
||||
if (GATED_ONLY.includes(bare)) refuse(`${where}.actions[${j}] declares ${bare}, a hard wait; a plugin action is always routed`);
|
||||
if (coreName(bare)) refuse(`${where}.actions[${j}] reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`);
|
||||
});
|
||||
if (plugin.services !== undefined && !Array.isArray(plugin.services)) refuse(`${where}.services must be an array`);
|
||||
const services = (plugin.services ?? []).map((service, j) => {
|
||||
const at = `${where}.services[${j}]`;
|
||||
if (!isPlain(service)) refuse(`${at} must be a JSON object`);
|
||||
for (const key of Object.keys(service)) {
|
||||
if (!PLUGIN_SERVICE_KEYS.includes(key)) refuse(`${at} has a key outside ${PLUGIN_SERVICE_KEYS.join(", ")}`);
|
||||
}
|
||||
const [bare] = declaredList([service.name], `${at}.name`, DECLARED_NAME);
|
||||
if (coreName(bare)) refuse(`${at}.name reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`);
|
||||
const scopes = declaredList(service.scopes, `${at}.scopes`, DECLARED_SCOPE);
|
||||
if (scopes.length === 0) refuse(`${at}.scopes must not be empty`);
|
||||
return { name: full(bare), scopes };
|
||||
});
|
||||
const names = [...tools, ...actions, ...services.map((s) => s.name.slice(name.length + 1))];
|
||||
const twice = names.find((n, j) => names.indexOf(n) !== j);
|
||||
if (twice !== undefined) refuse(`${where} declares ${JSON.stringify(full(twice))} twice`);
|
||||
return { name, tools: tools.map(full), actions: actions.map(full), services };
|
||||
}
|
||||
|
||||
// The registry checkComposition reads: the core set plus each plugin's declared set. The plugin
|
||||
// set is the caller's input and empty by default (PL1 passes the plugins enabled in the
|
||||
// organization). checkComposition accepts only a registry built here.
|
||||
export function buildRegistry({ plugins = [] } = {}) {
|
||||
if (!Array.isArray(plugins)) refuse("registry plugins must be an array");
|
||||
const core = new Set([...ACTIONS, ...TOOLS, ...SERVICES, HOST_SERVICE]);
|
||||
const declared = plugins.map((plugin, i) => checkPlugin(plugin, i, core));
|
||||
const pluginNames = declared.map((p) => p.name);
|
||||
const twice = pluginNames.find((n, j) => pluginNames.indexOf(n) !== j);
|
||||
if (twice !== undefined) refuse(`registry names plugin ${JSON.stringify(twice)} twice`);
|
||||
const services = Object.create(null);
|
||||
for (const name of SERVICES) services[name] = { kind: name };
|
||||
services[HOST_SERVICE] = { kind: HOST_SERVICE };
|
||||
for (const p of declared) for (const s of p.services) services[s.name] = { kind: "plugin", scopes: s.scopes };
|
||||
const pluginActions = declared.flatMap((p) => p.actions);
|
||||
const registry = deepFreeze({
|
||||
tools: [...TOOLS, ...declared.flatMap((p) => p.tools)],
|
||||
networks: [...NETWORKS],
|
||||
actions: [...ACTIONS, ...pluginActions],
|
||||
routed: [...ROUTED, ...pluginActions],
|
||||
services,
|
||||
plugins: declared,
|
||||
});
|
||||
BUILT.add(registry);
|
||||
return registry;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The validator.
|
||||
|
||||
function keysWithin(object, allowed, where) {
|
||||
Object.keys(object).forEach((key, i) => {
|
||||
if (!allowed.includes(key)) refuse(`composition ${where} has an unknown key (key ${i + 1}; allowed: ${allowed.join(", ")})`);
|
||||
});
|
||||
}
|
||||
|
||||
// A list of distinct strings. Entries are named by position only: this runs before anything is
|
||||
// known to be a registered name.
|
||||
function stringList(value, where, { nonEmpty = false } = {}) {
|
||||
if (!Array.isArray(value)) refuse(`composition ${where} must be an array`);
|
||||
if (nonEmpty && value.length === 0) refuse(`composition ${where} must not be empty`);
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
if (typeof value[i] !== "string" || value[i].includes("\0")) refuse(`composition ${where}[${i}] must be a string`);
|
||||
if (seen.has(value[i])) refuse(`composition ${where}[${i}] repeats an earlier entry`);
|
||||
seen.add(value[i]);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
function text(value, where) {
|
||||
if (typeof value !== "string" || value.includes("\0")) refuse(`composition ${where} must be a string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
// Step 1: the shape, with every unknown key refused. Returns a fresh copy; the input is not read
|
||||
// again after this.
|
||||
function shape(role) {
|
||||
if (!isPlain(role)) refuse("composition must be a JSON object");
|
||||
keysWithin(role, COMPOSITION_KEYS, "role");
|
||||
for (const key of REQUIRED) {
|
||||
if (role[key] === undefined) refuse(`composition requires "${key}"`);
|
||||
}
|
||||
const out = {};
|
||||
out.tools = stringList(role.tools, "tools", { nonEmpty: true });
|
||||
if (typeof role.network !== "string") refuse("composition network must be a string");
|
||||
out.network = role.network;
|
||||
|
||||
if (!isPlain(role.authority)) refuse("composition authority must be a JSON object");
|
||||
keysWithin(role.authority, AUTHORITY_KEYS, "authority");
|
||||
out.authority = {};
|
||||
for (const key of AUTHORITY_KEYS) out.authority[key] = stringList(role.authority[key], `authority.${key}`);
|
||||
out.authority.crossRole.forEach((action, i) => {
|
||||
if (out.authority.withinRole.includes(action)) refuse(`composition authority.crossRole[${i}] is also in authority.withinRole`);
|
||||
});
|
||||
|
||||
if (!Array.isArray(role.credentials)) refuse("composition credentials must be an array");
|
||||
out.credentials = role.credentials.map((entry, i) => {
|
||||
const where = `credentials[${i}]`;
|
||||
if (!isPlain(entry)) refuse(`composition ${where} must be a JSON object`);
|
||||
keysWithin(entry, CREDENTIAL_KEYS, where);
|
||||
if (typeof entry.service !== "string") refuse(`composition ${where}.service must be a string`);
|
||||
const copy = { service: entry.service };
|
||||
if (entry.scopes !== undefined) {
|
||||
if (Array.isArray(entry.scopes)) {
|
||||
copy.scopes = stringList(entry.scopes, `${where}.scopes`);
|
||||
} else if (isPlain(entry.scopes)) {
|
||||
// No prototype, so a "__proto__" group stays a key and step 2 refuses it.
|
||||
copy.scopes = Object.create(null);
|
||||
Object.keys(entry.scopes).forEach((group, j) => {
|
||||
if (group.includes("\0")) refuse(`composition ${where}.scopes key ${j + 1} must be a string`);
|
||||
copy.scopes[group] = stringList(entry.scopes[group], `${where}.scopes (group ${j + 1})`);
|
||||
});
|
||||
} else {
|
||||
refuse(`composition ${where}.scopes must be an array or a JSON object`);
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
|
||||
if (role.messaging === undefined) {
|
||||
out.messaging = null;
|
||||
} else {
|
||||
if (!isPlain(role.messaging)) refuse("composition messaging must be a JSON object");
|
||||
keysWithin(role.messaging, MESSAGING_KEYS, "messaging");
|
||||
out.messaging = {};
|
||||
for (const key of MESSAGING_KEYS) {
|
||||
out.messaging[key] = stringList(role.messaging[key], `messaging.${key}`);
|
||||
out.messaging[key].forEach((entry, i) => {
|
||||
if (!ID_PATTERN.test(entry)) refuse(`composition messaging.${key}[${i}] must be a role name matching ${ID_PATTERN}`);
|
||||
if (entry === "tester") refuse(`composition messaging.${key}[${i}] names the tester, which is never a role`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
out.skills = role.skills === undefined ? [] : stringList(role.skills, "skills");
|
||||
out.skills.forEach((name, i) => {
|
||||
if (!SKILL_NAME.test(name) || name === "." || name === "..") refuse(`composition skills[${i}] must be a skill directory name matching ${SKILL_NAME}`);
|
||||
});
|
||||
|
||||
out.instructions = text(role.instructions, "instructions");
|
||||
|
||||
if (role.personality === undefined) {
|
||||
out.personality = null;
|
||||
} else {
|
||||
if (!isPlain(role.personality)) refuse("composition personality must be a JSON object");
|
||||
keysWithin(role.personality, PERSONALITY_KEYS, "personality");
|
||||
out.personality = {};
|
||||
if (role.personality.summary !== undefined) out.personality.summary = text(role.personality.summary, "personality.summary");
|
||||
if (role.personality.traits !== undefined) out.personality.traits = stringList(role.personality.traits, "personality.traits");
|
||||
if (role.personality.voice !== undefined) out.personality.voice = text(role.personality.voice, "personality.voice");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkGitea(scopes, where) {
|
||||
if (!Array.isArray(scopes) || scopes.length === 0) refuse(`composition ${where}.scopes must be a non-empty array of read:<category> or write:<category>`);
|
||||
const categories = new Set();
|
||||
scopes.forEach((scope, i) => {
|
||||
const m = GITEA_SCOPE.exec(scope);
|
||||
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) {
|
||||
refuse(`composition ${where}.scopes[${i}] is not a grantable gitea scope (categories: ${GITEA_SCOPE_CATEGORIES.join(", ")}; admin and all are never granted)`);
|
||||
}
|
||||
if (categories.has(m[2])) refuse(`composition ${where}.scopes[${i}] names the category ${m[2]} again; a token holds one level per category`);
|
||||
categories.add(m[2]);
|
||||
});
|
||||
}
|
||||
|
||||
function checkVikunja(scopes, where) {
|
||||
if (!isPlain(scopes) || Object.keys(scopes).length === 0) refuse(`composition ${where}.scopes must name at least one vikunja route group`);
|
||||
Object.keys(scopes).forEach((group, j) => {
|
||||
if (!Object.hasOwn(VIKUNJA_GRANTABLE, group)) refuse(`composition ${where}.scopes group ${j + 1} is not a grantable vikunja route group`);
|
||||
const verbs = scopes[group];
|
||||
if (verbs.length === 0) refuse(`composition ${where}.scopes.${group} must not be empty`);
|
||||
verbs.forEach((verb, i) => {
|
||||
if (!VIKUNJA_GRANTABLE[group].includes(verb)) refuse(`composition ${where}.scopes.${group}[${i}] is not a grantable vikunja verb`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function checkPluginScopes(scopes, declared, service, where) {
|
||||
if (!Array.isArray(scopes) || scopes.length === 0) refuse(`composition ${where}.scopes must be a non-empty array of ${service} scopes`);
|
||||
scopes.forEach((scope, i) => {
|
||||
if (!declared.includes(scope)) refuse(`composition ${where}.scopes[${i}] is not a scope ${service} declares`);
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: every action, tool, network, service and scope must be in the registry.
|
||||
function registered(c, registry) {
|
||||
c.tools.forEach((tool, i) => {
|
||||
if (!registry.tools.includes(tool)) refuse(`composition tools[${i}] names no registered tool`);
|
||||
});
|
||||
if (!registry.networks.includes(c.network)) refuse(`composition network must be one of: ${registry.networks.join(", ")}`);
|
||||
for (const key of AUTHORITY_KEYS) {
|
||||
c.authority[key].forEach((action, i) => {
|
||||
if (!registry.actions.includes(action)) refuse(`composition authority.${key}[${i}] names no registered action`);
|
||||
});
|
||||
}
|
||||
const seen = new Set();
|
||||
c.credentials.forEach((entry, i) => {
|
||||
const where = `credentials[${i}]`;
|
||||
if (!Object.hasOwn(registry.services, entry.service)) refuse(`composition ${where}.service names no registered credential service`);
|
||||
if (seen.has(entry.service)) refuse(`composition ${where} names ${entry.service} again`);
|
||||
seen.add(entry.service);
|
||||
const { kind, scopes } = registry.services[entry.service];
|
||||
if (kind === HOST_SERVICE) {
|
||||
if (entry.scopes !== undefined) refuse(`composition ${where} names ${HOST_SERVICE}, which takes no scopes`);
|
||||
} else if (kind === "gitea") {
|
||||
checkGitea(entry.scopes, where);
|
||||
} else if (kind === "vikunja") {
|
||||
checkVikunja(entry.scopes, where);
|
||||
} else {
|
||||
checkPluginScopes(entry.scopes, scopes, entry.service, where);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function eachAction(c, fn) {
|
||||
for (const key of AUTHORITY_KEYS) c.authority[key].forEach((action, i) => fn(action, key, i));
|
||||
}
|
||||
|
||||
// Every string in the composition with its path, for step 6.
|
||||
function strings(c) {
|
||||
const out = [];
|
||||
c.tools.forEach((v, i) => out.push([`tools[${i}]`, v]));
|
||||
out.push(["network", c.network]);
|
||||
eachAction(c, (v, key, i) => out.push([`authority.${key}[${i}]`, v]));
|
||||
c.credentials.forEach((entry, i) => {
|
||||
out.push([`credentials[${i}].service`, entry.service]);
|
||||
if (Array.isArray(entry.scopes)) entry.scopes.forEach((v, j) => out.push([`credentials[${i}].scopes[${j}]`, v]));
|
||||
else if (entry.scopes) {
|
||||
for (const [group, verbs] of Object.entries(entry.scopes)) {
|
||||
out.push([`credentials[${i}].scopes (group key)`, group]);
|
||||
verbs.forEach((v, j) => out.push([`credentials[${i}].scopes.${group}[${j}]`, v]));
|
||||
}
|
||||
}
|
||||
});
|
||||
if (c.messaging) for (const key of MESSAGING_KEYS) c.messaging[key].forEach((v, i) => out.push([`messaging.${key}[${i}]`, v]));
|
||||
c.skills.forEach((v, i) => out.push([`skills[${i}]`, v]));
|
||||
out.push(["instructions", c.instructions]);
|
||||
if (c.personality) {
|
||||
if (c.personality.summary !== undefined) out.push(["personality.summary", c.personality.summary]);
|
||||
(c.personality.traits ?? []).forEach((v, i) => out.push([`personality.traits[${i}]`, v]));
|
||||
if (c.personality.voice !== undefined) out.push(["personality.voice", c.personality.voice]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function bounded(value, max, where, { empty = false } = {}) {
|
||||
if ((!empty && value.trim().length === 0) || value.length > max) {
|
||||
refuse(`composition ${where} must be ${empty ? "" : "non-empty and "}at most ${max} characters`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6, the bounds half.
|
||||
function checkBounds(c) {
|
||||
const b = COMPOSITION_BOUNDS;
|
||||
bounded(c.instructions, b.instructions, "instructions");
|
||||
if (c.personality) {
|
||||
if (c.personality.summary !== undefined) bounded(c.personality.summary, b.summary, "personality.summary");
|
||||
if (c.personality.voice !== undefined) bounded(c.personality.voice, b.voice, "personality.voice");
|
||||
const traits = c.personality.traits ?? [];
|
||||
if (traits.length > b.traits) refuse(`composition personality.traits must hold at most ${b.traits} entries`);
|
||||
traits.forEach((trait, i) => bounded(trait, b.trait, `personality.traits[${i}]`));
|
||||
}
|
||||
if (c.messaging) {
|
||||
for (const key of MESSAGING_KEYS) {
|
||||
if (c.messaging[key].length > b.messaging) refuse(`composition messaging.${key} must hold at most ${b.messaging} entries`);
|
||||
}
|
||||
}
|
||||
if (c.skills.length > b.skills) refuse(`composition skills must hold at most ${b.skills} entries`);
|
||||
}
|
||||
|
||||
// A plain JSON copy of a caller's object, read once. Getters, proxies and toJSON run here and only
|
||||
// here, and whatever they throw becomes the fixed message: their error text could carry role text.
|
||||
function snapshot(value, message) {
|
||||
let copy;
|
||||
try {
|
||||
copy = isPlain(value) ? JSON.parse(JSON.stringify(value)) : undefined;
|
||||
} catch {
|
||||
copy = undefined;
|
||||
}
|
||||
if (!isPlain(copy)) refuse(message);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// The chief's resolved role, as loadRole returns it. Read only for step 7; a malformed one refuses.
|
||||
function checkChief(chief) {
|
||||
const bad = () => refuse("checkComposition needs the chief's resolved role");
|
||||
if (!isPlain(chief) || !Array.isArray(chief.tools) || !NETWORKS.includes(chief.network)) bad();
|
||||
if (!isPlain(chief.authority) || !AUTHORITY_KEYS.every((k) => Array.isArray(chief.authority[k]))) bad();
|
||||
if (!Array.isArray(chief.credentials) || !chief.credentials.every((c) => isPlain(c) && typeof c.service === "string")) bad();
|
||||
if (chief.messaging !== null && chief.messaging !== undefined &&
|
||||
!(isPlain(chief.messaging) && MESSAGING_KEYS.every((k) => Array.isArray(chief.messaging[k])))) bad();
|
||||
return chief;
|
||||
}
|
||||
|
||||
// One string per credential scope a role holds: service, then scope (a vikunja scope is
|
||||
// group.verb; host has the one line "host").
|
||||
function credentialLines(credentials) {
|
||||
const out = [];
|
||||
for (const { service, scopes } of credentials) {
|
||||
if (scopes === undefined) out.push([service, service]);
|
||||
else if (Array.isArray(scopes)) for (const scope of scopes) out.push([service, scope]);
|
||||
else for (const [group, verbs] of Object.entries(scopes)) for (const verb of verbs) out.push([service, `${group}.${verb}`]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Validate a composition (section 4's seven steps, in order) against a registry from
|
||||
// buildRegistry, with the chief's resolved role for step 7. options.assertClean is the credential
|
||||
// store's check (credentials.assertClean, as the broker's secretCheck); it throws on a held
|
||||
// secret. Returns a frozen { composition, flagged }, where each flagged line is
|
||||
// { field, value, reasons } and reasons holds "resolve-right" (step 5), "above-chief" (step 7) or
|
||||
// both. A composition that fails a step refuses with an OrganizationConfigError.
|
||||
export function checkComposition(role, registry, chief, { assertClean } = {}) {
|
||||
if (!BUILT.has(registry)) refuse("checkComposition needs a registry from buildRegistry");
|
||||
if (typeof assertClean !== "function") refuse("checkComposition needs the credential store's assertClean");
|
||||
const boss = checkChief(snapshot(chief, "checkComposition needs the chief's resolved role"));
|
||||
|
||||
// 1. Unknown keys, and the shape.
|
||||
const c = shape(snapshot(role, "composition must be a JSON object"));
|
||||
|
||||
// 2. Every name in the registry.
|
||||
registered(c, registry);
|
||||
|
||||
// 3. Hard waits stay with the human (R4). From here on an action is a registered name and may be
|
||||
// quoted.
|
||||
eachAction(c, (action, key, i) => {
|
||||
if (!registry.routed.includes(action)) refuse(`composition authority.${key}[${i}] is ${action}, a hard wait the human always decides`);
|
||||
});
|
||||
|
||||
// 4. No hire chains (R9).
|
||||
eachAction(c, (action, key, i) => {
|
||||
if (NEVER_HIRED.includes(action)) refuse(`composition authority.${key}[${i}] is ${action}; a hired agent cannot hire or fire`);
|
||||
});
|
||||
|
||||
// 5. Every resolve right is a flagged line.
|
||||
const lines = [];
|
||||
const flag = (field, value, reason) => {
|
||||
const line = lines.find((l) => l.field === field && l.value === value);
|
||||
if (line) {
|
||||
if (!line.reasons.includes(reason)) line.reasons.push(reason);
|
||||
} else {
|
||||
lines.push({ field, value, reasons: [reason] });
|
||||
}
|
||||
};
|
||||
eachAction(c, (action, key) => {
|
||||
if (action.startsWith(RESOLVE_PREFIX)) flag(`authority.${key}`, action, FLAG_RESOLVE);
|
||||
});
|
||||
|
||||
// 6. No secret in any string (R1), and every field within its bound.
|
||||
for (const [where, value] of strings(c)) {
|
||||
let clean = true;
|
||||
try {
|
||||
assertClean(value);
|
||||
} catch {
|
||||
clean = false;
|
||||
}
|
||||
if (!clean) refuse(`composition ${where} holds a credential`);
|
||||
}
|
||||
checkBounds(c);
|
||||
|
||||
// 7. Everything the chief's own role does not hold (R8). A within-role action is held only
|
||||
// within-role; a cross-role one either way.
|
||||
const chiefWithin = boss.authority.withinRole;
|
||||
const chiefAny = [...chiefWithin, ...boss.authority.crossRole];
|
||||
c.authority.withinRole.forEach((action) => {
|
||||
if (!chiefWithin.includes(action)) flag("authority.withinRole", action, FLAG_ABOVE_CHIEF);
|
||||
});
|
||||
c.authority.crossRole.forEach((action) => {
|
||||
if (!chiefAny.includes(action)) flag("authority.crossRole", action, FLAG_ABOVE_CHIEF);
|
||||
});
|
||||
c.tools.forEach((tool) => {
|
||||
if (!boss.tools.includes(tool)) flag("tools", tool, FLAG_ABOVE_CHIEF);
|
||||
});
|
||||
if (NETWORKS.indexOf(c.network) > NETWORKS.indexOf(boss.network)) flag("network", c.network, FLAG_ABOVE_CHIEF);
|
||||
const held = new Set(credentialLines(boss.credentials).map(([s, v]) => `${s}\n${v}`));
|
||||
for (const [service, value] of credentialLines(c.credentials)) {
|
||||
if (!held.has(`${service}\n${value}`)) flag(`credentials.${service}`, value, FLAG_ABOVE_CHIEF);
|
||||
}
|
||||
// Both directions: the broker checks the sender's sendTo and the recipient's receiveFrom. A role
|
||||
// with no messaging key may message and hear anyone; "*" stands for that.
|
||||
if (boss.messaging) {
|
||||
for (const key of MESSAGING_KEYS) {
|
||||
const chiefList = boss.messaging[key];
|
||||
if (c.messaging === null) flag(`messaging.${key}`, "*", FLAG_ABOVE_CHIEF);
|
||||
else c.messaging[key].forEach((target) => {
|
||||
if (!chiefList.includes(target)) flag(`messaging.${key}`, target, FLAG_ABOVE_CHIEF);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of lines) line.reasons.sort();
|
||||
return deepFreeze({ composition: c, flagged: lines });
|
||||
}
|
||||
@@ -267,6 +267,12 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
|
||||
if (definitions[definition].messaging) {
|
||||
refuse(`${file} arbiters.${kind} names ${name}, whose role definition ${definition} declares messaging lists`);
|
||||
}
|
||||
const right = `decision.resolve.${kind}`;
|
||||
const narrowed = [vars, roles[name].vars].some(layer =>
|
||||
layer["limits.authority"] !== undefined && !layer["limits.authority"].includes(right));
|
||||
if (!definitions[definition].authority.withinRole.includes(right) || narrowed) {
|
||||
refuse(`${file} arbiters.${kind} names ${name}, whose role definition ${definition} lacks ${right}`);
|
||||
}
|
||||
arbiters[kind] = name;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { checkReplyLimits } from "../../library/src/limits.mjs";
|
||||
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
|
||||
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
|
||||
import { withRegistryWriter } from "./org-registry.mjs";
|
||||
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
|
||||
|
||||
export class OrganizationError extends Error {
|
||||
constructor(code, message = code) {
|
||||
@@ -272,7 +273,9 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
// The one commit path. `guard(old, document)` is what a caller may do to `projects`: write() keeps
|
||||
// them whole (C2-04 M9) and addProject() allows one exact addition. `document` may be a function of
|
||||
// the old document, for a writer that builds what it publishes from what it read under the lock.
|
||||
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard }) {
|
||||
// #1873 A2: `pathsOnly` logs each change's path and presence without its values, for a verb whose
|
||||
// values can hold a var a person typed (a secret pasted in the wrong place must not reach evidence).
|
||||
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard, pathsOnly = false }) {
|
||||
requireId(actor, "authenticated actor");
|
||||
requireId(id, "organization id");
|
||||
let revoked = false;
|
||||
@@ -320,7 +323,8 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
syncDir(tx);
|
||||
const event = {
|
||||
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
|
||||
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
|
||||
changes: pathsOnly ? changes(old, written).map(({ path, oldPresent, newPresent }) => ({ path, oldPresent, newPresent })) : changes(old, written),
|
||||
...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
|
||||
};
|
||||
log(id, { ...event, state: "prepared" });
|
||||
syncDir(home);
|
||||
@@ -382,6 +386,36 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
}, { revision, actor, guard, detail: { verb: "project.remote.set", project } });
|
||||
}
|
||||
|
||||
// #1873 A2 (design section 7): the only writer of `projects.<p>.agents`, one entry per call. `entry`
|
||||
// is a define ({definition, vars?}), a narrow ({vars}), an exclude ({exclude: true}) or null to
|
||||
// remove the entry. The human owner only: every agent actor, the tester included, refuses
|
||||
// human-required, and so does any actor that is not the organization's human. The guard checks the
|
||||
// candidate and the serialized bytes: nothing but that entry changes, the project exists and is not
|
||||
// a library, and the project's entries and union pass A1's checks against the current root level.
|
||||
// The change log names paths only; the detail names the project, the agent and the form.
|
||||
function setProjectAgent(id, { revision, project, name, entry }, actor) {
|
||||
requireId(id, "organization id");
|
||||
requireId(project, "project id");
|
||||
requireId(name, "agent name");
|
||||
if (entry !== null && (entry === undefined || typeof entry !== "object" || Array.isArray(entry))) fail("project-agents-invalid", "an entry is an object, or null to remove it");
|
||||
const root = rootForProjectCheck(dir, rolesDir);
|
||||
const guard = (old, document) => {
|
||||
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
|
||||
if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required");
|
||||
if (entry === null && old.projects?.[project]?.agents?.[name] === undefined) fail("agent-not-found", `projects.${project}.agents.${name} does not exist`);
|
||||
const problem = projectAgentProblem(old, document, { orgId: id, project, name, entry, rolesDir, root });
|
||||
if (problem) fail(...problem);
|
||||
};
|
||||
return commit(id, old => {
|
||||
const target = old.projects?.[project];
|
||||
if (target === null || typeof target !== "object" || Array.isArray(target)) return old;
|
||||
const agents = { ...(target.agents ?? {}) };
|
||||
if (entry === null) delete agents[name]; else agents[name] = entry;
|
||||
const { agents: _drop, ...rest } = target;
|
||||
return { ...old, projects: { ...old.projects, [project]: Object.keys(agents).length ? { ...rest, agents } : rest } };
|
||||
}, { revision, actor, guard, pathsOnly: true, detail: { verb: "project.agents.set", project, agent: name, form: entryForm(entry) } });
|
||||
}
|
||||
|
||||
// Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may
|
||||
// write this business meanwhile. `fn` must be synchronous: the lock is a directory held for its
|
||||
// duration, and an awaited call would leave a half-checked world to whoever finds the lock gone.
|
||||
@@ -449,5 +483,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
// ceilings. Version 1 files still load: they carry no authority, so every
|
||||
// vocabulary action is gated for them.
|
||||
|
||||
import { existsSync, lstatSync } from "node:fs";
|
||||
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import {
|
||||
@@ -155,6 +155,9 @@ function checkContract(contract, file) {
|
||||
refuse(`role contract not found: ${path}`);
|
||||
}
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) refuse(`role contract must be a non-empty regular file: ${path}`);
|
||||
if (!/^## Authority[\t ]*\r?$/m.test(readFileSync(path, "utf8"))) {
|
||||
refuse(`role contract ${path} requires a ## Authority section`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export function rejectUnknownKeys(object, allowed, where) {
|
||||
|
||||
export function requireId(value, where) {
|
||||
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
|
||||
refuse(`${where} must match ${ID_PATTERN} (got ${JSON.stringify(value)})`);
|
||||
refuse(`${where} must match ${ID_PATTERN}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function requireString(value, where, { max = 200 } = {}) {
|
||||
}
|
||||
|
||||
export function requirePositiveInt(value, where) {
|
||||
if (!Number.isSafeInteger(value) || value < 1) refuse(`${where} must be a positive integer (got ${JSON.stringify(value)})`);
|
||||
if (!Number.isSafeInteger(value) || value < 1) refuse(`${where} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ export function requireDistinctList(value, where, check, { nonEmpty = false } =
|
||||
|
||||
// Calendar date YYYY-MM-DD that exists (2026-02-30 refuses).
|
||||
export function requireDate(value, where) {
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${where} must be a date YYYY-MM-DD (got ${JSON.stringify(value)})`);
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${where} must be a date YYYY-MM-DD`);
|
||||
const d = new Date(`${value}T00:00:00Z`);
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) refuse(`${where} is not a real date: ${value}`);
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) refuse(`${where} is not a real date`);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export const ACTIONS = Object.freeze([
|
||||
"policy.change",
|
||||
"prd.approve",
|
||||
"decision.resolve.technical",
|
||||
"decision.resolve.delivery",
|
||||
// Row 285 (C2-08b, #1790): a creative agent writes a supporting Markdown file in the human's library.
|
||||
// Added as a hard wait and routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6; role.mjs grants it within-role
|
||||
// to the creative roles only.
|
||||
@@ -93,6 +94,7 @@ export const ROUTED = Object.freeze([
|
||||
"policy.change",
|
||||
"prd.approve",
|
||||
"decision.resolve.technical",
|
||||
"decision.resolve.delivery",
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
"role.hire",
|
||||
"role.fire",
|
||||
|
||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user