#!/usr/bin/env node /** Node Playwright sidecar. The Bare daemon never loads Playwright itself. */ import { createRequire } from 'node:module'; import { extensionLaunch, installedExtensionDirs } from './extensions.js'; import { pageSliceOffsets } from './reading.js'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import readline from 'node:readline'; const require = createRequire(import.meta.url); const { SEARCH_HOME, duckduckgoSearchUrl, isBlankStart } = require('./startpage.cjs'); const SEARCH_BUDGET_MS = 25_000; const FETCH_BUDGET_MS = 30_000; const CHALLENGE_MS = 20_000; const CHALLENGE_RE = /just a moment|attention required|verify (?:that )?you are human|checking your browser|enable javascript|security check|captcha|cloudflare|unusual traffic|please wait/i; const SEARCH_URLS = { duckduckgo: (q) => duckduckgoSearchUrl(q), ddg: (q) => duckduckgoSearchUrl(q), ddg_lite: (q) => duckduckgoSearchUrl(q), startpage: (q) => duckduckgoSearchUrl(q), start: (q) => duckduckgoSearchUrl(q), google: (q) => `https://www.google.com/search?q=${encodeURIComponent(q)}`, bing: (q) => `https://www.bing.com/search?q=${encodeURIComponent(q)}`, bing_rss: (q) => `https://www.bing.com/search?q=${encodeURIComponent(q)}`, wikipedia: (q) => `https://en.wikipedia.org/w/index.php?search=${encodeURIComponent(q)}`, hn: (q) => `https://hn.algolia.com/?q=${encodeURIComponent(q)}`, github: (q) => `https://github.com/search?q=${encodeURIComponent(q)}&type=repositories`, npm: (q) => `https://www.npmjs.com/search?q=${encodeURIComponent(q)}`, mdn: (q) => `https://developer.mozilla.org/en-US/search?q=${encodeURIComponent(q)}`, stackoverflow: (q) => `https://stackoverflow.com/search?q=${encodeURIComponent(q)}`, arxiv: (q) => `https://arxiv.org/search/?query=${encodeURIComponent(q)}&searchtype=all`, }; function stateRoot() { return path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local/state'), 'jarvis', 'browser'); } function isBlockedHostname(hostname) { if (!hostname) return true; const h = String(hostname).toLowerCase().replace(/^\[|\]$/g, ''); if (h === 'localhost' || h.endsWith('.localhost') || h === '0.0.0.0' || h === '::' || h === '::1' || h === 'metadata.google.internal' || h.endsWith('.internal')) return true; const v4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (v4) { const a = Number(v4[1]); const b = Number(v4[2]); if (a === 0 || a === 10 || a === 127) return true; if (a === 169 && b === 254) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 192 && b === 168) return true; if (a === 100 && b >= 64 && b <= 127) return true; } if (h.includes(':') && (h === '::1' || h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd'))) return true; return false; } function assertPublicHttpUrl(raw) { let url; try { url = new URL(String(raw)); } catch { throw new Error('invalid URL'); } if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('only http(s) URLs are allowed'); if (isBlockedHostname(url.hostname)) throw new Error('private, loopback, and metadata hosts are blocked'); return url.href; } function reply(id, payload) { process.stdout.write(`${JSON.stringify({ id, ...payload })}\n`); } function fail(id, error) { reply(id, { ok: false, error: String(error && error.message || error) }); } async function loadPlaywright() { try { return require('playwright'); } catch (error) { throw new Error(`Playwright is not installed (${error.message}). From the Jarvis tree run: npm install && npm run browser:install`); } } function challengeText(title, text) { return CHALLENGE_RE.test(String(title || '')) || CHALLENGE_RE.test(String(text || '').slice(0, 1200)); } async function waitOutChallenge(page, timeoutMs) { const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0); while (Date.now() < deadline) { const title = await page.title().catch(() => ''); const text = await page.locator('body').innerText({ timeout: 1000 }).catch(() => ''); if (!challengeText(title, text)) return false; await page.waitForTimeout(400); } const title = await page.title().catch(() => ''); const text = await page.locator('body').innerText({ timeout: 1000 }).catch(() => ''); return challengeText(title, text); } async function extractHits(page, limit) { const max = Math.min(15, Math.max(1, Number(limit) || 8)); return page.evaluate((cap) => { const out = []; const seen = new Set(); const unwrap = (href) => { try { const u = new URL(href); const host = u.hostname.replace(/^www\./, ''); const dest = u.searchParams.get('uddg') || u.searchParams.get('url'); if (dest && /^https?:/i.test(dest)) return dest; if ((host === 'google.com' || host.endsWith('.google.com')) && u.pathname === '/url') { const q = u.searchParams.get('q'); if (q && /^https?:/i.test(q)) return q; } if (['google.com', 'bing.com', 'googleusercontent.com'].includes(host)) return ''; return u.href; } catch { return ''; } }; const push = (href, title, snippet) => { const url = unwrap(href); if (!url || !/^https?:/i.test(url) || seen.has(url)) return; seen.add(url); out.push({ url, title: String(title || url).replace(/\s+/g, ' ').trim().slice(0, 200) || url, snippet: String(snippet || '').replace(/\s+/g, ' ').trim().slice(0, 280), }); }; const blocks = document.querySelectorAll('article[data-testid="result"], [data-testid="result"], li.b_algo, div.g, a.result__a'); for (const node of blocks) { const a = node.tagName === 'A' ? node : node.querySelector('a[href]'); if (!a) continue; const heading = node.querySelector('h3, h2') || a; const snip = node.querySelector('[data-result="snippet"], .b_caption p, .result__snippet, .VwiC3b'); push(a.href, heading.innerText, snip ? snip.innerText : ''); if (out.length >= cap) return out; } if (!out.length) { for (const a of document.querySelectorAll('a[href^="http"]')) { const label = (a.innerText || '').replace(/\s+/g, ' ').trim(); if (label.length < 8) continue; push(a.href, label, ''); if (out.length >= cap) break; } } return out.slice(0, cap); }, max); } const PAGE_TEXT_CAP = 64000; const PAGE_SHOTS = 4; async function walkPage(page) { return page.evaluate(async () => { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); for (const node of document.querySelectorAll('details')) node.open = true; const view = Math.max(400, window.innerHeight || 800); const step = Math.max(500, Math.floor(view * 0.85)); let lastHeight = 0; let still = 0; for (let i = 0; i < 36; i++) { const height = Math.max(document.documentElement?.scrollHeight || 0, document.body?.scrollHeight || 0); const y = Math.min(i * step, Math.max(0, height - view)); window.scrollTo(0, y); await sleep(80); if (height > lastHeight + 40) still = 0; else still += 1; lastHeight = Math.max(lastHeight, height); if (y + view >= height - 16 && still >= 2) { return { height: lastHeight, view, screens: Math.max(1, Math.ceil(lastHeight / view)), complete: true }; } } const height = Math.max(document.documentElement?.scrollHeight || 0, document.body?.scrollHeight || 0, lastHeight); return { height, view, screens: Math.max(1, Math.ceil(height / view)), complete: false }; }); } async function readPage(page) { return page.evaluate((cap) => { const clean = (value) => String(value || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); const skip = 'nav, footer, [role="navigation"], [role="contentinfo"], [aria-label*="cookie" i], [id*="cookie" i], [class*="cookie" i], [class*="consent" i]'; function visibleText(root) { if (!root) return ''; const clone = root.cloneNode(true); for (const node of clone.querySelectorAll('script, style, noscript, ' + skip)) node.remove(); return clean(clone.innerText || clone.textContent || ''); } const full = visibleText(document.body); const text = full.slice(0, cap); const headings = [...document.querySelectorAll('h1, h2, h3, h4')] .map((node) => clean(node.innerText)) .filter(Boolean) .slice(0, 80); const tables = [...document.querySelectorAll('table')].slice(0, 12).map((table) => { return [...table.rows].slice(0, 80).map((row) => [...row.cells].map((cell) => clean(cell.innerText).replace(/\s+/g, ' ')).filter(Boolean).join(' | ')).filter(Boolean).join('\n'); }).filter(Boolean); const seen = []; for (const node of document.querySelectorAll('img, figure')) { if (seen.length >= 60) break; const img = node.tagName === 'IMG' ? node : node.querySelector('img'); const caption = node.tagName === 'FIGURE' ? clean(node.querySelector('figcaption')?.innerText) : ''; const alt = clean(img?.getAttribute('alt') || img?.getAttribute('title') || ''); const label = [alt, caption].filter(Boolean).join(' — '); if (label.length > 2) seen.push(label.slice(0, 180)); } return { text, headings, tables, seen, chars: full.length, truncated: full.length > cap }; }, PAGE_TEXT_CAP); } async function pageShot(page, index) { const dir = '/tmp/jarvis-browser'; fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, 'view-' + Date.now() + '-' + index + '.jpg'); await page.screenshot({ path: file, type: 'jpeg', quality: 55, fullPage: false }); return { path: file, mime: 'image/jpeg' }; } async function pageShots(page, offsets) { const shots = []; const stops = offsets.length ? offsets : [0]; for (let i = 0; i < stops.length; i++) { await page.evaluate((top) => window.scrollTo(0, top), stops[i]).catch(() => {}); await new Promise((resolve) => setTimeout(resolve, 40)); try { shots.push(await pageShot(page, i + 1)); } catch {} } await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {}); return shots; } async function snapshot(page) { const walked = await walkPage(page).catch(() => ({ height: 0, view: 800, screens: 1, complete: true })); const reading = await readPage(page).catch(() => ({ text: '', headings: [], tables: [], seen: [] })); const title = await page.title().catch(() => ''); const refs = await page.evaluate(() => { const skip = 'nav, footer, [role="navigation"], [role="contentinfo"], [aria-label*="cookie" i], [id*="cookie" i], [class*="cookie" i]'; const nodes = [...document.querySelectorAll('a, button, input, textarea, select, [role="button"], [role="link"], [contenteditable="true"]')]; const content = nodes.filter((el) => !el.closest(skip)); const chrome = nodes.filter((el) => el.closest(skip)); const items = []; let i = 1; for (const el of content.concat(chrome)) { if (items.length >= 48) break; const name = (el.innerText || el.value || el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('name') || '').replace(/\s+/g, ' ').trim().slice(0, 120); if (!name && el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA' && el.tagName !== 'SELECT') continue; el.setAttribute('data-jarvis-ref', String(i)); items.push({ ref: String(i), role: (el.getAttribute('role') || el.tagName.toLowerCase()), name }); i += 1; } return items; }); const screens = walked.screens || 1; const coverage = walked.complete ? `full page, ${screens} screen${screens === 1 ? '' : 's'}` : `walked ${screens} screens; an infinite scroll may continue below`; const result = { url: page.url(), title, refs, text: reading.text || '', headings: reading.headings || [], tables: reading.tables || [], seen: reading.seen || [], coverage: reading.truncated ? coverage + '; page text cut at the length cap' : coverage, }; result.images = await pageShots(page, pageSliceOffsets(walked.height, walked.view, PAGE_SHOTS)); if (challengeText(title, result.text)) { result.challenge = true; result.next_action = 'Complete the prompt in the Jarvis browser window, then snapshot again.'; } return result; } async function locate(page, input) { const ref = input.ref != null ? String(input.ref) : ''; if (ref) { const handle = page.locator(`[data-jarvis-ref="${ref}"]`).first(); if (await handle.count()) return handle; } const selector = input.selector != null ? String(input.selector) : ''; if (selector) return page.locator(selector).first(); const text = input.text != null && !input.submit ? String(input.text) : ''; if (text && input.action === 'click') return page.getByText(text, { exact: false }).first(); return null; } let context; let page; let browserApp; let relaunching = false; let shuttingDown = false; let relaunchCount = 0; async function launchBrowser() { const root = stateRoot(); const profile = process.env.JARVIS_BROWSER_PROFILE || path.join(root, 'profile'); const browsers = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(root, 'ms-playwright'); process.env.PLAYWRIGHT_BROWSERS_PATH = browsers; fs.mkdirSync(profile, { recursive: true }); fs.mkdirSync(browsers, { recursive: true }); const { chromium } = await loadPlaywright(); const canShow = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); const headless = process.env.JARVIS_BROWSER_HEADLESS === '1' || !canShow; const extensions = extensionLaunch(installedExtensionDirs()); const next = await chromium.launchPersistentContext(profile, { headless, viewport: { width: 1280, height: 800 }, args: ['--disable-blink-features=AutomationControlled', '--homepage=' + SEARCH_HOME, ...extensions.args], ...(extensions.ignoreDefaultArgs ? { ignoreDefaultArgs: extensions.ignoreDefaultArgs } : {}), }); context = next; browserApp = typeof next.browser === 'function' ? next.browser() : null; const reopen = () => { if (shuttingDown || relaunching || relaunchCount >= 3) return; relaunching = true; relaunchCount += 1; context = null; page = null; setTimeout(() => { launchBrowser().catch(() => {}).finally(() => { relaunching = false; }); }, 400); }; next.on('close', reopen); if (browserApp) browserApp.on('disconnected', reopen); page = next.pages()[0] || await next.newPage(); if (isBlankStart(page.url())) { await page.goto(SEARCH_HOME, { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {}); } return { headless, extensions: extensions.extensions }; } async function currentPage() { if (context && page && !page.isClosed()) return page; if (!context && !relaunching) await launchBrowser().catch(() => {}); if (page && !page.isClosed()) return page; if (!context) throw new Error('Jarvis browser window is restarting'); page = context.pages().find((item) => !item.isClosed()) || await context.newPage(); return page; } async function gotoUrl(target, timeoutMs) { const url = assertPublicHttpUrl(target); const tab = await currentPage(); const response = await tab.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs }); await tab.waitForLoadState('networkidle', { timeout: Math.min(8000, timeoutMs) }).catch(() => {}); const challenged = await waitOutChallenge(tab, Math.min(CHALLENGE_MS, timeoutMs)); return { tab, url: tab.url(), status: response ? response.status() : 0, challenged }; } async function fetchPage(input, timeoutMs) { const opened = await gotoUrl(input.url, timeoutMs); const tab = opened.tab; const url = opened.url; const status = opened.status; const challenged = opened.challenged; const title = await tab.title().catch(() => ''); const text = await tab.locator('body').innerText().catch(() => ''); let html = ''; try { html = await tab.content(); } catch { html = ''; } if (html.length > 2 * 1024 * 1024) html = html.slice(0, 2 * 1024 * 1024); const result = { url, title, status, html, text: String(text || '').slice(0, 30_000), via: 'browser', }; if (challenged) { result.challenge = true; result.next_action = 'Complete the prompt in the Jarvis browser window, then call the tool again.'; } if (status >= 400 && !challenged) result.error = `HTTP ${status}`; return result; } async function handle(message) { const action = String(message.action || '').trim(); const timeoutMs = Number(message.timeoutMs) > 0 ? Number(message.timeoutMs) : (action === 'search' ? SEARCH_BUDGET_MS : FETCH_BUDGET_MS); const tab = await currentPage(); if (action === 'search') { const engine = String(message.engine || 'duckduckgo').trim() || 'duckduckgo'; const build = SEARCH_URLS[engine]; if (!build) throw new Error(`unknown engine ${engine}`); const query = String(message.query || '').trim(); if (!query) throw new Error('query required'); const { tab: searchTab, url, challenged } = await gotoUrl(build(query), timeoutMs); if (challenged) { return { error: 'Provider requires a browser security challenge', code: 'bot_challenge', challenge: true, url, next_action: 'Complete the prompt in the Jarvis browser window, then call the tool again.', }; } const hits = await extractHits(searchTab, message.limit); return hits.map((hit) => ({ ...hit, source: engine })); } if (action === 'fetch' || action === 'navigate') { const result = await fetchPage(message, timeoutMs); if (action === 'navigate') { const shot = await snapshot(await currentPage()); return { ...shot, status: result.status, challenge: result.challenge, next_action: result.next_action, error: result.error }; } return result; } if (action === 'snapshot') return snapshot(tab); if (action === 'click') { await snapshot(tab); const target = await locate(tab, message); if (!target) throw new Error('click target not found'); await target.click({ timeout: Math.min(8000, timeoutMs) }); await tab.waitForLoadState('domcontentloaded', { timeout: 8000 }).catch(() => {}); return snapshot(tab); } if (action === 'type') { const value = String(message.text || ''); await snapshot(tab); const target = await locate(tab, { ...message, action: 'type' }); if (target) { await target.click({ timeout: 4000 }).catch(() => {}); await target.fill(value, { timeout: Math.min(8000, timeoutMs) }).catch(async () => { await tab.keyboard.type(value, { delay: 20 }); }); } else { await tab.keyboard.type(value, { delay: 20 }); } if (message.submit) await tab.keyboard.press('Enter'); return snapshot(tab); } if (action === 'press') { const key = String(message.key || message.combo || '').trim(); if (!key) throw new Error('key required'); await tab.keyboard.press(key); return snapshot(tab); } if (action === 'scroll') { await tab.mouse.wheel(Number(message.dx) || 0, Number(message.dy) || 600); return snapshot(tab); } if (action === 'wait') { const ms = Math.min(20_000, Math.max(0, Number(message.ms) || 1000)); await tab.waitForTimeout(ms); if (message.text) await tab.getByText(String(message.text), { exact: false }).first().waitFor({ timeout: timeoutMs }).catch(() => {}); return snapshot(tab); } throw new Error(`unsupported browser action ${action}`); } async function main() { const opened = await launchBrowser(); process.stdout.write(`${JSON.stringify({ type: 'ready', headless: opened.headless, extensions: opened.extensions, homepage: SEARCH_HOME })}\n`); const rl = readline.createInterface({ input: process.stdin }); rl.on('close', () => { shuttingDown = true; }); for await (const line of rl) { if (!line.trim()) continue; let message; try { message = JSON.parse(line); } catch { continue; } const id = message.id; try { const result = await handle(message); relaunchCount = 0; reply(id, { ok: true, result }); } catch (error) { fail(id, error); } } shuttingDown = true; } main().catch((error) => { process.stdout.write(`${JSON.stringify({ type: 'error', reason: String(error && error.message || error) })}\n`); process.exit(1); });