/** * Headless-browser render worker — runs in a DEDICATED CHILD PROCESS. * * This is the isolation boundary for the bot-evasion tier. It is spawned by * browser.ts (see `renderWithBrowser`) with a single JSON request in argv[2], * launches a sandboxed headless Chromium, renders one URL, and prints exactly one * JSON result line to stdout before exiting. Nothing but that string crosses back * to the pi agent process. * * ISOLATION GUARANTEES: * - Page JavaScript executes ONLY in Chromium's renderer process (its own OS * process, behind Chromium's sandbox). It never runs in this Node process and * never reaches the agent. We obtain the DOM via Playwright's built-in * `page.content()` — we NEVER pass a page-derived/untrusted string into * `page.evaluate`, which is the one and only way page script could reach V8. * - The Chromium sandbox is kept ON (`chromiumSandbox: true`) unless the global * `browserNoSandbox` flag is set. On a sandbox launch failure we fail loudly * with setup guidance — we never silently drop the sandbox. * - SSRF parity: the browser does its own DNS and follows redirects, bypassing * the Node fetch guard, so every request is re-validated in a route handler * using the SAME `hostResolvesToBlocked` predicate as safe-fetch.ts. */ import { classifyBlock, hostResolvesToBlocked, MAX_SNIFF_BYTES } from "./safe-fetch.ts"; interface Request { url: string; timeoutMs: number; allowPrivateNetwork: boolean; maxBytes: number; noSandbox: boolean; userAgent?: string; acceptLanguage?: string; } interface Result { html?: string; blocked?: true; status?: number; blockReason?: string; error?: string; unavailable?: boolean; sandboxFailure?: boolean; } function isSandboxLaunchError(msg: string): boolean { return /no usable sandbox|namespace|clone|CLONE_NEW|setuid|chrome-sandbox|operation not permitted/i.test(msg); } function isMissingLibError(msg: string): boolean { return /error while loading shared libraries|cannot open shared object|libnss3|libnspr4|libatk|libgbm|libgtk/i.test(msg); } const MISSING_LIB_GUIDANCE = "Chromium is installed but is missing system libraries (e.g. libnss3/libnspr4) and cannot start. " + "Install Chromium's OS dependencies once: `sudo npx playwright install-deps chromium` (run from the " + "extension dir), or on Debian/Ubuntu/WSL: `sudo apt-get install -y libnss3 libnspr4 libatk1.0-0 " + "libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 " + "libgbm1 libasound2`."; const SANDBOX_GUIDANCE = "Chromium failed to launch with its sandbox enabled. On WSL2/containers this usually means " + "unprivileged user namespaces are disabled. Enable them (e.g. `sysctl -w kernel.unprivileged_userns_clone=1`, " + "or on newer kernels `sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`), or — only if you accept that " + "this removes the renderer sandbox that contains hostile page JS — set \"browserNoSandbox\": true in the global config.json."; async function render(req: Request): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any let chromium: any; try { ({ chromium } = await import("playwright")); } catch { return { unavailable: true, error: "playwright not installed" }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any let browser: any; try { browser = await chromium.launch({ headless: true, chromiumSandbox: !req.noSandbox, args: [ "--disable-dev-shm-usage", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--disable-extensions", "--disable-background-networking", "--mute-audio", ], }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); if (isMissingLibError(msg)) return { error: MISSING_LIB_GUIDANCE }; if (isSandboxLaunchError(msg)) return { sandboxFailure: true, error: SANDBOX_GUIDANCE }; return { error: `chromium launch failed: ${msg}` }; } try { const context = await browser.newContext({ acceptDownloads: false, locale: "en-US", userAgent: req.userAgent, viewport: { width: 1280, height: 800 }, }); context.setDefaultNavigationTimeout(req.timeoutMs); context.setDefaultTimeout(req.timeoutMs); // One interceptor, two jobs: drop heavy sub-resources, and SSRF-re-validate // EVERY request (main navigation, each redirect hop, every sub-resource). // eslint-disable-next-line @typescript-eslint/no-explicit-any await context.route("**/*", async (route: any) => { const r = route.request(); const type = r.resourceType(); if (type === "image" || type === "media" || type === "font") return route.abort(); let u: URL; try { u = new URL(r.url()); } catch { return route.abort(); } if (u.protocol !== "http:" && u.protocol !== "https:") return route.abort(); if (await hostResolvesToBlocked(u.hostname, req.allowPrivateNetwork)) return route.abort(); return route.continue(); }); const page = await context.newPage(); const resp = await page.goto(req.url, { waitUntil: "domcontentloaded", timeout: req.timeoutMs }); const status: number = resp?.status() ?? 200; const headers: Record = resp?.headers() ?? {}; // Bounded settle: let a JS interstitial (e.g. Cloudflare) resolve itself. let html: string = await page.content(); let cls = classifyBlock(status, headers, html.slice(0, MAX_SNIFF_BYTES)); const settleDeadline = Date.now() + Math.min(req.timeoutMs, 12_000); while (cls.blocked && Date.now() < settleDeadline) { await page.waitForTimeout(1000); html = await page.content(); cls = classifyBlock(200, {}, html.slice(0, MAX_SNIFF_BYTES)); } if (cls.blocked) { return { blocked: true, status, blockReason: cls.reason, error: `blocked: ${cls.reason ?? "bot protection"} (rendered)` }; } if (Buffer.byteLength(html, "utf8") > req.maxBytes) { return { error: `rendered page too large (> ${req.maxBytes} bytes)` }; } return { html }; } catch (e) { return { error: `browser render failed: ${e instanceof Error ? e.message : String(e)}` }; } finally { try { await browser.close(); } catch { /* ignore */ } } } async function main(): Promise { let result: Result; try { const req = JSON.parse(process.argv[2] ?? "") as Request; result = await render(req); } catch (e) { result = { error: `browser worker: ${e instanceof Error ? e.message : String(e)}` }; } // Single, final write — the parent reads exactly this line. process.stdout.write(`${JSON.stringify(result)}\n`, () => process.exit(0)); setTimeout(() => process.exit(0), 2000).unref(); } void main();