Add policy-bound encrypted agent session restoration and audit traces

This commit is contained in:
Karl committed 2026-10-13 19:41:27 +02:00
1 parent e8a654fb38
commit b991ee9da6
27 files changed
+405 -24

No files matched your search

+2
View File
@@ -14,3 +14,5 @@ test('download scan settings remain explicit and cannot alter upload policy',()=
test('new history and window capability controls require explicit opt-in and explain limits',()=>{for(const scope of ['history:list','history:go','window:state','window:control']){const tag=html.match(new RegExp(`<input[^>]*id="agent-scope-${scope}"[^>]*>`))[0];assert.equal(/\bchecked\b/.test(tag),false);}assert.ok(html.includes('History read and history navigation are separate capabilities'));assert.ok(html.includes('never grants OS or administrator access'));const editor=readFileSync(new URL('../apps/server/public/agent-grants-admin.js',import.meta.url),'utf8');assert.ok(editor.includes('enabled:false'));assert.ok(editor.includes('scopes:[]'));assert.ok(editor.includes('check.disabled=!agentGrantsEditable'));});
test('vault agent capabilities start unchecked and describe human-unlocked exact-origin access',()=>{const source=readFileSync(new URL('../apps/server/public/admin.js',import.meta.url),'utf8'),scopes=vm.runInNewContext(source.match(/const agentScopes=(\[[^;]+\]);/)[1]);for(const scope of ['vault:status','vault:list','vault:fill']){assert.ok(scopes.includes(scope));const tag=html.match(new RegExp(`<input[^>]*id="agent-scope-${scope}"[^>]*>`))[0];assert.equal(/\bchecked\b/.test(tag),false);}for(const text of ['A person must unlock the local vault','agents cannot unlock it or export passwords',"credential's exact origin"])assert.ok(html.includes(text));const editor=readFileSync(new URL('../apps/server/public/agent-grants-admin.js',import.meta.url),'utf8');for(const text of ['human unlock','Agents cannot unlock the vault or export passwords'])assert.ok(editor.includes(text));for(const unsupported of ['vault:unlock','vault:export','vault:mutate'])assert.ok(!scopes.includes(unsupported));});
test('session persistence capabilities are explicit opt-in with no credential state restoration',()=>{const source=readFileSync(new URL('../apps/server/public/admin.js',import.meta.url),'utf8'),scopes=vm.runInNewContext(source.match(/const agentScopes=(\[[^;]+\]);/)[1]);for(const scope of ['sessions:save','sessions:list','sessions:restore']){assert.ok(scopes.includes(scope));const tag=html.match(new RegExp(`<input[^>]*id="agent-scope-${scope}"[^>]*>`))[0];assert.equal(/\bchecked\b/.test(tag),false);}for(const text of ['Saving, listing and restoring sessions require separate capabilities','no passwords, form values or page state','never restores revoked agent authority'])assert.ok(html.includes(text));const editor=readFileSync(new URL('../apps/server/public/agent-grants-admin.js',import.meta.url),'utf8');assert.ok(editor.includes('Restoration rechecks current policy'));});
+14
View File
@@ -0,0 +1,14 @@
import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';const require=createRequire(import.meta.url);const {createAgentSessionAdapters,safeSessionTab}=require('../apps/browser/agent-session-adapters.cjs');
function fixture(){let revision='r1',saved=null,decision={decision:'allow',coverage:{complete:true}},restores=0;const policy={version:1,agents:{enabled:true,scopes:['sessions:save','sessions:list','sessions:restore'],allowedHosts:['example.com']}};const c={ready:true,policy,sessionId:'tab1',token:'token',actor:{agentId:'agent1',generation:1},webContents:{getURL:()=> 'https://example.com/docs'},destinationAllowed:()=>true};const store={list:()=>({revision,collections:saved?[saved]:[]}),save:(value,opt)=>{assert.equal(opt.expectedRevision,revision);saved={id:'id1',...value};revision='r2';return {id:saved.id,revision};},load:()=>({revision,...saved})};const adapters=createAgentSessionAdapters({getContext:()=>c,getSessions:()=>[{id:'tab1',title:'Public docs'}],contextCurrent:()=>true,getOwner:()=>({deviceId:'device1',ownerId:c.actor.agentId}),store,inspect:async()=>decision,restore:async(tabs,opt)=>{opt.assertCurrent();restores++;return {completed:true,tabCount:tabs.length};}});return {adapters,c,store,setDecision:v=>decision=v,setRevision:v=>revision=v,restores:()=>restores};}
test('session collections classify before storage, restore requires one-use plan',async()=>{const f=fixture();await f.adapters['sessions:save']({name:'Work',sessionIds:['tab1']});assert.equal((await f.adapters['sessions:list']()).collections.length,1);const plan=await f.adapters['sessions:restore']({id:'id1'});assert.equal(f.restores(),0);assert.equal((await f.adapters['sessions:restore']({planId:plan.planId})).completed,true);await assert.rejects(f.adapters['sessions:restore']({planId:plan.planId}));assert.equal(f.restores(),1);});
test('incomplete and blocked classification fails closed before store/export',async()=>{for(const verdict of [{decision:'block'},{decision:'allow',coverage:{complete:false}},{decision:'allow',ruleId:'timeout'}]){const f=fixture();f.setDecision(verdict);await assert.rejects(f.adapters['sessions:save']({name:'Work',sessionIds:['tab1']}));assert.equal(f.store.list().collections.length,0);}});
test('changed generation and changed storage invalidate restore plans',async()=>{for(const mutate of [f=>f.c.actor.generation++,f=>f.setRevision('r3'),f=>f.c.policy={...f.c.policy}]){const f=fixture();await f.adapters['sessions:save']({name:'Work',sessionIds:['tab1']});const p=await f.adapters['sessions:restore']({id:'id1'});mutate(f);await assert.rejects(f.adapters['sessions:restore']({planId:p.planId}));assert.equal(f.restores(),0);}});
test('session metadata rejects auth pages, URL secrets and arbitrary state',()=>{for(const url of ['https://example.com/login','https://example.com/oauth/callback','https://example.com/docs?token=x','https://example.com/#code=secret','file:///tmp/a','https://user:[email protected]/','https://example.com/%70assword/reset'])assert.throws(()=>safeSessionTab({url,title:'Public'}));assert.throws(()=>safeSessionTab({url:'https://example.com/',title:'password=secret'}));});
test('session scopes default off and cancellation denies operations',async()=>{const f=fixture();f.c.policy.agents.scopes=[];await assert.rejects(f.adapters['sessions:list']());f.c.policy.agents.scopes=['sessions:list'];await assert.rejects(f.adapters['sessions:list']({}, {signal:AbortSignal.abort()}));});
test('changed native target while DLP awaits prevents snapshot persistence',async()=>{const f=fixture();let moved=false;f.c.webContents.getURL=()=>moved?'https://example.com/changed':'https://example.com/docs';const original=f.store.list;f.store.list=()=>{moved=true;return original();};await assert.rejects(f.adapters['sessions:save']({name:'Work',sessionIds:['tab1']}));assert.equal(original().collections.length,0);});
test('changing stored revision during final restore inspection prevents release',async()=>{const f=fixture();await f.adapters['sessions:save']({name:'Work',sessionIds:['tab1']});const p=await f.adapters['sessions:restore']({id:'id1'});const load=f.store.load;f.store.load=()=>{const value=load();f.setRevision('r3');return value;};await assert.rejects(f.adapters['sessions:restore']({planId:p.planId}));assert.equal(f.restores(),0);});
test('metadata never persists page state or credential-shaped collection names',async()=>{const f=fixture();await assert.rejects(f.adapters['sessions:save']({name:'access_token=abcd',sessionIds:['tab1']}));assert.throws(()=>safeSessionTab({url:'https://example.com/',title:'Public',pageState:'credential'}));});
test('benign app search and fragments retain full target while encoded credential parameters fail',()=>{const url='https://example.com/docs?project=public&page=2#chapter-3';assert.equal(safeSessionTab({url,title:'Docs'}).url,url);for(const bad of ['https://example.com/docs?%2574oken=x','https://example.com/docs#sid=abcd','https://example.com/%256cogin'])assert.throws(()=>safeSessionTab({url:bad,title:'Docs'}));});
test('session save preserves groups and detects asynchronous group changes',async()=>{const f=fixture();let group='Research';f.c.webContents.getURL=()=> 'https://example.com/docs';const original=f.store.list;f.store.list=()=>{group='Changed';return original();};const {createAgentSessionAdapters}=require('../apps/browser/agent-session-adapters.cjs');const adapter=createAgentSessionAdapters({getContext:()=>f.c,getSessions:()=>[{id:'tab1',title:'Public docs',group}],contextCurrent:()=>true,getOwner:()=>({deviceId:'device1',ownerId:'agent1'}),store:f.store,inspect:async()=>({decision:'allow'}),restore:async()=>({completed:true})});await assert.rejects(adapter['sessions:save']({name:'Work',sessionIds:['tab1']}));assert.equal(original().collections.length,0);f.store.list=original;await adapter['sessions:save']({name:'Work',sessionIds:['tab1']});assert.equal(original().collections[0].tabs[0].group,'Changed');});
test('native restore can deliberately change activation but source navigation remains bound',async()=>{for(const changeNavigation of [false,true]){const f=fixture();let activation=1,navigation=4;f.c.token={foreground:true,activation:1,navigationId:4,url:'https://example.com/docs',tabId:'tab1'};const adapter=createAgentSessionAdapters({getContext:()=>f.c,getSessions:()=>[{id:'tab1',title:'Public docs'}],contextCurrent:t=>t.navigationId===navigation&&(!t.foreground||t.activation===activation),getOwner:()=>({deviceId:'device1',ownerId:'agent1'}),store:f.store,inspect:async()=>({decision:'allow'}),restore:async(_tabs,{assertCurrent})=>{activation++;if(changeNavigation)navigation++;assertCurrent();return {completed:true};}});await adapter['sessions:save']({name:'Work',sessionIds:['tab1']});const p=await adapter['sessions:restore']({id:'id1'});if(changeNavigation)await assert.rejects(adapter['sessions:restore']({planId:p.planId}));else assert.equal((await adapter['sessions:restore']({planId:p.planId})).completed,true);}});
+9
View File
@@ -0,0 +1,9 @@
import test from 'node:test';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {createRequire} from 'node:module';
const require=createRequire(import.meta.url),{startAgentControl}=require('../apps/browser/agent-control.cjs'),{requestAgent}=require('../apps/browser/agent-transport-client.cjs');
const id=()=>crypto.randomUUID();
async function fixture(t,actions,scopes=['sessions:save','sessions:list','sessions:restore']){const calls=[],events=[],expiresAt=new Date(Date.now()+60000).toISOString();const api=await startAgentControl({principalMode:'individual',principals:[{id:'session.agent',generation:1,enabled:true,expiresAt,scopes}],authorize:async({scope})=>({allowed:scopes.includes(scope),role:'agent',scopes}),actions:Object.fromEntries(Object.entries(actions).map(([action,fn])=>[action,async(args,context)=>{calls.push({action,args,principalId:context.principalId,generation:context.generation});return fn(args,context);} ])),audit:async e=>events.push(e)});t.after(()=>api.close());const credential=api.issuePrincipal('session.agent',1,expiresAt);return {calls,events,send:async(action,args={})=>{const response=await requestAgent(credential,{action,args});return {status:response.status,...await response.json()};}};}
test('protected session requests dispatch only canonical arguments and exact opaque save response',async t=>{const collection=id(),revision=id(),tab=id();const f=await fixture(t,{'sessions:save':()=>({id:collection,revision,tabCount:1,password:'must-not-release',pageState:'private'})});const result=await f.send('sessions:save',{name:'Research',sessionIds:[tab],id:collection,expectedRevision:revision});assert.equal(result.status,200);assert.deepEqual(result.result,{id:collection,revision,tabCount:1});assert.deepEqual(f.calls[0].args,{name:'Research',sessionIds:[tab],id:collection,expectedRevision:revision});assert.equal(f.calls[0].principalId,'session.agent');});
test('protected transport rejects arbitrary persistence fields and ambiguous restoration stages before dispatch',async t=>{const collection=id(),plan=id(),f=await fixture(t,{'sessions:save':()=>({}),'sessions:restore':()=>({}),'sessions:list':()=>({})});for(const args of [{name:'Research',sessionIds:[id()],pageState:'private'},{name:'Research',sessionIds:[id()],id:'forged'},{name:'Research',sessionIds:[]},{name:'Research',sessionIds:Array(33).fill(id())}])assert.equal((await f.send('sessions:save',args)).status,400);for(const args of [{},{id:collection,planId:plan},{id:collection,url:'https://evil.test/'},{planId:'forged'}])assert.equal((await f.send('sessions:restore',args)).status,400);assert.equal((await f.send('sessions:list',{ownerId:'other-agent'})).status,400);assert.equal(f.calls.length,0);});
test('protected two-stage restore returns bounded plans then native IDs, never serialized state',async t=>{const collection=id(),plan=id(),tab=id();const f=await fixture(t,{'sessions:restore':args=>args.id?{id:collection,planId:plan,tabCount:1,pageState:'private'}:{completed:true,sessionIds:[tab],activeSessionId:tab,contents:{password:'private'},url:'https://private.test'}});assert.deepEqual((await f.send('sessions:restore',{id:collection})).result,{id:collection,planId:plan,tabCount:1});assert.deepEqual((await f.send('sessions:restore',{planId:plan})).result,{completed:true,sessionIds:[tab],activeSessionId:tab});});
test('protected collection inventory validates destinations and rejects pageState rather than leaking adapter objects',async t=>{const collection=id(),revision=id();let row={url:'https://example.test/search?q=research#results',title:'Research',group:'Work'};const f=await fixture(t,{'sessions:list':()=>({revision,collections:[{id:collection,name:'Research',activeIndex:0,tabs:[row],ownerId:'never-release'}],password:'never-release'})});assert.deepEqual((await f.send('sessions:list')).result,{revision,collections:[{id:collection,name:'Research',activeIndex:0,tabs:[row]}]});for(const bad of [{...row,pageState:'private'},{...row,url:'https://example.test/?token=secret'},{...row,url:'https://user:[email protected]/'},{...row,title:'api_key=secretvalue'}]){row=bad;const result=await f.send('sessions:list');assert.notEqual(result.status,200);assert.equal(JSON.stringify(result).includes('secretvalue'),false);}});
test('protected session scopes are individually denied and malformed native IDs never release',async t=>{const f=await fixture(t,{'sessions:save':()=>({id:id(),revision:id(),tabCount:1}),'sessions:restore':()=>({completed:true,sessionIds:['invalid'],activeSessionId:'invalid'})},['sessions:restore']);assert.equal((await f.send('sessions:save',{name:'Research',sessionIds:[id()]})).status,403);assert.equal(f.calls.length,0);assert.notEqual((await f.send('sessions:restore',{planId:id()})).status,200);});
+16
View File
@@ -0,0 +1,16 @@
import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';const require=createRequire(import.meta.url),{restoreNativeSession}=require('../apps/browser/session-restore.cjs');
function fixture(count=1){let active='old0',activation=1,revision='r1',authority=true,creates=0;const records=Array.from({length:count},(_,i)=>({id:'old'+i,contents:{isDestroyed:()=>false}})),removed=[],stages=[];const registry={all:()=>records,active:()=>records.find(r=>r.id===active),context:()=>({activation}),get:id=>{const row=records.find(r=>r.id===id);if(!row)throw Error('Unknown tab');return row;},remove:id=>{removed.push(id);records.splice(records.findIndex(r=>r.id===id),1);if(active===id)active=records[0]?.id;}};const options={activeIndex:0,assertCurrent:()=>{if(!authority)throw Error('Authority revoked');},revision:'r1',owner:{deviceId:'device',ownerId:'owner'}},deps={registry,store:{list:()=>({revision})},activate:id=>{if(active!==id){active=id;activation++;}},traceOperation:async(name,fn)=>{stages.push(name);return fn();},create:async(_input,controls)=>{creates++;controls.assertCurrent();const native={id:'new'+creates,contents:{isDestroyed:()=>false}};records.push(native);controls.onCreated(native);controls.assertCurrent();if(active!==native.id){active=native.id;activation++;}controls.onActivated(native);controls.assertCurrent();return native.id;}};return {records,removed,stages,options,deps,setRevision:r=>revision=r,revoke:()=>authority=false,creates:()=>creates,active:()=>active};}
const tabs=[{url:'https://example.com/docs',title:'Docs',group:'Research'},{url:'https://example.com/other',title:'Other'}];
test('restore creates owned native tabs once and selects saved active index',async()=>{const f=fixture();f.options.activeIndex=1;const r=await restoreNativeSession(tabs,f.options,f.deps);assert.deepEqual(r.sessionIds,['new1','new2']);assert.equal(r.activeSessionId,'new2');assert.equal(f.creates(),2);assert.deepEqual(f.removed,[]);assert.deepEqual(f.stages,['preflight','native','native']);});
test('capacity and changed stored revision fail before any create',async()=>{for(const f of [fixture(32),fixture()]){if(f.records.length===1)f.setRevision('r2');await assert.rejects(restoreNativeSession(tabs,f.options,f.deps));assert.equal(f.creates(),0);assert.deepEqual(f.removed,[]);}});
test('authority revoked during creation rolls back only created tabs without retry',async()=>{const f=fixture(),create=f.deps.create;f.deps.create=async(input,c)=>{const id=await create(input,c);f.revoke();return id;};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps),/Authority revoked/);assert.equal(f.creates(),1);assert.deepEqual(f.removed,['new1']);assert.equal(f.active(),'old0');assert.deepEqual(f.records.map(r=>r.id),['old0']);assert.ok(f.stages.includes('rollback'));});
test('internally removed failed creation is skipped during exact-owned rollback',async()=>{const f=fixture(),create=f.deps.create;f.deps.create=async(input,c)=>{const id=await create(input,c);f.deps.registry.remove(id);throw Error('Native creation failed');};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps),/Native creation failed/);assert.deepEqual(f.removed,['new1']);assert.equal(f.creates(),1);assert.equal(f.active(),'old0');});
test('external foreign tab change triggers failure without closing foreign tabs',async()=>{const f=fixture(),create=f.deps.create;f.deps.create=async(input,c)=>{const id=await create(input,c);f.records.push({id:'foreign',contents:{isDestroyed:()=>false}});return id;};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps));assert.deepEqual(f.removed,['new1']);assert.deepEqual(f.records.map(r=>r.id),['old0','foreign']);});
test('identity replacement prevents cleanup from removing replacement tab',async()=>{const f=fixture(),create=f.deps.create;f.deps.create=async(input,c)=>{const id=await create(input,c);f.records.find(r=>r.id===id).contents={isDestroyed:()=>false};throw Error('Replaced');};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps),/Replaced/);assert.deepEqual(f.removed,[]);assert.equal(f.creates(),1);});
test('storage revision changed during create rolls back created tabs',async()=>{const f=fixture(),create=f.deps.create;f.deps.create=async(input,c)=>{const id=await create(input,c);f.setRevision('r2');return id;};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps));assert.deepEqual(f.removed,['new1']);assert.equal(f.creates(),1);});
test('rollback attempts every exact-owned tab once when one close fails',async()=>{const f=fixture(),create=f.deps.create,remove=f.deps.registry.remove;f.deps.create=async(input,c)=>{const id=await create(input,c);if(f.creates()===2)f.revoke();return id;};const attempts=[];f.deps.registry.remove=id=>{attempts.push(id);if(id==='new2')throw Error('Close failed');remove(id);};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps),e=>e.message==='Managed session rollback incomplete'&&e.cause.message==='Authority revoked');assert.deepEqual(attempts,['new2','new1']);assert.equal(f.active(),'old0');assert.equal(f.creates(),2);assert.deepEqual(f.records.map(r=>r.id),['old0','new2']);});
test('create callback cannot claim original tab ownership for rollback',async()=>{const f=fixture();f.deps.create=async(_input,c)=>{c.onCreated(f.records[0]);return 'old0';};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps),/ownership mismatch/);assert.deepEqual(f.removed,[]);assert.deepEqual(f.records.map(r=>r.id),['old0']);});
test('switching away and back during creation invalidates activation epoch',async()=>{const f=fixture(2),create=f.deps.create;f.deps.create=async(input,c)=>{const id=await create(input,c);f.deps.activate('old1');f.deps.activate(id);return id;};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps),/context changed/);assert.equal(f.creates(),1);assert.deepEqual(f.removed,['new1']);assert.equal(f.active(),'old0');});
test('native activation hook rejects unexpected extra activation epochs',async()=>{const f=fixture(2),create=f.deps.create;f.deps.create=async(input,c)=>create(input,{...c,onActivated:native=>{f.deps.activate('old1');f.deps.activate(native.id);c.onActivated(native);}});await assert.rejects(restoreNativeSession(tabs,f.options,f.deps),/activation changed/);assert.deepEqual(f.removed,['new1']);});
test('registry without native activation context is rejected before release',async()=>{const f=fixture();delete f.deps.registry.context;await assert.rejects(restoreNativeSession(tabs,f.options,f.deps));assert.equal(f.creates(),0);});
+17
View File
@@ -0,0 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';import os from 'node:os';import path from 'node:path';import crypto from 'node:crypto';
import snapshot from '../apps/browser/session-snapshot.cjs';
const {sanitizeSessionCollection,createSessionSnapshotStore}=snapshot;
const sample=()=>({name:'Research',tabs:[{url:'https://example.test/docs',title:'Documentation',group:'Research'}],activeIndex:0});
function withStore(fn){const dir=fs.mkdtempSync(path.join(os.tmpdir(),'safetynet-session-store-')),filePath=path.join(dir,'sessions.bin'),key=crypto.randomBytes(32);const options={filePath,protect:plain=>{const nonce=crypto.randomBytes(12),cipher=crypto.createCipheriv('aes-256-gcm',key,nonce),body=Buffer.concat([cipher.update(plain),cipher.final()]);return Buffer.concat([nonce,cipher.getAuthTag(),body]);},unprotect:bytes=>{const decipher=crypto.createDecipheriv('aes-256-gcm',key,bytes.subarray(0,12));decipher.setAuthTag(bytes.subarray(12,28));return Buffer.concat([decipher.update(bytes.subarray(28)),decipher.final()]);}};try{fn({store:createSessionSnapshotStore(options),options,filePath,dir});}finally{key.fill(0);fs.rmSync(dir,{recursive:true,force:true});}}
const owner={deviceId:crypto.randomUUID(),ownerId:'agent:research'};
test('session collection preserves usable native target but excludes serialized page state',()=>{assert.deepEqual(sanitizeSessionCollection(sample()),sample());for(const extra of ['pageState','formValues','history','password']){const value=sample();value.tabs[0][extra]='never persist';assert.throws(()=>sanitizeSessionCollection(value));}});
test('session collection rejects auth targets and encoded known secrets rather than changing semantics',()=>{for(const url of ['https://example.test/?token=x','https://example.test/#code=x','https://example.test/callback','https://user:[email protected]/','file:///C:/secret','https://example.test/password%253Dsensitive','https://example.test/sk-123456789012345678901']){const value=sample();value.tabs[0].url=url;assert.throws(()=>sanitizeSessionCollection(value));}const value=sample();value.tabs[0].title='api_key=highlysensitive';assert.throws(()=>sanitizeSessionCollection(value));});
test('session store ciphertext survives restart, enforces owner and CAS, preserves multiple collections',()=>withStore(({store,options,filePath})=>{let state=store.list(owner);const first=store.save(sample(),{...owner,expectedRevision:state.revision});assert.equal(fs.readFileSync(filePath).includes(Buffer.from('Documentation')),false);assert.throws(()=>store.save(sample(),{...owner,expectedRevision:state.revision}));state=store.list(owner);const second=store.save({...sample(),name:'Another'},{...owner,expectedRevision:state.revision});assert.equal(store.list(owner).collections.length,2);const restarted=createSessionSnapshotStore(options);assert.equal(restarted.load({...owner,id:first.id}).tabs[0].url,'https://example.test/docs');assert.equal(restarted.list({...owner,ownerId:'other'}).collections.length,0);assert.throws(()=>restarted.load({...owner,ownerId:'other',id:first.id}));assert.throws(()=>restarted.save({...sample(),id:first.id},{...owner,ownerId:'other',expectedRevision:second.revision}));restarted.remove({...owner,id:first.id,expectedRevision:second.revision});assert.equal(restarted.list(owner).collections.length,1);}));
test('session store rejects ciphertext alteration and unsafe files',()=>withStore(({store,filePath,dir})=>{const initial=store.list(owner);store.save(sample(),{...owner,expectedRevision:initial.revision});const bytes=fs.readFileSync(filePath);bytes[bytes.length-1]^=1;fs.writeFileSync(filePath,bytes);assert.throws(()=>store.list(owner));fs.unlinkSync(filePath);fs.mkdirSync(filePath);assert.throws(()=>store.list(owner));fs.rmdirSync(filePath);fs.writeFileSync(filePath,'synthetic');fs.linkSync(filePath,path.join(dir,'alias'));assert.throws(()=>store.list(owner));}));
test('session bounds reject oversized and structurally invalid collections',()=>{for(const change of [s=>s.tabs=Array.from({length:33},()=>s.tabs[0]),s=>s.activeIndex=1,s=>s.name='x'.repeat(101),s=>s.tabs[0].title='x'.repeat(201),s=>s.tabs[0].group='x'.repeat(101)]){const value=sample();change(value);assert.throws(()=>sanitizeSessionCollection(value));}});
test('benign query and fragment destinations retain exact navigation semantics',()=>{const value=sample();value.tabs[0].url='https://example.test/search?q=research&page=2#results';assert.equal(sanitizeSessionCollection(value).tabs[0].url,value.tabs[0].url);});
test('persistence checks authority after protection before committing ciphertext',()=>withStore(({store,options,filePath})=>{const initial=store.list(owner);store.save(sample(),{...owner,expectedRevision:initial.revision});const before=fs.readFileSync(filePath);let permitted=true;const racing=createSessionSnapshotStore({...options,protect:plain=>{const encrypted=options.protect(plain);permitted=false;return encrypted;}});assert.throws(()=>racing.save({...sample(),name:'Forbidden after change'},{...owner,expectedRevision:store.list(owner).revision,assertCurrent:()=>{if(!permitted)throw Error('Authority changed');}}));assert.deepEqual(fs.readFileSync(filePath),before);assert.equal(store.list(owner).collections.length,1);}));
test('persistence rechecks CAS after a protection callback commits a competing collection',()=>withStore(({store,options,filePath})=>{const first=store.save(sample(),{...owner,expectedRevision:store.list(owner).revision});let competed=false;const racing=createSessionSnapshotStore({...options,protect:plain=>{const encrypted=options.protect(plain);if(!competed){competed=true;store.save({...sample(),name:'Winning update'},{...owner,expectedRevision:first.revision});}return encrypted;}});assert.throws(()=>racing.save({...sample(),name:'Stale overwrite'},{...owner,expectedRevision:first.revision}));assert.equal(store.list(owner).collections.some(row=>row.name==='Winning update'),true);assert.equal(store.list(owner).collections.some(row=>row.name==='Stale overwrite'),false);}));
+11
View File
@@ -0,0 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {createServer} from 'node:http';
import {DiagnosticsSchema} from '../packages/core/diagnostics.mjs';
const require=createRequire(import.meta.url),{createDiagnostics}=require('../apps/browser/diagnostics.cjs'),{createAgentSessionAdapters}=require('../apps/browser/agent-session-adapters.cjs');
async function fixture(t){const payloads=[],server=createServer((req,res)=>{const chunks=[];req.on('data',chunk=>chunks.push(chunk));req.on('end',()=>{payloads.push(JSON.parse(Buffer.concat(chunks).toString()));res.setHeader('content-type','application/json');res.end('{}');});}).listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve));const diagnostics=createDiagnostics();diagnostics.configure(DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint:`http://127.0.0.1:${server.address().port}`,sampleRate:1,debugSampleRate:1,flushIntervalMs:10000}));t.after(async()=>{await diagnostics.shutdown();server.closeAllConnections();await new Promise(resolve=>server.close(resolve));});let revision='r1',collection,restores=0;const policy={version:1,agents:{enabled:true,scopes:['sessions:save','sessions:list','sessions:restore'],allowedHosts:['example.com']}},c={ready:true,policy,sessionId:'00000000-0000-4000-8000-000000000001',token:'native-token',actor:{agentId:'private-actor',generation:1},webContents:{getURL:()=> 'https://example.com/private-project'},destinationAllowed:()=>true};const store={list:()=>({revision,collections:collection?[collection]:[]}),save:value=>{collection={id:'fixture-id',...value};revision='r2';return {id:collection.id,revision};},load:()=>({revision,...collection})};const adapters=createAgentSessionAdapters({getContext:()=>c,getSessions:()=>[{id:c.sessionId,title:'Internal roadmap fixture'}],contextCurrent:()=>true,getOwner:()=>({deviceId:'device-owner',ownerId:'private-owner'}),store,inspect:async()=>({decision:'allow',coverage:{complete:true}}),restore:async(_tabs,opts)=>{opts.assertCurrent();restores++;return {completed:true};},traceOperation:(operation,fn)=>diagnostics.span('browser.session',{operation:'sessions.'+operation,url:'https://example.com/private-project',title:'Internal roadmap fixture',token:'private-transport-token'},fn)});const run=(action,args)=>diagnostics.span('browser.agent',{operation:action},()=>adapters[action](args));return {diagnostics,payloads,run,restores:()=>restores,spans:()=>payloads.flatMap(p=>p.resourceSpans||[]).flatMap(p=>p.scopeSpans||[]).flatMap(p=>p.spans||[])};}
test('session save/list/restore exports real OTLP stages correlated to each agent request without content',async t=>{const f=await fixture(t);await f.run('sessions:save',{name:'Roadmap collection fixture',sessionIds:['00000000-0000-4000-8000-000000000001']});await f.run('sessions:list',{});const plan=await f.run('sessions:restore',{id:'fixture-id'});await f.run('sessions:restore',{planId:plan.planId});await f.diagnostics.flush();const spans=f.spans(),op=s=>s.attributes.find(a=>a.key==='safetynet.operation')?.value.stringValue;assert.equal(f.restores(),1);for(const stage of ['sessions.inspect','sessions.save','sessions.list','sessions.load','sessions.restore']){const children=spans.filter(s=>s.name==='browser.session'&&op(s)===stage);assert.ok(children.length,stage);for(const child of children){const parent=spans.find(s=>s.spanId===child.parentSpanId);assert.ok(parent,stage+' parent');assert.equal(parent.name,'browser.agent');assert.equal(child.traceId,parent.traceId);assert.ok(BigInt(child.endTimeUnixNano)>=BigInt(child.startTimeUnixNano));}}for(const action of ['sessions:save','sessions:list','sessions:restore'])assert.ok(spans.some(s=>s.name==='browser.agent'&&op(s)===action));const serialized=JSON.stringify(f.payloads);for(const privateValue of ['private-project','Internal roadmap fixture','Roadmap collection fixture','private-transport-token','private-owner','private-actor','fixture-id',plan.planId])assert.equal(serialized.includes(privateValue),false,privateValue);assert.equal(f.diagnostics.status().failedExports,0);assert.equal(f.diagnostics.status().lifetimeFailedExports,0);});
test('admin diagnostics disable stops export but never repeats or suppresses session operations',async t=>{const f=await fixture(t);await f.run('sessions:save',{name:'Fixture',sessionIds:['00000000-0000-4000-8000-000000000001']});await f.diagnostics.flush();const before=f.spans().length;assert.ok(before>0);f.diagnostics.configure({enabled:false});assert.equal(f.diagnostics.status().enabled,false);const p=await f.run('sessions:restore',{id:'fixture-id'});await f.run('sessions:restore',{planId:p.planId});await f.diagnostics.flush();assert.equal(f.restores(),1);assert.equal(f.spans().length,before);});
test('synchronous audit stage exports retain exact returns, throws and parentage',async t=>{const f=await fixture(t),value={durable:true},error=new RangeError('private persistence failure');let calls=0;await f.diagnostics.span('browser.agent',{operation:'agent.audit'},()=>{assert.equal(f.diagnostics.spanSync('browser.management',{operation:'audit.serialize'},()=>{calls++;return value;}),value);assert.throws(()=>f.diagnostics.spanSync('browser.management',{operation:'audit.encrypt'},()=>{calls++;throw error;}),e=>e===error);for(const operation of ['audit.write','audit.fsync','audit.commit'])f.diagnostics.spanSync('browser.management',{operation},()=>{calls++;return 0;});});await f.diagnostics.flush();assert.equal(calls,5);const spans=f.spans(),parent=spans.find(s=>s.name==='browser.agent');for(const operation of ['audit.serialize','audit.encrypt','audit.write','audit.fsync','audit.commit']){const child=spans.find(s=>s.attributes.some(a=>a.key==='safetynet.operation'&&a.value.stringValue===operation));assert.ok(child,operation);assert.equal(child.parentSpanId,parent.spanId);assert.equal(child.traceId,parent.traceId);}assert.equal(spans.find(s=>s.attributes.some(a=>a.value.stringValue==='audit.encrypt')).status.code,2);assert.equal(JSON.stringify(f.payloads).includes('private persistence failure'),false);assert.equal(f.diagnostics.status().activeSpans,0);f.diagnostics.configure({enabled:false});const before=f.spans().length;assert.equal(f.diagnostics.spanSync('browser.management',{operation:'audit.write'},()=>{calls++;return value;}),value);assert.throws(()=>f.diagnostics.spanSync('browser.management',{operation:'audit.encrypt'},()=>{calls++;throw error;}),e=>e===error);await f.diagnostics.flush();assert.equal(calls,7);assert.equal(f.spans().length,before);});
test('synchronous diagnostic instrumentation failure never replays protected work',async t=>{const f=await fixture(t);let calls=0;const hostile=new Proxy({}, {ownKeys(){throw Error('private attribute failure');}});assert.equal(f.diagnostics.spanSync('browser.security',hostile,()=>++calls),1);assert.equal(calls,1);assert.equal(f.diagnostics.spanSync('browser.security',{operation:'audit.commit'},()=>{calls++;f.diagnostics.configure({enabled:false});return 'committed';}),'committed');assert.equal(calls,2);await f.diagnostics.flush();assert.equal(f.diagnostics.status().activeSpans,0);assert.equal(f.spans().length,0);});