132 lines
13 KiB
JavaScript
132 lines
13 KiB
JavaScript
'use strict';
|
|
const http = require('node:http');
|
|
const crypto = require('node:crypto');
|
|
const fs = require('node:fs/promises');
|
|
const path = require('node:path');
|
|
|
|
const APP_ACTIONS=Object.freeze(['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group']);
|
|
const SCOPES = Object.freeze({ sessions: 'sessions:list', navigate: 'browser:navigate', read: 'browser:read', click: 'browser:interact', type: '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}`);
|
|
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 = { sessions: [], navigate: ['sessionId', 'url'], read: ['sessionId'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], 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'] }[action];
|
|
if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument');
|
|
const clean = {};
|
|
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'].includes(action)) {
|
|
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 === '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};}
|
|
}
|
|
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==='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.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 === '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, timeoutMs = 10000, maxRequestsPerMinute = 60 } = {}) {
|
|
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');
|
|
const token = crypto.randomBytes(32).toString('base64url');
|
|
const expected = Buffer.from(`Bearer ${token}`);
|
|
let enabled = true, windowStart = Date.now(), requests = 0;
|
|
const running = new Set();
|
|
const server = http.createServer(async (req, res) => {
|
|
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 action, outcome = 'denied', timer;
|
|
try {
|
|
const supplied = Buffer.from(req.headers.authorization || '');
|
|
if (!enabled || supplied.length !== expected.length || !crypto.timingSafeEqual(supplied, expected)) throw fail(401, 'Unauthorized');
|
|
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' || req.url !== '/v1/actions') throw fail(404, 'Not found');
|
|
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');
|
|
let size = 0, chunks = [];
|
|
for await (const chunk of req) { size += chunk.length; if (size > 32768) throw fail(413, 'Request too large'); chunks.push(chunk); }
|
|
let body; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw fail(400, 'Invalid JSON'); }
|
|
const request = validate(body); action = request.action;
|
|
if (typeof actions[action] !== 'function') throw fail(403, 'Action unavailable');
|
|
running.add(controller);
|
|
const operation = (async () => {
|
|
const grant = await authorize({ ...request, signal: controller.signal });
|
|
if (!enabled || 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 audit({ action, scope: request.scope, sessionId: request.sessionId, outcome: 'authorized' });
|
|
if (!enabled || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
|
return project(action, await actions[action](request.args, { signal: controller.signal }));
|
|
})();
|
|
const result = await Promise.race([operation, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(fail(504, 'Action timed out')); }, timeoutMs); })]);
|
|
if (!enabled || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
|
outcome = 'completed';
|
|
await audit({ action, outcome });
|
|
reply(200, { result });
|
|
} catch (error) {
|
|
// 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'];
|
|
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 audit({ action: action || 'agent-request', outcome, status: error.status || 500 }); } catch { /* Deny even if audit unavailable. */ }
|
|
reply(error.status || 500, { error: error.status ? error.message : 'Agent action failed' });
|
|
} finally { clearTimeout(timer); running.delete(controller); }
|
|
});
|
|
server.requestTimeout = 15000; server.headersTimeout = 10000; server.maxHeadersCount = 32;
|
|
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; for (const c of running) c.abort(); };
|
|
const close = async () => { revoke(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); };
|
|
try {
|
|
const credential = { address, token };
|
|
if (persistCredential) await persistCredential(credential);
|
|
if (credentialFile) {
|
|
if (!path.isAbsolute(credentialFile)) throw new TypeError('Credential path must be absolute');
|
|
await fs.writeFile(credentialFile, JSON.stringify(credential), { mode: 0o600, flag: 'wx' });
|
|
}
|
|
} catch (error) { await close(); throw error; }
|
|
return { address, token, close, revoke };
|
|
}
|
|
module.exports = { startAgentControl, SCOPES };
|