import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { request } from 'node:http'; import { fileURLToPath } from 'node:url'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs'; import { close } from './fixture.mjs'; // CLI paths below are repository-relative, including under npm --prefix packages/webui test. process.chdir(fileURLToPath(new URL('../../..', import.meta.url))); const cli = 'packages/webui/src/cli.mjs'; const run = (args, env = {}) => spawnSync(process.execPath, [cli, 'serve', ...args], { encoding: 'utf8', env: { ...process.env, ...env } }); test('dev owner refuses incompatible flags before loading configuration', () => { for (const args of [['--sso'], ['--public-host','example.test'], ['--auth-header','x-auth'], ['--allow-user','x'], ['--proof-header','x-proof'], ['--proof-file','/unused'], ['--tester-socket']]) { const r = run(['--dev-local-owner', ...args]); assert.equal(r.status, 2); assert.match(r.stderr, /--dev-local-owner cannot be combined/); } }); test('dev owner refuses the default instance before reading configuration', () => { for (const config of ['', `${process.env.HOME}/.config/mosaic-dev/config.json`, '/invalid/config.json']) { const r = run(['--dev-local-owner'], { MOSAIC_CONFIG: config }); assert.equal(r.status, 2); assert.match(r.stderr, /--dev-local-owner requires a named instance/); } }); test('dev owner refuses a named config symlink to the default config', async t => { const { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } = await import('node:fs'); const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const home=mkdtempSync(join(tmpdir(),'dev-owner-home-')); t.after(()=>rmSync(home,{recursive:true,force:true})); const dev=join(home,'.config','mosaic-dev'), named=join(home,'.config','mosaic-test'); mkdirSync(dev,{recursive:true}); mkdirSync(named,{recursive:true}); writeFileSync(join(dev,'config.json'),'{}'); symlinkSync(join(dev,'config.json'),join(named,'config.json')); const r=run(['--dev-local-owner'],{HOME:home,MOSAIC_CONFIG:join(named,'config.json')}); assert.equal(r.status,2); assert.match(r.stderr,/--dev-local-owner requires a named instance/); }); for (const scenario of [ { name: 'missing marker', owner: null, reason: /instance-root-unclaimed.*holds no instance\.json naming test/ }, { name: 'default dev marker', owner: 'dev', reason: /instance-root-mismatch.*claimed by instance dev, not test/ }, { name: 'different instance marker', owner: 'other', reason: /instance-root-mismatch.*claimed by instance other, not test/ }, { name: 'named hard link to default config', owner: 'dev', hardLink: true, reason: /instance-root-mismatch.*claimed by instance dev, not test/ }, ]) { test(`dev owner entry points refuse ${scenario.name} before startup`, async t => { const { existsSync, linkSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } = await import('node:fs'); const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const { fixture } = await import('../../cli/tests/helpers.mjs'); const root = mkdtempSync(join(tmpdir(), 'dev-owner-claim-')); t.after(() => rmSync(root, { recursive: true, force: true })); const f = fixture(root), home = join(root, 'home'), base = join(home, '.config'); mkdirSync(base, { recursive: true }); if (scenario.hardLink) { renameSync(join(root, 'config'), join(base, 'mosaic-dev')); mkdirSync(join(base, 'mosaic-test')); linkSync(join(base, 'mosaic-dev', 'config.json'), join(base, 'mosaic-test', 'config.json')); } else renameSync(join(root, 'config'), join(base, 'mosaic-test')); if (scenario.owner) writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: scenario.owner })); const env = { ...f.env, HOME: home, MOSAIC_CONFIG: join(base, 'mosaic-test', 'config.json') }; for (const args of [ ['packages/webui/src/cli.mjs', 'serve', '--port', '0', '--dev-local-owner'], ['packages/cli/src/cli.mjs', 'bus', 'start', 'acme', '--dev-local-owner'], ]) { const r = spawnSync(process.execPath, args, { env, encoding: 'utf8', timeout: 3000 }); assert.notEqual(r.status, 0, args.join(' ')); assert.match(r.stderr, scenario.reason, args.join(' ')); assert.doesNotMatch(r.stdout, /DEV LOCAL OWNER MODE|Mosaic Console:|bus host up:/); assert.equal(existsSync(join(f.dataRoot, 'bus')), false, 'no bus startup state'); } }); } test('dev identity is fixed and cannot be configured as a real SSO issuer', () => { const provider = createDevOwner(), a = provider.session(), b = provider.session(); assert.equal(a,b); assert.deepEqual({provider:a.provider,iss:a.iss,sub:a.sub}, DEV_OWNER); assert.equal(a.name,'Local operator'); assert.ok(Object.isFrozen(a)); }); test('dev owner binds only 127.0.0.1', async () => { await assert.rejects(startServer({ host: '127.0.0.2', port: 0, sso: createDevOwner() }), /127\.0\.0\.1/); }); function call(port, { host = `127.0.0.1:${port}`, method = 'GET', origin, path = '/api/config' } = {}) { return new Promise((resolve, reject) => { const req = request({ host:'127.0.0.1', port, path, method, headers: { host, ...(origin === undefined ? {} : { origin }) } }, res => { let body=''; res.on('data', c => body += c); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(body) })); }); req.on('error', reject); req.end(); }); } test('dev owner enforces exact Host and Origin and exposes mode', async t => { const server = await startServer({ port: 0, board:'http://127.0.0.1:9', sso:createDevOwner(), send: async () => ({}) }); t.after(() => close(server)); const port=server.address().port; for (const host of [`127.0.0.1:${port}`, `localhost:${port}`]) { const r=await call(port,{host}); assert.equal(r.status,200); assert.equal(r.body.devOwner,true); assert.equal(r.body.send,true); assert.notEqual((await call(port,{host,method:'POST',origin:`http://${host}`})).body.error,'cross-origin request refused'); } for (const host of ['evil.example', `127.0.0.2:${port}`, `localhost:${port+1}`]) assert.equal((await call(port,{host})).status,403); for (const origin of [undefined,'http://evil.example',`http://localhost:${port+1}`]) assert.equal((await call(port,{method:'POST',origin})).status,403); }); test('dev owner resolves a decision through the real Console wrapper and bus', {timeout:90000}, async t => { const { mkdtempSync, mkdirSync, renameSync, chmodSync, openSync, closeSync, readFileSync, rmSync, writeFileSync } = await import('node:fs'); const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const { spawn } = await import('node:child_process'); const { fixture, notifyConfig } = await import('../../cli/tests/helpers.mjs'); const { loadSystem, bootConfig } = await import('../../cli/src/config.mjs'); const { createOrg } = await import('../../organization/src/org-service.mjs'); const { Store } = await import('../../bus/src/store.mjs'); const { Broker } = await import('../../bus/src/broker.mjs'); const root=mkdtempSync(join(tmpdir(),'o-')); // P19 #875: t.after hooks run in registration order, so removing root came first, both // `bus stop` calls then failed, and SIGTERM to the outer `bus start` reached neither the // --internal-host process nor its broker; they outlived the file. One hook now stops the // Console and both buses while root exists, fails if any recorded process survives, then // removes root. Each process is recorded with its start time so a reused pid is not it. const startOf=pid=>{ try { const raw=readFileSync(`/proc/${pid}/stat`,'utf8'), fields=raw.slice(raw.lastIndexOf(')')+2).split(' '); return fields[0]==='Z'||fields[0]==='X'?null:fields[19]; } catch { return null; } }; const tracked=new Map(); const track=pid=>{ const start=startOf(pid); if(!start||tracked.has(pid))return; tracked.set(pid,start); let kids=''; try { kids=readFileSync(`/proc/${pid}/task/${pid}/children`,'utf8'); } catch {} for(const kid of kids.split(' ').filter(Boolean))track(Number(kid)); }; const survivors=()=>[...tracked].filter(([pid,start])=>startOf(pid)===start).map(([pid])=>pid); const hostOf=(out,org)=>JSON.parse(out.split('\n').find(line=>line.startsWith('{') && line.includes(`"org":"${org}"`))).pid; let env, pid; t.after(async()=>{ try { if(pid){ try { process.kill(pid,'SIGTERM'); } catch {} } if(env) for(const org of ['acme','beta']) spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop',org],{env,encoding:'utf8'}); for(let i=0;i<100 && survivors().length;i++) await new Promise(r=>setTimeout(r,100)); const left=survivors(); // Stop what survived so the failure is this assertion, not a leak into the next file. for(const p of left){ try { process.kill(p,'SIGKILL'); } catch {} } assert.deepEqual(left,[],`processes still running after teardown: ${left.join(' ')}`); } finally { rmSync(root,{recursive:true,force:true}); } }); const f=fixture(root); const home=join(root,'home'), configDir=join(home,'.config','mosaic-test'); mkdirSync(join(home,'.config'),{recursive:true}); renameSync(join(root,'config'),configDir); chmodSync(configDir, 0o700); writeFileSync(join(f.dataRoot,'instance.json'), JSON.stringify({instanceVersion:1,name:'test'})); env={...f.env,HOME:home,MOSAIC_CONFIG:join(configDir,'config.json')}; const ctx={configDir,dataRoot:f.dataRoot,instance:'test',principal:DEV_OWNER}; const doc=structuredClone(f.doc); doc.human=DEV_OWNER.sub; doc.projects.stack.root='/__mosaic_org__/acme/projects/stack'; rmSync(join(configDir,'businesses','acme.json')); chmodSync(join(configDir,'businesses'),0o700); const org=await createOrg(ctx,'acme',doc); const system=loadSystem({env,instance:'test'}), boot=bootConfig({system,businessId:'acme',orgRoot:org.root,env}); const store=new Store(org.root), broker=new Broker({store,businesses:boot.businesses}); const cap=broker.bindLaunch({business:'acme',role:'chief',run:'chief-run',harness:'pi',address:'chief'}); broker.request(cap,{verb:'role.claim'}); const decision=broker.request(cap,{verb:'decision.raise',args:{action:'prd.approve',question:'Choose?',blocking:false,options:[{key:'yes',text:'Yes'},{key:'no',text:'No'}],recommendation:'yes',approvalChoice:'yes'}}); store.close(); const betaDoc=structuredClone(doc); betaDoc.id='beta'; betaDoc.projects.stack.root='/__mosaic_org__/beta/projects/stack'; const betaOrg=await createOrg(ctx,'beta',betaDoc); const betaBoot=bootConfig({system,businessId:'beta',orgRoot:betaOrg.root,env}); const betaStore=new Store(betaOrg.root), betaBroker=new Broker({store:betaStore,businesses:betaBoot.businesses}); const betaCap=betaBroker.bindLaunch({business:'beta',role:'chief',run:'beta-chief',harness:'pi',address:'chief'}); betaBroker.request(betaCap,{verb:'role.claim'}); const betaDecision=betaBroker.request(betaCap,{verb:'decision.raise',args:{action:'prd.approve',question:'Beta only?',blocking:false,options:[{key:'yes',text:'Yes'},{key:'no',text:'No'}],recommendation:'yes',approvalChoice:'yes'}}); betaStore.close(); let betaOut=''; const betaBus=spawn(process.execPath,['packages/cli/src/cli.mjs','bus','start','beta','--dev-local-owner','--no-launch'],{env,stdio:['ignore','pipe','pipe']}); betaBus.stdout.on('data',c=>betaOut+=c); betaBus.stderr.on('data',c=>betaOut+=c); track(betaBus.pid); let bus=spawn(process.execPath,['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner','--no-launch'],{env,stdio:['ignore','pipe','pipe']}); let busOut=''; bus.stdout.on('data',c=>busOut+=c); bus.stderr.on('data',c=>busOut+=c); track(bus.pid); const wait=async (fn) => { for(let i=0;i<300;i++){ const v=fn(); if(v)return v; await new Promise(r=>setTimeout(r,100)); } throw Error(`startup timeout: ${busOut}`); }; await wait(()=>busOut.includes('\"org\":\"acme\"')); await wait(()=>betaOut.includes('\"org\":\"beta\"')); track(hostOf(busOut,'acme')); track(hostOf(betaOut,'beta')); const hostLog=()=>readFileSync(join(org.root,'bus-host','host.log'),'utf8'); assert.match(hostLog(),/DEV LOCAL OWNER MODE: console owner local-dev accepted \(named instance test\)/); assert.match(hostLog(),/launches disabled/); // Reparent only the disposable Console to init: the bus deliberately rejects agent ancestry. const log=join(root,'console.log'), fd=openSync(log,'w'); const launch=spawnSync(process.execPath,['-e',`const {spawn}=require('node:child_process');const p=spawn(process.execPath,[require('node:path').resolve('packages/webui/src/cli.mjs'),'serve','--port','0','--board','http://127.0.0.1:9','--business','acme','--send','--dev-local-owner'],{detached:true,stdio:['ignore',1,2]});console.log('PID '+p.pid);p.unref();`],{env,stdio:['ignore',fd,fd]}); closeSync(fd); assert.equal(launch.status,0); const output=()=>readFileSync(log,'utf8'); pid=Number((await wait(()=>/PID (\d+)/.exec(output()))) [1]); track(pid); const port=Number((await wait(()=>/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//.exec(output())))[1]); assert.match(output(),/DEV LOCAL OWNER MODE: no sign-in, every local request acts as the owner \(named instance test\)/); const post=async (path,body,extra={})=>{ const host=`127.0.0.1:${port}`, data=JSON.stringify(body); return new Promise((resolve,reject)=>{const req=request({host:'127.0.0.1',port,path,method:'POST',headers:{host,origin:`http://${host}`,'sec-fetch-site':'same-origin','content-type':'application/json','content-length':Buffer.byteLength(data),...extra}},res=>{let raw='';res.on('data',c=>raw+=c);res.on('end',()=>resolve({status:res.statusCode,body:JSON.parse(raw)}));});req.on('error',reject);req.end(data);}); }; const wrongOrg=await post(`/api/orgs/acme/decisions/${betaDecision.id}/confirmation`,{choice:'yes'}); assert.equal(wrongOrg.status,404,JSON.stringify(wrongOrg)); const betaStillOpen=await post(`/api/orgs/beta/decisions/${betaDecision.id}/confirmation`,{choice:'yes'}); assert.equal(betaStillOpen.status,200,JSON.stringify(betaStillOpen)); assert.equal(betaStillOpen.body.id,betaDecision.id); const crossSite=await post(`/api/orgs/acme/decisions/${decision.id}/confirmation`,{choice:'yes'},{'sec-fetch-site':'cross-site'}); assert.equal(crossSite.status,403); const confirmation=await post(`/api/orgs/acme/decisions/${decision.id}/confirmation`,{choice:'yes'}); assert.equal(confirmation.status,200,JSON.stringify(confirmation)); const result=await post(`/api/orgs/acme/decisions/${decision.id}/resolve`,{choice:'yes',confirmation:confirmation.body.confirmation}); assert.equal(result.status,200,JSON.stringify(result)); assert.equal(result.body.choice,'yes'); const stopped = new Promise(resolve => bus.once('exit', resolve)); const stop = spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop','acme'],{env,encoding:'utf8'}); assert.equal(stop.status,0,stop.stderr); await stopped; busOut=''; bus=spawn(process.execPath,['packages/cli/src/cli.mjs','bus','start','acme','--no-launch'],{env,stdio:['ignore','pipe','pipe']}); bus.stdout.on('data',c=>busOut+=c); bus.stderr.on('data',c=>busOut+=c); track(bus.pid); await wait(()=>busOut.includes('\"org\":\"acme\"')); track(hostOf(busOut,'acme')); const refused=await post(`/api/orgs/acme/decisions/${decision.id}/confirmation`,{choice:'yes'}); assert.equal(refused.status,403,JSON.stringify(refused)); assert.equal(refused.body.error,'human-required'); });