1. Stale bus host lock refusals from bus status, start, stop and restart name mosaic bus recover ORG. 2. org definitions: unknown org, unknown or malformed name and bad id give one refusal or usage line, no stack trace, no host path. 3. Pack, board and workflow refusals print their hint as template save does; tested with the real CliError transport shape. 4. console start and stop clear a console/start.lock whose named supervisor (pid and start time) is gone; a live or unnamed holder refuses. 5. Template save keeps free text verbatim and writes no placeholders; the preview lists each field that mentions an org-specific name. 6. A request the shim never sent (no socket, connect refused) exits 5 and the CLI reports bus-not-running with the start and recover line. 7. autonomy-precondition carries the check that failed, including on the hire paths. 8. Saving under an existing template name refuses template-exists with a next step; refusal table entry added, channel-proof pins moved.
173 lines
14 KiB
JavaScript
173 lines
14 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',reason:'no enforced budget covers new agents; authority away from the loop needs one'});
|
|
}
|
|
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',reason:'no enforced budget covers new agents; authority away from the loop needs one'});
|
|
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 a class moves out of the loop only from near the loop, and the refusal says so (E2E6 fix 7)',async t=>{
|
|
const f=await settingsFixture(t);await f.set({'cross-role':'in the loop'});
|
|
await assert.rejects(f.set({'cross-role':'out of the loop',notices:true}),{code:'autonomy-precondition',reason:'cross-role moves out of the loop only from near the loop'});
|
|
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});
|
|
// E2E6 fix 7: each refusal names the check that failed.
|
|
await assert.rejects(f.set({merge:'near the loop'}),{code:'autonomy-precondition',reason:'owner notices are off; a class near the loop needs them on'});
|
|
await assert.rejects(f.set({merge:'out of the loop',notices:true}),{code:'autonomy-precondition',reason:'merge needs your review of its trail since the last autonomy change'});
|
|
f.call(f.owner,'autonomy.review.console',{decisionClass:'merge'});
|
|
await assert.rejects(f.set({merge:'out of the loop',notices:true}),{code:'autonomy-precondition',reason:'merge needs a near-loop decision since the last autonomy change, and your trail review after it'});
|
|
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',reason:'merge needs a near-loop decision since the last autonomy change, and your trail review after it'});
|
|
f.call(f.owner,'autonomy.review.console',{decisionClass:'merge'});
|
|
await f.set({merge:'out of the loop',notices:true});
|
|
});
|