241 lines
18 KiB
JavaScript
241 lines
18 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { Broker } from '../src/broker.mjs';
|
|
import { Store } from '../src/store.mjs';
|
|
|
|
test('A4 direct instance.hire and instance.fire refuse and cannot write', t => {
|
|
const root = mkdtempSync(join(tmpdir(), 'a4-'));
|
|
const store = new Store(root);
|
|
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
|
let writes = 0;
|
|
const b = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['role.hire','role.fire'], crossRole: [] } } }, launch: { by: 'chief', instances: [] } } }, organization: () => { writes++; } });
|
|
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
|
b.request(chief, { verb: 'role.claim' });
|
|
for (const verb of ['instance.hire', 'instance.fire']) assert.throws(() => b.request(chief, { verb, args: { name: 'scout', definition: 'coder', reason: 'Need help' } }), { code: 'decision-required' });
|
|
assert.equal(writes, 0);
|
|
});
|
|
|
|
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
|
import { organizationWriter } from '../../organization/src/organization.mjs';
|
|
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
|
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
|
import { readFileSync, mkdirSync } from 'node:fs';
|
|
const INLINE = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: 'Read and report sk-test-NOT-A-SECRET-1234.' };
|
|
const SUBJECT={provider:'test',iss:'https://issuer.test',sub:'owner'};
|
|
function fixture(t) {
|
|
const root=mkdtempSync(join(tmpdir(),'a4-bus-')), rolesDir=rolesCopy(root);
|
|
const file=writeJson(join(root,'businesses/demo.json'),{...businessDoc(root,'demo'),plugins:{harness:['pi']}});
|
|
const storeRoot=join(root,'data');mkdirSync(storeRoot,{mode:0o700});
|
|
const writer=organizationWriter({dir:root,rolesDir}),store=new Store(storeRoot);
|
|
const service=hireDecisions({writer,rolesDir,dataRoot:storeRoot,configDir:root,assertClean:()=>{}});
|
|
const loaded=()=>{const doc=loadBusiness('demo',{dir:root,rolesDir});return {...doc,roles:Object.fromEntries(Object.entries(doc.roles).map(([n,e])=>[n,{...doc.definitions[e.definition],...e}]))};};
|
|
let writes=0;
|
|
const b=new Broker({store,businesses:{demo:loaded()},hires:{...service,apply:async(...args)=>{writes++;const r=await service.apply(...args);b.reloadBusiness('demo',loaded());return r;}}});
|
|
const owner=b.bindConsole({business:'demo',human:'jason',subject:SUBJECT});
|
|
const member=rights=>b.bindHireConsole({business:'demo',actor:'member',subject:{...SUBJECT,sub:'member'},rights});
|
|
const caps={owner,chief:b.bindLaunch({business:'demo',role:'chief',run:'chief-run',harness:'pi'}),coder:b.bindLaunch({business:'demo',role:'coder',run:'coder-run',harness:'pi'})};
|
|
b.request(caps.chief,{verb:'role.claim'});b.request(caps.coder,{verb:'role.claim'});
|
|
const call=(cap,verb,args={})=>b.request(cap,{verb,args});
|
|
const request=extra=>({op:'hire',level:'org',name:'scout',role:{inline:INLINE},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Need help',...extra});
|
|
const raise=extra=>call(caps.chief,'hire.raise',request(extra));
|
|
t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
|
|
const reloadChief=authority=>{const doc=loaded();doc.roles.chief.authority=authority;b.reloadBusiness('demo',doc);};
|
|
return {root,rolesDir,b,store,file,writer,service,owner,member,caps,call,request,raise,reloadChief,writes:()=>writes};
|
|
}
|
|
test('A4 only the chief raises; forged flagged lines refuse without a record',async t=>{
|
|
const {call,caps,request,store}=fixture(t);
|
|
await assert.rejects(call(caps.coder,'hire.raise',request()),{code:'role-not-chief'});
|
|
await assert.rejects(call(caps.chief,'hire.raise',request({flagged:[]})),{code:'invalid-request'});
|
|
assert.equal(store.get('SELECT count(*) n FROM decisions').n,0);
|
|
});
|
|
test('A4 approve is digest bound and consumed once; generic decide never resolves hire, fire or change',async t=>{
|
|
const {raise,call,owner,writes,store,file,b,caps}=fixture(t);
|
|
const d=await raise();const p=await call(owner,'hire.preview.console',{id:d.id});
|
|
assert.ok(!JSON.stringify(p).includes('sk-test-NOT-A-SECRET-1234'));
|
|
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:'0'.repeat(64)}),{code:'decision-stale'});
|
|
for(const action of ['role.hire','role.fire','role.change']) {
|
|
if(action!==d.action)store.run('INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',action,'2026-01-01T00:00:00.000Z','demo','chief','chief-run','gated',action,'human','Question?',JSON.stringify([{key:'yes',text:'Yes'},{key:'no',text:'No'}]),'no',0);
|
|
assert.throws(()=>call(owner,'decision.resolve.console',{id:action===d.action?d.id:action,choice:'yes',digest:p.digest}),{code:'hire-resolution-required'});
|
|
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
|
assert.throws(()=>call(human,'decision.resolve',{id:action===d.action?d.id:action,choice:'yes'}),{code:'hire-resolution-required'});
|
|
}
|
|
const before=readFileSync(file);
|
|
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest});
|
|
assert.equal(writes(),1);assert.notDeepEqual(readFileSync(file),before);
|
|
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest}),{code:'decision-closed'});
|
|
assert.equal(writes(),1);
|
|
for(const row of [...store.all('SELECT * FROM decisions'),...store.all('SELECT * FROM events')])assert.ok(!JSON.stringify(row).includes('sk-test-NOT-A-SECRET-1234'));
|
|
});
|
|
test('A4 denied and expired decisions leave organization bytes untouched',async t=>{
|
|
const {raise,call,owner,file,writes}=fixture(t);
|
|
const before=readFileSync(file),d=await raise();
|
|
await call(owner,'hire.resolve.console',{id:d.id,choice:'no',digest:d.hire.digest});
|
|
const e=await raise(); const original=Date.now;
|
|
try{Date.now=()=>original()+8*86400000;await assert.rejects(call(owner,'hire.resolve.console',{id:e.id,choice:'yes',digest:e.hire.digest}),{code:'decision-expired'});}finally{Date.now=original;}
|
|
assert.deepEqual(readFileSync(file),before);assert.equal(writes(),0);
|
|
});
|
|
test('A4 members with org.hire refuse flagged or credential grants; decide alone refuses all hires',async t=>{
|
|
const {raise,call,member}=fixture(t);
|
|
const plain=await raise();
|
|
await assert.rejects(call(member(['decide']),'hire.resolve.console',{id:plain.id,choice:'yes',digest:plain.hire.digest}),{code:'right-required'});
|
|
const flagged=await raise({role:{inline:{...INLINE,tools:['read','bash']}}});
|
|
const credential=await raise({role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}});
|
|
for(const d of [flagged,credential])await assert.rejects(call(member(['org.hire']),'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest}),{code:'hire-owner-required'});
|
|
await call(member(['org.hire']),'hire.resolve.console',{id:plain.id,choice:'yes',digest:plain.hire.digest});
|
|
});
|
|
test('A4 human Console start raises and resolves one recorded decision with computed lines',async t=>{
|
|
const {call,owner,request,store,writes}=fixture(t);
|
|
const r=await call(owner,'hire.start.console',request({role:{inline:{...INLINE,tools:['read','bash']}}}));
|
|
assert.equal(r.op,'resolved');assert.equal(writes(),1);
|
|
const event=store.all("SELECT body FROM events WHERE json_extract(body,'$.operation')='decision.raise'").map(e=>JSON.parse(e.body))[0];
|
|
assert.deepEqual(event.hire.flagged,[{field:'tools',value:'bash',reasons:['above-chief']}]);
|
|
assert.equal(store.get('SELECT raised_by_role FROM decisions WHERE id=?',r.id).raised_by_role,'human');
|
|
});
|
|
test('A4 a stale organization digest never reaches the writer',async t=>{
|
|
const {raise,call,owner,writer,file,writes}=fixture(t);
|
|
const d=await raise(),old=writer.read('demo');
|
|
writeJson(file,{...old.document,vars:{...old.document.vars,'tracker.pollSeconds':61}});
|
|
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest}),{code:'decision-stale'});
|
|
assert.equal(writes(),0);
|
|
});
|
|
|
|
test('A4 approved credential role refuses launch until a binding is present',async t=>{
|
|
const {raise,call,owner,b,caps,writer,file,root,rolesDir}=fixture(t);
|
|
const d=await raise({role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}});
|
|
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest});
|
|
assert.throws(()=>b.request(b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),{verb:'instance.launch',args:{instance:'scout'}}),{code:'credential-unbound'});
|
|
// Binding implementation is outside A4. The existing file shape supplies its metadata here.
|
|
const doc=writer.read('demo').document;
|
|
doc.roles.scout.credentials={gitea:{store:true,rotateBy:'2099-01-01'}};
|
|
writeJson(file,doc);
|
|
const loaded=loadBusiness('demo',{dir:root,rolesDir});
|
|
b.reloadBusiness('demo',{...loaded,roles:Object.fromEntries(Object.entries(loaded.roles).map(([n,e])=>[n,{...loaded.definitions[e.definition],...e}]))});
|
|
assert.doesNotThrow(()=>b.request(b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),{verb:'instance.launch',args:{instance:'scout'}}));
|
|
});
|
|
test('A4 refusals and records never carry invalid role text',async t=>{
|
|
const {raise,store}=fixture(t);
|
|
await assert.rejects(raise({role:{inline:{...INLINE,authority:{withinRole:['sk-test-NOT-A-SECRET-1234'],crossRole:[]}}}}),e=>{
|
|
assert.ok(!JSON.stringify(e).includes('sk-test-NOT-A-SECRET-1234'));return true;
|
|
});
|
|
assert.ok(!JSON.stringify(store.all('SELECT * FROM events')).includes('sk-test-NOT-A-SECRET-1234'));
|
|
});
|
|
|
|
test('A4 a human may mark a hire seen, and the chief may withdraw its own proposal',async t=>{
|
|
const {raise,call,b,caps,writes}=fixture(t),d=await raise();
|
|
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
|
assert.equal(call(human,'decision.seen',{id:d.id}).op,'seen');
|
|
assert.equal(call(caps.chief,'decision.withdraw',{id:d.id}).op,'withdrawn');
|
|
assert.equal(writes(),0);
|
|
});
|
|
|
|
test('A4 Console previews and consumes only the exact narrowed grants and route',async t=>{
|
|
const {raise,call,owner,writer,store}=fixture(t);
|
|
const d=await raise({role:{inline:{...INLINE,tools:['read','bash'],credentials:[{service:'gitea',scopes:['read:repository']}]}}});
|
|
const adjust={role:{tools:['read']},credentials:[],route:{harness:'pi',model:'claude-sonnet-5-5'}};
|
|
const p=await call(owner,'hire.preview.console',{id:d.id,adjust});
|
|
assert.notEqual(p.digest,d.hire.digest);assert.deepEqual(p.flagged,[]);assert.deepEqual(p.credentialLines,[]);
|
|
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest}),{code:'decision-stale'});
|
|
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest,adjust});
|
|
const doc=writer.read('demo').document,entry=doc.definitions[doc.roles.scout.definition.slice(4)];
|
|
assert.deepEqual(entry.role.tools,['read']);assert.deepEqual(entry.role.credentials,[]);
|
|
const consume=JSON.parse(store.get("SELECT body FROM events WHERE json_extract(body,'$.operation')='hire.consume'").body);
|
|
assert.equal(consume.hire.digest,p.digest);assert.deepEqual(consume.hire.credentialLines,[]);
|
|
});
|
|
|
|
test('A4 Console lists template hashes without role text, under org.hire',async t=>{
|
|
const {call,owner,member}=fixture(t);
|
|
const list=await call(owner,'hire.templates.console');
|
|
assert.ok(list.templates.some(x=>x.name==='coder'&&/^[a-f0-9]{64}$/.test(x.sha256)));
|
|
assert.ok(list.templates.every(x=>Object.keys(x).sort().join(',')==='name,sha256'));
|
|
assert.throws(()=>call(member(['decide']),'hire.templates.console'),{code:'right-required'});
|
|
});
|
|
|
|
test('A4 chief proposals require current role.hire for hire/change and role.fire for fire',async t=>{
|
|
const f=fixture(t);
|
|
f.reloadChief({withinRole:['message.send'],crossRole:[]});
|
|
for(const op of ['hire','change','fire'])for(const verb of ['hire.raise','decision.raise']) {
|
|
const hire=f.request({op});
|
|
await assert.rejects(f.call(f.caps.chief,verb,verb==='decision.raise'?{action:`role.${op}`,hire}:hire),{code:'not-authorized'});
|
|
}
|
|
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
|
|
f.reloadChief({withinRole:['role.hire'],crossRole:[]});
|
|
assert.ok((await f.raise()).id);
|
|
assert.ok((await f.raise({op:'change',name:'coder'})).id);
|
|
const fire={op:'fire',level:'org',name:'coder',reason:'Finished'};
|
|
await assert.rejects(f.call(f.caps.chief,'hire.raise',fire),{code:'not-authorized'});
|
|
f.reloadChief({withinRole:['role.fire'],crossRole:[]});
|
|
assert.ok((await f.call(f.caps.chief,'hire.raise',fire)).id);
|
|
await assert.rejects(f.raise(),{code:'not-authorized'});
|
|
});
|
|
|
|
test('A4 chief authority is checked again after asynchronous proposal preparation',async t=>{
|
|
const f=fixture(t),pending=f.raise();
|
|
f.reloadChief({withinRole:['message.send'],crossRole:[]});
|
|
await assert.rejects(pending,{code:'not-authorized'});
|
|
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
|
|
});
|
|
|
|
test('A4 a member start needing owner approval refuses before saving a decision',async t=>{
|
|
const f=fixture(t),cap=f.member(['org.hire']),before=readFileSync(f.file);
|
|
for(const inline of [{...INLINE,tools:['read','bash']},{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}])
|
|
await assert.rejects(f.call(cap,'hire.start.console',f.request({role:{inline}})),{code:'hire-owner-required'});
|
|
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
|
|
assert.deepEqual(readFileSync(f.file),before);
|
|
assert.equal((await f.call(cap,'hire.start.console',f.request())).op,'resolved');
|
|
});
|
|
|
|
test('A4 inbox and decision reads expire unopened hires once without changing configuration',async t=>{
|
|
const f=fixture(t),d=await f.raise({role:{inline:{...INLINE,tools:['read','bash']}}}),before=readFileSync(f.file);
|
|
const human=f.b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
|
const original=Date.now;
|
|
try {
|
|
Date.now=()=>original()+8*86400000;
|
|
await assert.rejects(f.call(f.member(['org.hire']),'hire.preview.console',{id:d.id}),{code:'decision-expired'});
|
|
assert.deepEqual(f.call(human,'inbox'),[]);
|
|
const recent=f.call(human,'decisions',{list:'recent'});
|
|
assert.equal(recent[0].closed.by,'expiry');
|
|
const another=await f.raise();
|
|
Date.now=()=>original()+16*86400000;
|
|
assert.deepEqual(f.call(human,'inbox'),[]);
|
|
f.call(human,'decisions',{list:'recent'});
|
|
assert.equal(f.store.get("SELECT count(*) n FROM decision_events WHERE decision=? AND by='expiry'",another.id).n,1);
|
|
} finally {Date.now=original;}
|
|
assert.deepEqual(readFileSync(f.file),before);assert.equal(f.writes(),0);
|
|
});
|
|
test('AU a distinct authority resolver applies a plain near-loop hire, while flagged and credential hires stay human',async t=>{
|
|
const f=fixture(t),doc=JSON.parse(readFileSync(f.file,'utf8'));
|
|
doc.definitions={resolver:{rev:1,role:{tools:['read'],network:'none',authority:{withinRole:['decision.resolve.technical','decision.resolve.authority'],crossRole:[]},credentials:[]},instructions:'Resolve independent decisions.'}};
|
|
doc.roles.cto={definition:'org:resolver'};
|
|
doc.plugins['limit-check']=['token-limit'];doc.launch.limits={tokens:{providers:{zai:{limit:100000,since:'2026-01-01T00:00:00.000Z'}}}};
|
|
doc.autonomy={authority:'near the loop',authorityResolver:'cto',notices:true};writeJson(f.file,doc);
|
|
const live=loadBusiness('demo',{dir:f.root,rolesDir:f.rolesDir});
|
|
const business={...live,roles:Object.fromEntries(Object.entries(live.roles).map(([n,e])=>[n,{...live.definitions[e.definition],...e}]))};
|
|
business.roles.cto.authority={withinRole:['decision.resolve.authority'],crossRole:[]};f.b.reloadBusiness('demo',business);
|
|
const resolver=f.b.bindLaunch({business:'demo',role:'cto',run:'cto-run',harness:'pi'});f.call(resolver,'role.claim');
|
|
const d=await f.raise();assert.equal(d.route_to,'cto');
|
|
await assert.rejects(Promise.resolve().then(()=>f.call(f.caps.chief,'decision.resolve',{id:d.id,choice:'yes'})),{code:'not-resolver'});
|
|
await f.call(resolver,'decision.resolve',{id:d.id,choice:'yes'});
|
|
assert.equal(f.writes(),1);assert.equal(JSON.parse(readFileSync(f.file)).roles.scout.hireDecision,d.id);
|
|
for(const change of [{name:'flagged',role:{inline:{...INLINE,tools:['bash']}}},{name:'credential',role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}}]) {
|
|
const decision=await f.raise(change);assert.equal(decision.route_to,'human');
|
|
}
|
|
});
|
|
|
|
test('AU R5 a change naming the authority resolver stays with a person; no agent resolves itself',async t=>{
|
|
const f=fixture(t),doc=JSON.parse(readFileSync(f.file,'utf8'));
|
|
doc.definitions={judge:{rev:1,role:{tools:['read'],network:'none',authority:{withinRole:['decision.resolve.authority'],crossRole:[]},credentials:[]},instructions:'Resolve independent authority decisions.'}};
|
|
doc.roles.judge={definition:'org:judge',vars:{harness:'pi',model:'claude-opus-5-5'}};
|
|
doc.plugins['limit-check']=['token-limit'];doc.launch.limits={tokens:{providers:{zai:{limit:100000,since:'2026-01-01T00:00:00.000Z'}}}};
|
|
doc.autonomy={authority:'near the loop',authorityResolver:'judge',notices:true};writeJson(f.file,doc);
|
|
const loaded=loadBusiness('demo',{dir:f.root,rolesDir:f.rolesDir});
|
|
f.b.reloadBusiness('demo',{...loaded,roles:Object.fromEntries(Object.entries(loaded.roles).map(([n,e])=>[n,{...loaded.definitions[e.definition],...e}]))});
|
|
const judge=f.b.bindLaunch({business:'demo',role:'judge',run:'judge-run',harness:'pi'});f.call(judge,'role.claim');
|
|
const d=await f.raise({op:'change',name:'judge',role:{inline:{...INLINE,authority:{withinRole:['decision.resolve.authority'],crossRole:[]},instructions:'Narrow this resolver'}}});
|
|
assert.ok(d.hire.flagged.some(x=>x.value==='decision.resolve.authority'));assert.equal(d.route_to,'human');
|
|
await assert.rejects(Promise.resolve().then(()=>f.call(judge,'decision.resolve',{id:d.id,choice:'yes'})));
|
|
assert.equal(f.writes(),0);
|
|
});
|