Adds the harness plug-in kind (interface 1). packages/plugins/harness/pi and claude-code hold what core branched on: the adapter, thinking levels, preflight, version probe, sandbox mounts, builtins line, typed tool names, gate timeout, bundle files, adapter env, turn end and session-files path. Core reaches a module only through the loader. A session's route is the instance's harness, model and thinking vars, or the three fallback. vars; a missing one refuses no-route, and the system file's execution keys are never read for a session. The launcher checks the primary route, then the fallback: harness-off, harness-refused, thinking-unsupported, then preflight. A failed preflight falls back when a fallback is set, and any refusal of the fallback names the primary's failure too. Account checks run only when the route's module counts an account. The manifest records route, routeReason and harnessVersion, replacing piVersion and claudeVersion. pi gets --thinking, Claude Code --effort, both from MOSAIC_THINKING. The business file refuses a partial fallback. routes and routes.set are human-only bus verbs outside ORGANIZATION_KEYS; checkRoutes is shared by routes.set, instances.set and limits.set and checks only changed routes. The chief of staff changes an instance's model, and the assistant's whole vars (Q169), through limits.set, so its route write runs the same checks. A model outside launch.models refuses unknown-model, primary or fallback, and routes.set passes that code through. A new run binds only on a switched-on harness module name and records its transcript path, which the usage reader uses. Settings gains a Routing table with six selects per instance and loses the Role models fieldset; it posts changed rows to /api/routes. webui/src/bus.mjs is unchanged. Until Jason's `mosaic plugins set` adds "harness": ["claude-code"], a new launch refuses harness-off.
81 lines
4.4 KiB
JavaScript
81 lines
4.4 KiB
JavaScript
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the launcher asks the bus's limit checks before
|
|
// every launch, after the agent limits. A stub host answers the mayLaunch op; no sandbox, Docker
|
|
// or session process. Any answer that isn't an allow refuses (fail closed, lead decision 150).
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {readFileSync} from 'node:fs';
|
|
import {fileURLToPath} from 'node:url';
|
|
import {createLauncher} from '../src/launcher.mjs';
|
|
import {fixture,tmp} from './helpers.mjs';
|
|
import {launchLogFile} from '../../seat/src/session.mjs';
|
|
|
|
const HIT={path:['tokens','instances','coder'],limit:100,count:120,unknownTurns:1};
|
|
|
|
// answer is what the mayLaunch op returns, or an Error it throws.
|
|
function setup(t,answer){
|
|
const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),ops=[];
|
|
const host={business:'acme',beforeClose:()=>{},op:async x=>{
|
|
ops.push(x);
|
|
if(x.op==='identity')return {business:'acme',role:'pm'};
|
|
if(x.op==='mayLaunch'){if(answer instanceof Error)throw answer;return answer;}
|
|
if(x.op==='authorizeLaunch')throw Object.assign(Error('fixture authorization stop'),{code:'fixture-policy'});
|
|
return {};
|
|
}};
|
|
const launcher=createLauncher({host,system:JSON.parse(readFileSync(f.config,'utf8')),env:f.env,bwrap:'/usr/bin/true',log:()=>{}});
|
|
const last=()=>JSON.parse(readFileSync(launchLogFile(f.dataRoot,'acme'),'utf8').trim().split('\n').at(-1));
|
|
return {launcher,ops,last};
|
|
}
|
|
|
|
test('REQ-LIMIT-4 the launcher asks mayLaunch with the instance, provider, model and account (row 126), and goes on to authorization on an allow',async t=>{
|
|
const {launcher,ops}=setup(t,{allow:true});
|
|
assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy');
|
|
assert.deepEqual(ops.find(x=>x.op==='mayLaunch'),{op:'mayLaunch',business:'acme',instance:'coder',provider:'zai',model:'claude-sonnet-5-5',account:null});
|
|
assert.ok(ops.findIndex(x=>x.op==='mayLaunch')<ops.findIndex(x=>x.op==='authorizeLaunch'));
|
|
});
|
|
|
|
test('REQ-LIMIT-3 REQ-LIMIT-4 a launch whose subject is at its limit is refused limit-reached and logged with the check, limit and count',async t=>{
|
|
const {launcher,ops,last}=setup(t,{allow:false,check:'token-limit',hit:HIT});
|
|
assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'limit-reached'});
|
|
const event=last();
|
|
assert.equal(event.event,'refused');
|
|
assert.equal(event.code,'limit-reached');
|
|
assert.deepEqual(event.limit,{check:'token-limit',...HIT});
|
|
assert.deepEqual(JSON.parse(event.why),HIT);
|
|
assert.ok(ops.find(x=>x.op==='refuse'&&x.code==='limit-reached'));
|
|
assert.equal(ops.find(x=>x.op==='authorizeLaunch'),undefined);
|
|
assert.deepEqual(launcher.registry.sessions,[]);
|
|
});
|
|
|
|
test('REQ-LIMIT-4 fail closed: a refused op, a check failure or a malformed answer refuses limit-check-failed, naming the check and the code',async t=>{
|
|
for(const [answer,why] of [
|
|
[Object.assign(Error('op refused'),{code:'quota-gone'}),'bus: quota-gone'],
|
|
[Error('no code'),'bus: launch-op-refused'],
|
|
[{allow:false,check:'token-limit',code:'not-loaded'},'token-limit: not-loaded'],
|
|
[{allow:false,check:'token-limit',code:'check-threw'},'token-limit: check-threw'],
|
|
[{allow:false,check:null,code:'organization-unreadable'},'bus: organization-unreadable'],
|
|
[{allow:'yes'},'bus: malformed-answer'],
|
|
[{},'bus: malformed-answer'],
|
|
[null,'bus: malformed-answer'],
|
|
[undefined,'bus: malformed-answer'],
|
|
]){
|
|
const {launcher,ops,last}=setup(t,answer);
|
|
assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'limit-check-failed'},why);
|
|
const event=last();
|
|
assert.equal(event.code,'limit-check-failed',why);
|
|
assert.equal(event.why,why);
|
|
assert.equal(event.limit,undefined,why);
|
|
assert.equal(ops.find(x=>x.op==='authorizeLaunch'),undefined,why);
|
|
}
|
|
});
|
|
|
|
test('REQ-LIMIT-4 the launcher reaches token-limit only through the bus: launcher.mjs imports no limit check',()=>{
|
|
const src=readFileSync(fileURLToPath(new URL('../src/launcher.mjs',import.meta.url)),'utf8');
|
|
assert.doesNotMatch(src,/token-limit/);
|
|
assert.doesNotMatch(src,/limit-check\//);
|
|
// Row 65: the launcher reaches its harness through the plug-in loader, and every load names only
|
|
// the harness kind, so a limit check still can't load here.
|
|
assert.deepEqual([...src.matchAll(/\.load\(([^)]*)\)/g)].map(m=>m[1]),['{ harness: [m.harness] }']);
|
|
assert.doesNotMatch(src,/modules\(\s*["'](?!harness["'])/);
|
|
assert.doesNotMatch(src,/\bimport\s*\(/);
|
|
});
|