Files
stack/packages/bus/tests/autonomy.test.mjs

165 lines
13 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './spec-fixtures.mjs';
const SUBJECT = {provider:'test',iss:'https://issuer.test',sub:'owner'};
async function setup(t, autonomy, autonomyHost=null) {
const f=await fixture(t,{autonomyHost,roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:['git.merge.protected','task.scope.change','policy.change','prd.approve']}}}});
if(autonomy) {f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);}
f.owner=f.broker.bindConsole({business:'demo',human:'jason',subject:SUBJECT});
return f;
}
test('AU no setting keeps merge with technical arbiter',async t=>{
const f=await setup(t);const d=f.raise({action:'git.merge.protected'});assert.equal(d.route_to,'chief');
});
test('AU in-loop merge keeps the arbiter resolver, waits for person, decline returns to arbiter',async t=>{
const f=await setup(t,{merge:'in the loop'}),d=f.raise({action:'git.merge.protected',target:'vikunja:1/2'});
assert.equal(d.route_to,'chief');
assert.throws(()=>f.call(f.human,'decision.resolve',{id:d.id,choice:'yes'}),{code:'not-resolver'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
assert.throws(()=>f.broker.authorize(f.coder,'git.merge.protected',{decision:d.id,target:'vikunja:1/2'}),{code:'confirmation-required'});
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:false,reason:'Please revise'});
assert.equal(f.call(f.chief,'inbox').find(x=>x.id===d.id)?.id,d.id);
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true});
assert.equal(f.broker.authorize(f.coder,'git.merge.protected',{decision:d.id,target:'vikunja:1/2'}).decision,d.id);
});
test('AU R5 subject resolver or resolver definition routes to person',async t=>{
const f=await setup(t);
for(const target of ['chief','chief-of-staff'])assert.equal(f.raise({action:'policy.change',target}).route_to,'human');
});
test('AU mismatched PRD domain refuses at raise with expected domain',async t=>{
const f=await setup(t);
assert.throws(()=>f.raise({action:'prd.approve',domain:'delivery'}),e=>e.code==='invalid-domain' && /technical/.test(e.reason));
assert.equal(f.store.get("SELECT count(*) n FROM decisions WHERE action='prd.approve'").n,0);
});
test('AU revoked resolver right refuses when an approved decision is consumed',async t=>{
const f=await setup(t),d=f.raise({action:'task.scope.change',target:'vikunja:1/2'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
f.business.roles.chief.authority.withinRole=[];f.broker.reloadBusiness('demo',f.business);
assert.throws(()=>f.broker.authorize(f.coder,'task.scope.change',{decision:d.id,target:'vikunja:1/2'}),{code:'not-resolver'});
});
test('AU member, agent and dev tester cannot change settings',async t=>{
const revision='a'.repeat(64),f=await setup(t,null,{get:()=>({revision,autonomy:{}})});
const member=f.broker.bindHireConsole({business:'demo',actor:'member',subject:{...SUBJECT,sub:'member'},rights:['org.hire']});
for(const cap of [f.coder,member])assert.throws(()=>f.call(cap,'autonomy.set.console',{revision,autonomy:{merge:'near the loop'}}),{code:'human-required'});
assert.throws(()=>f.call(f.human,'autonomy.set.console',{revision,autonomy:{merge:'near the loop'}}),{code:'human-required'});
});
test('AU near-loop merge sends owner a bounded notice; expired reverse names ordinary path',async t=>{
const f=await setup(t,{merge:'near the loop',notices:true,reverseWindowHours:1}),d=f.raise({action:'git.merge.protected'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
const n=f.call(f.human,'inbox').find(x=>x.id===d.id);
assert.equal(n.autonomy.notice,true);assert.ok(Date.parse(n.autonomy.reverseUntil)>Date.now());
const now=Date.now;
try{Date.now=()=>now()+3600001;await assert.rejects(Promise.resolve().then(()=>f.call(f.owner,'decision.reverse.console',{id:d.id})),e=>e.code==='reverse-window-closed' && /ordinary.*decision/.test(e.reason));}finally{Date.now=now;}
});
test('AU no setting can move each hard wait from a person',async t=>{
const f=await setup(t,{'cross-role':'out of the loop',merge:'near the loop',authority:'out of the loop'});
for(const action of ['git.push.protected','host.sudo','credential.mint','spend','deploy','data.delete']){
if(action==='host.sudo')continue; // Its host grant precondition is covered in hard-waits.test.mjs.
assert.equal(f.raise({action}).route_to,'human');
}
});
test('AU owner setting changes have immutable actor trail; bad classes/levels refuse; tester refused',async t=>{
let f,autonomy={},revision='a'.repeat(64);
const host={get:()=>({autonomy,revision}),set:(_b,a)=>{autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'transaction-1'};},reversible:()=>true};
f=await setup(t,null,host);
await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{merge:'in the loop'}});
assert.equal(f.store.all("SELECT actor_role,actor_run,body FROM events WHERE json_extract(body,'$.operation')='autonomy.set'").length,1);
const row=f.store.get("SELECT body FROM events WHERE json_extract(body,'$.operation')='autonomy.set'");assert.deepEqual(JSON.parse(row.body).subject,SUBJECT);
for(const value of [{unknown:'near the loop'},{merge:'fast'}])await assert.rejects(f.call(f.owner,'autonomy.set.console',{revision,autonomy:value}),{code:'invalid-autonomy'});
f.business.vars={'bus.tester':true};f.broker.reloadBusiness('demo',f.business);const tester=f.broker.bindTester({business:'demo'});
assert.throws(()=>f.call(tester,'autonomy.set.console',{revision,autonomy:{}}),{code:'human-required'});
assert.throws(()=>f.broker.recordEvent(f.coder,{kind:'action.allowed',body:{operation:'autonomy.confirmed',decision:'forged'}}),{code:'reserved-event'});
});
test('AU reverse consumes once and rechecks the window at the effect boundary',async t=>{
let effects=0;
const host={reverse:async({beforeWrite,record})=>{beforeWrite();effects++;return {commit:'b'.repeat(40)};}};
const f=await setup(t,{merge:'near the loop',notices:true},host),d=f.raise({action:'git.merge.protected'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
const r=await f.call(f.owner,'decision.reverse.console',{id:d.id});assert.equal(r.reversed,true);assert.equal(effects,1);
await assert.rejects(f.call(f.owner,'decision.reverse.console',{id:d.id}),{code:'decision-reversed'});
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
});
test('AU a near-loop reverse that crosses the deadline cannot write',async t=>{
let effects=0;const now=Date.now;
const f=await setup(t,{merge:'near the loop',notices:true,reverseWindowHours:1},{reverse:async({beforeWrite})=>{Date.now=()=>now()+3600001;beforeWrite();effects++;return {};}});
const d=f.raise({action:'git.merge.protected'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
try{await assert.rejects(f.call(f.owner,'decision.reverse.console',{id:d.id}),{code:'reverse-window-closed'});}finally{Date.now=now;}
assert.equal(effects,0);
});
test('AU policy near refuses with a named next step; in and the existing out level remain usable',async t=>{
let f,autonomy={},revision='a'.repeat(64);
const host={get:()=>({autonomy,revision}),set:(_b,a)=>{autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'policy-setting'};},reversible:()=>false};
f=await setup(t,null,host);
await assert.rejects(f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'near the loop'}}),e=>e.code==='policy-reverse-unavailable' && /in the loop or out of the loop/.test(e.nextStep));
assert.deepEqual(autonomy,{});
await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'in the loop'}});
const d=f.raise({action:'policy.change',target:'policy-1'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
assert.throws(()=>f.broker.authorize(f.coder,'policy.change',{decision:d.id,target:'policy-1'}),{code:'confirmation-required'});
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true});
assert.equal(f.broker.authorize(f.coder,'policy.change',{decision:d.id,target:'policy-1'}).decision,d.id);
await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'out of the loop'}});
const out=f.raise({action:'policy.change',target:'policy-2'});f.call(f.chief,'decision.resolve',{id:out.id,choice:'yes'});
assert.equal(f.broker.authorize(f.coder,'policy.change',{decision:out.id,target:'policy-2'}).decision,out.id);
});
test('AU near cross-role authorization without a trusted inverse fails closed before consumption',async t=>{
const f=await setup(t,{'cross-role':'near the loop',notices:true}),d=f.raise({action:'task.scope.change',target:'vikunja:1/2'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
assert.throws(()=>f.broker.authorize(f.coder,'task.scope.change',{decision:d.id,target:'vikunja:1/2'}),{code:'reversal-unavailable'});
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.decision')=? AND json_extract(body,'$.action')='task.scope.change' AND json_extract(body,'$.operation') IS NOT 'decision.raise'",d.id).n,0);
});
test('AU a withdrawal after decline is terminal for resolver, owner and confirmation',async t=>{
const f=await setup(t,{merge:'in the loop'}),d=f.raise({action:'git.merge.protected'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:false,reason:'Revise'});
f.call(f.coder,'decision.withdraw',{id:d.id});
assert.throws(()=>f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}),{code:'decision-closed'});
for(const cap of [f.chief,f.human])assert.ok(!f.call(cap,'inbox').some(x=>x.id===d.id));
assert.throws(()=>f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true}),e=>['decision-closed','decision-not-eligible'].includes(e.code));
});
async function settingsFixture(t) {
let f,autonomy={},revision='a'.repeat(64),writes=0;
const host={get:()=>({autonomy,revision}),set:(_b,a)=>{writes++;autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'setting-test'};},reversible:()=>true};
f=await setup(t,null,host);
f.business.roles.judge={definition:'judge',authority:{withinRole:['decision.resolve.authority'],crossRole:[]}};
f.business.roles.reviewer.authority.withinRole.push('review.verdict');
f.business.launch={by:'chief',models:{model:{provider:'anthropic',model:'test-model'}},limits:{tokens:{}}};
f.business.plugins={'limit-check':['token-limit']};
f.broker.reloadBusiness('demo',f.business);
return {...f,set:value=>f.call(f.owner,'autonomy.set.console',{revision,autonomy:value}),writes:()=>writes};
}
const authorityNear={authority:'near the loop',authorityResolver:'judge',notices:true};
const tokenCap={limit:1000,since:'2026-01-01T00:00:00.000Z'};
test('AU authority near requires an enforced organization budget covering every allowed model, including zero caps',async t=>{
const f=await settingsFixture(t);
for(const tokens of [{},{instances:{coder:tokenCap}},{providers:{unrelated:tokenCap}}]){
f.business.launch.limits.tokens=tokens;f.broker.reloadBusiness('demo',f.business);
await assert.rejects(f.set(authorityNear),{code:'autonomy-precondition'});
}
f.business.launch.limits.tokens={providers:{anthropic:{...tokenCap,limit:0}}};
f.business.plugins={'limit-check':[]};f.broker.reloadBusiness('demo',f.business);
await assert.rejects(f.set(authorityNear),{code:'autonomy-precondition'});
f.business.plugins={'limit-check':['token-limit']};f.broker.reloadBusiness('demo',f.business);
await f.set(authorityNear);assert.equal(f.writes(),1);
});
test('AU authority out refuses until per-definition agent limits exist, even with current agents capped',async t=>{
const f=await settingsFixture(t);
f.business.launch.limits.tokens={providers:{anthropic:tokenCap},instances:Object.fromEntries(Object.keys(f.business.roles).map(n=>[n,tokenCap]))};
f.broker.reloadBusiness('demo',f.business);await f.set(authorityNear);
f.call(f.owner,'autonomy.review.console',{decisionClass:'authority'});
await assert.rejects(f.set({...authorityNear,authority:'out of the loop'}),e=>e.code==='authority-limits-unavailable' && /near the loop/.test(e.nextStep));
assert.equal(f.writes(),1);
});
test('AU moving out requires an actual near resolution and a later owner review',async t=>{
const f=await settingsFixture(t);await f.set({merge:'in the loop'});await f.set({merge:'near the loop',notices:true});
f.call(f.owner,'autonomy.review.console',{decisionClass:'merge'});
await assert.rejects(f.set({merge:'out of the loop',notices:true}),{code:'autonomy-precondition'});
const d=f.raise({action:'git.merge.protected'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
await assert.rejects(f.set({merge:'out of the loop',notices:true}),{code:'autonomy-precondition'});
f.call(f.owner,'autonomy.review.console',{decisionClass:'merge'});
await f.set({merge:'out of the loop',notices:true});
});