diff --git a/packages/bus/src/autonomy-host.mjs b/packages/bus/src/autonomy-host.mjs index 3e6e6a80b..e7f17b6bc 100644 --- a/packages/bus/src/autonomy-host.mjs +++ b/packages/bus/src/autonomy-host.mjs @@ -5,8 +5,11 @@ import { randomBytes } from 'node:crypto'; import { prepareRevert, finishLand, dropLand } from '../../seat/src/working.mjs'; import { isMosaicStack, runGate, gateEnv } from '../../seat/src/landing.mjs'; const refuse=code=>{throw Object.assign(new Error(code),{code});}; -export function autonomyHost({writer,dataRoot,business,hires,reload,processing,env=process.env,gate={classify:isMosaicStack,run:runGate}}) { +// countsAccount(name) answers what the named harness module declares (runtime harnessLevels); +// the broker's authority budget check reads it here instead of naming a harness. +export function autonomyHost({writer,dataRoot,business,hires,reload,processing,countsAccount=()=>null,env=process.env,gate={classify:isMosaicStack,run:runGate}}) { return { + countsAccount, get:id=>{const r=writer.read(id);return {revision:r.revision,autonomy:r.document.autonomy??{}};}, set:(id,args,actor)=>{ const old=writer.read(id);if(old.revision!==args.revision)refuse('revision-conflict'); diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 7b64da2ea..1851b9374 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -6415,7 +6415,7 @@ export class Broker { if(!role?.authority?.withinRole.includes(right) || (cls==='authority' && name===b.launch?.by))fail('autonomy-precondition'); } if(['merge','policy'].includes(cls) && !Object.values(b.roles).some(r=>r.authority?.withinRole.includes('review.verdict')))fail('autonomy-precondition'); - if(cls==='authority' && !authorityBudgetCovered(b))fail('autonomy-precondition'); + if(cls==='authority' && !this.#budgetCovered(b))fail('autonomy-precondition'); if(after==='out of the loop') { if(before!=='near the loop')fail('autonomy-precondition'); const review=this.#store.get("SELECT seq,body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.review' AND json_extract(body,'$.decisionClass')=? ORDER BY seq DESC LIMIT 1",s.business,cls); @@ -6504,7 +6504,7 @@ export class Broker { let route='human'; if(!immediate && level!=='in the loop' && p.level!=='root' && !p.flagged.length && !p.credentialLines.length) { if(level==='out of the loop')fail('authority-limits-unavailable'); - if(!authorityBudgetCovered(this.#business(s.business)))fail('autonomy-precondition'); + if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition'); route=b.autonomy?.authorityResolver; if(!b.roles[route]?.authority?.withinRole.includes('decision.resolve.authority'))fail('autonomy-precondition'); if(route===s.role || resolverSubject(b,route,p.name))route='human'; @@ -6518,6 +6518,8 @@ export class Broker { if(immediate) return this.#hireConsole(s,'hire.resolve.console',{id:did,choice:'yes',digest:p.digest}); return {...this.#decisionView(this.#decision(s,did)),hire:p}; } + // The harness fact comes from the host's loaded modules; no host, no account coverage. + #budgetCovered(b) { return authorityBudgetCovered(b,{countsAccount:this.#autonomyHost?.countsAccount}); } // Trusted runtime inverse only; never a socket verb. hireReverse(...args) { if(!this.#hires?.reverse)fail('reversal-unavailable'); @@ -6534,7 +6536,7 @@ export class Broker { if(this.#expireHire(s,d,p))fail('decision-expired'); if(this.#autonomyLevel(d)==='in the loop')fail('hire-resolution-required'); if(this.#autonomyLevel(d)==='out of the loop')fail('authority-limits-unavailable'); - if(!authorityBudgetCovered(this.#business(s.business)))fail('autonomy-precondition'); + if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition'); if(!this.#business(s.business).roles[s.role]?.authority?.withinRole.includes('decision.resolve.authority'))fail('not-resolver'); }; validate(); @@ -6554,7 +6556,7 @@ export class Broker { const recheck=()=>{ this.#agent(s); if(this.#autonomyLevel(d)==='out of the loop')fail('authority-limits-unavailable'); - if(!authorityBudgetCovered(this.#business(s.business)))fail('autonomy-precondition'); + if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition'); if(this.#autonomyLevel(d)==='in the loop' || !this.#business(s.business).roles[s.role]?.authority?.withinRole.includes('decision.resolve.authority') || resolverSubject(this.#business(s.business),s.role,p.name))fail('not-resolver'); }; recheck(); diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 37f6da205..0539360ca 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -111,18 +111,28 @@ export async function harnessLevels({ log = (e) => process.stderr.write(`plugins try { names = [...new Set([...names,...Object.keys(readPluginLock(dataRoot).plugins).filter(name=>hasModule('harness',name,dataRoot))])]; } catch { log({event:'harness-levels-unread',message:'installed harness discovery refused'}); } } + const accounts = new Map(); for (const name of names) { const loader = createLoader({ log, dataRoot }); if ((await loader.load({ harness: [name] })).refused.length) continue; + const [module] = loader.modules('harness'); try { - const list = loader.modules('harness')[0].levels(); + const list = module.levels(); if (Array.isArray(list) && list.every((level) => typeof level === 'string')) levels.set(name, Object.freeze([...list])); } catch (e) { log({ event: 'harness-levels-failed', name, message: e.message }); } + // AU (#1873): whether this harness's launches spend launch.account, as its module declares. + try { + const counts = module.countsAccount(); + if (typeof counts === 'boolean') accounts.set(name, counts); + } catch (e) { + log({ event: 'harness-account-failed', name, message: e.message }); + } } const lookup = (name) => levels.get(name) ?? null; lookup.names = [...levels.keys()].sort(); + lookup.countsAccount = (name) => accounts.get(name) ?? null; return lookup; } // Row 224 C2-05b: a host-only adapter. A failed fetch keeps serving immutable old @@ -891,7 +901,7 @@ export async function startBroker({ return INSTANCE_VERBS.has(verb)?{...result,restart:!live}:result; }):null, // Row 194 (D2.3): the role files as they stand, for the hire checks. - autonomy: organization ? autonomyHost({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),dataRoot,business:id=>businesses[id],hires:{reverse:(...args)=>broker.hireReverse(...args)},reload,processing}) : null, + autonomy: organization ? autonomyHost({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),dataRoot,business:id=>businesses[id],countsAccount:levels.countsAccount,hires:{reverse:(...args)=>broker.hireReverse(...args)},reload,processing}) : null, hires: organization ? (()=>{ const service=hireDecisions({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),rolesDir:organization.rolesDir,configDir:organization.dir,dataRoot, system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),scanDefinition:(name,entry)=>scanDefinitions({[name]:entry},value=>credentials.assertClean(value)),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry}); diff --git a/packages/bus/tests/autonomy-authority.test.mjs b/packages/bus/tests/autonomy-authority.test.mjs index e0db934f8..b63c3007e 100644 --- a/packages/bus/tests/autonomy-authority.test.mjs +++ b/packages/bus/tests/autonomy-authority.test.mjs @@ -17,6 +17,8 @@ function world(t,options={}) { const root=mkdtempSync(join(tmpdir(),'au-authority-')),rolesDir=rolesCopy(root),dataRoot=join(root,'data');mkdirSync(dataRoot,{mode:0o700}); const doc=businessDoc(root,'demo');doc.plugins={harness:['pi'],'limit-check':['token-limit']}; doc.launch.limits.tokens={providers:{zai:cap}}; + // #1873: an account quota alone, covered only through the host's declared harness fact. + if(options.account){doc.launch.account={name:'shared',credential:'FIXTURE_TOKEN'};doc.launch.limits.tokens={keys:{shared:cap}};} doc.definitions={judge:{rev:1,role:{...inline,authority:{withinRole:['decision.resolve.authority'],crossRole:[]}},instructions:'Judge'},scout:{rev:3,role:structuredClone(inline),instructions:'Prior role'}}; // Definition instructions live beside the role composition. for(const d of Object.values(doc.definitions))delete d.role.instructions; @@ -31,7 +33,7 @@ function world(t,options={}) { const reload=id=>{if(reversing && options.crossAfterCommit)Date.now=()=>now()+3600001;if(reversing && options.reloadRefused)return false;broker.reloadBusiness(id,loaded());return true;}; const service=hireDecisions({writer,rolesDir,configDir:root,dataRoot,assertClean:()=>{}}); const hires=authorityHost(service,{reload,modelKeys:{prepareRevoke:async(instance,guard)=>{guard();assert.equal(instance,'demo/scout');order.push('prepare');},completeRevoke:instance=>{assert.equal(instance,'demo/scout');assert.ok(store.get("SELECT 1 FROM events WHERE json_extract(body,'$.action')='fire.stopped'"));assert.equal(writer.read('demo').document.definitions.scout.instructions,'Changed role');order.push('complete');}}}); - const host=autonomyHost({writer,dataRoot,business:()=>loaded(),hires,reload}); + const host=autonomyHost({writer,dataRoot,business:()=>loaded(),hires,reload,...(options.countsAccount?{countsAccount:options.countsAccount}:{})}); const instances=instanceSettings({dir:root,rolesDir,writer,revoke:()=>{order.push('fire-revoke');}}); broker=new Broker({store,businesses:{demo:loaded()},hires,autonomy:host,organization:(id,verb,args,actor,context)=>{if(verb==='instance.fireCheck'){instances.checkFire(id,args.name);return {};}if(verb==='instance.fire')return instances.fire(id,args,{actor,context});throw Error('unexpected organization verb');}}); const caps={};const call=(who,verb,args={})=>broker.request(caps[who],{verb,args}); @@ -107,3 +109,11 @@ test('AU inverse receipt-write failure returns committed with trailPending and n assert.equal(f.writer.read('demo').document.definitions.scout.instructions,'Prior role'); assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0); }); +test('AU an account-quota authority raise routes to the resolver only when the host says every harness spends the account (#1873)',async t=>{ + const covered=world(t,{account:true,countsAccount:name=>name==='pi'}),d=await covered.raise();assert.equal(d.route_to,'judge'); + for(const countsAccount of [undefined,()=>false,()=>null]){ + const f=world(t,{account:true,countsAccount}); + await assert.rejects(f.raise(),{code:'autonomy-precondition'}); + assert.equal(f.store.get("SELECT count(*) n FROM decisions").n,0); + } +}); diff --git a/packages/bus/tests/routes-verb.test.mjs b/packages/bus/tests/routes-verb.test.mjs index 5abac21f6..75b589ec5 100644 --- a/packages/bus/tests/routes-verb.test.mjs +++ b/packages/bus/tests/routes-verb.test.mjs @@ -96,6 +96,9 @@ test('row 65 runtime: harnessLevels answers each module in the tree, and null fo assert.deepEqual(levels('pi'), ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']); assert.deepEqual(levels('claude-code'), ['low', 'medium', 'high', 'xhigh', 'max']); for (const name of ['cursor', '', '../pi']) assert.equal(levels(name), null, name); + // AU (#1873): each module's own countsAccount answer, read once with its levels. + assert.deepEqual([levels.countsAccount('pi'), levels.countsAccount('claude-code')], [false, true]); + for (const name of ['cursor', '', '../pi']) assert.equal(levels.countsAccount(name), null, name); assert.deepEqual(logged, []); }); diff --git a/packages/organization/src/autonomy.mjs b/packages/organization/src/autonomy.mjs index 41701b12f..d5df0a51c 100644 --- a/packages/organization/src/autonomy.mjs +++ b/packages/organization/src/autonomy.mjs @@ -30,12 +30,15 @@ export function resolverSubject(business, resolver, target) { } // Aggregate provider/model limits cover names that do not exist yet. An account -// quota covers every future launch only when every enabled harness uses that account. -export function authorityBudgetCovered(business) { +// quota covers every future launch only when every enabled harness's module declares +// that its launches spend launch.account (its countsAccount export). The host passes +// that answer as countsAccount(name) -> true|false|null; core names no harness, and +// without the lookup no account quota counts. +export function authorityBudgetCovered(business, {countsAccount=()=>null}={}) { const launch=business.launch, tokens=launch?.limits?.tokens, checks=business.plugins?.['limit-check']??[]; const quota=x=>Number.isSafeInteger(x?.limit) && x.limit>=0 && typeof x.since==='string' && !Number.isNaN(Date.parse(x.since)); const account=launch?.account?.name, harnesses=business.plugins?.harness??[]; - if(account && harnesses.length && harnesses.every(h=>h==='claude-code') && + if(account && harnesses.length && harnesses.every(h=>countsAccount(h)===true) && (checks.includes('daily-quota') && quota(tokens?.daily?.[account]) || checks.includes('token-limit') && quota(tokens?.keys?.[account]) && tokens.keys[account].reset===undefined))return true; const models=Object.values(launch?.models??{}); diff --git a/packages/organization/tests/autonomy.test.mjs b/packages/organization/tests/autonomy.test.mjs index 556a34ad2..cac58ea4b 100644 --- a/packages/organization/tests/autonomy.test.mjs +++ b/packages/organization/tests/autonomy.test.mjs @@ -38,8 +38,25 @@ test('AU authority budgets cover all selectable models and only enforced account b.launch.limits.tokens.models={two:{n:cap}};assert.equal(authorityBudgetCovered(b),true); b.plugins['limit-check']=[];assert.equal(authorityBudgetCovered(b),false); b.launch.account={name:'shared'};b.launch.limits.tokens={keys:{shared:cap}};b.plugins['limit-check']=['token-limit']; - assert.equal(authorityBudgetCovered(b),false); - b.plugins.harness=['claude-code'];assert.equal(authorityBudgetCovered(b),true); - b.plugins.harness.push('pi');assert.equal(authorityBudgetCovered(b),false); - b.plugins.harness=['claude-code'];b.plugins['limit-check']=['daily-quota'];b.launch.limits.tokens={daily:{shared:{...cap,reset:'00:00',timeZone:'UTC'}}};assert.equal(authorityBudgetCovered(b),true); + // What today's two harness modules declare (packages/plugins/harness/*/index.mjs countsAccount). + const declared={countsAccount:h=>({'claude-code':true,pi:false})[h]??null}; + assert.equal(authorityBudgetCovered(b,declared),false); + b.plugins.harness=['claude-code'];assert.equal(authorityBudgetCovered(b,declared),true); + b.plugins.harness.push('pi');assert.equal(authorityBudgetCovered(b,declared),false); + b.plugins.harness=['claude-code'];b.plugins['limit-check']=['daily-quota'];b.launch.limits.tokens={daily:{shared:{...cap,reset:'00:00',timeZone:'UTC'}}};assert.equal(authorityBudgetCovered(b,declared),true); +}); +test('AU an account quota covers authority only when every enabled harness declares it spends the launch account (#1873)',()=>{ + const cap={limit:0,since:'2026-01-01T00:00:00.000Z'}; + const b={plugins:{harness:['alpha','beta'],'limit-check':['token-limit']},launch:{account:{name:'shared'},models:{one:{provider:'one',model:'m'}},limits:{tokens:{keys:{shared:cap}}}}}; + const declares=map=>({countsAccount:h=>map[h]??null}); + // The declared fact decides, whatever the harness is called. + assert.equal(authorityBudgetCovered(b,declares({alpha:true,beta:true})),true); + assert.equal(authorityBudgetCovered(b,declares({alpha:true,beta:false})),false); + assert.equal(authorityBudgetCovered(b,declares({alpha:true})),false,'an undeclared harness is not covered'); + assert.equal(authorityBudgetCovered(b,declares({alpha:true,beta:'yes'})),false,'only true counts'); + b.plugins.harness=['claude-code']; + assert.equal(authorityBudgetCovered(b,declares({'claude-code':false})),false,'the name alone covers nothing'); + assert.equal(authorityBudgetCovered(b),false,'no lookup: fail closed'); + // The provider/model branch needs no harness fact. + b.launch.limits.tokens.providers={one:cap};assert.equal(authorityBudgetCovered(b),true); });