#!/usr/bin/env node // Persistent CDP pipe client, based on webui/tests/browser.mjs. No injected app script. import { spawn, fork, spawnSync } from 'node:child_process'; import { createServer, createConnection } from 'node:net'; import {createServer as httpServer,request as httpRequest} from 'node:http'; import { once } from 'node:events'; import { createHash, X509Certificate } from 'node:crypto'; import { appendFileSync, chmodSync, existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { exclusive, fail, isMain, options, plainPath, privateDir, readPrivate } from './support-common.mjs'; const ownedDaemons = new Map(); const HERE = fileURLToPath(import.meta.url); const INTERACTIVE = new Set(['button','link','textbox','searchbox','combobox','checkbox','radio','switch','tab','menuitem','option','spinbutton','slider']); const delay = ms => new Promise(r => setTimeout(r, ms)); export function testURL(value, origin = null) { const u = new URL(value, origin ?? undefined); if (u.hostname !== '127.0.0.1' || !['17440','17443'].includes(u.port) || !['http:','https:'].includes(u.protocol) || u.username || u.password || (u.port === '17443' && u.protocol !== 'https:') || (u.port === '17440' && u.protocol !== 'http:')) fail('only loopback test Console ports 17440/17443 allowed'); if (origin && u.origin !== origin) fail('navigation must stay on the selected test origin'); return u; } async function chromium(evidence, ca, proxy) { const cert = new X509Certificate(readPrivate(ca)); if (!cert.ca || Date.parse(cert.validTo) <= Date.now() || Date.parse(cert.validFrom) > Date.now()) fail('valid private test CA required'); const xdg = join(evidence, 'xdg'); privateDir(xdg, true); const pki = join(xdg, 'pki'); privateDir(pki, true); const nss = join(pki, 'nssdb'); privateDir(nss, true); for (const args of [['-N','--empty-password','-d',`sql:${nss}`], ['-A','-d',`sql:${nss}`,'-n','ui797-private-test-ca','-t','C,,','-i',ca]]) { const r = spawnSync('certutil', args, { encoding:'utf8' }); if (r.status !== 0) fail('private Chromium CA trust setup failed (certutil required)'); } for (const name of readdirSync(nss)) chmodSync(join(nss,name),0o600); const profile = join(evidence, 'profile'); privateDir(profile, true); const executable = process.env.CHROMIUM || '/usr/bin/chromium'; plainPath(executable); const chromeArgs = ['--headless','--no-first-run','--no-default-browser-check','--disable-dev-shm-usage', '--disable-background-networking','--disable-component-update','--disable-sync','--disable-quic', `--proxy-server=http://127.0.0.1:${proxy.port}`,'--proxy-bypass-list=<-loopback>', '--force-webrtc-ip-handling-policy=disable_non_proxied_udp','--remote-debugging-pipe', `--user-data-dir=${profile}`]; // Bubblewrap brings up the new namespace's own loopback. There is no veth, // route, DNS forwarder or host-network FD. Only the fixed proxy has a relay. // Start with bwrap's empty root. A network namespace does not hide pathname // Unix sockets: never expose host /run, /tmp, home, devices or the repository. const system = ['/usr','/etc/ld.so.cache','/etc/nsswitch.conf','/etc/passwd','/etc/group', '/etc/fonts','/etc/ssl/certs','/etc/localtime']; const mounts = system.filter(path=>existsSync(path)).flatMap(path=>['--ro-bind',path,path]); for (const path of ['/bin','/sbin','/lib','/lib64']) if (existsSync(path)) { mounts.push(...(lstatSync(path).isSymbolicLink() ? ['--symlink',readlinkSync(path),path] : ['--ro-bind',path,path])); } const home='/home/browser', runtime='/run/browser'; const environment={PATH:'/usr/bin:/bin',LANG:'C.UTF-8',HOME:home,PWD:home,TMPDIR:'/tmp', XDG_RUNTIME_DIR:runtime,XDG_DATA_HOME:xdg,XDG_CONFIG_HOME:home+'/.config',XDG_CACHE_HOME:home+'/.cache'}; const child = spawn('/usr/bin/bwrap', ['--die-with-parent','--unshare-user','--unshare-net','--unshare-pid','--unshare-ipc','--unshare-uts', ...mounts,'--dev','/dev','--proc','/proc','--tmpfs','/tmp','--tmpfs','/run','--tmpfs','/home', '--dir',home,'--dir',runtime,'--bind',evidence,evidence, '--dir',home+'/.pki','--bind',nss,home+'/.pki/nssdb', '--ro-bind',process.execPath,process.execPath, '--ro-bind',fileURLToPath(new URL('./browser-network.mjs',import.meta.url)),'/opt/ui797/browser-network.mjs', ...(executable.startsWith('/usr/') ? [] : ['--ro-bind',executable,executable]), '--chdir',home,'--clearenv',...Object.entries(environment).flatMap(([key,value])=>['--setenv',key,value]), '--',process.execPath,'/opt/ui797/browser-network.mjs',readlinkSync('/proc/self/ns/net'), proxy.socket,String(proxy.port),executable,...chromeArgs], { env:{PATH:'/usr/bin:/bin',LANG:'C.UTF-8'}, stdio: ['ignore','ignore','ignore','pipe','pipe'] }); let id = 0, buffer = ''; const pending = new Map(), events = new Map(); const rejectAll = () => { for (const p of pending.values()) { clearTimeout(p.timer); p.reject(new Error('Chromium exited')); } pending.clear(); }; child.once('error', rejectAll); child.once('exit', rejectAll); child.stdio[3].on('error', rejectAll); child.stdio[4].on('error', rejectAll); child.stdio[4].on('data', chunk => { buffer += chunk.toString(); let end; while ((end = buffer.indexOf('\0')) !== -1) { const msg = JSON.parse(buffer.slice(0,end)); buffer = buffer.slice(end+1); if (msg.method) for (const f of events.get(msg.method) ?? []) f(msg.params, msg.sessionId); const p = pending.get(msg.id); if (p) { pending.delete(msg.id); clearTimeout(p.timer); msg.error ? p.reject(new Error('CDP refused ' + p.method)) : p.resolve(msg.result); } } }); const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { const key = ++id, timer = setTimeout(() => { pending.delete(key); reject(new Error('CDP timeout: ' + method)); }, 15000); pending.set(key, { resolve, reject, timer, method }); child.stdio[3].write(JSON.stringify({ id:key, method, params, ...(sessionId ? { sessionId } : {}) }) + '\0'); }); const on = (method, f) => { if (!events.has(method)) events.set(method,new Set()); events.get(method).add(f); }; let targetId, sessionId; try { ({ targetId } = await send('Target.createTarget', { url:'about:blank' })); ({ sessionId } = await send('Target.attachToTarget', { targetId, flatten:true })); } catch (e) { const closed=once(child,'close'); if (child.exitCode===null && child.signalCode===null) { child.kill('SIGKILL'); await closed; } throw e; } const call = (method, params) => send(method, params, sessionId); let closing; return { call, send, on, child, close() { return closing ??= (async () => { const exited=once(child,'close'); await send('Browser.close').catch(() => {}); if (child.exitCode === null && child.signalCode === null) await exited; })(); } }; } // External transport boundary: Chromium must use this proxy even for loopback. // It never resolves/opens a socket for an unapproved destination. CDP is defense in depth. async function originProxy(origin, evidence) { const allowed=new URL(origin),sockets=new Set(); const track=socket=>{sockets.add(socket);socket.once('close',()=>sockets.delete(socket));return socket;}; const server=httpServer((req,res)=>{ let url; try {url=testURL(req.url,origin);if(url.origin!==origin || allowed.protocol!=='http:')throw Error();} catch {res.writeHead(403);res.end();return;} const upstream=httpRequest({hostname:'127.0.0.1',port:Number(allowed.port),method:req.method,path:url.pathname+url.search,headers:req.headers},reply=>{ res.writeHead(reply.statusCode,reply.headers);reply.pipe(res); }); upstream.on('socket',track); upstream.on('error',()=>{if(!res.headersSent)res.writeHead(502);res.end();}); req.on('aborted',()=>upstream.destroy());req.pipe(upstream); }); server.on('connection',track); server.on('upgrade',(_req,socket)=>socket.destroy()); server.on('connect',(req,peer,head)=>{ // Only TLS to the selected HTTPS origin may tunnel. WS/other-origin tunnels refuse. if(allowed.protocol!=='https:' || req.url!==allowed.host){peer.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');return;} const upstream=track(createConnection({host:'127.0.0.1',port:Number(allowed.port)})); upstream.once('connect',()=>{peer.write('HTTP/1.1 200 Connection Established\r\n\r\n');if(head.length)upstream.write(head);peer.pipe(upstream);upstream.pipe(peer);}); upstream.on('error',()=>peer.destroy());peer.on('error',()=>upstream.destroy()); peer.once('close',()=>upstream.destroy());upstream.once('close',()=>peer.destroy()); }); await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);}); const socket=join(evidence,'proxy.sock'); const bridge=createServer(peer=>{ track(peer); const upstream=track(createConnection({host:'127.0.0.1',port:server.address().port})); peer.on('error',()=>upstream.destroy());upstream.on('error',()=>peer.destroy()); peer.once('close',()=>upstream.destroy());upstream.once('close',()=>peer.destroy()); peer.pipe(upstream);upstream.pipe(peer); }); try { if(Buffer.byteLength(socket)>100) fail('evidence path too long for Unix socket'); await new Promise((resolve,reject)=>{bridge.once('error',reject);bridge.listen(socket,resolve);}); chmodSync(socket,0o600); } catch(e) { const closed=Promise.all([new Promise(resolve=>bridge.close(resolve)),new Promise(resolve=>server.close(resolve))]); for(const peer of sockets)peer.destroy();await closed;throw e; } let closing; return {port:server.address().port,socket,close(){return closing??=(async()=>{ const closed=Promise.all([new Promise(resolve=>bridge.close(resolve)),new Promise(resolve=>server.close(resolve))]); for(const socket of sockets)socket.destroy();await closed; })();}}; } export async function daemon(evidence) { privateDir(evidence); const state = JSON.parse(readPrivate(join(evidence, 'browser.json'))), origin = testURL(state.origin).origin; let blocked=false; const proxy=await originProxy(origin,evidence); let b; try { b = await chromium(evidence, state.ca,proxy); const closeChrome=b.close; b.close=async()=>{try{await closeChrome();}finally{await proxy.close();}}; let sequence = 0, generation = 0, refs = new Map(), detail = {}; const completed = new Map(), pendingDownloads = new Set(); const downloadsDir = join(evidence,'downloads'); privateDir(downloadsDir, true); b.on('Browser.downloadWillBegin', e => { pendingDownloads.add(e.guid); }); b.on('Browser.downloadProgress', e => { if (e.state === 'completed') { completed.set(e.guid, true); pendingDownloads.delete(e.guid); } }); b.on('Page.frameNavigated', e => { if (!e.frame.parentId) { generation++; refs.clear(); } }); // A native alert/confirm/prompt blocks the page (and input, screenshots and the AX tree) until it // is answered, so the tester answers it with `dialog accept|dismiss`; its type and text are recorded. let dialog = null, dialogOpened = () => {}; b.on('Page.javascriptDialogOpening', e => { dialog = { type:e.type, message:String(e.message).slice(0,500) }; dialogOpened(); }); b.on('Page.javascriptDialogClosed', () => { dialog = null; }); const refused = async id => { blocked = true; await b.call('Fetch.failRequest', { requestId:id, errorReason:'BlockedByClient' }).catch(() => {}); }; // Includes subresources, redirects and popups. No external live Console can be contacted. b.on('Fetch.requestPaused', e => { let allowed = false; try { allowed = testURL(e.request.url).origin === origin; } catch {} (allowed ? b.call('Fetch.continueRequest', { requestId:e.requestId }) : refused(e.requestId)).catch(() => { blocked = true; }); }); // Deny new targets (including window.open) before they are allowed to run. b.on('Target.attachedToTarget', e => { if (e.waitingForDebugger) b.send('Target.closeTarget', { targetId:e.targetInfo.targetId }).catch(() => {}); }); await b.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true }); await b.call('Page.enable'); await b.call('Accessibility.enable'); await b.call('DOM.enable'); await b.call('Network.enable'); await b.call('Network.setBlockedURLs', { urls:['ws://*','wss://*'] }); // Detection latches the session; the forced proxy enforces BEFORE outbound connection. // Chromium's own background probes are also refused by the proxy, without poisoning UI actions. b.on('Network.webSocketCreated',e=>{ try {testURL(e.url.replace(/^ws/,'http'),origin);} catch {blocked=true;} }); await b.call('Fetch.enable', { patterns:[{ urlPattern:'*', requestStage:'Request' }] }); await b.send('Browser.setDownloadBehavior', { behavior:'allowAndName', downloadPath:downloadsDir, eventsEnabled:true }); await b.call('Emulation.setDeviceMetricsOverride', { width:1280, height:1000, deviceScaleFactor:1, mobile:false }); const shot = async name => { const { data } = await b.call('Page.captureScreenshot', { format:'png' }); exclusive(join(evidence,name), Buffer.from(data,'base64')); return name; }; async function snapshot() { const { nodes } = await b.call('Accessibility.getFullAXTree'); const text = [], elements = []; const next = new Map(); for (const n of nodes) { if (n.ignored) continue; const role = n.role?.value, name = n.name?.value ?? ''; if (role === 'StaticText' && name) text.push(name); if (INTERACTIVE.has(role) && n.backendDOMNodeId) { const ref = `r${generation}-${n.backendDOMNodeId}`; next.set(ref,n.backendDOMNodeId); // Values (including secret input) never enter snapshots or receipts. elements.push({ ref, role, name, disabled:n.properties?.some(p => p.name === 'disabled' && p.value?.value === true) ?? false }); } } refs = next; return { text:text.join('\n'), elements }; } const currentURL = async () => { const { frameTree } = await b.call('Page.getFrameTree'); if (frameTree.frame.url !== 'about:blank') testURL(frameTree.frame.url, origin); }; async function focus(ref) { const backendNodeId = refs.get(ref); if (!backendNodeId) fail('unknown or stale ref; snapshot again'); await b.call('DOM.scrollIntoViewIfNeeded', { backendNodeId }); const { quads } = await b.call('DOM.getContentQuads', { backendNodeId }); const q = quads?.[0]; if (!q || Math.abs((q[2]-q[0])*(q[5]-q[1])) < 1) fail('element has no visible target'); const x = (q[0]+q[2]+q[4]+q[6])/4, y = (q[1]+q[3]+q[5]+q[7])/4; await b.call('Input.dispatchMouseEvent', { type:'mouseMoved',x,y }); await b.call('Input.dispatchMouseEvent', { type:'mousePressed',x,y,button:'left',clickCount:1 }); // The release does not complete while a dialog it opened is waiting; return when one opens. const opened = new Promise(resolve => { dialogOpened = resolve; }); const released = b.call('Input.dispatchMouseEvent', { type:'mouseReleased',x,y,button:'left',clickCount:1 }); released.catch(() => {}); try { await Promise.race([released, opened]); } finally { dialogOpened = () => {}; } } async function press(key) { const parts = key.split('+'), base = parts.pop(); let modifiers = 0; for (const part of parts) { const mask = { Alt:1, Ctrl:2, Control:2, Meta:4, Shift:8 }[part]; if (!mask || (modifiers & mask)) fail('unsupported key modifier'); modifiers |= mask; } let spec = { Enter:['Enter',13,'\r'], Tab:['Tab',9,''], Escape:['Escape',27,''], Backspace:['Backspace',8,''], Delete:['Delete',46,''], Home:['Home',36,''], End:['End',35,''], PageUp:['PageUp',33,''], PageDown:['PageDown',34,''], ArrowDown:['ArrowDown',40,''], ArrowUp:['ArrowUp',38,''], ArrowLeft:['ArrowLeft',37,''], ArrowRight:['ArrowRight',39,''], Space:['Space',32,' '] }[base]; if (!spec && /^[a-zA-Z0-9]$/.test(base)) spec = [/[a-zA-Z]/.test(base) ? `Key${base.toUpperCase()}` : `Digit${base}`,base.toUpperCase().charCodeAt(0),base]; if (!spec) fail('unsupported key'); detail.key=key; const event = { key:base === 'Space' ? ' ' : base,code:spec[0],windowsVirtualKeyCode:spec[1],modifiers }; await b.call('Input.dispatchKeyEvent', { ...event,type:'keyDown',text:modifiers ? '' : spec[2] }); await b.call('Input.dispatchKeyEvent', { ...event,type:'keyUp' }); } async function navigate(path) { const url = testURL(path,origin); // Authentication query strings are never accepted as CLI inputs or retained. if (url.pathname === '/auth/callback' || url.pathname.startsWith('/oidc/')) fail('navigate via the Console sign-in control'); const r = await b.call('Page.navigate', { url:url.href }); if (r.errorText) fail('navigation failed'); for (let n=0;n<100;n++) { const { root } = await b.call('DOM.getDocument'); if (root.childNodeCount > 0) { await delay(100); return; } await delay(50); } fail('navigation timed out'); } async function perform(command,args) { switch (command) { case 'start': await navigate('/'); return snapshot(); case 'goto': if (args.length !== 1) fail('goto PATH'); await navigate(args[0]); return snapshot(); case 'snapshot': if (args.length) fail('snapshot takes no arguments'); return snapshot(); case 'click': { let ref; if (args.length === 2 && args[0] === '--text') { const s = await snapshot(), found = s.elements.filter(e => e.name === args[1] && !e.disabled); if (found.length !== 1) fail('text must name exactly one enabled interactive element'); ref = found[0].ref; } else if (args.length === 1) ref = args[0]; else fail('click REF or --text TEXT'); if (/^r[0-9]+-[0-9]+$/.test(ref)) detail.ref = ref; await focus(ref); await delay(150); if (dialog) { detail.dialog = { ...dialog }; return { dialog:{ ...dialog } }; } return snapshot(); } case 'dialog': { if (args.length !== 1 || !['accept','dismiss'].includes(args[0])) fail('dialog accept|dismiss'); if (!dialog) fail('no dialog is open'); detail.dialog = { ...dialog, answer:args[0] }; await b.call('Page.handleJavaScriptDialog', { accept:args[0] === 'accept' }); dialog = null; await delay(150); return snapshot(); } case 'type': if (args.length !== 2) fail('type REF TEXT'); if (/^r[0-9]+-[0-9]+$/.test(args[0])) detail.ref=args[0]; await focus(args[0]); await b.call('Input.insertText', { text:args[1] }); return snapshot(); case 'press': if (args.length !== 1) fail('press KEY'); await press(args[0]); await delay(100); return snapshot(); case 'wait': { const o = options([...args],['--text','--timeout']); const timeout = Number(o['--timeout'] ?? 10); if (!o['--text'] || !Number.isFinite(timeout) || timeout <= 0 || timeout > 120) fail('wait --text TEXT --timeout 0..120 seconds'); const end = Date.now()+timeout*1000; do { const s = await snapshot(); if (s.text.includes(o['--text']) || s.elements.some(e => e.name.includes(o['--text']))) return s; await delay(100); } while (Date.now() { const file = join(downloadsDir,name), bytes = readFileSync(file); chmodSync(file,0o600); return { file, bytes:bytes.length, sha256:createHash('sha256').update(bytes).digest('hex') }; }); } case 'stop': return { stopped:true }; default: fail('unknown browser command'); } } async function action(command,args) { detail = {}; // No screenshot can be taken while a dialog blocks the page; other commands are refused and journaled. const n = String(++sequence).padStart(5,'0'), before = dialog ? null : await shot(`${n}-before.png`); let result, error, after; try { if (dialog && command !== 'dialog' && command !== 'stop') fail(`a ${dialog.type} dialog is open; dialog accept|dismiss`); // The frame tree is unavailable while a dialog is open; the page cannot navigate until it closes. if (command !== 'stop' && !dialog) await currentURL(); result = await perform(command,args); if (command !== 'stop') { if (!dialog) await currentURL(); if (blocked) fail('non-test network request blocked; stop session'); } } catch (e) { error = e.message; } finally { after = dialog ? null : await shot(`${n}-after.png`).catch(() => null); // No text input, URLs, auth codes, tokens, cookie/header/body values in the journal. appendFileSync(join(evidence,'actions.jsonl'), JSON.stringify({ sequence,at:new Date().toISOString(),command,...detail,before,after,ok:!error,error:error ?? null })+'\n', { mode:0o600 }); } if (error) throw new Error(error); return result; } let tail = Promise.resolve(), stopping = false; const socket = join(evidence,'browser.sock'); if (Buffer.byteLength(socket)>100) fail('evidence path too long for Unix socket'); const server = createServer(peer => { peer.on('error', () => {}); peer.setTimeout(150000, () => peer.destroy()); let data = ''; peer.on('data', chunk => { data += chunk; if (data.length > 65536) return peer.destroy(); if (!data.includes('\n')) return; peer.removeAllListeners('data'); tail = tail.then(async () => { let request; try { request = JSON.parse(data); if (!Array.isArray(request.args) || request.args.some(a => typeof a !== 'string') || stopping) fail('bad browser command'); const value = await action(request.command,request.args); if (request.command === 'stop') { stopping = true; server.close(); await b.close(); if (existsSync(socket)) unlinkSync(socket); } peer.end(JSON.stringify({ ok:true,value })+'\n'); } catch (e) { peer.end(JSON.stringify({ ok:false,error:e.message })+'\n'); } }); }); }); await new Promise((resolve,reject) => { server.once('error',reject); server.listen(socket,resolve); }); chmodSync(socket,0o600); const cleanup = async () => { server.close(); await b.close(); if (existsSync(socket)) unlinkSync(socket); }; for (const signal of ['SIGTERM','SIGINT']) process.once(signal, () => cleanup().finally(() => process.exit(0))); return { server, cleanup }; } catch (e) { await b?.close().catch(() => {}); await proxy.close(); throw e; } } export async function callBrowser(evidence, command, args=[]) { privateDir(evidence); readPrivate(join(evidence,'browser.json')); const value=await new Promise((resolve,reject) => { const peer = createConnection(join(evidence,'browser.sock')); let data = ''; peer.on('error', () => {}); peer.setTimeout(150000, () => peer.destroy(new Error('browser action timed out; inspect receipts before retry'))); peer.on('error',reject); peer.on('connect', () => peer.write(JSON.stringify({ command,args })+'\n')); peer.on('data',chunk => { data += chunk; }); peer.on('end', () => { try { const r = JSON.parse(data); r.ok ? resolve(r.value) : reject(new Error(r.error)); } catch(e) { reject(e); } }); }); if (command === 'stop' && ownedDaemons.has(evidence)) await ownedDaemons.get(evidence).exited; return value; } export async function start({ evidence, origin, ca }) { if (testURL(origin).origin !== origin) fail('origin must have no path, query or fragment'); plainPath(ca); readPrivate(ca); plainPath(evidence); privateDir(evidence,true); exclusive(join(evidence,'browser.json'),JSON.stringify({ version:1,origin,ca })+'\n'); const child = fork(HERE,['--daemon',evidence],{ detached:true,stdio:['ignore','ignore','ignore','ipc'] }); const exited=new Promise(resolve=>child.once('exit',resolve)); ownedDaemons.set(evidence,{child,exited}); exited.then(()=>ownedDaemons.delete(evidence)); try { await new Promise((resolve,reject) => { const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('browser daemon startup timed out')); },20000); child.once('error',reject); child.once('exit', () => { clearTimeout(timer); reject(new Error('browser daemon failed to start')); }); child.once('message',m => { clearTimeout(timer); m.ok ? resolve() : reject(new Error(m.error)); }); }); } catch (e) { child.kill('SIGTERM'); if (child.connected) child.disconnect(); await exited; throw e; } child.disconnect(); child.unref(); try { return await callBrowser(evidence,'start'); } catch(e) { await callBrowser(evidence,'stop').catch(async()=>{child.kill('SIGTERM');await exited;}); throw e; } } export async function main(argv, env=process.env) { const command = argv.shift(); if (command === '--daemon') { await daemon(argv[0]); process.send?.({ok:true}); return; } if (command === 'start') { const o = options(argv,['--evidence','--origin','--ca']); return start({evidence:o['--evidence'],origin:o['--origin'],ca:o['--ca']}); } let evidence = env.UI797_EVIDENCE; const index = argv.indexOf('--evidence'); if (index >= 0) { evidence = argv[index+1]; argv.splice(index,2); } return callBrowser(evidence,command,argv); } if (isMain(import.meta.url)) main(process.argv.slice(2)).then(r => { if (r !== undefined) console.log(JSON.stringify(r,null,2)); }).catch(e => { if (process.send) process.send({ok:false,error:'browser daemon startup refused'}); else console.error(e.message); process.exitCode=1; });