diff --git a/packages/cli/tests/gate-lane-image.test.mjs b/packages/cli/tests/gate-lane-image.test.mjs index 21b4b6d08..45ef12b6a 100644 --- a/packages/cli/tests/gate-lane-image.test.mjs +++ b/packages/cli/tests/gate-lane-image.test.mjs @@ -3,10 +3,18 @@ import { mkdtempSync,rmSync,writeFileSync,mkdirSync,cpSync,readFileSync,statSync import { spawnSync } from 'node:child_process'; const image=await import('../../../scripts/testing/lane-image.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};}); const docker=await import('../../../scripts/testing/lane-docker.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};}); + +const processingInputs=['packages/processing-control/src/state.mjs','packages/processing-control/src/entrypoint.mjs']; +function copyProcessingInputs(root){ + mkdirSync(join(root,'packages/processing-control/src'),{recursive:true}); + for(const file of processingInputs)cpSync(new URL('../../../'+file,import.meta.url),join(root,file)); +} + test('a changed COPY input or actual image ID refuses compatible image sharing',t=>{ assert.equal(typeof image.imageFingerprint,'function','image fingerprint required'); const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true})); for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f)); + copyProcessingInputs(root); writeFileSync(join(root,'RELEASE'),'0.0.1\n'); for(const d of ['contracts','src','adapters']){mkdirSync(join(root,d));writeFileSync(join(root,d,'one'),'same');} const digest=image.imageFingerprint(root),id='sha256:'+'a'.repeat(64);const receipt={inputDigest:digest,imageId:id,tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'}; @@ -25,6 +33,7 @@ test('exclusive image preparation records actual image ID and build wall time an assert.equal(typeof image.prepareImage,'function','prepareImage is required'); const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true})); for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f)); + copyProcessingInputs(root); writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d)); const calls=[];const invoke=(args)=>{calls.push(args);if(args[0]==='compose')return '';return JSON.stringify(args[2]==='node:24-bookworm-slim'?{RepoDigests:['node@sha256:'+'b'.repeat(64)]}:{Id:'sha256:'+'a'.repeat(64),Os:'linux',Architecture:'amd64'});}; assert.throws(()=>image.prepareImage(root,{grant:'',tempRoot:root},invoke),/grant/); @@ -40,6 +49,7 @@ test('cleanup refuses a foreign Docker label and removes only an exact owned pro function imageFixture(t){ const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true})); for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f)); + copyProcessingInputs(root); writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d)); const receipt={inputDigest:image.imageFingerprint(root),imageId:'sha256:'+'a'.repeat(64),tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'}; return {root,receipt}; @@ -134,3 +144,12 @@ test('guard owns every Compose file argument and refuses caller configuration ov for(const args of [['compose','-f','foreign.yaml','run','mosaic-agent'],['compose','--project-directory','/tmp','run','mosaic-agent']]) assert.throws(()=>docker.guardDocker(args,run),/unowned command/); }); + +// Row 191 image-input manifest: either copied reader must invalidate the pinned image. +for(const file of processingInputs)test(`changing ${file} invalidates the image receipt`,t=>{ + const {root,receipt}=imageFixture(t); + const inspect=()=>({Id:receipt.imageId,Os:'linux',Architecture:'amd64'}); + assert.equal(image.verifyImage(receipt,root,inspect).imageId,receipt.imageId); + writeFileSync(join(root,file),readFileSync(join(root,file),'utf8')+'\n// changed fixture input\n'); + assert.throws(()=>image.verifyImage(receipt,root,inspect),/image inputs differ/); +}); diff --git a/packages/cli/tests/launch-account.test.mjs b/packages/cli/tests/launch-account.test.mjs index f107c4204..fe8a4b009 100644 --- a/packages/cli/tests/launch-account.test.mjs +++ b/packages/cli/tests/launch-account.test.mjs @@ -6,6 +6,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import {createLauncher} from '../src/launcher.mjs'; +import {initializeControl} from '../../processing-control/src/state.mjs'; import {fixture,tmp} from './helpers.mjs'; import {launchLogFile,SESSION_CREDENTIALS} from '../../seat/src/session.mjs'; @@ -19,6 +20,7 @@ function setup(t,{harness='claude-code',account=ACCOUNT,set=['CLAUDE_CODE_OAUTH_ if(account)doc.launch.account=account; return doc; }),ops=[]; + initializeControl(f.dataRoot); for(const k of SESSION_CREDENTIALS.flat())delete f.env[k]; for(const k of set)f.env[k]=VALUE; const host={business:'acme',beforeClose:()=>{},op:async x=>{ diff --git a/packages/cli/tests/launch-limit-checks.test.mjs b/packages/cli/tests/launch-limit-checks.test.mjs index e31ef2666..028c19e27 100644 --- a/packages/cli/tests/launch-limit-checks.test.mjs +++ b/packages/cli/tests/launch-limit-checks.test.mjs @@ -6,14 +6,16 @@ import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import {fileURLToPath} from 'node:url'; import {createLauncher} from '../src/launcher.mjs'; +import {initializeControl} from '../../processing-control/src/state.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){ +function setup(t,answer,{initialize=true}={}){ const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),ops=[]; + if(initialize)initializeControl(f.dataRoot); const host={business:'acme',beforeClose:()=>{},op:async x=>{ ops.push(x); if(x.op==='identity')return {business:'acme',role:'pm'}; @@ -78,3 +80,11 @@ test('REQ-LIMIT-4 the launcher reaches token-limit only through the bus: launche assert.doesNotMatch(src,/modules\(\s*["'](?!harness["'])/); assert.doesNotMatch(src,/\bimport\s*\(/); }); + +// Row 191: only trusted bus startup initializes control; an early seat launch refuses. +test('a launcher before trusted control initialization refuses processing-uninitialized',async t=>{ + const {launcher,ops}=setup(t,{allow:true},{initialize:false}); + assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'processing-uninitialized'}); + assert.equal(ops.some(x=>x.op==='mayLaunch'||x.op==='authorizeLaunch'),false); + assert.deepEqual(launcher.registry.sessions,[]); +}); diff --git a/packages/cli/tests/launch-limits.test.mjs b/packages/cli/tests/launch-limits.test.mjs index 0d8183cdd..4220c5a0f 100644 --- a/packages/cli/tests/launch-limits.test.mjs +++ b/packages/cli/tests/launch-limits.test.mjs @@ -4,11 +4,13 @@ import assert from 'node:assert/strict'; import {join} from 'node:path'; import {readFileSync,writeFileSync} from 'node:fs'; import {createLauncher} from '../src/launcher.mjs'; +import {initializeControl} from '../../processing-control/src/state.mjs'; import {fixture,tmp} from './helpers.mjs'; import {systemFor,writeJson} from '../../business/tests/helpers.mjs'; import {launchLogFile} from '../../seat/src/session.mjs'; test('each launch rereads limits and model selections; lowered limits keep sessions and log exact hit',async t=>{ const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),file=join(root,'config/businesses/acme.json'),ops=[]; + initializeControl(f.dataRoot); const host={business:'acme',beforeClose:()=>{},op:async x=>{ops.push(x);if(x.op==='identity')return {business:'acme',role:'pm'};if(x.op==='mayLaunch')return {allow:true};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 session={business:'acme',instance:'reviewer',run:'fixture-existing',harness:'pi',provider:'zai',model:'claude-sonnet-5-5'};launcher.registry.add(session); diff --git a/packages/cli/tests/launch-routes.test.mjs b/packages/cli/tests/launch-routes.test.mjs index 0c61a7de4..f28a220ea 100644 --- a/packages/cli/tests/launch-routes.test.mjs +++ b/packages/cli/tests/launch-routes.test.mjs @@ -7,6 +7,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import {createLauncher} from '../src/launcher.mjs'; +import {initializeControl} from '../../processing-control/src/state.mjs'; import {fakeHarnesses,fixture,tmp} from './helpers.mjs'; import {launchLogFile,SESSION_CREDENTIALS} from '../../seat/src/session.mjs'; @@ -25,6 +26,7 @@ const ON=['pi','claude-code','down','sparse','metered','broken','throws','absent // vars are the coder's whole agent vars; edit changes the rest of the file. async function setup(t,vars,{on=ON,edit=doc=>doc,env:more={}}={}){ const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars=vars;doc.plugins={...doc.plugins,harness:on};return edit(doc);}),ops=[]; + initializeControl(f.dataRoot); Object.assign(f.env,more); const host={business:'acme',beforeClose:()=>{},op:async x=>{ ops.push(x); diff --git a/scripts/testing/lane-image.mjs b/scripts/testing/lane-image.mjs index 62b912da0..965103d47 100644 --- a/scripts/testing/lane-image.mjs +++ b/scripts/testing/lane-image.mjs @@ -6,11 +6,11 @@ import { refuse } from './lane-state.mjs'; export function imageFingerprint(checkout){ const dockerfile=readFileSync(join(checkout,'Containerfile'),'utf8'); const copies=dockerfile.split('\n').filter(l=>/^\s*(COPY|ADD)\s/i.test(l)).map(l=>l.trim()); - const expected=['COPY package.json package-lock.json ./','COPY contracts /opt/mosaic/contracts','COPY src /opt/mosaic/src','COPY adapters /opt/mosaic/adapters']; + const expected=['COPY package.json package-lock.json ./','COPY contracts /opt/mosaic/contracts','COPY src /opt/mosaic/src','COPY packages/processing-control/src/state.mjs packages/processing-control/src/entrypoint.mjs /opt/mosaic/processing-control/','COPY adapters /opt/mosaic/adapters']; if(JSON.stringify(copies)!==JSON.stringify(expected))refuse('image COPY inputs changed; manifest review required'); const hash=createHash('sha256'); const visit=rel=>{const path=join(checkout,rel),st=lstatSync(path);if(st.isSymbolicLink())refuse('image input is a symlink');hash.update(JSON.stringify([rel,st.mode&0o777,st.isDirectory()?'d':'f'])+'\n');if(st.isDirectory())for(const n of readdirSync(path).sort())visit(`${rel}/${n}`);else if(st.isFile())hash.update(readFileSync(path));else refuse('unsupported image input');}; - for(const rel of ['Containerfile','package.json','package-lock.json','contracts','src','adapters'])visit(rel); + for(const rel of ['Containerfile','package.json','package-lock.json','contracts','src','adapters','packages/processing-control/src/state.mjs','packages/processing-control/src/entrypoint.mjs'])visit(rel); return hash.digest('hex'); } export function inspectImage(tag,docker=process.env.MOSAIC_GATE_REAL_DOCKER??'docker'){