Add scoped native history and window agent controls

This commit is contained in:
Karl committed 2026-10-13 18:48:40 +02:00
1 parent adf0940e5f
commit 61fcd8336f
27 files changed
+503 -25

No files matched your search

+55 -4
View File
@@ -1,4 +1,5 @@
'use strict';
const {randomUUID}=require('node:crypto');
function domAction(mode, selector, text, expectedUrl) {
if (location.href !== expectedUrl) throw Error('Document changed before action');
const visible = e => {
@@ -106,15 +107,15 @@ const hostMatches = (hostname, pattern) => {
const name=hostname.toLowerCase().replace(/\.$/,''),p=String(pattern).toLowerCase();
return p.startsWith('*.') ? name.endsWith(p.slice(1)) && name!==p.slice(2) : name===p;
};
function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,history,ai,inspect,audit,onInteraction,appControls={}} = {}) {
function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,history,getHistory,goHistory,ai,inspect,audit,onInteraction,appControls={}} = {}) {
if ([getContext,getSessions,contextCurrent,navigate,ai,inspect,audit].some(fn=>typeof fn!=='function')) throw TypeError('All adapter callbacks are required');
const snapshot = (args,signal,action) => {
const c = getContext(args.sessionId);
if (signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.webContents || c.webContents.isDestroyed?.() || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable');
if (!c.sessionId || !c.token || (args.sessionId !== undefined && args.sessionId !== c.sessionId)) throw Error('Unknown managed session');
if (['click','type','scroll','select','check','back','forward','reload'].includes(action) && (c.foreground !== true || c.interactive===false)) throw Error('Managed session unavailable');
if (['click','type','scroll','select','check','back','forward','reload','history:go'].includes(action) && (c.foreground !== true || c.interactive===false)) throw Error('Managed session unavailable');
const url = c.webContents.getURL();
const changesDestination=['navigate','back','forward'].includes(action);
const changesDestination=['navigate','back','forward','history:go'].includes(action);
if (typeof c.destinationAllowed!=='function' || (!changesDestination && (!/^https?:\/\//.test(url) || c.destinationAllowed(url,c.policy)!==true))) throw Error('Session destination blocked');
const agents=c.policy.agents;
if (!Array.isArray(agents.scopes) || !agents.scopes.includes(action)) throw Error('Agent scope denied');
@@ -152,7 +153,7 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his
await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.url,outcome:'completed',sensitiveField:false,...(target?{target}:{})});check(initial,args,signal);}
return {completed:result?.completed===true};
};
const appActions=['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
const appActions=['window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
// Native history owns target resolution and pre-release destination checks. A
// refreshed token permits intentional navigation but never a different tab.
const historyAdapters=Object.fromEntries(['back','forward','reload'].map(action=>[action,async(args={}, {signal}={})=>{
@@ -166,6 +167,55 @@ await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.
check({...refreshed,action,version:initial.version,url},args,signal,url);
return {completed:true};
}]));
const historySnapshots=new Map();
const historyInventory=initial=>{
if(typeof getHistory!=='function')throw Error('Managed history unavailable');
const data=getHistory(initial);
if(!data||!Array.isArray(data.entries)||data.entries.length>100||!Number.isInteger(data.activeIndex)||data.activeIndex<0||data.activeIndex>=data.entries.length)throw Error('Invalid managed history');
return {activeIndex:data.activeIndex,entries:data.entries.map(row=>{
if(typeof row.url!=='string'||row.url.length>4096||typeof row.title!=='string'||row.title.length>200)throw Error('Invalid managed history');
return {url:row.url,title:row.title};
}),...(data.nativeBinding===undefined?{}:/^[a-f0-9]{64}$/.test(data.nativeBinding)&&typeof data.nativeBinding==='string'?{nativeBinding:data.nativeBinding}:(()=>{throw Error('Invalid managed history');})())};
};
const historyCredential=text=>{let value=text;for(let i=0;i<3;i++){if(secret(value))return true;try{const next=decodeURIComponent(value);if(next===value)break;value=next;}catch{break;}}return secret(value);};
const historyAdaptersExtra={
'history:list':async(args={}, {signal}={})=>{
const initial=snapshot(args,signal,'history:list'),data=historyInventory(initial),binding=JSON.stringify(data),actorBinding=JSON.stringify(initial.actor??null);
const current=()=>{check(initial,args,signal);if(getContext(initial.sessionId)?.policy!==initial.policy||JSON.stringify(getContext(initial.sessionId)?.actor??null)!==actorBinding||JSON.stringify(historyInventory(initial))!==binding)throw Error('Managed history changed during action');};
const entries=[];
for(let index=0;index<data.entries.length;index++){
const row=data.entries[index];let url;try{url=new URL(row.url);}catch{continue;}
if(!['http:','https:'].includes(url.protocol)||initial.destinationAllowed(url.href,initial.policy)!==true||(initial.policy.agents.allowedHosts.length&&!initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))continue;
let decodedUrl=row.url;for(let pass=0;pass<3;pass++){try{const next=decodeURIComponent(decodedUrl);if(next===decodedUrl)break;decodedUrl=next;}catch{break;}}
const credential=!!(url.username||url.password)||historyCredential(row.title+'\n'+row.url)||/[?&#](?:password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|secret|client[_-]?secret|code|authorization|session|sid)=/i.test(decodedUrl);
let allowed=false;
if(!credential){
const verdict=await inspect(row.title+'\n'+row.url,{...initial,action:'history:list'});current();
if(!verdict||!['allow','block'].includes(verdict.decision)||verdict.coverage?.complete===false||/uninspectable|size-limit|inspection-incomplete|unavailable|failure|failed|timeout|unsupported|encrypted|parser/i.test(verdict.ruleId||''))throw Error('Inspection unavailable');
allowed=verdict.decision==='allow';
}
entries.push({navigable:!credential,entryId:randomUUID(),title:allowed?row.title.slice(0,160):'Protected page',...(allowed?{url:row.url}:{}),active:index===data.activeIndex,index});
}
current();
for(const [id,item] of historySnapshots)if(item.expires<=Date.now())historySnapshots.delete(id);
while(historySnapshots.size>=32)historySnapshots.delete(historySnapshots.keys().next().value);
const snapshotId=randomUUID();historySnapshots.set(snapshotId,{initial,binding,actorBinding,entries,expires:Date.now()+60000});
return {snapshotId,entries:entries.map(({index,navigable,...row})=>row)};
},
'history:go':async(args={}, {signal}={})=>{
const initial=snapshot(args,signal,'history:go'),saved=historySnapshots.get(args.snapshotId);
if(!saved||saved.expires<=Date.now()||saved.initial.sessionId!==initial.sessionId||saved.actorBinding!==JSON.stringify(initial.actor??null)||saved.initial.webContents!==initial.webContents||saved.initial.policy!==initial.policy||saved.initial.version!==initial.version||!contextCurrent(saved.initial.token)||saved.initial.url!==initial.url||JSON.stringify(historyInventory(initial))!==saved.binding)throw Error('Managed history changed during action');
const entry=saved.entries.find(row=>row.entryId===args.entryId);
if(!entry||entry.navigable!==true||typeof goHistory!=='function')throw Error('Unknown managed history entry');
const target=JSON.parse(saved.binding).entries[entry.index].url,url=new URL(target);
if(!['http:','https:'].includes(url.protocol)||url.username||url.password||initial.destinationAllowed(target,initial.policy)!==true||(initial.policy.agents.allowedHosts.length&&!initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))throw Error('Destination blocked');
check(initial,args,signal);historySnapshots.delete(args.snapshotId);
const result=await goHistory(entry.index,initial,{signal,historyBinding:saved.binding}),refreshed=result?.context;
if(result?.completed!==true||!refreshed||refreshed.sessionId!==initial.sessionId||refreshed.webContents!==initial.webContents||refreshed.policy!==initial.policy||(refreshed.policyVersion??refreshed.policy?.version)!==initial.version)throw Error('Managed session changed during action');
check({...refreshed,action:'history:go',version:initial.version,url:target},args,signal,target);
return {completed:true};
}
};
const appSnapshot=action=>{const c=getContext();if(!c?.ready||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes(action)||(c.policy.expiresAt&&Date.parse(c.policy.expiresAt)<=Date.now()))throw Error('Agent scope denied');return c;};
const appAdapters=Object.fromEntries(appActions.map(action=>[action,async(args={}, {signal}={})=>{
const initial=appSnapshot(action),version=initial.policyVersion??initial.policy.version;
@@ -186,6 +236,7 @@ await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.
return {
...appAdapters,
...historyAdapters,
...historyAdaptersExtra,
sessions:async(args={}, {signal}={})=>{
const c=getContext();
if(signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.policy.agents.scopes?.includes('sessions') || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable');