2026-10-13 10:33:31 +02:00
'use strict' ;
const http = require ( 'node:http' );
const crypto = require ( 'node:crypto' );
2026-10-13 16:58:15 +02:00
const channel = require ( '../../packages/core/policy-channel.cjs' );
2026-10-13 10:33:31 +02:00
2026-10-13 21:00:41 +02:00
const APP_ACTIONS = Object . freeze ([ 'vault:prepare-submit' , 'sessions:save' , 'sessions:list' , 'sessions:restore' , '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' ]);
2026-10-13 16:58:15 +02:00
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' );
2026-10-13 21:00:41 +02:00
const keys = { 'sessions:save' : [ 'name' , 'sessionIds' , 'id' , 'expectedRevision' ], 'sessions:list' : [], 'sessions:restore' : [ 'id' , 'planId' ], 'vault:status' : [ 'sessionId' ], 'vault:list' : [ 'sessionId' ], 'vault:fill' : [ 'sessionId' , 'credentialRef' ], 'vault:prepare-submit' : [ '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 = {};
2026-10-13 19:41:27 +02:00
if ( action === 'sessions:save' ){
clean . name = bounded ( args . name , 80 , 'session name' );
if ( ! Array . isArray ( args . sessionIds ) || args . sessionIds . length < 1 || args . sessionIds . length > 32 || new Set ( args . sessionIds ). size !== args . sessionIds . length ) throw fail ( 400 , 'Invalid session selection' );
clean . sessionIds = args . sessionIds . map ( id => bounded ( id , 128 , 'session id' ));
for ( const key of [ 'id' , 'expectedRevision' ]) if ( args [ key ] !== undefined ) clean [ key ] = bounded ( args [ key ], 36 , key );
}
if ( action === 'sessions:restore' ){
if (( args . id !== undefined ) === ( args . planId !== undefined )) throw fail ( 400 , 'Invalid session restore' );
const key = args . planId !== undefined ? 'planId' : 'id' ; clean [ key ] = bounded ( args [ key ], 36 , key );
}
if ( action . startsWith ( 'sessions:' )) for ( const key of [ 'id' , 'expectedRevision' , 'planId' ]) if ( clean [ key ] !== undefined &&! /^[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 session handle' );
2026-10-13 19:14:32 +02:00
if ( action . startsWith ( 'vault:' )){
2026-10-13 21:00:41 +02:00
if ( args . sessionId !== undefined || [ 'vault:fill' , 'vault:prepare-submit' ]. includes ( action )) clean . sessionId = bounded ( args . sessionId , 128 , 'sessionId' );
if ([ 'vault:fill' , 'vault:prepare-submit' ]. includes ( action )){ 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' );}
2026-10-13 19:14:32 +02:00
}
2026-10-13 18:48:40 +02:00
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 ;
}
2026-10-13 16:58:15 +02:00
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' );
}
2026-10-13 16:58:15 +02:00
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 };}
}
2026-10-13 16:58:15 +02:00
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 ) {
2026-10-13 19:41:27 +02:00
if ( action . startsWith ( 'sessions:' )){
const handle = 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 session handle' ); return value ;};
if ( action === 'sessions:save' ) return { id : handle ( result ? . id ), revision : handle ( result ? . revision ), tabCount : sessionCount ( result ? . tabCount )};
if ( action === 'sessions:restore' ){
if ( result ? . planId ) return { planId : handle ( result . planId ), id : handle ( result . id ), tabCount : sessionCount ( result . tabCount )};
if ( result ? . completed !== true ||! Array . isArray ( result . sessionIds ) || result . sessionIds . length < 1 || result . sessionIds . length > 32 || new Set ( result . sessionIds ). size !== result . sessionIds . length ||! result . sessionIds . includes ( result . activeSessionId )) throw fail ( 502 , 'Invalid restored sessions' );
return { completed : true , sessionIds : result . sessionIds . map ( handle ), activeSessionId : handle ( result . activeSessionId )};
}
if ( ! Array . isArray ( result ? . collections ) || result . collections . length > 16 ) throw fail ( 502 , 'Invalid session collections' );
return { revision : handle ( result . revision ), collections : result . collections . map ( row =>{ if ( ! Array . isArray ( row . tabs ) || row . tabs . length < 1 || row . tabs . length > 32 ||! Number . isInteger ( row . activeIndex ) || row . activeIndex < 0 || row . activeIndex >= row . tabs . length ) throw fail ( 502 , 'Invalid session collection' ); return { id : handle ( row . id ), name : bounded ( row . name , 80 , 'session name' ), activeIndex : row . activeIndex , tabs : row . tabs . map ( require ( './agent-session-adapters.cjs' ). safeSessionTab )};})};
}
2026-10-13 19:14:32 +02:00
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 };});
}
2026-10-13 21:00:41 +02:00
if ( action === 'vault:prepare-submit' ) return { completed : result ? . completed === true , prepared : result ? . prepared === true };
2026-10-13 19:14:32 +02:00
if ( action === 'vault:fill' ) return { completed : result ? . completed === true };
2026-10-13 18:48:40 +02:00
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 };}
2026-10-13 16:58:15 +02:00
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 } : {}) }));
}
2026-10-13 16:58:15 +02:00
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 };
}
2026-10-13 19:41:27 +02:00
function sessionCount ( value ){ if ( ! Number . isSafeInteger ( value ) || value < 1 || value > 32 ) throw fail ( 502 , 'Invalid session count' ); return value ;}
2026-10-13 10:33:31 +02:00
2026-10-13 16:58:15 +02:00
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' );
2026-10-13 16:58:15 +02:00
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 ();
2026-10-13 16:58:15 +02:00
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 ();
2026-10-13 16:58:15 +02:00
let principal , verifiedPrincipal , action , outcome = 'denied' , timer , protectedContext , bundle , validatedRequest ;
2026-10-13 10:33:31 +02:00
try {
2026-10-13 16:58:15 +02:00
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' );
2026-10-13 16:58:15 +02:00
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' );
2026-10-13 16:58:15 +02:00
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' );
2026-10-13 16:58:15 +02:00
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 () => {
2026-10-13 16:58:15 +02:00
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 ); })]);
2026-10-13 16:58:15 +02:00
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' ;
2026-10-13 16:58:15 +02:00
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 ) {
2026-10-13 16:58:15 +02:00
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' ) {
2026-10-13 16:58:15 +02:00
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. */ }
}
2026-10-13 16:58:15 +02:00
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 } ` ;
2026-10-13 16:58:15 +02:00
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 {
2026-10-13 16:58:15 +02:00
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 ; }
2026-10-13 16:58:15 +02:00
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 };