Add inspected agent viewport export with policy-bound final release
This commit is contained in:
1 parent
c8b6ff9cdd
commit
e7d778aeac
24 files changed
+483
-32
No files matched your search
@@ -4,7 +4,7 @@ const crypto = require('node:crypto');
|
||||
const channel=require('../../packages/core/policy-channel.cjs');
|
||||
|
||||
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']);
|
||||
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])) });
|
||||
const SCOPES = Object.freeze({ screenshot:'browser:screenshot', 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])) });
|
||||
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}`);
|
||||
@@ -16,7 +16,7 @@ function validate(body) {
|
||||
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 = { '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];
|
||||
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'],screenshot:['sessionId'], 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];
|
||||
if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument');
|
||||
const clean = {};
|
||||
if(action==='sessions:save'){
|
||||
@@ -70,6 +70,7 @@ function validate(body) {
|
||||
}
|
||||
// Never return adapter objects wholesale: session handles and sanitized text only.
|
||||
function project(action, result) {
|
||||
if(action==='screenshot')return require('./agent-visual-export.cjs').validateVisualResult(result);
|
||||
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)};
|
||||
@@ -125,10 +126,12 @@ function project(action, result) {
|
||||
}
|
||||
function sessionCount(value){if(!Number.isSafeInteger(value)||value<1||value>32)throw fail(502,'Invalid session count');return value;}
|
||||
|
||||
async function startAgentControl({ authorize, actions, audit, credentialFile, persistCredential, onError, traceOperation = async (_name,fn)=>fn(), timeoutMs = 10000, maxRequestsPerMinute = 60, principalMode = 'legacy', principals = [] } = {}) {
|
||||
async function startAgentControl({ authorize, actions, audit, credentialFile, persistCredential, onError, traceOperation = async (_name,fn)=>fn(), traceRelease = (_name,fn)=>fn(), timeoutMs = 10000, actionTimeouts = {}, maxRequestsPerMinute = 60, principalMode = 'legacy', principals = [] } = {}) {
|
||||
if(credentialFile!==undefined)throw new TypeError('Plaintext pairing files are prohibited; use protected persistCredential');
|
||||
if (typeof authorize !== 'function' || typeof audit !== 'function' || !actions) throw new TypeError('authorize, actions and audit are required');
|
||||
if (typeof authorize !== 'function' || typeof audit !== 'function' || typeof traceRelease!=='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(!actionTimeouts||typeof actionTimeouts!=='object'||Array.isArray(actionTimeouts)||Object.entries(actionTimeouts).some(([action,limit])=>!Object.hasOwn(SCOPES,action)||!Number.isInteger(limit)||limit<10||limit>60000))throw TypeError('Invalid action deadlines');
|
||||
const deadlines=Object.freeze({...actionTimeouts});
|
||||
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();
|
||||
@@ -155,7 +158,7 @@ async function startAgentControl({ authorize, actions, audit, credentialFile, pe
|
||||
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;
|
||||
let principal, verifiedPrincipal, action, outcome = 'denied', timer, protectedContext, bundle, validatedRequest, releaseGuard;
|
||||
try {
|
||||
let proof;
|
||||
if(principalMode==='individual'){
|
||||
@@ -183,23 +186,23 @@ async function startAgentControl({ authorize, actions, audit, credentialFile, pe
|
||||
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');};
|
||||
const reauthorize=async()=>{if(!principal&&action!=='screenshot')return;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');};
|
||||
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));
|
||||
const value=await traceOperation('agent.dispatch',()=>actions[action](request.args, { signal: controller.signal,...(verifiedPrincipal?{principalId:verifiedPrincipal.id,generation:verifiedPrincipal.generation}:{}) }));if(action==='screenshot'){releaseGuard=require('./agent-visual-export.cjs').getVisualReleaseGuard(value);if(typeof releaseGuard!=='function')throw fail(403,'Visual release context unavailable');releaseGuard();}return traceOperation('agent.project',()=>project(action,value));
|
||||
})();
|
||||
const result = await Promise.race([operation, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(fail(504, 'Action timed out')); }, timeoutMs); })]);
|
||||
const result = await Promise.race([operation, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(fail(504, 'Action timed out')); }, deadlines[action]??timeoutMs); })]);
|
||||
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||
await reauthorize();
|
||||
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);
|
||||
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');if(action==='screenshot'){if(typeof releaseGuard!=='function')throw fail(403,'Visual release context unavailable');let called=false,refused;const once=()=>{if(!called){called=true;try{releaseGuard();}catch(error){refused=error;}}if(refused)throw refused;};try{const observation=traceRelease('visual.release',once);observation?.catch?.(()=>{});}catch{}if(!called)once();if(refused)throw fail(403,'Visual release context changed');}reply(200,encryptedReply);
|
||||
} catch (error) {
|
||||
outcome=(error.status||500)>=500?'failed':'denied';
|
||||
// Diagnostics expose only known static reasons, never arbitrary adapter errors.
|
||||
|
||||
Reference in new issue
Block a user