diff --git a/package.json b/package.json index da6092199..66a3a94a6 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,11 @@ "private": true, "type": "module", "scripts": { - "test": "npm run test:provider-limits && npm run test:toolbar-guard && npm run test:pdf-selection && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security", + "test": "npm run test:provider-limits && npm run test:toolbar-guard && npm run test:pdf-read && npm run test:pdf-selection && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security", "test:provider-limits": "node test/provider-model-limits.mjs", "test:security": "node test/security/injection-corpus.mjs", "test:toolbar-guard": "node test/rich-text-toolbar-guard.mjs", + "test:pdf-read": "node test/pdf-read.mjs", "test:pdf-selection": "node test/pdf-selection.mjs", "test:pdf-mime-handler": "node test/pdf-mime-handler-e2e.mjs", "test:injection-bench": "node test/llm/run-scenarios.mjs --category prompt-injection", diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 66bda5e3a..b97223137 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -27779,27 +27779,30 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return { success: false, error: 'read_pdf: no url provided and could not read the active tab URL.' }; } + const provider = this._activeProvider(tabId); + const supportsPdfPassthrough = providerSupportsPdfPassthrough(provider); const result = await extractPdfText(pdfUrl, { fromPage: args.fromPage, toPage: args.toPage, maxChars: args.maxChars, + includeDocument: supportsPdfPassthrough, }); // Tier 2 — Anthropic Claude PDF passthrough. If the active provider // can natively consume PDFs as a `document` content block AND the - // file fits under the size cap, attach the raw bytes via - // `_attachDocument`. The batch loop strips this field before + // file fits under the size cap, attach base64 encoded from the same + // bytes used for text extraction via `_attachDocument`. The batch + // loop strips this private field before // stringifying the tool result and pushes the document as a // follow-up user message (analogous to the `_attachImage` path // used by `screenshot`). - const provider = this._activeProvider(tabId); - const bytes = result._pdfBytes; - delete result._pdfBytes; + const pdfBase64 = result._pdfBase64; + delete result._pdfBase64; if ( - bytes && - providerSupportsPdfPassthrough(provider) && - bytes.length <= PDF_PASSTHROUGH_MAX_BYTES + pdfBase64 && + supportsPdfPassthrough && + result.byteLength <= PDF_PASSTHROUGH_MAX_BYTES ) { let docName = result.title || ''; if (!docName) { @@ -27808,11 +27811,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d docName = decodeURIComponent(u.pathname.split('/').pop() || 'document.pdf'); } catch { docName = 'document.pdf'; } } - const docBlock = buildClaudeDocumentBlock(bytes, docName); + const docBlock = buildClaudeDocumentBlock(pdfBase64, docName); return { ...result, method: 'pdf_text+claude_document', - description: `PDF text extracted (${result.pageCount} pages); raw bytes also attached as Claude document block for full-fidelity reading.`, + description: `PDF text extracted (${result.pageCount} pages); the same PDF bytes are also attached as a Claude document block for full-fidelity reading.`, _attachDocument: docBlock, }; } diff --git a/src/chrome/src/agent/pdf-extraction.js b/src/chrome/src/agent/pdf-extraction.js new file mode 100644 index 000000000..27c951545 --- /dev/null +++ b/src/chrome/src/agent/pdf-extraction.js @@ -0,0 +1,182 @@ +/** + * Browser-neutral PDF fetching and text extraction helpers. + * + * PDF.js itself is loaded by an extension page, not by the MV3 service + * worker. Keeping the extraction loop here lets the offscreen host own the + * browser-only PDF.js runtime while the agent facade remains lightweight. + */ + +export const PDF_EXTRACTION_MESSAGE = 'offscreen-pdf-extract'; +export const PDF_EXTRACTION_READY_MESSAGE = 'offscreen-pdf-extract-ready'; +export const PDF_PASSTHROUGH_MAX_BYTES = 16 * 1024 * 1024; + +const ALLOWED_PDF_PROTOCOLS = new Set(['http:', 'https:', 'file:']); +const DEFAULT_PDF_HANDLER_PAGE = 'src/ui/pdf-handler.html'; +const BASE64_MAX_INPUT_BYTES = 32 * 1024 * 1024; + +// Derived from the manifest for the same reason as the background path below: +// renaming the viewer page would otherwise make read_pdf report a bogus +// "must use http:, https:, or file:" on our own PDF tabs. +function pdfHandlerPage(runtime) { + const manifest = typeof runtime?.getManifest === 'function' ? runtime.getManifest() : null; + return manifest?.mime_types_handler?.['application/pdf']?.handler_url || DEFAULT_PDF_HANDLER_PAGE; +} + +// When the native MIME handler owns a PDF tab, the tab URL is our own viewer +// page wrapping the real URL in ?url=. read_pdf falls back to the tab URL when +// called without an explicit one, so unwrap it before the scheme check. +function unwrapPdfHandlerUrl(url, runtime = globalThis.chrome?.runtime) { + if (url.protocol !== 'chrome-extension:' && url.protocol !== 'moz-extension:') return url; + if (typeof runtime?.getURL !== 'function') return url; + let handler; + try { + handler = new URL(runtime.getURL(pdfHandlerPage(runtime))); + } catch { + return url; + } + if (url.origin !== handler.origin || url.pathname !== handler.pathname) return url; + const inner = url.searchParams.get('url'); + if (!inner) return url; + try { + return new URL(inner); + } catch { + return url; + } +} + +export function normalizePdfUrl(value, runtime = globalThis.chrome?.runtime) { + let url; + try { + url = new URL(String(value || '').trim()); + } catch { + throw new Error('PDF extraction requires a valid URL.'); + } + url = unwrapPdfHandlerUrl(url, runtime); + if (!ALLOWED_PDF_PROTOCOLS.has(url.protocol)) { + throw new Error('PDF URL must use http:, https:, or file:.'); + } + return url.href; +} + +// Derived from the manifest rather than hardcoded: a rename of the service +// worker entry would otherwise reject every extraction with a "not ready" +// error that points at the wrong subsystem. +function backgroundScriptPath(runtime) { + const manifest = typeof runtime?.getManifest === 'function' ? runtime.getManifest() : null; + return manifest?.background?.service_worker || 'src/background.js'; +} + +export function isTrustedPdfExtractionSender(sender, runtime = globalThis.chrome?.runtime) { + if (!runtime?.id || typeof runtime.getURL !== 'function') return false; + return sender?.id === runtime.id + && sender?.tab == null + && sender?.url === runtime.getURL(backgroundScriptPath(runtime)); +} + +export function bytesToBase64(bytes) { + if (bytes.length > BASE64_MAX_INPUT_BYTES) { + throw new Error(`PDF too large for base64 conversion (${bytes.length} bytes, cap ${BASE64_MAX_INPUT_BYTES}).`); + } + let binary = ''; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode.apply(null, bytes.subarray(offset, offset + chunkSize)); + } + return btoa(binary); +} + +export async function fetchPdfBytes(url, { timeoutMs = 60000 } = {}) { + const normalizedUrl = normalizePdfUrl(url); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + let response; + try { + response = await fetch(normalizedUrl, { credentials: 'include', signal: controller.signal }); + } catch (error) { + if (normalizedUrl.startsWith('file:')) { + throw new Error( + 'Cannot fetch local PDF from a file:// URL. WebBrain needs ' + + 'file-URL access in Chrome: open chrome://extensions, find ' + + 'WebBrain, click "Details", and enable "Allow access to file URLs". ' + + 'Then reload the PDF tab and try read_pdf again.' + ); + } + throw new Error(`PDF fetch failed: ${error.message}`); + } + if (!response.ok) { + throw new Error(`PDF fetch returned HTTP ${response.status} ${response.statusText}`); + } + return new Uint8Array(await response.arrayBuffer()); + } finally { + clearTimeout(timeout); + } +} + +export async function extractPdfTextFromBytes(pdfjs, bytes, opts = {}) { + const fromPage = Math.max(1, Math.floor(opts.fromPage || 1)); + const requestedTo = opts.toPage ? Math.floor(opts.toPage) : fromPage + 49; + const maxChars = Math.max(1000, Math.floor(opts.maxChars || 50000)); + + // PDF.js may transfer this buffer to its worker, detaching the caller's + // Uint8Array. Capture the length before getDocument() so metadata remains + // accurate after parsing. + const byteLength = bytes.length; + const loadingTask = pdfjs.getDocument({ + data: bytes, + verbosity: 0, + }); + const pdf = await loadingTask.promise; + + const totalPages = pdf.numPages; + const startPage = Math.min(fromPage, totalPages); + const endPage = Math.min(totalPages, Math.max(startPage, requestedTo)); + + let title = ''; + try { + const meta = await pdf.getMetadata(); + title = meta?.info?.Title || ''; + } catch { /* metadata is best-effort */ } + + const pages = []; + let charCount = 0; + let truncated = false; + let lastRead = startPage - 1; + + for (let pageNumber = startPage; pageNumber <= endPage; pageNumber++) { + const page = await pdf.getPage(pageNumber); + const content = await page.getTextContent(); + const pageText = content.items + .map(item => (item && typeof item.str === 'string' ? item.str : '')) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + + if (charCount + pageText.length > maxChars) { + const remaining = Math.max(0, maxChars - charCount); + pages.push(pageText.slice(0, remaining) + '… [page truncated, use read_pdf with fromPage to read more]'); + lastRead = pageNumber; + truncated = true; + page.cleanup?.(); + break; + } + + pages.push(pageText); + charCount += pageText.length; + lastRead = pageNumber; + page.cleanup?.(); + } + + return { + success: true, + title, + totalPages, + fromPage: startPage, + toPage: lastRead, + pageCount: pages.length, + pages, + hasExtractableText: pages.join('\n').length > 100, + truncated, + byteLength, + }; +} diff --git a/src/chrome/src/agent/pdf-tools.js b/src/chrome/src/agent/pdf-tools.js index aa0e183e3..7795af98a 100644 --- a/src/chrome/src/agent/pdf-tools.js +++ b/src/chrome/src/agent/pdf-tools.js @@ -23,40 +23,50 @@ * additional context. */ -let pdfjsModule = null; - -/** - * Lazy-load pdfjs only on first PDF read. The legacy bundle is ~1 MB - * and the worker is ~2.3 MB; we don't want to pay that startup cost - * for users who never open a PDF. - */ -async function getPdfjs() { - if (pdfjsModule) return pdfjsModule; - pdfjsModule = await import(chrome.runtime.getURL('vendor/pdfjs/pdf.mjs')); - // Worker URL must be set BEFORE the first getDocument() call. We resolve - // it via runtime.getURL so it works at any extension-id deploy target. - pdfjsModule.GlobalWorkerOptions.workerSrc = - chrome.runtime.getURL('vendor/pdfjs/pdf.worker.mjs'); - return pdfjsModule; +import { ensureOffscreen } from '../offscreen/ensure.js'; +import { + PDF_EXTRACTION_MESSAGE, + PDF_EXTRACTION_READY_MESSAGE, + PDF_PASSTHROUGH_MAX_BYTES, + bytesToBase64, +} from './pdf-extraction.js'; + +// chrome.offscreen.createDocument() resolves when the document exists, not +// when its module scripts have registered their listeners. Probe the PDF host +// for up to one second so the first read after a cold start cannot race it. +const PDF_HOST_READY_ATTEMPTS = 40; +const PDF_HOST_READY_RETRY_MS = 25; + +function wait(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); } -/** - * Cheap byte-array → base64 conversion that doesn't blow the call - * stack on multi-MB PDFs. fromCharCode.apply has a per-call argument - * limit (~64k in V8), so we chunk. - */ -const BASE64_MAX_INPUT_BYTES = 32 * 1024 * 1024; // 32 MB safety cap - -function bytesToBase64(bytes) { - if (bytes.length > BASE64_MAX_INPUT_BYTES) { - throw new Error(`PDF too large for base64 conversion (${bytes.length} bytes, cap ${BASE64_MAX_INPUT_BYTES}).`); - } - let bin = ''; - const chunk = 0x8000; - for (let i = 0; i < bytes.length; i += chunk) { - bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); +async function waitForPdfExtractionHost() { + let lastError = null; + for (let attempt = 0; attempt < PDF_HOST_READY_ATTEMPTS; attempt++) { + let refusal = null; + try { + const response = await chrome.runtime.sendMessage({ type: PDF_EXTRACTION_READY_MESSAGE }); + if (response?.ready === true) return; + // An explicit error means the host answered and refused. Retrying cannot + // change the outcome, and reporting it as "not ready" sends whoever is + // debugging to the wrong subsystem. + if (response?.error) refusal = new Error(response.error); + } catch (error) { + // No listener: the document went away between ensureOffscreen() seeing it + // and this probe. Recreate it rather than retrying into a dead channel. + lastError = error; + try { + await ensureOffscreen(); + } catch (ensureError) { + lastError = ensureError; + } + } + if (refusal) throw refusal; + if (attempt + 1 < PDF_HOST_READY_ATTEMPTS) await wait(PDF_HOST_READY_RETRY_MS); } - return btoa(bin); + const detail = lastError?.message ? ` ${lastError.message}` : ''; + throw new Error(`The offscreen PDF parser did not become ready.${detail}`); } /** @@ -79,41 +89,6 @@ export function isPdfUrl(url) { return false; } -/** - * Fetch the PDF binary from `url`. Returns a Uint8Array. - * Throws with a helpful message on failure — file:// URLs in Chrome - * require the user-toggle "Allow access to file URLs" at - * chrome://extensions, which we explain instead of leaving the - * agent guessing. - */ -export async function fetchPdfBytes(url) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 60000); - try { - let res; - try { - res = await fetch(url, { credentials: 'include', signal: controller.signal }); - } catch (e) { - if (typeof url === 'string' && url.startsWith('file://')) { - throw new Error( - 'Cannot fetch local PDF from a file:// URL. WebBrain needs ' + - 'file-URL access in Chrome: open chrome://extensions, find ' + - 'WebBrain, click "Details", and enable "Allow access to file URLs". ' + - 'Then reload the PDF tab and try read_pdf again.' - ); - } - throw new Error(`PDF fetch failed: ${e.message}`); - } - if (!res.ok) { - throw new Error(`PDF fetch returned HTTP ${res.status} ${res.statusText}`); - } - const buf = await res.arrayBuffer(); - return new Uint8Array(buf); - } finally { - clearTimeout(timeout); - } -} - /** * Extract text from a PDF. * @@ -130,91 +105,22 @@ export async function fetchPdfBytes(url) { * having to render every page to PNG ourselves. */ export async function extractPdfText(url, opts = {}) { - const fromPage = Math.max(1, Math.floor(opts.fromPage || 1)); - const requestedTo = opts.toPage ? Math.floor(opts.toPage) : fromPage + 49; - const maxChars = Math.max(1000, Math.floor(opts.maxChars || 50000)); - - const bytes = await fetchPdfBytes(url); - const pdfjs = await getPdfjs(); - - const loadingTask = pdfjs.getDocument({ - data: bytes, - // Suppress pdfjs's noisy console.warn for "non-embedded font fallback" etc. - // We surface real errors via the catch below. - verbosity: 0, + await ensureOffscreen(); + await waitForPdfExtractionHost(); + const response = await chrome.runtime.sendMessage({ + type: PDF_EXTRACTION_MESSAGE, + url, + options: { + fromPage: opts.fromPage, + toPage: opts.toPage, + maxChars: opts.maxChars, + includeBase64: opts.includeDocument === true, + }, }); - const pdf = await loadingTask.promise; - - const totalPages = pdf.numPages; - const startPage = Math.min(fromPage, totalPages); - const endPage = Math.min(totalPages, Math.max(startPage, requestedTo)); - - // Best-effort title from the document's metadata dictionary. - let title = ''; - try { - const meta = await pdf.getMetadata(); - title = meta?.info?.Title || ''; - } catch { /* ignore */ } - - const pages = []; - let charCount = 0; - let truncated = false; - // Last page actually read, so the truncation notice's "read more with - // fromPage" advice resolves to a page that was really covered. Reporting - // `endPage` after an early `break` would make a caller resume past the - // unread pages and silently lose them. - let lastRead = startPage - 1; - - for (let i = startPage; i <= endPage; i++) { - const page = await pdf.getPage(i); - const content = await page.getTextContent(); - - // pdfjs returns text items as a flat array with positional info. - // For LLM consumption we just join them with spaces — preserving - // exact layout would be more accurate but blows the token budget. - const pageText = content.items - .map((item) => (item && typeof item.str === 'string' ? item.str : '')) - .join(' ') - .replace(/\s+/g, ' ') - .trim(); - - if (charCount + pageText.length > maxChars) { - const remaining = Math.max(0, maxChars - charCount); - pages.push(pageText.slice(0, remaining) + '… [page truncated, use read_pdf with fromPage to read more]'); - lastRead = i; - truncated = true; - break; - } - - pages.push(pageText); - charCount += pageText.length; - lastRead = i; - - // Free per-page resources — pdfjs caches aggressively otherwise. - page.cleanup?.(); + if (!response?.ok || !response.result) { + throw new Error(response?.error || 'The offscreen PDF parser returned no result.'); } - - // Heuristic: <100 chars across the whole requested range almost certainly - // means the pages are scanned images with no text layer. Tell the model. - const hasExtractableText = pages.join('\n').length > 100; - - return { - success: true, - title, - totalPages, - fromPage: startPage, - toPage: lastRead, - pageCount: pages.length, - pages, - hasExtractableText, - truncated, - byteLength: bytes.length, - // The raw bytes are kept on `_pdfBytes` for the Tier 2 Claude - // passthrough path; the batch loop strips it before stringifying - // so the LLM doesn't see ~1 MB of base64 nonsense in the tool - // result text. - _pdfBytes: bytes, - }; + return response.result; } /** @@ -236,21 +142,24 @@ export function providerSupportsPdfPassthrough(provider) { } /** - * Build the `document` content block for the Anthropic Messages API - * from raw PDF bytes. Caller is responsible for size-checking — Claude's - * cap is ~32 MB base64 / ~24 MB binary as of writing, but we cap - * lower (16 MB binary) to leave room for the rest of the conversation. + * Build the `document` content block for the Anthropic Messages API from + * raw PDF bytes or base64 produced from those same bytes. Caller is + * responsible for size-checking — Claude's cap is ~32 MB base64 / ~24 MB + * binary as of writing, but we cap lower (16 MB binary) to leave room for + * the rest of the conversation. */ -export function buildClaudeDocumentBlock(bytes, name) { +export function buildClaudeDocumentBlock(bytesOrBase64, name) { return { type: 'document', source: { type: 'base64', media_type: 'application/pdf', - data: bytesToBase64(bytes), + data: typeof bytesOrBase64 === 'string' + ? bytesOrBase64 + : bytesToBase64(bytesOrBase64), }, ...(name ? { title: name } : {}), }; } -export const PDF_PASSTHROUGH_MAX_BYTES = 16 * 1024 * 1024; // 16 MB +export { PDF_PASSTHROUGH_MAX_BYTES }; diff --git a/src/chrome/src/offscreen/ensure.js b/src/chrome/src/offscreen/ensure.js index 15f5c9ab7..269a9801e 100644 --- a/src/chrome/src/offscreen/ensure.js +++ b/src/chrome/src/offscreen/ensure.js @@ -5,6 +5,7 @@ * and the set of `reasons` declared at createDocument time is fixed — you * cannot add reasons later. So both consumers of the offscreen document * (the localhost-fetch proxy in offscreen.js, local WebGPU inference worker, + * PDF text extraction in pdf-extraction-host.js, * offline SQLite/E5 retrieval workers, * large-file staging in * skill-download.js, Emergency Box downloads in emergency-download-host.js, @@ -46,7 +47,7 @@ const OFFSCREEN_REASONS = [ 'AUDIO_PLAYBACK', ]; const OFFSCREEN_JUSTIFICATION = - 'Proxy localhost requests; run local WebGPU models and offline reference search; stage validated large downloads; capture active tab and mic; maintain a localhost controller WebSocket; play conditional watch alerts.'; + 'Proxy localhost requests; parse PDFs; run local WebGPU models and offline reference search; stage validated large downloads; capture active tab and mic; maintain a localhost controller WebSocket; play conditional watch alerts.'; let ready = false; let inflight = null; diff --git a/src/chrome/src/offscreen/offscreen.html b/src/chrome/src/offscreen/offscreen.html index e42ffa64f..d53ba0b9f 100644 --- a/src/chrome/src/offscreen/offscreen.html +++ b/src/chrome/src/offscreen/offscreen.html @@ -4,6 +4,7 @@ + diff --git a/src/chrome/src/offscreen/pdf-extraction-host.js b/src/chrome/src/offscreen/pdf-extraction-host.js new file mode 100644 index 000000000..6287eb1bc --- /dev/null +++ b/src/chrome/src/offscreen/pdf-extraction-host.js @@ -0,0 +1,79 @@ +import { + PDF_EXTRACTION_MESSAGE, + PDF_EXTRACTION_READY_MESSAGE, + PDF_PASSTHROUGH_MAX_BYTES, + bytesToBase64, + extractPdfTextFromBytes, + fetchPdfBytes, + isTrustedPdfExtractionSender, + normalizePdfUrl, +} from '../agent/pdf-extraction.js'; + +const PDF_MESSAGE_TYPES = new Set([PDF_EXTRACTION_MESSAGE, PDF_EXTRACTION_READY_MESSAGE]); + +let pdfjsPromise = null; + +function getPdfjs() { + if (!pdfjsPromise) { + pdfjsPromise = import(chrome.runtime.getURL('vendor/pdfjs/pdf.mjs')).then((pdfjs) => { + pdfjs.GlobalWorkerOptions.workerSrc = + chrome.runtime.getURL('vendor/pdfjs/pdf.worker.mjs'); + return pdfjs; + }); + // The offscreen document outlives any single read, so never memoize a + // rejection: one transient import failure would otherwise make every + // later read_pdf fail until the document is torn down. + pdfjsPromise.catch(() => { pdfjsPromise = null; }); + } + return pdfjsPromise; +} + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!PDF_MESSAGE_TYPES.has(message?.type)) return false; + + // A 16 MB PDF answers with ~21 MB of base64, and an oversized or + // unserializable reply makes sendResponse throw. Without this guard that + // throw would reach the catch below and call sendResponse a second time on + // an already-closed channel, hiding the original failure. + let responded = false; + const respond = (payload) => { + if (responded) return; + responded = true; + try { + sendResponse(payload); + } catch (error) { + console.warn('[pdf-extraction-host] failed to deliver response', error); + } + }; + + if (!isTrustedPdfExtractionSender(sender)) { + respond({ ok: false, ready: false, error: 'Unauthorized PDF extraction sender.' }); + return false; + } + if (message.type === PDF_EXTRACTION_READY_MESSAGE) { + respond({ ok: true, ready: true }); + return false; + } + + (async () => { + const url = normalizePdfUrl(message.url); + const bytes = await fetchPdfBytes(url); + // PDF.js can transfer and detach the input buffer, so keep a copy of the + // optional Claude document taken before parsing: text and document then + // come from the same fetch. The ~4/3 base64 message overhead is + // intentional to preserve that single-fetch, byte-identical guarantee. + // The encode itself is deferred until parsing succeeds, so a corrupt PDF + // that throws in getDocument() does not pay for a string nobody reads. + const wantsBase64 = message.options?.includeBase64 === true + && bytes.length <= PDF_PASSTHROUGH_MAX_BYTES; + const passthrough = wantsBase64 ? bytes.slice() : null; + const pdfjs = await getPdfjs(); + const result = await extractPdfTextFromBytes(pdfjs, bytes, message.options || {}); + if (passthrough) result._pdfBase64 = bytesToBase64(passthrough); + respond({ ok: true, result }); + })().catch((error) => { + respond({ ok: false, error: error?.message || String(error) }); + }); + + return true; +}); diff --git a/src/chrome/vendor/pdfjs/README.md b/src/chrome/vendor/pdfjs/README.md index f62af8035..beb3d7d58 100644 --- a/src/chrome/vendor/pdfjs/README.md +++ b/src/chrome/vendor/pdfjs/README.md @@ -4,7 +4,7 @@ Mozilla PDF.js, used by `src/agent/pdf-tools.js` to extract text from PDFs the user is viewing in their browser. The Chrome PDF viewer is a `chrome-extension://` page that our content scripts can't inject into, so instead of trying to scrape the viewer's DOM we fetch the PDF -binary and parse it with pdfjs in the service worker. +binary and parse it with pdfjs in the shared offscreen document. ## Source @@ -31,7 +31,9 @@ differently in the worker context). Worth the size for the resilience. ## How it's loaded -`pdf-tools.js` does a lazy dynamic import on the first PDF read: +`src/offscreen/pdf-extraction-host.js` does a lazy dynamic import on the first +PDF read. PDF.js must run in an extension page because the Chrome MV3 service +worker rejects dynamic `import()`: ```js const pdfjs = await import(chrome.runtime.getURL('vendor/pdfjs/pdf.mjs')); @@ -44,8 +46,16 @@ pdfjs.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL('vendor/pdfjs/pdf.worker.mjs'); ``` -Both files are listed in `manifest.json`'s `web_accessible_resources` -so `chrome.runtime.getURL` returns a fetchable URL. +The Agent's `read_pdf` facade ensures the shared offscreen document, waits for +an explicit ready response, and then sends the PDF URL plus bounded page +options. The host accepts requests only from the extension's background +service worker and only fetches `http:`, `https:`, or `file:` URLs. For Claude +passthrough, it encodes the same fetched bytes before PDF.js transfers the +buffer to its worker, so text extraction and the document attachment cannot +diverge. Both PDF.js files are listed in `manifest.json`'s +`web_accessible_resources`, so `chrome.runtime.getURL` returns a fetchable URL +without loading the multi-megabyte modules during ordinary service-worker +startup. ## Updating diff --git a/test/pdf-mime-handler-e2e.mjs b/test/pdf-mime-handler-e2e.mjs index f470d991a..fa769bc7e 100644 --- a/test/pdf-mime-handler-e2e.mjs +++ b/test/pdf-mime-handler-e2e.mjs @@ -65,12 +65,14 @@ function createMinimalPdf() { async function startPdfServer() { const pdf = createMinimalPdf(); + let requestCount = 0; const server = createServer((request, response) => { const url = new URL(request.url || '/', 'http://127.0.0.1'); if (url.pathname !== '/document.pdf') { response.writeHead(404).end('not found'); return; } + requestCount += 1; response.writeHead(200, { 'content-type': pdfMimeType, 'content-length': String(pdf.byteLength), @@ -87,6 +89,8 @@ async function startPdfServer() { assert.ok(address && typeof address === 'object'); return { server, + pdf, + requestCount: () => requestCount, url: `http://127.0.0.1:${address.port}/document.pdf`, }; } @@ -159,6 +163,50 @@ async function main() { )); assert.equal(apiAvailable, true, `Chrome ${browser.version()} does not expose the public MIME handler options API.`); + const ensured = await settings.evaluate(async () => chrome.runtime.sendMessage({ + target: 'background', + action: 'ensure_offscreen_offline_rag_host', + })); + assert.equal(ensured?.ready, true, 'The background did not create the shared offscreen host.'); + + const rejected = await settings.evaluate(async url => chrome.runtime.sendMessage({ + type: 'offscreen-pdf-extract', + url, + options: { fromPage: 1, toPage: 1, maxChars: 5000 }, + }), fixture.url); + assert.equal(rejected?.ok, false, 'An extension page bypassed the background-only PDF extraction gate.'); + assert.match(rejected?.error || '', /Unauthorized PDF extraction sender/); + assert.equal(fixture.requestCount(), 0, 'An unauthorized PDF extraction request reached the network.'); + + const backgroundUrl = `chrome-extension://${extensionId}/src/background.js`; + // serviceWorkers() is a snapshot: Playwright may not have observed the + // worker yet, and Chrome can idle it out during the steps above. + let background = context.serviceWorkers().find(worker => worker.url() === backgroundUrl); + if (!background) { + background = await context.waitForEvent('serviceworker', { + predicate: worker => worker.url() === backgroundUrl, + timeout: 10000, + }).catch(() => null); + } + assert.ok(background, 'The WebBrain service worker was not available for the PDF extraction test.'); + const ready = await background.evaluate(async () => chrome.runtime.sendMessage({ + type: 'offscreen-pdf-extract-ready', + })); + assert.equal(ready?.ready, true, ready?.error || 'The offscreen PDF parser did not become ready.'); + + const requestsBeforeExtraction = fixture.requestCount(); + const extraction = await background.evaluate(async url => chrome.runtime.sendMessage({ + type: 'offscreen-pdf-extract', + url, + options: { fromPage: 1, toPage: 1, maxChars: 5000, includeBase64: true }, + }), fixture.url); + assert.equal(extraction?.ok, true, extraction?.error || 'The offscreen PDF parser failed.'); + assert.equal(fixture.requestCount() - requestsBeforeExtraction, 1, 'Claude-compatible extraction fetched the PDF more than once.'); + assert.equal(extraction.result?.totalPages, 1); + assert.equal(extraction.result?.byteLength, fixture.pdf.byteLength); + assert.match(extraction.result?.pages?.[0] || '', /WebBrain PDF MIME handler test/); + assert.deepEqual(Buffer.from(extraction.result?._pdfBase64 || '', 'base64'), fixture.pdf); + await waitForNativeHandlerOption(settings, false); // Installation initially registers public MIME handlers as enabled. Allow // the post-registration reconciliation pass to settle before navigating. diff --git a/test/pdf-read.mjs b/test/pdf-read.mjs new file mode 100644 index 000000000..32742a4a4 --- /dev/null +++ b/test/pdf-read.mjs @@ -0,0 +1,247 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const pdfExtractionModulePath = path.join(root, 'src', 'chrome', 'src', 'agent', 'pdf-extraction.js'); +const pdfToolsModulePath = path.join(root, 'src', 'chrome', 'src', 'agent', 'pdf-tools.js'); +const pdfExtractionHostPath = path.join(root, 'src', 'chrome', 'src', 'offscreen', 'pdf-extraction-host.js'); +const offscreenHtmlPath = path.join(root, 'src', 'chrome', 'src', 'offscreen', 'offscreen.html'); + +async function testMv3PdfExtractionUsesTheSharedOffscreenHost() { + const pdfTools = await readFile(pdfToolsModulePath, 'utf8'); + const host = await readFile(pdfExtractionHostPath, 'utf8'); + const offscreenHtml = await readFile(offscreenHtmlPath, 'utf8'); + assert.match(pdfTools, /ensureOffscreen\(\)/); + assert.match(pdfTools, /PDF_EXTRACTION_READY_MESSAGE/); + assert.match(pdfTools, /type: PDF_EXTRACTION_MESSAGE/); + assert.doesNotMatch( + pdfTools, + /import\(chrome\.runtime\.getURL\('vendor\/pdfjs\/pdf\.mjs'\)\)/, + 'MV3 service workers reject dynamic import() at runtime', + ); + assert.match(host, /import\(chrome\.runtime\.getURL\('vendor\/pdfjs\/pdf\.mjs'\)\)/); + assert.match(host, /extractPdfTextFromBytes/); + assert.match(host, /isTrustedPdfExtractionSender\(sender\)/); + assert.match(offscreenHtml, /