350 lines
14 KiB
JavaScript
350 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
/** Node Playwright sidecar. The Bare daemon never loads Playwright itself. */
|
|
import { createRequire } from 'node:module';
|
|
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_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) => `https://duckduckgo.com/?q=${encodeURIComponent(q)}`,
|
|
ddg_lite: (q) => `https://duckduckgo.com/?q=${encodeURIComponent(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);
|
|
}
|
|
|
|
async function pageText(page) {
|
|
const text = await page.locator('body').innerText({ timeout: 2000 }).catch(() => '');
|
|
return String(text || '').replace(/\n{3,}/g, '\n\n').trim().slice(0, 6000);
|
|
}
|
|
|
|
async function snapshot(page) {
|
|
let aria = '';
|
|
try {
|
|
aria = await page.locator('body').ariaSnapshot({ timeout: 2000 });
|
|
} catch {
|
|
aria = '';
|
|
}
|
|
const text = await pageText(page);
|
|
const title = await page.title().catch(() => '');
|
|
const refs = await page.evaluate(() => {
|
|
const items = [];
|
|
const nodes = document.querySelectorAll('a, button, input, textarea, select, [role="button"], [role="link"], [contenteditable="true"]');
|
|
let i = 1;
|
|
for (const el of nodes) {
|
|
if (items.length >= 80) 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, href: el.href || undefined });
|
|
i += 1;
|
|
}
|
|
return items;
|
|
});
|
|
const result = { url: page.url(), title, refs, text, aria: String(aria || '').slice(0, 4000) };
|
|
if (challengeText(title, 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;
|
|
|
|
async function currentPage() {
|
|
if (page && !page.isClosed()) return page;
|
|
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 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;
|
|
context = await chromium.launchPersistentContext(profile, {
|
|
headless,
|
|
viewport: { width: 1280, height: 800 },
|
|
args: ['--disable-blink-features=AutomationControlled'],
|
|
});
|
|
page = context.pages()[0] || await context.newPage();
|
|
process.stdout.write(`${JSON.stringify({ type: 'ready', headless })}\n`);
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
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);
|
|
reply(id, { ok: true, result });
|
|
} catch (error) {
|
|
fail(id, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
process.stdout.write(`${JSON.stringify({ type: 'error', reason: String(error && error.message || error) })}\n`);
|
|
process.exit(1);
|
|
});
|