import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { standardFontsDir } from "./pdf-ops"; /** * Verification engines for sub-agents: demos use the app's own Electron * browser-service via puppeteer-core, with system Chrome as fallback. * SVGs use resvg (browser fallback); PDFs use pdfjs + Skia (no browser). * No extra Chromium is shipped or downloaded. * * Chrome fallback resolution: CHROME_PATH first, then common macOS, * Windows and Linux install paths. `available()` is a cheap executable * check; the helper must still prove it can launch/connect at job time. * * Test seam: `headlessOverride.current` replaces the whole API (tests inject * a fake; nothing here touches a real browser). Builders call `getHeadless()`. */ export interface DemoRunResult { /** console.error / console messages of type "error". */ consoleErrors: string[]; /** Uncaught exceptions (window.onerror / unhandled rejections). */ pageErrors: string[]; /** Network requests that failed (CDN scripts that didn't load, …). */ failedRequests: string[]; /** Value of the optional probe expression, when requested and successful. */ probeResult?: unknown; /** True when nothing above recorded anything. */ ok: boolean; } export interface RunDemoOptions { /** How long to let the page settle (animations, async CDN loads) — default 2s. */ settleMs?: number; /** Optional JS expression evaluated in the page after settling; its value * is returned to the builder so it can assert on internal state. */ probe?: string; } export interface RenderSvgOptions { /** Viewport size — defaults to the SVG's width/height attrs, else 900×700. */ width?: number; height?: number; } export interface RenderPdfOptions { /** Render DPI — 72 is 100% zoom; the skill's guidance is 100/150/200. */ dpi?: number; } export interface RenderedPdfPage { page: number; png: Buffer; } export interface HeadlessApi { /** True when a browser helper or Chrome is resolvable (cheap, no launch). */ available(): boolean; /** Why not — for error messages. */ unavailableReason(): string | null; /** Execute HTML in a browser, capture errors, optionally probe. */ runDemo(html: string, opts?: RunDemoOptions): Promise; /** Rasterize an SVG to a PNG buffer (for verify-by-looking). */ renderSvg(svg: string, opts?: RenderSvgOptions): Promise; /** Render PDF pages to PNGs natively (pdfjs + Skia) for the pdf-reader. */ renderPdfPages(pdfPath: string, pages: number[], opts?: RenderPdfOptions): Promise; } /** Test seam — undefined in production. */ export const headlessOverride: { current?: HeadlessApi } = {}; export function getHeadless(): HeadlessApi { return headlessOverride.current ?? realHeadless; } // ─── Chrome resolution ─────────────────────────────────────────────────────── const CHROME_CANDIDATES = [ ...(process.platform === "win32" ? [ path.join(process.env.PROGRAMFILES ?? "C:\\Program Files", "Google", "Chrome", "Application", "chrome.exe"), path.join(process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)", "Google", "Chrome", "Application", "chrome.exe"), ...(process.env.LOCALAPPDATA ? [path.join(process.env.LOCALAPPDATA, "Google", "Chrome", "Application", "chrome.exe")] : []), ] : []), "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser", ]; export function resolveChromeExecutablePath(): string | null { const candidates = [process.env.CHROME_PATH, ...CHROME_CANDIDATES].filter( (p): p is string => typeof p === "string" && p.length > 0, ); for (const candidate of candidates) { try { if (fs.existsSync(candidate)) return candidate; } catch { /* not readable — try the next */ } } return null; } const NO_CHROME_MESSAGE = "No render engine available: the app's own browser helper failed to start and no system Chrome/Chromium was found. " + "Install Google Chrome or set CHROME_PATH to a Chrome executable. " + "(Verification is mandatory — demos are never delivered untested.)"; // ─── The app's own Chromium as a browser service (Wave E stage 3) ──────────── // Demos execute JS and need a browser. PRIMARY: this very app's binary // spawned with --browser-service (main.cjs boots a hidden window with a CDP // port and prints "BROWSER_SERVICE_CDP "). FALLBACK: system Chrome. // A Node IPC channel owns its lifetime: parent death/disconnect quits the // helper on every OS (Windows Electron stdin is synthetic EOF). No orphans. // PDFs and SVGs never come here (native now). /** Handshake line parser (pure; exported for tests). */ export function parseBrowserServiceLine(line: string): number | null { const m = line.match(/^BROWSER_SERVICE_CDP (\d+)$/); if (!m) return null; const port = Number(m[1]); return port >= 1 && port <= 65535 ? port : null; } /** Spawn env sanitizer (pure; exported for tests): the helper must run as a * FULL Electron app — ELECTRON_RUN_AS_NODE must not leak — and must not * inherit the dev-shell userData redirect. */ export function browserServiceSpawnEnv( env: Record, ): Record { const out: Record = { ...env }; delete out.ELECTRON_RUN_AS_NODE; delete out.LH_DEV_URL; // The artifact renderer is not an authorized app API client. delete out.LH_DESKTOP_API_SECRET; delete out.LH_DESKTOP_SERVER; return out; } /** The helper binary: inside the packaged app the server runs under * ELECTRON_RUN_AS_NODE so process.execPath IS the app binary; in dev * (plain node) the electron package resolves to the dev binary path. */ export function browserServiceBinary(): string | null { if (process.versions.electron) return process.execPath; try { const req = createRequire(import.meta.url); const p = req("electron") as unknown as string; return typeof p === "string" && p.length > 0 ? p : null; } catch { return null; } } const BROWSER_SERVICE_TIMEOUT_MS = 15_000; let browserServiceProc: import("node:child_process").ChildProcess | null = null; async function connectBrowserService(): Promise { const puppeteer = (await import("puppeteer-core")).default; const { spawn } = await import("node:child_process"); let port: number | null = null; if (browserServiceProc && browserServiceProc.exitCode === null) { // Helper alive from a previous run — reuse its endpoint. port = (browserServiceProc as { __cdpPort?: number }).__cdpPort ?? null; } if (port === null) { const binary = browserServiceBinary(); if (!binary) return null; // Packaged: execPath runs the bundled app (flag reaches main.cjs). // Dev: the bare electron binary needs the repo root as the app arg — // flagless it would open Electron's default app instead of our shell. const appArgs = process.versions.electron ? ["--browser-service"] : [ // src/agent/subagents → repo root path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."), "--browser-service", ]; const proc = spawn(binary, appArgs, { // Cast: the sanitizer is pure over a loose record; NODE_ENV is // always present in a real process.env. env: browserServiceSpawnEnv(process.env) as NodeJS.ProcessEnv, stdio: ["ignore", "pipe", "pipe", "ipc"] as const, }); browserServiceProc = proc; port = await new Promise((resolve) => { let buf = ""; const timer = setTimeout(() => { proc.kill(); resolve(null); }, BROWSER_SERVICE_TIMEOUT_MS); const onData = (chunk: Buffer) => { buf += chunk.toString("utf8"); const line = buf.split("\n").find((l) => parseBrowserServiceLine(l) !== null); if (line !== undefined) { clearTimeout(timer); resolve(parseBrowserServiceLine(line)); } }; proc.stdout!.on("data", onData); proc.once("exit", () => { clearTimeout(timer); resolve(null); }); }); if (port === null) { browserServiceProc = null; return null; } (proc as { __cdpPort?: number }).__cdpPort = port; } try { const version = await fetch(`http://127.0.0.1:${port}/json/version`); const info = (await version.json()) as { webSocketDebuggerUrl?: string }; if (!info.webSocketDebuggerUrl) return null; // connect ≠ launch: browser.close() below merely disconnects — the // helper stays resident for the next job (IPC-owned lifetime). return await puppeteer.connect({ browserWSEndpoint: info.webSocketDebuggerUrl, defaultViewport: null, }); } catch { return null; } } /** Serialized helper-page use: the helper owns ONE page target (Electron's * CDP has no Target.createTarget — pages must be windows the helper made), * so concurrent jobs queue here instead of fighting over one webContents. */ let helperPageChain: Promise = Promise.resolve(); async function withBrowserPage(fn: (page: import("puppeteer-core").Page) => Promise): Promise { // Helper first (owner ruling 2026-10-10: the app's own Chromium primary, // system Chrome fallback) — deterministic across users, zero install. const helper = await connectBrowserService(); if (helper) { const run = async (): Promise => { const pages = await helper.pages(); const page = pages.find((p) => p.target().type() === "page"); if (!page) throw new Error("browser service: no page target"); try { return await fn(page); } finally { // Drop all page state (JS heap, listeners) between jobs. await page.goto("about:blank").catch(() => {}); } }; const next = helperPageChain.then(run, run); helperPageChain = next.catch(() => {}); return next; } // Chrome fallback: per-job launch, close on the way out (old semantics). const puppeteer = (await import("puppeteer-core")).default; const executablePath = resolveChromeExecutablePath(); const browser = await puppeteer.launch({ headless: true, // Explicit path when we found one; otherwise let puppeteer resolve the // system Chrome install itself via the "chrome" channel. ...(executablePath ? { executablePath } : { channel: "chrome" as const }), args: ["--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage"], }); try { const page = await browser.newPage(); return await fn(page); } finally { await browser.close().catch(() => {}); } } /** A temp dir per invocation; removed in finally. */ function tempDir(prefix: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } // ─── Demo execution ────────────────────────────────────────────────────────── async function runDemo(html: string, opts: RunDemoOptions = {}): Promise { const settleMs = opts.settleMs ?? 2000; const dir = tempDir("lh-demo-"); const file = path.join(dir, "demo.html"); fs.writeFileSync(file, html, "utf-8"); const consoleErrors: string[] = []; const pageErrors: string[] = []; const failedRequests: string[] = []; let probeResult: unknown; let probed = false; try { await withBrowserPage(async (page) => { page.on("console", (msg) => { if (msg.type() === "error") consoleErrors.push(msg.text()); }); page.on("pageerror", (err) => pageErrors.push(err instanceof Error ? err.message : String(err))); page.on("requestfailed", (req) => failedRequests.push(`${req.url()} (${req.failure()?.errorText ?? "failed"})`)); try { await page.goto(pathToFileURL(file).href, { waitUntil: "load", timeout: 30_000 }); await new Promise((resolve) => setTimeout(resolve, settleMs)); if (opts.probe) { try { probeResult = await page.evaluate(opts.probe); } catch (err) { pageErrors.push(`probe failed: ${err instanceof Error ? err.message : String(err)}`); } probed = true; } } catch (err) { pageErrors.push(err instanceof Error ? err.message : String(err)); } }); } finally { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } } return { consoleErrors, pageErrors, failedRequests, ...(probed ? { probeResult } : {}), ok: consoleErrors.length === 0 && pageErrors.length === 0 && failedRequests.length === 0, }; } // ─── SVG rasterization ─────────────────────────────────────────────────────── function svgViewport(svg: string, opts: RenderSvgOptions): { width: number; height: number } { if (opts.width && opts.height) return { width: opts.width, height: opts.height }; const width = Number(svg.match(/\bwidth\s*=\s*"?(\d+(?:\.\d+)?)/)?.[1]); const height = Number(svg.match(/\bheight\s*=\s*"?(\d+(?:\.\d+)?)/)?.[1]); return { width: Math.ceil(Number.isFinite(width) && width > 0 ? width : 900), height: Math.ceil(Number.isFinite(height) && height > 0 ? height : 700), }; } /** Canonicalize a raw Chrome screenshot for the model's eyes (2026-09-19 fix). * * WHY: Chrome writes PNGs with a SPLIT IDAT stream (4KB chunks) — and * Alibaba's vision decoder (behind OpenRouter's qwen3.8-flash, the * visual-maker's look-model) rejected those outright during the * 2026-09-18/19 window: "The image format is illegal and cannot be opened", * deterministically, at every size (50–800px) — while every single-IDAT * form passed (a handcrafted 1×1, and sharp re-encodes). On a later re-test * raw Chrome PNGs passed too (the upstream decoder was fixed or is flaky * per backend), but the canonical form stays: PNG spec says the IDAT chunks * are an arbitrary SPLIT of one zlib stream, so merging them is the more * standard shape, byte-identical stream, zero re-encoding. * * Pure Node on purpose (no sharp): historically the process already carried * a native image lib via @huggingface/transformers' nested sharp + libvips, * and importing the root sharp (Next's optional dependency) put a SECOND * libvips into the process — macOS's ObjC runtime warns (duplicate * GNotificationCenterDelegate, "mysterious crashes"). The transformers * embedder died with wave 6 (2026-09-23) but the rule lives on: one native * image lib per process; app code carries none. */ export function providerSafePng(png: Buffer): Buffer { const SIG_LEN = 8; if (png.length < SIG_LEN + 12 || png.subarray(0, SIG_LEN).toString("hex") !== "89504e470d0a1a0a") return png; // Walk the chunks once, merging the IDAT split into its canonical // single-chunk form (first IDAT's position, concatenated payload). const parts: Array<{ type: string; data: Buffer }> = []; let off = SIG_LEN; while (off + 12 <= png.length) { const len = png.readUInt32BE(off); if (off + 12 + len > png.length) break; // truncated tail — leave as-is parts.push({ type: png.subarray(off + 4, off + 8).toString("ascii"), data: png.subarray(off + 8, off + 8 + len) }); off += 12 + len; } const idats = parts.filter((p) => p.type === "IDAT"); if (idats.length < 2) return png; // already canonical const merged = Buffer.concat(idats.map((p) => p.data)); let emittedIdat = false; const out: Buffer[] = [png.subarray(0, SIG_LEN)]; for (const p of parts) { if (p.type !== "IDAT") out.push(chunk(p.type, p.data)); else if (!emittedIdat) { out.push(chunk("IDAT", merged)); emittedIdat = true; } } return Buffer.concat(out); } /** One PNG chunk (length + type + data + CRC32) — the spec's exact layout. */ function chunk(type: string, data: Buffer): Buffer { const out = Buffer.alloc(12 + data.length); out.writeUInt32BE(data.length, 0); out.write(type, 4, "ascii"); data.copy(out, 8); out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length); return out; } /** CRC-32 (PNG's polynomial, table-less) for chunk checksums. */ function crc32(buf: Buffer): number { let c = ~0; for (const byte of buf) { c ^= byte; for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); } return ~c >>> 0; } async function renderSvg(svg: string, opts: RenderSvgOptions = {}): Promise { // NATIVE FIRST (Wave E stage 2, owner 2026-10-10): resvg rasterizes // static SVG in-process — visual-maker SVGs never execute script, so a // browser adds nothing but a process. White background mirrors the old // Chrome page (html,body{background:#fff}); fitTo width mirrors // svgViewport semantics (attrs win, viewBox-only gets the 900 default, // opts override). The browser path below stays as the fallback for // exotic-SVG features resvg cannot parse — failures there used to be // the ONLY path, so nothing regresses. const { width } = svgViewport(svg, opts); try { const { Resvg } = await import("@resvg/resvg-js"); const resvg = new Resvg(svg, { fitTo: { mode: "width", value: width }, font: { loadSystemFonts: true }, background: "#ffffff", }); return providerSafePng(resvg.render().asPng()); } catch { /* fall through to the browser path */ } const dir = tempDir("lh-visual-"); const file = path.join(dir, "figure.html"); fs.writeFileSync( file, `${svg}`, "utf-8", ); return withBrowserPage(async (page) => { const { width, height } = svgViewport(svg, opts); await page.setViewport({ width, height }); await page.goto(pathToFileURL(file).href, { waitUntil: "load", timeout: 30_000 }); const shot = (await page.screenshot({ fullPage: true, type: "png" })) as Buffer; // Chrome's raw split-IDAT PNG is canonicalized before it reaches a // provider (see providerSafePng) — the visual-maker feeds this exact // buffer back to the vision model as its only way to "look". return providerSafePng(Buffer.from(shot)); }).finally(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } }); } // ─── PDF page rasterization (pdfjs paints, Skia hosts) ───────────────── /** Render PDF pages to PNGs — the sources-v0 pdf-reader's eyes. * * History: pdfjs ALWAYS painted the pages (scale = dpi/72, the skill's * zoom arithmetic) — first inside a headless Chrome tab (canvas shim, * toDataURL, 2026-09-19), now into @napi-rs/canvas in this process (Wave * E, 2026-10-10). The paint engine never changed; only the canvas host: * Skia in-process via pdfjs-dist v6's own Node integration. No browser, * no temp files, no base64 hop. */ async function renderPdfPages( pdfPath: string, pages: number[], opts: RenderPdfOptions = {}, ): Promise { const dpi = opts.dpi ?? 150; // NATIVE CANVAS (Wave E, owner 2026-10-10): pdfjs paints into // @napi-rs/canvas in this process — no browser involved. pdfjs-dist v6's // own Node integration IS @napi-rs/canvas (its optionalDependency: the // NodeCanvasFactory requires it), so the paint engine is the exact code // that used to run in the Chrome shim tab; only the canvas host changed // (Skia in-process instead of a DOM canvas). Text-side pdf tools never // needed a browser; now renders don't either. const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); const { createCanvas } = await import("@napi-rs/canvas"); const data = new Uint8Array(fs.readFileSync(path.resolve(pdfPath))); // Standard-font data as a directory path (live-smoke finding 2026-09-19: // without it pdfjs renders non-embedded standard fonts via fallback); // verbosity 0 = errors only (JBIG2 noise on scanned PDFs). const loadingTask = pdfjs.getDocument({ data, standardFontDataUrl: standardFontsDir(), verbosity: 0 }); const doc = await loadingTask.promise; try { const out: RenderedPdfPage[] = []; for (const pageNo of pages) { if (pageNo < 1 || pageNo > doc.numPages) continue; const pg = await doc.getPage(pageNo); const vp = pg.getViewport({ scale: dpi / 72 }); const canvas = createCanvas(Math.ceil(vp.width), Math.ceil(vp.height)); const ctx = canvas.getContext("2d"); // pdfjs's render types speak the DOM context; @napi-rs/canvas's // context is the same 2D API implemented over Skia. await pg.render({ canvasContext: ctx as unknown as CanvasRenderingContext2D, canvas: canvas as unknown as HTMLCanvasElement, viewport: vp, }).promise; out.push({ page: pageNo, // Skia-encoded PNG → providerSafePng for the same single-IDAT // canonical form every other vision-model image gets. png: providerSafePng(canvas.toBuffer("image/png")), }); } return out; } finally { await loadingTask.destroy().catch(() => {}); } } // ─── the real API ──────────────────────────────────────────────────────────── export const realHeadless: HeadlessApi = { available() { // The helper binary exists by construction in the packaged app; in dev // it resolves via the electron package. System Chrome widens coverage. return browserServiceBinary() !== null || resolveChromeExecutablePath() !== null; }, unavailableReason() { return browserServiceBinary() === null && resolveChromeExecutablePath() === null ? NO_CHROME_MESSAGE : null; }, runDemo, renderSvg, renderPdfPages, };