import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import { registerHooks } from 'node:module'; const stopFault = { active:false }; Object.assign(globalThis,{__beehivePrivateMoveStopFault:stopFault}); registerHooks({load(url,context,next){ const result=next(url,context); if (!url.endsWith('/src/owned.ts')) return result; const source=String(result.source), marker='async stop() {'; if(!source.includes(marker)) throw Error('Owned Stop test seam changed'); return {...result,source:source.replace(marker,marker+" if ((globalThis as any).__beehivePrivateMoveStopFault.active) throw Error('Synthetic owned Stop failure');")}; }}); const {registrationFixture}=await import('./registration-fixture.ts'); import { managementClient } from '../src/intents.ts'; import { message, newKey, publicKey, type Message } from '../src/protocol.ts'; import { signSuccessor, verifySuccessors, privateExchange } from '../src/private-handoff.ts'; import { createGenesis } from '../src/assignment.ts'; import { hash, type Assignment, type Grant } from '../src/handoff.ts'; async function until(fn:()=>boolean) { for(let n=0;n<400;n++) { if(fn()) return; await delay(20); } throw Error('Private Move evidence missing'); } test('private two-host owned-process transfer, exact replay and reverse signed successor',async t=>{ const home=realpathSync(mkdtempSync(join(tmpdir(),'beehive-pairing-cli-private-move-'))); const f=await registrationFixture(home,()=>{},true,true).catch(error=>{console.error(error);throw error;}); const inventory=new Map(); const c=managementClient(join(home,'client'),f.url,f.ownerSecret,m=>{if(m.type==='inventory')inventory.set(m.host,m);},()=>{},{catalog:{version:1,owner:publicKey(f.ownerSecret),relay:f.url,registrations:[]}}); t.after(async()=>{c.close();await f.close();rmSync(home,{recursive:true,force:true});}); await c.ready; await until(()=>inventory.size===2); const source=()=>inventory.get(f.host)!, dest=()=>inventory.get(f.destination!.host)!; const send=async(m:Message)=>{c.submit(m);await until(()=>c.status().some(s=>s.request.id===m.id&&s.state!=='pending'));return c.status().find(s=>s.request.id===m.id)!;}; assert.equal((await send(message('start',f.host,f.agent,source().revision))).state,'completed'); await until(()=>source().body.phase==='running'); const original=source().body.actualRun; const prior=message('save',dest().host,f.agent,dest().revision,dest().body.selectedNext as Record); assert.equal((await send(prior)).state,'completed');await until(()=>dest().revision>prior.revision); const duplicate=message('move',f.host,f.agent,source().revision,{target:dest().host,targetRevision:dest().revision,selection:dest().body.selectedNext});duplicate.id=prior.id; const other=managementClient(join(home,'other-controller'),f.url,f.ownerSecret,()=>{},()=>{},{catalog:{version:1,owner:publicKey(f.ownerSecret),relay:f.url,registrations:[]}}); t.after(()=>other.close());await other.ready;await delay(100);other.submit(duplicate); await until(()=>other.status().some(o=>o.request.id===duplicate.id&&o.state==='failed')); assert.deepEqual(source().body.actualRun,original,'cross-controller destination ID conflict cannot stop source'); const move=message('move',f.host,f.agent,source().revision,{target:dest().host,targetRevision:dest().revision,selection:dest().body.selectedNext}); assert.equal((await send(move)).state,'completed'); await until(()=>dest().body.phase==='running'&&source().body.phase==='stopped'); assert.equal(source().body.actualRun,null);assert.notDeepEqual(dest().body.actualRun,original); assert.equal(dest().body.assignedHost,dest().host); const chain=dest().body.assignmentChain as Grant[];assert.equal(chain.length,1);assert.ok(chain[0]!.proof); const before=JSON.stringify(dest().body.actualRun); c.reconcile();await delay(250);assert.equal(JSON.stringify(dest().body.actualRun),before); assert.throws(()=>c.submit(move),/already prepared/); assert.equal((await send(message('start',f.host,f.agent,source().revision))).state,'failed'); const reverse=message('move',dest().host,f.agent,dest().revision,{target:f.host,targetRevision:source().revision,selection:source().body.selectedNext}); assert.equal((await send(reverse)).state,'completed'); await until(()=>source().body.phase==='running'&&dest().body.phase==='stopped'); assert.equal((source().body.assignmentChain as Grant[]).length,2); assert.equal((await send(message('stop',f.host,f.agent,source().revision))).state,'completed'); assert.equal(c.status().find(s=>s.request.id===move.id)!.state,'completed','later Stop/Move cannot rewrite historical completion'); c.close(); const reopened=managementClient(join(home,'client'),f.url,f.ownerSecret,()=>{},()=>{},{catalog:{version:1,owner:publicKey(f.ownerSecret),relay:f.url,registrations:[]}}); t.after(()=>reopened.close());await reopened.ready; assert.equal(reopened.status().find(s=>s.request.id===move.id)!.state,'completed','durable destination receipt survives controller reopen'); assert.ok(f.relay.independentWraps>0);assert.deepEqual(f.errors,[]); }); test('unseen successors require exact source/relay signature and authenticated pair',()=>{ const secret=newKey(),source=publicKey(secret),owner=publicKey(newKey()),agent=publicKey(newKey()),target=publicKey(newKey()); const genesis=createGenesis(owner,agent,source), current:Assignment={genesis,assignedHost:source}; const selection={model:'fixture',workspace:'/tmp',profile:'default'}; const op=message('move',source,agent,0,{target,targetRevision:0,selection}); const unsigned:Grant={root:hash(genesis),predecessor:hash(genesis),source,target,agent,operation:op,prepared:'token',selection,targetRevision:0,sourceRun:null}; const grant=signSuccessor(unsigned,secret,'wss://fixture.invalid'); const next:Assignment={genesis,assignedHost:target,chain:[grant]}; verifySuccessors(current,next,'wss://fixture.invalid'); assert.throws(()=>verifySuccessors(current,{...next,chain:[unsigned]},'wss://fixture.invalid'),/Unsigned/); assert.throws(()=>verifySuccessors(current,next,'wss://other.invalid'),/invalid successor/); assert.throws(()=>verifySuccessors(current,{...next,chain:[{...grant,sourceRun:'forged'}]},'wss://fixture.invalid'),/invalid successor/); const m=message('grant',target,agent,0,{assignment:next}); assert.equal(privateExchange({sender:source,message:m},owner,target),m); assert.throws(()=>privateExchange({sender:target,message:m},owner,target),/authority/); assert.throws(()=>privateExchange({sender:source,message:m},publicKey(newKey()),target),/authority/); }); test('private destination failure and stale CAS preserve source; cancelled late preparation cannot transfer; lost grant recovers once',async t=>{ const home=realpathSync(mkdtempSync(join(tmpdir(),'beehive-pairing-cli-private-move-faults-'))); const f=await registrationFixture(home,()=>{},true,true); const inventory=new Map(); const c=managementClient(join(home,'client'),f.url,f.ownerSecret,m=>{if(m.type==='inventory')inventory.set(m.host,m);},()=>{},{catalog:{version:1,owner:publicKey(f.ownerSecret),relay:f.url,registrations:[]}}); t.after(async()=>{c.close();await f.close();rmSync(home,{recursive:true,force:true});}); await c.ready;await until(()=>inventory.size===2); const source=()=>inventory.get(f.host)!,dest=()=>inventory.get(f.destination!.host)!; const outcome=async(m:Message)=>{c.submit(m);await until(()=>c.status().some(s=>s.request.id===m.id&&s.state!=='pending'));return c.status().find(s=>s.request.id===m.id)!;}; await outcome(message('start',f.host,f.agent,source().revision));await until(()=>source().body.phase==='running'); const actual=source().body.actualRun; const move=()=>message('move',f.host,f.agent,source().revision,{target:dest().host,targetRevision:dest().revision,selection:dest().body.selectedNext}); const stale=move();stale.body.targetRevision=100; assert.equal((await outcome(stale)).state,'failed');assert.deepEqual(source().body.actualRun,actual); const {credentialReference}=await import('../src/credential-store.ts'); const key=credentialReference('agent',f.agent),secret=f.destination!.credentials.read(key)!; f.destination!.credentials.remove(key); assert.equal((await outcome(move())).state,'failed');assert.deepEqual(source().body.actualRun,actual); f.destination!.credentials.create(key,secret); f.setHoldPrepared(true);const cancelled=move();c.submit(cancelled);await until(()=>f.heldPrepared>0); await outcome(message('stop',f.host,f.agent,source().revision));f.releasePrepared();await until(()=>source().body.phase==='stopped');await delay(100); assert.equal(source().body.assignedHost,f.host);assert.equal(dest().body.phase,'stopped'); assert.equal(c.status().find(s=>s.request.id===cancelled.id)!.state,'failed'); await until(()=>dest().revision>0); f.setDropGrant(true);const recover=move();c.submit(recover); await until(()=>source().body.assignedHost===dest().host); assert.equal(dest().body.phase,'stopped','lost grant does not manufacture destination authority'); assert.equal(c.status().find(s=>s.request.id===recover.id)!.state,'pending'); f.setDropGrant(false);c.reconcile();await until(()=>dest().body.phase==='running'); const run=dest().body.actualRun;c.reconcile();await delay(200);assert.deepEqual(dest().body.actualRun,run); f.setDropGrant(true); const reverse=message('move',dest().host,f.agent,dest().revision,{target:source().host,targetRevision:source().revision,selection:source().body.selectedNext}); c.submit(reverse);await until(()=>dest().body.assignedHost===source().host); f.credentials.remove(key);f.setDropGrant(false);c.reconcile(); await until(()=>c.status().find(s=>s.request.id===reverse.id)?.state==='failed'); await until(()=>source().body.assignedHost===source().host&&source().body.phase==='stopped'); assert.equal(source().body.actualRun,null);assert.equal(dest().body.actualRun,null); f.credentials.create(key,secret); assert.equal((await outcome(message('start',source().host,f.agent,source().revision))).state,'completed'); }); test('relay-listed agent Move uses typed submission and observes destination before completion',async t=>{ const home=realpathSync(mkdtempSync(join(tmpdir(),'beehive-pairing-cli-private-move-manager-'))); let snapshot: import('../src/manager-controller.ts').ManagerSnapshot; const f=await registrationFixture(home,s=>{snapshot=s;},true,true); t.after(async()=>{await f.close();rmSync(home,{recursive:true,force:true});}); let id=0;const request=(action:string,values?:Record,target?:string,revision?:number)=>f.controller.request({id:++id,action,values,target,revision}); await request('signin');const row=()=>snapshot!.agents.find(a=>a.id===f.agent)!; await until(()=>!!row()?.target&&!!row()?.destinations?.length); f.setDropGrant(true); const d=row().destinations![0]!; const r=await request('move',{destination:d.target,destinationRevision:String(d.revision)},row().target,row().revision); assert.equal(r.state,'submitted',snapshot!.status);if(r.state!=='submitted')return; await until(()=>snapshot!.diagnostics!.some(o=>o.id===`operation:${r.operationId}`&&o.label.includes('Pending'))); f.setDropGrant(false);await request('reconcile'); await until(()=>row().label.includes('running')&&row().target===d.target); await until(()=>snapshot!.diagnostics!.some(o=>o.id===`operation:${r.operationId}`&&o.detail.includes('Move complete; destination running'))); assert.ok(!JSON.stringify(snapshot!).includes(f.nsec)); }); test('failed owned source Stop retains quarantine, actual run and source authority; explicit repaired Stop recovers without destination execution',async t=>{ const home=realpathSync(mkdtempSync(join(tmpdir(),'beehive-pairing-cli-private-move-stop-'))); const f=await registrationFixture(home,()=>{},true,true),inventory=new Map(); const c=managementClient(join(home,'client'),f.url,f.ownerSecret,m=>{if(m.type==='inventory')inventory.set(m.host,m);},()=>{},{catalog:{version:1,owner:publicKey(f.ownerSecret),relay:f.url,registrations:[]}}); t.after(async()=>{stopFault.active=false;c.close();await f.close();rmSync(home,{recursive:true,force:true});}); await c.ready;await until(()=>inventory.size===2); const source=()=>inventory.get(f.host)!,dest=()=>inventory.get(f.destination!.host)!; const outcome=async(m:Message)=>{c.submit(m);await until(()=>c.status().some(s=>s.request.id===m.id&&s.state!=='pending'));return c.status().find(s=>s.request.id===m.id)!;}; await outcome(message('start',f.host,f.agent,source().revision));await until(()=>source().body.phase==='running'); const actual=source().body.actualRun;stopFault.active=true; const move=message('move',f.host,f.agent,source().revision,{target:dest().host,targetRevision:dest().revision,selection:dest().body.selectedNext}); assert.equal((await outcome(move)).state,'failed'); await until(()=>source().body.phase==='quarantined'); assert.deepEqual(source().body.actualRun,actual);assert.equal(source().body.assignedHost,f.host);assert.equal(dest().body.phase,'stopped'); stopFault.active=false;assert.equal((await outcome(message('stop',f.host,f.agent,source().revision))).state,'completed'); await until(()=>source().body.phase==='stopped');assert.equal(source().body.actualRun,null);assert.equal(dest().body.actualRun,null); });