import test from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; import {generateKeyPairSync} from 'node:crypto'; import channel from '../packages/core/policy-channel.cjs'; import control from '../apps/browser/agent-control.cjs'; import transport from '../apps/browser/agent-transport-client.cjs'; const input={action:'bookmarks:mutate',args:{action:'delete',id:'fixture',expectedRevision:7,policyVersion:9}}; const busy=()=>Object.assign(Error('Management update in progress. Try again.'),{status:409,code:'browser-management-busy'}); async function fixture(t,fn,authorize=async({scope})=>({allowed:true,role:'agent',scopes:[scope]})){ let attempts=0,mutations=0,challenges=0;const seen=[]; const api=await control.startAgentControl({authorize,audit:async()=>{},traceOperation:async(name,run)=>{if(name==='agent.challenge')challenges++;return run();},actions:{'bookmarks:mutate':async args=>{attempts++;seen.push(args);await fn(attempts);mutations++;return {completed:true};}}}); t.after(()=>api.close());return {api,stats:()=>({attempts,mutations,challenges,seen})}; } test('authenticated busy retries use fresh challenges and preserve CAS inputs, mutation executes once',async t=>{ const x=await fixture(t,n=>{if(n<=2)throw busy();});const response=await transport.requestAgent(x.api,input);assert.equal(response.status,200); const s=x.stats();assert.equal(s.attempts,3);assert.equal(s.mutations,1);assert.equal(s.challenges,3);for(const args of s.seen)assert.deepEqual(args,input.args); }); test('busy retries stop after three retries',async t=>{ const x=await fixture(t,()=>{throw busy();});const response=await transport.requestAgent(x.api,input);assert.equal(response.status,409);assert.equal((await response.json()).code,'browser-management-busy'); assert.equal(x.stats().attempts,4);assert.equal(x.stats().challenges,4);assert.equal(x.stats().mutations,0); }); test('CAS conflicts and denial never retry',async t=>{ const x=await fixture(t,()=>{throw Object.assign(Error('Bookmarks changed. Refresh before editing.'),{status:409});});assert.equal((await transport.requestAgent(x.api,input)).status,409);assert.equal(x.stats().attempts,1); const denied=await fixture(t,()=>{},async()=>({allowed:false}));assert.equal((await transport.requestAgent(denied.api,input)).status,403);assert.equal(denied.stats().challenges,1);assert.equal(denied.stats().attempts,0); }); test('unauthenticated plaintext busy code cannot trigger retry',async t=>{ let calls=0;const server=http.createServer((req,res)=>{calls++;res.writeHead(409,{'content-type':'application/json'});res.end(JSON.stringify({error:'Management update in progress. Try again.',code:'browser-management-busy'}));});await new Promise(r=>server.listen(0,'127.0.0.1',r));t.after(()=>new Promise(r=>server.close(r))); const response=await transport.requestAgent({address:`http://127.0.0.1:${server.address().port}`,token:'synthetic',protocol:'safetynet-policy-mlkem1024-mldsa87-v1'},input);assert.equal(response.status,409);assert.equal(calls,1);assert.equal((await response.json()).code,undefined); }); test('busy-looking message or code alone does not authorize retry',async t=>{ for(const error of [Object.assign(Error('Management update in progress. Try again.'),{status:409}),Object.assign(Error('Unrelated conflict'),{status:409,code:'browser-management-busy'})]){ const x=await fixture(t,()=>{throw error;});const response=await transport.requestAgent(x.api,input);assert.equal(response.status,409);assert.equal((await response.json()).code,undefined);assert.equal(x.stats().attempts,1); } }); async function rewriteOuterStatus(api,status,run){ const original=globalThis.fetch; globalThis.fetch=async(url,options)=>{const response=await original(url,options);if(String(url)===api.address+'/v1/actions')return new Response(await response.text(),{status,headers:{'content-type':'application/json'}});return response;}; try{return await run();}finally{globalThis.fetch=original;} } test('authenticated busy 409 still retries when outer HTTP status is changed to 200',async t=>{ const x=await fixture(t,n=>{if(n<=2)throw busy();}); const response=await rewriteOuterStatus(x.api,200,()=>transport.requestAgent(x.api,input));assert.equal(response.status,200);assert.equal(x.stats().attempts,3);assert.equal(x.stats().mutations,1); }); test('authenticated success 200 never retries or fails when outer HTTP status is changed to 409',async t=>{ const x=await fixture(t,()=>{});const response=await rewriteOuterStatus(x.api,409,()=>transport.requestAgent(x.api,input));assert.equal(response.ok,true);assert.equal(response.status,200);assert.equal(x.stats().attempts,1);assert.equal(x.stats().mutations,1); }); test('missing or invalid authenticated status refuses the encrypted response',async t=>{ const ed=generateKeyPairSync('ed25519'),pq=generateKeyPairSync('ml-dsa-87');let item,status; const server=http.createServer(async(req,res)=>{ const chunks=[];for await(const chunk of req)chunks.push(chunk);res.writeHead(200,{'content-type':'application/json'}); if(req.url==='/v1/challenge'){item=channel.challenge('local-agent',ed.privateKey,pq.privateKey);res.end(JSON.stringify(item.bundle));return;} const wire=JSON.parse(Buffer.concat(chunks)),context=channel.receive(wire.handshake,item.bundle,item.privateKey); try{res.end(JSON.stringify(channel.response({kind:'agent-response',...(status===undefined?{}:{status}),result:{completed:true}},item.bundle,context)));}finally{context.responseKey.fill(0);} });await new Promise(r=>server.listen(0,'127.0.0.1',r));t.after(()=>new Promise(r=>server.close(r))); const credential={address:`http://127.0.0.1:${server.address().port}`,token:'synthetic',protocol:channel.VERSION,edPublic:ed.publicKey.export({type:'spki',format:'pem'}),pqPublic:pq.publicKey.export({type:'spki',format:'pem'})}; for(const invalid of [undefined,199,600,409.5,'409']){status=invalid;await assert.rejects(transport.requestAgent(credential,input),/Protected agent response refused/);} });