Files
safetynet/apps/browser/agent-control.cjs
T

210 lines
29 KiB
JavaScript
Raw Normal View History

2026-10-13 10:33:31 +02:00
'use strict';
const http = require('node:http');
const crypto = require('node:crypto');
const channel=require('../../packages/core/policy-channel.cjs');
2026-10-13 10:33:31 +02:00
const APP_ACTIONS=Object.freeze(['vault:status','vault:list','vault:fill','history:list','history:go','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']);
const SCOPES = Object.freeze({ sessions: 'sessions:list', navigate: 'browser:navigate', back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate', read: 'browser:read', inspect:'browser:read', scroll:'browser:interact', click: 'browser:interact', type: 'browser:interact', select:'browser:interact', check:'browser:interact', ai: 'ai:request',...Object.fromEntries(APP_ACTIONS.map(action=>[action,action])) });
2026-10-13 10:33:31 +02:00
const fail = (status, message) => Object.assign(new Error(message), { status });
const bounded = (value, max, name) => {
if (typeof value !== 'string' || !value.length || value.length > max || /\u0000/.test(value)) throw fail(400, `Invalid ${name}`);
return value;
};
function validate(body) {
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => !['action', 'args'].includes(k))) throw fail(400, 'Invalid action envelope');
const action = body.action;
if (!Object.hasOwn(SCOPES, action)) throw fail(400, 'Unsupported action');
const args = body.args ?? {};
if (!args || typeof args !== 'object' || Array.isArray(args)) throw fail(400, 'Invalid arguments');
const keys = { 'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['sessionId','credentialRef'],'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action];
2026-10-13 10:33:31 +02:00
if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument');
const clean = {};
if(action.startsWith('vault:')){
if(args.sessionId!==undefined||action==='vault:fill')clean.sessionId=bounded(args.sessionId,128,'sessionId');
if(action==='vault:fill'){clean.credentialRef=bounded(args.credentialRef,36,'credentialRef');if(!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean.credentialRef))throw fail(400,'Invalid credential reference');}
}
if(action.startsWith('history:')){
clean.sessionId=bounded(args.sessionId,128,'sessionId');
if(action==='history:go')for(const key of ['snapshotId','entryId']){clean[key]=bounded(args[key],36,key);if(!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean[key]))throw fail(400,'Invalid history handle');}
}
if(action==='window:control'){if(!['minimize','maximize','restore','focus'].includes(args.command))throw fail(400,'Invalid window command');clean.command=args.command;}
2026-10-13 10:33:31 +02:00
if (action !== 'sessions' && args.sessionId !== undefined) clean.sessionId = bounded(args.sessionId, 128, 'sessionId');
if (action === 'navigate') {
let url; try { url = new URL(bounded(args.url, 4096, 'url')); } catch { throw fail(400, 'Invalid URL'); }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw fail(400, 'Unsupported URL');
clean.url = url.href;
}
if (['click', 'type','select','check'].includes(action)) {
2026-10-13 10:33:31 +02:00
clean.selector = bounded(args.selector, 512, 'selector');
// A small CSS subset prevents pseudo-elements, escapes and control characters.
if (!/^[a-zA-Z0-9_#.*\[\]="' >+~-]+$/.test(clean.selector) || /password|autocomplete/i.test(clean.selector)) throw fail(400, 'Unsupported selector');
}
if(action==='select'){if(typeof args.value!=='string'||args.value.length>1024||/\u0000/.test(args.value))throw fail(400,'Invalid option value');clean.value=args.value;}
if(action==='check'){if(typeof args.checked!=='boolean')throw fail(400,'Invalid checked state');clean.checked=args.checked;}
if(action==='scroll'){for(const key of ['x','y']){if(!Number.isSafeInteger(args[key])||Math.abs(args[key])>100000)throw fail(400,'Invalid scroll');clean[key]=args[key];}}
2026-10-13 10:33:31 +02:00
if (action === 'type') clean.text = bounded(args.text, 16384, 'text');
if (action === 'ai') clean.prompt = bounded(args.prompt, 16384, 'prompt');
if(action.startsWith('tabs:')&&action!=='tabs:create')clean.sessionId=bounded(args.sessionId,128,'sessionId');
if(action==='tabs:create'&&args.url!==undefined){let url;try{url=new URL(bounded(args.url,4096,'url'));}catch{throw fail(400,'Invalid URL');}if(!['http:','https:'].includes(url.protocol)||url.username||url.password)throw fail(400,'Unsupported URL');clean.url=url.href;}
if(['tabs:create','tabs:group'].includes(action)&&args.group!==undefined){if(typeof args.group!=='string'||args.group.length>40||/[\u0000-\u001f\u007f]/.test(args.group))throw fail(400,'Invalid group');clean.group=args.group;}
if(action==='tabs:group'&&args.group===undefined)throw fail(400,'Invalid group');
if(action==='settings:update'){try{clean.appearance=require('./managed-settings.cjs').validateAppearance(args.appearance);}catch{throw fail(400,'Invalid appearance settings');}}
if(action==='bookmarks:mutate'){
if(!['add','update','delete'].includes(args.action)||!Number.isSafeInteger(args.expectedRevision)||args.expectedRevision<0||!Number.isSafeInteger(args.policyVersion)||args.policyVersion<1)throw fail(400,'Invalid bookmark operation');
clean.action=args.action;clean.expectedRevision=args.expectedRevision;clean.policyVersion=args.policyVersion;
if(args.action==='delete')clean.id=bounded(args.id,80,'bookmark id');else{const item=args.bookmark;if(!item||typeof item!=='object'||Array.isArray(item)||Object.keys(item).some(key=>!['id','title','url','folder'].includes(key)))throw fail(400,'Invalid bookmark');const folder=item.folder===undefined?'':item.folder;if(typeof folder!=='string'||folder.length>80||/[\u0000-\u001f\u007f]/.test(folder))throw fail(400,'Invalid bookmark folder');clean.bookmark={id:bounded(item.id,80,'bookmark id'),title:bounded(item.title,120,'bookmark title'),url:bounded(item.url,2048,'bookmark URL'),folder};}
}
if(action==='library:organize-apply'){clean.proposalId=bounded(args.proposalId,128,'proposal id');if(args.confirm!==undefined&&typeof args.confirm!=='boolean'||args.autoApply!==undefined&&typeof args.autoApply!=='boolean')throw fail(400,'Invalid organization confirmation');if(args.confirm!==undefined)clean.confirm=args.confirm;if(args.autoApply!==undefined)clean.autoApply=args.autoApply;}
2026-10-13 10:33:31 +02:00
return { action, args: clean, scope: SCOPES[action], sessionId: clean.sessionId };
}
// Never return adapter objects wholesale: session handles and sanitized text only.
function project(action, result) {
if(action==='vault:status'){
if(!result||!['exists','locked','enabled','allowAutofill'].every(key=>typeof result[key]==='boolean'))throw fail(502,'Invalid vault response');
return Object.fromEntries(['exists','locked','enabled','allowAutofill'].map(key=>[key,result[key]]));
}
if(action==='vault:list'){
if(!Array.isArray(result)||result.length>32)throw fail(502,'Invalid vault response');
return result.map(row=>{let origin;try{origin=new URL(row.origin);}catch{throw fail(502,'Invalid vault response');}if(typeof row.credentialRef!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(row.credentialRef)||typeof row.origin!=='string'||row.origin.length>2048||origin.protocol!=='https:'||origin.username||origin.password||origin.origin!==row.origin)throw fail(502,'Invalid vault response');return {credentialRef:row.credentialRef,origin:row.origin};});
}
if(action==='vault:fill')return {completed:result?.completed===true};
if(action==='history:list'){
const uuid=value=>{if(typeof value!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value))throw fail(502,'Invalid history response');return value;};
if(!Array.isArray(result?.entries)||result.entries.length>100)throw fail(502,'Invalid history response');
return {snapshotId:uuid(result.snapshotId),entries:result.entries.map(row=>{
if(typeof row.title!=='string'||row.title.length>200||typeof row.active!=='boolean')throw fail(502,'Invalid history response');
const item={entryId:uuid(row.entryId),title:row.title,active:row.active};
if(row.url!==undefined){let u;try{u=new URL(row.url);}catch{throw fail(502,'Invalid history response');}if(typeof row.url!=='string'||row.url.length>4096||!['http:','https:'].includes(u.protocol)||u.username||u.password)throw fail(502,'Invalid history response');item.url=u.href;}
return item;
})};
}
if(action==='window:state'){
if(!result||!['width','height'].every(key=>Number.isSafeInteger(result[key])&&result[key]>=1&&result[key]<=32768)||!['visible','focused','minimized','maximized','fullScreen'].every(key=>typeof result[key]==='boolean'))throw fail(502,'Invalid window response');
return Object.fromEntries(['width','height','visible','focused','minimized','maximized','fullScreen'].map(key=>[key,result[key]]));
}
if(action==='history:go'||action==='window:control')return {completed:result?.completed===true};
2026-10-13 10:33:31 +02:00
if(action==='application:state')return {ready:result?.ready===true,policyVersion:Number.isSafeInteger(result?.policyVersion)?result.policyVersion:0,activeTabId:typeof result?.activeTabId==='string'?result.activeTabId.slice(0,128):null,tabCount:Number.isSafeInteger(result?.tabCount)?result.tabCount:0};
if(action==='settings:get'||action==='settings:update'){const appearance=result?.appearance||{};return {visible:result?.visible===true,hiddenSections:Array.isArray(result?.hiddenSections)?result.hiddenSections.filter(s=>['appearance','library','ai','passwords','documentScan','agents'].includes(s)):[],lockedSections:Array.isArray(result?.lockedSections)?result.lockedSections.filter(s=>['appearance','library','ai','passwords','documentScan','agents'].includes(s)):[],appearance:{mode:['system','light','dark'].includes(appearance.mode)?appearance.mode:'system',accent:['blue','teal','violet'].includes(appearance.accent)?appearance.accent:'blue',tabMode:['compact','separate'].includes(appearance.tabMode)?appearance.tabMode:'separate'}};}
if(action==='bookmarks:list'){const items=values=>{if(!Array.isArray(values)||values.length>1000)throw fail(502,'Invalid bookmarks');return values.map(item=>({id:bounded(item.id,80,'bookmark id'),title:bounded(item.title,120,'bookmark title'),url:bounded(item.url,2048,'bookmark URL'),folder:typeof item.folder==='string'?item.folder.slice(0,80):''}));};return {managed:items(result?.managed||[]),personal:{revision:Number.isSafeInteger(result?.personal?.revision)?result.personal.revision:0,items:items(result?.personal?.items||[])},allowUserAdd:result?.allowUserAdd===true,allowUserEdit:result?.allowUserEdit===true,allowUserDelete:result?.allowUserDelete===true,policyVersion:Number.isSafeInteger(result?.policyVersion)?result.policyVersion:0};}
if(action==='library:organize-plan'){const plan=result?.plan;if(!plan||!Array.isArray(plan.bookmarks)||!Array.isArray(plan.tabs)||plan.bookmarks.length>100||plan.tabs.length>100)throw fail(502,'Invalid organization plan');return {proposalId:bounded(result.proposalId,128,'proposal id'),plan:{bookmarks:plan.bookmarks.map(row=>({id:bounded(row.id,80,'bookmark id'),folder:typeof row.folder==='string'?row.folder.slice(0,80):''})),tabs:plan.tabs.map(row=>({id:bounded(row.id,80,'tab id'),group:typeof row.group==='string'?row.group.slice(0,40):''}))},expiresAt:typeof result.expiresAt==='string'&&Number.isFinite(Date.parse(result.expiresAt))?result.expiresAt.slice(0,32):null};}
if(action==='library:organize-apply')return {completed:result?.completed===true};
2026-10-13 10:33:31 +02:00
if(action.startsWith('tabs:')||action==='bookmarks:mutate')return {completed:result?.completed===true,...(typeof result?.sessionId==='string'?{sessionId:bounded(result.sessionId,128,'session id')}:{})};
if (action === 'sessions') {
if (!Array.isArray(result) || result.length > 100) throw fail(502, 'Invalid session response');
return result.map(s => ({ id: bounded(s.id, 128, 'session id'), ...(typeof s.title === 'string' ? { title: s.title.slice(0, 256) } : {}), ...(typeof s.foreground === 'boolean' ? {foreground:s.foreground} : {}) }));
}
if(action==='inspect'){if(!result||typeof result.text!=='string'||!Array.isArray(result.controls)||result.controls.length>100)throw fail(502,'Invalid inspection');const value={text:result.text,controls:result.controls.map(row=>{const item=Object.fromEntries(['selector','role','label','value','text'].filter(key=>typeof row[key]==='string').map(key=>[key,row[key]]));if(typeof row.checked==='boolean')item.checked=row.checked;if(row.options!==undefined){if(!Array.isArray(row.options)||row.options.length>20)throw fail(502,'Invalid inspection options');item.options=row.options.map(option=>{if(typeof option.value!=='string'||option.value.length>128||typeof option.label!=='string'||option.label.length>160||typeof option.disabled!=='boolean')throw fail(502,'Invalid inspection option');return {value:option.value,label:option.label,disabled:option.disabled};});}return item;})};if(JSON.stringify(value).length>65536)throw fail(502,'Invalid inspection');return value;}
2026-10-13 10:33:31 +02:00
if (action === 'read' || action === 'ai') {
if (typeof result?.text !== 'string' || result.text.length > 65536) throw fail(502, 'Invalid text response');
return { text: result.text };
}
return { completed: result?.completed !== false };
}
async function startAgentControl({ authorize, actions, audit, credentialFile, persistCredential, onError, traceOperation = async (_name,fn)=>fn(), timeoutMs = 10000, maxRequestsPerMinute = 60, principalMode = 'legacy', principals = [] } = {}) {
if(credentialFile!==undefined)throw new TypeError('Plaintext pairing files are prohibited; use protected persistCredential');
2026-10-13 10:33:31 +02:00
if (typeof authorize !== 'function' || typeof audit !== 'function' || !actions) throw new TypeError('authorize, actions and audit are required');
if (!Number.isInteger(timeoutMs) || timeoutMs < 10 || timeoutMs > 60000 || !Number.isInteger(maxRequestsPerMinute) || maxRequestsPerMinute < 1 || maxRequestsPerMinute > 1000) throw new TypeError('Invalid limits');
if(!['legacy','individual'].includes(principalMode))throw new TypeError('Invalid principal mode');
const token = principalMode==='legacy'?crypto.randomBytes(32).toString('base64url'):undefined;
const serviceId=crypto.randomUUID(), principalRecords=new Map(), approved=new Map(), owners=new Map(), generationFloor=new Map();
const validGrant=g=>g&&/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(g.id)&&Number.isSafeInteger(g.generation)&&g.generation>=1&&Number.isFinite(Date.parse(g.expiresAt));
const principalLive=p=>p&&principalRecords.get(p.id)===p&&approved.get(p.id)?.enabled!==false&&p.generation===approved.get(p.id)?.generation&&Date.parse(p.expiresAt)>Date.now();
const invalidate=id=>{for(const [key,item]of challenges)if(item.principalId===id)challenges.delete(key);for(const [key,item]of proofs)if(item.principalId===id)proofs.delete(key);for(const [controller,owner]of owners)if(owner===id)controller.abort();};
const reconcilePrincipals=grants=>{
if(!Array.isArray(grants)||grants.length>32||grants.some(g=>!validGrant(g))||new Set(grants.map(g=>g.id)).size!==grants.length)throw new TypeError('Invalid principal grants');
const next=new Map(grants.map(g=>{const copy=JSON.parse(JSON.stringify(g)),floor=generationFloor.get(g.id)||0;if(copy.generation<floor)copy.enabled=false;else generationFloor.set(g.id,copy.generation);return [g.id,copy];}));
for(const [id,old]of approved){const current=next.get(id);if(!current||current.enabled===false||Date.parse(current.expiresAt)<=Date.now()||JSON.stringify(old)!==JSON.stringify(current))invalidate(id);if(!current||current.enabled===false||current.generation!==old.generation||current.expiresAt!==old.expiresAt||Date.parse(current.expiresAt)<=Date.now())principalRecords.delete(id);}
approved.clear();for(const [id,g]of next)approved.set(id,g);
};
const proofs=new Map();
const ed=crypto.generateKeyPairSync('ed25519'),pq=crypto.generateKeyPairSync('ml-dsa-87');
const edPublic=ed.publicKey.export({type:'spki',format:'pem'}),pqPublic=pq.publicKey.export({type:'spki',format:'pem'});
const challenges=new Map();
reconcilePrincipals(principals);
for(const g of principals)if(g.token&&g.enabled!==false&&Date.parse(g.expiresAt)>Date.now()){if(!/^[A-Za-z0-9_-]{43}$/.test(g.token))throw new TypeError('Invalid principal token');principalRecords.set(g.id,{id:g.id,generation:g.generation,expiresAt:g.expiresAt,token:g.token});}
2026-10-13 10:33:31 +02:00
let enabled = true, windowStart = Date.now(), requests = 0;
const running = new Set();
const server = http.createServer(async (req, res) => traceOperation('agent.request',async()=>{
2026-10-13 10:33:31 +02:00
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Content-Type-Options', 'nosniff');
const reply = (status, data) => { if (!res.destroyed && !res.writableEnded) { res.writeHead(status); res.end(JSON.stringify(data)); } };
const controller = new AbortController();
let principal, verifiedPrincipal, action, outcome = 'denied', timer, protectedContext, bundle, validatedRequest;
2026-10-13 10:33:31 +02:00
try {
let proof;
if(principalMode==='individual'){
const match=/^SafetyNet-Agent-PQ ([a-zA-Z0-9][a-zA-Z0-9._-]{0,79})\.([1-9][0-9]{0,15})\.([0-9]{13})\.([A-Za-z0-9_-]{43})\.([A-Za-z0-9_-]{64})$/.exec(req.headers.authorization||'');
principal=match&&principalRecords.get(match[1]);if(!principalLive(principal)||principal.generation!==Number(match?.[2]))throw fail(401,'Unauthorized');
proof=[match[0],match[3],match[4],match[5]];
}else proof=/^SafetyNet-PQ ([0-9]{13})\.([A-Za-z0-9_-]{43})\.([A-Za-z0-9_-]{64})$/.exec(req.headers.authorization||'');
if(!enabled||!proof||Math.abs(Date.now()-Number(proof[1]))>30000)throw fail(401,'Unauthorized');
for(const [nonce,expiresAt] of proofs)if((typeof expiresAt==='object'?expiresAt.expiresAt:expiresAt)<Date.now())proofs.delete(nonce);
2026-10-13 10:33:31 +02:00
if (req.headers.origin || req.headers['sec-fetch-site'] || !/^127\.0\.0\.1:\d+$/.test(req.headers.host || '')) throw fail(403, 'Browser-origin requests forbidden');
let size=0;const chunks=[];for await(const chunk of req){size+=chunk.length;if(size>131072)throw fail(413,'Request too large');chunks.push(chunk);}const wireBody=Buffer.concat(chunks);
const digest=crypto.createHash('sha384').update(wireBody).digest('base64url');const expected=crypto.createHmac('sha384',principal?.token||token).update(principal?JSON.stringify(['safetynet-agent-auth-v1',req.url,principal.id,principal.generation,Number(proof[1]),proof[2],digest]):`${req.url}:${proof[1]}:${proof[2]}:${digest}`).digest('base64url');
if(proofs.has(principal?`${principal.id}:${proof[2]}`:proof[2])||proofs.size>=4096||(principal&&[...proofs.values()].filter(value=>value.principalId===principal.id).length>=256)||!crypto.timingSafeEqual(Buffer.from(proof[3]),Buffer.from(expected)))throw fail(401,'Unauthorized');
proofs.set(principal?`${principal.id}:${proof[2]}`:proof[2],principal?{principalId:principal.id,expiresAt:Number(proof[1])+30000}:Number(proof[1])+30000);if(!enabled||(principal&&!principalLive(principal)))throw fail(401,'Unauthorized');
verifiedPrincipal=principal;
if (req.headers.origin || req.headers['sec-fetch-site'] || !/^127\.0\.0\.1:\d+$/.test(req.headers.host || '')) throw fail(403, 'Browser-origin requests forbidden');
if (req.method !== 'POST' || !['/v1/challenge','/v1/actions'].includes(req.url)) throw fail(404, 'Not found');
2026-10-13 10:33:31 +02:00
if (!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] || '')) throw fail(415, 'JSON required');
if (Date.now() - windowStart >= 60000) { windowStart = Date.now(); requests = 0; }
if (++requests > maxRequestsPerMinute) throw fail(429, 'Rate limit exceeded');
if(req.url==='/v1/challenge'){for(const [id,item] of challenges)if(item.bundle.expiresAt<=Date.now())challenges.delete(id);if(challenges.size>=64)throw fail(429,'Challenge capacity exceeded');const item=await traceOperation('agent.challenge',()=>channel.challenge(principal?`agent:${principal.id}:${principal.generation}:${serviceId}`:'local-agent',ed.privateKey,pq.privateKey));if(!enabled||(principal&&!principalLive(principal)))throw fail(403,'Agent access revoked');challenges.set(item.bundle.id,{...item,principalId:principal?.id});reply(200,item.bundle);return;}
let body; try { body = JSON.parse(wireBody.toString('utf8')); } catch { throw fail(400, 'Invalid JSON'); }
try{await traceOperation('agent.decrypt',()=>{if(!body||Object.keys(body).sort().join(',')!=='handshake,payload')throw Error();const item=challenges.get(body.handshake?.id);if(!item||item.principalId!==principal?.id)throw Error();challenges.delete(item.bundle.id);bundle=item.bundle;protectedContext=channel.receive(body.handshake,bundle,item.privateKey);const value=channel.finish(body.payload,bundle,protectedContext);if(value?.kind!=='agent-request'||Object.keys(value).sort().join(',')!=='action,args,kind')throw Error();body={action:value.action,args:value.args};});}catch{protectedContext?.responseKey.fill(0);protectedContext=undefined;throw fail(400,'Protected agent channel refused');}
const request = validate(body); action = request.action;validatedRequest=request;
2026-10-13 10:33:31 +02:00
if (typeof actions[action] !== 'function') throw fail(403, 'Action unavailable');
running.add(controller);if(principal)owners.set(controller,principal.id);
// Authorizers must be side-effect free: individual grants are checked at each release boundary.
const reauthorize=async()=>{if(!principal)return;const grant=await traceOperation('agent.authorize',()=>authorize({...request,principalId:principal.id,generation:principal.generation,signal:controller.signal}));if(!enabled||!principalLive(principal)||controller.signal.aborted||grant?.allowed!==true||!['agent','admin'].includes(grant.role)||!Array.isArray(grant.scopes)||!grant.scopes.includes(request.scope))throw fail(403,'Policy denied action');};
2026-10-13 10:33:31 +02:00
const operation = (async () => {
const grant = await traceOperation('agent.authorize',()=>authorize({ ...request,...(principal?{principalId:principal.id,generation:principal.generation}:{}), signal: controller.signal }));
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted || grant?.allowed !== true || !['agent', 'admin'].includes(grant.role) || !Array.isArray(grant.scopes) || !grant.scopes.includes(request.scope)) throw fail(403, 'Policy denied action');
await traceOperation('agent.audit',()=>audit({ ...(principal?{principalId:principal.id,generation:principal.generation}:{}),action, scope: request.scope, sessionId: request.sessionId, outcome: 'authorized' }));
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked');
await reauthorize();
const value=await traceOperation('agent.dispatch',()=>actions[action](request.args, { signal: controller.signal,...(verifiedPrincipal?{principalId:verifiedPrincipal.id,generation:verifiedPrincipal.generation}:{}) }));return traceOperation('agent.project',()=>project(action,value));
2026-10-13 10:33:31 +02:00
})();
const result = await Promise.race([operation, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(fail(504, 'Action timed out')); }, timeoutMs); })]);
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked');
await reauthorize();
2026-10-13 10:33:31 +02:00
outcome = 'completed';
await traceOperation('agent.audit',()=>audit({ ...(principal?{principalId:principal.id,generation:principal.generation}:{}),action, outcome,scope:request.scope,sessionId:request.sessionId }));
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked');
await reauthorize();
const encryptedReply=await traceOperation('agent.encrypt',()=>channel.response({kind:'agent-response',status:200,result},bundle,protectedContext));await reauthorize();if(!enabled||(principal&&!principalLive(principal))||controller.signal.aborted)throw fail(403,'Agent access revoked');reply(200,encryptedReply);
2026-10-13 10:33:31 +02:00
} catch (error) {
outcome=(error.status||500)>=500?'failed':'denied';
2026-10-13 10:33:31 +02:00
// Diagnostics expose only known static reasons, never arbitrary adapter errors.
if (typeof onError === 'function') {
const reasons=['Document changed before action','Managed session unavailable','Unknown managed session','Session destination blocked','Agent scope denied','Agent destination denied','Managed session changed during action','Sensitive content cannot be returned','Inspection unavailable','Company policy blocks content access','Target is unavailable or sensitive','Target cannot receive text','Invalid text response','Policy denied action','Action timed out',
'Management update in progress. Try again.','Managed policy changed during bookmark update','Bookmark update awaits management synchronization','Bookmark update requires a fresh signed policy','Bookmarks changed. Refresh before editing.','Bookmark changes are controlled by your administrator','Company bookmarks cannot be changed','Bookmark identity is unavailable','Invalid bookmark details','Bookmark destination is not approved','Managed browser access is paused','Managed application changed','Management request refused','Management response exceeds its limit','Protected management identity unavailable','Policy changed without a new version','Company policy expired','Required private content inspector is unavailable','Security event queue is full'];
2026-10-13 10:33:31 +02:00
const reason=reasons.find(value=>String(error.message).includes(value)) || 'unclassified-action-failure';
try { await onError({action:action || 'agent-request',status:error.status || 500,reason}); } catch { /* Diagnostics cannot change enforcement. */ }
}
try { await traceOperation('agent.audit',()=>audit({ ...(verifiedPrincipal?{principalId:verifiedPrincipal.id,generation:verifiedPrincipal.generation}:{}),action: action || 'agent-request', outcome, status: error.status || 500,...(validatedRequest?{scope:validatedRequest.scope,sessionId:validatedRequest.sessionId}:{}) })); } catch { /* Deny even if audit unavailable. */ }
const busy=action==='bookmarks:mutate'&&error.status===409&&error.code==='browser-management-busy'&&error.message==='Management update in progress. Try again.';
const data={kind:'agent-response',status:error.status||500,error:error.status?error.message:'Agent action failed',...(busy?{code:'browser-management-busy'}:{})};reply(error.status||500,protectedContext?channel.response(data,bundle,protectedContext):{error:data.error});
} finally { clearTimeout(timer); running.delete(controller);owners.delete(controller); protectedContext?.responseKey.fill(0); }
}));
server.maxConnections=32; server.requestTimeout = 15000; server.headersTimeout = 10000; server.maxHeadersCount = 32;
2026-10-13 10:33:31 +02:00
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
const address = `http://127.0.0.1:${server.address().port}`;
const revoke = () => { enabled = false; challenges.clear(); proofs.clear(); for (const c of running) c.abort(); };
const expiryTimer=setInterval(()=>{for(const [id,p]of principalRecords)if(!principalLive(p)){invalidate(id);principalRecords.delete(id);}},100);expiryTimer.unref();
const close = async () => { clearInterval(expiryTimer);revoke(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); };
2026-10-13 10:33:31 +02:00
try {
const credential = { address, token, edPublic, pqPublic, protocol:channel.VERSION };
if (persistCredential&&principalMode==='legacy') await persistCredential(credential);
2026-10-13 10:33:31 +02:00
} catch (error) { await close(); throw error; }
const issuePrincipal=(id,generation,expiresAt)=>{
const g=approved.get(id);if(principalMode!=='individual'||!g||g.enabled===false||g.generation!==generation||g.expiresAt!==expiresAt||Date.parse(expiresAt)<=Date.now()||!enabled)throw fail(403,'Principal grant unavailable');
let p=principalRecords.get(id);if(!principalLive(p)){invalidate(id);p={id,generation,expiresAt,token:crypto.randomBytes(32).toString('base64url')};principalRecords.set(id,p);}
return {address,token:p.token,edPublic,pqPublic,protocol:channel.VERSION,principalId:id,generation,expiresAt,principalBinding:`agent:${id}:${generation}:${serviceId}`};
};
return { address,...(principalMode==='legacy'?{token}:{}),edPublic,pqPublic,protocol:channel.VERSION,close,revoke,issuePrincipal,reconcilePrincipals };
2026-10-13 10:33:31 +02:00
}
module.exports = { startAgentControl, SCOPES };