A click on the test Console's Resume button opened window.confirm and the
browser daemon hung: the mouse release, screenshots and the frame tree all
block until the dialog is answered, and ui-browser had no way to answer it.
A click that opens a dialog now returns {dialog:{type,message}}. The new
command `dialog accept|dismiss` answers it and records the type, message
and answer in the receipt. While a dialog is open other commands except
stop refuse (journaled), and no screenshot or URL check is attempted.
A real-Chromium confirm test covers accept, dismiss and the refusals.
402 lines
26 KiB
JavaScript
402 lines
26 KiB
JavaScript
#!/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()<end);
|
|
fail('text wait timed out'); break;
|
|
}
|
|
case 'screenshot': {
|
|
if (args.length !== 1 || !/^[a-zA-Z0-9][a-zA-Z0-9.-]{0,80}$/.test(args[0])) fail('safe screenshot NAME required');
|
|
return { file:await shot(args[0] + '.png') };
|
|
}
|
|
case 'downloads': {
|
|
const end = Date.now()+10000;
|
|
while (pendingDownloads.size && Date.now()<end) await delay(50);
|
|
if (pendingDownloads.size) fail('download still pending');
|
|
return [...completed.keys()].map(name => { 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; });
|