Repository navigation
fix(pixel): read PDF text in the shared page reader; binary bodies are never page reads - #6724
Open
Lightheartdevs wants to merge 3 commits into
Open
Lightheartdevs wants to merge 3 commits into
Lightheartdevs wants to merge 3 commits into
Conversation
…e never page reads OpenClaw's web_fetch decodes any 2xx body as text. On 2026-09-25 it returned the NVIDIA RTX 5070 user guide PDF (tower2 round 061 session b63ee849 row 276, tower1 session f702a671 row 327) as undecoded bytes: contentType application/pdf, extractor raw, 2,444 and 2,722 U+FFFD in 15,994 characters. Completion assurance credited any 2xx web_fetch with text as a page read, so ODS listed the PDF among the pages read successfully (the round 061 replay test asserted exactly that). - document-body.mjs: recognizes PDF and other binary bodies by content type or by their bytes (PDF signature, NUL, replacement/control density). - The shared reader (createPublicPageReader) extracts a PDF's text through pdf-text.mjs, or returns a not-read receipt with kind, content type, bytes and reason. Images and other binary bodies are receipts without reading the body; text-typed bodies that are really binary are not pages. - pdf-text.mjs: dependency-free PDF text extraction (node:zlib only) in a worker thread: 8 MB body, first 10 pages, 300k chars, 48 MB decoded (16 MB per stream), 192 MB heap, at most 5 s and half the read timeout, terminated on timeout or abort. Classic and compressed object layouts, simple and composite fonts via ToUnicode, form XObjects. Encrypted, scanned and unmapped documents are reported as not read. - Completion assurance no longer counts a binary web_fetch body as a read or a returned source; the round 061 replay now lists seven pages. - A binary web_fetch result (native or deferred) is persisted as a receipt that names the pixel_ods_web_extract call to read a PDF's text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a source read The repository-research check (canonicalWebFetchSucceeded) accepted any 2xx web_fetch from the owner's GitHub repository as a successful source read, including a PDF or other binary file returned as undecoded bytes. Such a receipt no longer satisfies it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n process Review of #6724 found that small crafted PDFs aborted the whole gateway (exit 134, "Reached heap limit"): a worker thread that runs out of heap during a large allocation takes the process down with it. Two triggers were reported (an ASCII85 stream of "z" groups decoded into a growing JS array, and ToUnicode bfrange lists with no total cap); a third, 7.6 MB of empty dictionaries inside every parser bound, did the same. - Extraction now runs in its own Node process (pdf-text-worker.mjs): spawned from process.execPath with --max-old-space-size, an empty environment and no inherited flags, the document on stdin and one JSON line on stdout, SIGKILL at the deadline or on abort. A heap abort (SIGABRT, or exit 134 on Windows) is reported as too-large; a worker that cannot start (spawn error, missing script) as unavailable. - At most two extraction processes run at once in one gateway; the others wait within their own deadline. - Decoders stop at their bound before they grow: ASCII85 writes into a preallocated array capped at the stream's share of the decoded-bytes bound. ToUnicode maps keep at most 65,536 codes (bfchar and list ranges together, lists read lazily), 10,000 ranges and 256 code space ranges; maps and CID widths share a 500,000-entry budget per document. Object streams count toward maxObjects, shown text stops growing past the text bound, and the glyph cache is capped per document. - RTF and PostScript are text, not binary, bodies. - Docs and the header no longer claim the worker had no file or network access; the process is not a sandbox, the extractor just does not use it. Tests: real bombs (the review's two cases) through pixel_ods_web_extract in a separate gateway process that must exit 0 with a not-read receipt; a real heap exhaustion of the extraction process (Windows by default, or ODS_PDF_OOM_TEST=1, since an abort leaves a crash report on Linux/macOS); decoder and table bounds; process spawn, kill and exit mapping; the concurrency limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Sep 25, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Review fixes (
d7268e0a9)Blocker: a small crafted PDF crashed the whole gateway. The extractor ran in a worker thread with a 192 MB heap limit. In Node, a worker that runs out of heap during a large allocation takes the whole process down (
FATAL ERROR: Reached heap limit, exit 134). I reproduced the review's two cases locally (Windows, node v24.14.1) throughpixel_ods_web_extracton the old heada2dcacbaf:zgroups (29 MB decoded)not_read: too-large; the extraction process exits 0not_read: no-text; the extraction process exits 0extractPdfTextdirectly; through the tool, 3 of 3 runs ended only the workernot_read: too-largeWhat changed:
pdf-text-worker.mjs). It is started fromprocess.execPathwith--max-old-space-size=192, an empty environment (so noNODE_OPTIONS) and no inherited flags. The document goes in on stdin and one JSON line comes back on stdout. The process is killed with SIGKILL at the deadline or when the caller aborts. If it runs out of heap, only it ends, and the PDF is reported astoo-large. If it cannot start (spawn error, missing script), the PDF is reported asunavailable, notunsupported.too-large.maxObjects, shown text stops growing past the text bound, and the glyph cache is capped per document. Each of these could also grow without limit before.web_extractcalls all read PDFs.too-largenow says "larger than the PDF reader's size or memory bounds". The docs also say that PDFs encrypted with only an owner password are reported as encrypted.Tests added (all in
tests/pdf_reader.test.mjs, fixtures generated intests/fixtures/pdf-fixtures.mjs):pixel_ods_web_extracton the ASCII85 and ToUnicode bombs through the real reader and extraction. The test asserts that process exits 0 with the exact not-read receipt. On the old head this exits 134.too-large, and that a gateway process reading it throughpixel_ods_web_extractexits 0. An abort writes a crash report on Linux and macOS (apport on tower2), so this test runs by default only on Windows, or withODS_PDF_OOM_TEST=1. On tower2 it is skipped.zgroups and a partial last group still decodes. The ASCII85 bomb istoo-largebefore any large allocation. The ToUnicode bomb finishes in well under a second asno-text. Ten maps pass the per-document budget.too-large; exit 1 and spawn failures areunavailable; a forged or oversized message isunsupported). There are also two real cases: a missing worker script and a missing executable, bothunavailable.Fail-before: I ran the new test file against the old head's plugin code. 7 of 22 tests fail: the bounds test, both process tests, the gateway-survival test, the real out-of-memory test, the RTF/PostScript assertion, and the oversized-PDF test (only because of the new receipt wording).
What was wrong
A PDF fetched as raw bytes was listed as a page read. OpenClaw's
web_fetchdecodes any 2xx body as text. On 2026-09-25 it fetched the NVIDIA RTX 5070 user guide (https://www.nvidia.com/content/geforce-gtx/geforce-rtx-5070-user-guide-r1.pdf) in two fleet runs:b63ee849, row 276 (0-indexed; 277 counting from 1), 11:23:35ZcontentType: application/pdf,extractor: raw, 15,994 characters, 2,444 U+FFFDf702a671, row 327, 15:09:36ZIn both, the body starts
%PDF-1.7 … <</Linearized 1/L 5381674: the bytes of a 5.4 MB PDF, never decoded. I read these from the session transcripts, read-only.completion-assurance.mjscounted any 2xxweb_fetchwith non-empty text as a page read. So the PDF went into the host's "Pages Pixel read successfully" list, the stop-synthesis sources and the cited-page check. The existing round-061 replay test inprogress_finalization.test.mjsexpected exactly this: its list of eight pages read included the PDF.Three related gaps:
createPublicPageReader, used bypixel_ods_web_extractand the host citation check) turned down every PDF as "not a supported text document". Nothing in ODS could read a PDF.canonicalWebFetchSucceeded) also accepted any 2xxweb_fetch, so a binary file from the repository counted as a source read.What changed
plugin/document-body.mjs(new). Recognizes a PDF or other binary body from its content type or from the bytes themselves: a PDF signature, a NUL, or mostly replacement and control characters. The same check is used in three places: the reader, the read receipts and theweb_fetchprojection.web-extract.mjs).application/octet-streamor with no type, have their text extracted.kind,contentType,bytes(from Content-Length or the bytes read) and the reason.application/pdfis added to the reader's type sets. That coverspixel_ods_web_extract, the host citation check andpixel_ods_search_readfrom feat(pixel): search and read public pages in one call (pixel_ods_search_read) #6698.pixel_ods_web_extractcites PDF evidence asTargeted evidence from <url> (PDF text, pages 1-N of M), withdetails.document = {kind, read, bytes, pages_read, page_count, text_truncated}. It reports a PDF it could not read aspdf-textwithnot_readset totoo-large,timeout,encrypted,no-text,unsupportedorunavailable.plugin/pdf-text.mjsandpdf-text-worker.mjs(new): bounded PDF text extraction.node:zlibandnode:child_process. Each extraction runs in its own short-lived Node process (see the review fixes above), at most two at a time.completion-assurance.mjs). A binaryweb_fetchbody no longer counts as a read or as a returned source. That covers cited-page attribution, the host's list of pages read and the stop synthesis.web-result-projection.mjs, and two lines intool-loop-guard.mjs).web_fetchresult is saved as a short receipt instead of the bytes. This applies to native calls and to Tool Search calls.pixel_ods_web_extractcall:{"url": …}, plus a shortquery.createPersistedToolResultModelProjection), so the run that made the call sees the receipt, not the bytes.COMPLETION-RELIABILITY.mdanddocs/PIXEL.md.No new dependency. The plugin's
package.jsonhas no dependencies and there is no lockfile. OpenClaw bundlesclawpdf, which is PDFium compiled to WASM (4.3 MB). I did not use it: it cannot be resolved from the plugin directory, and it is not pure JavaScript.Tests
Full plugin JS suite on tower2 (host node v22.23.1, throwaway dir,
remote-js-test.sh, removed afterwards):d7268e0a9a2dcacbafd7268e0a9merged with #66981f0158f2d(import lines kept from both)The tests that did not pass are skipped/todo: the same 38 as on the previous head, plus the real out-of-memory test, which is skipped on Linux.
/var/crashon tower2 was still empty after these runs.Locally (Windows, node v24.14.1):
pdf_reader.test.mjs22 of 22, including the real out-of-memory test. The related files together (pdf_reader,completion_assurance,host_citation_verification,partial_citation_delivery,progress_finalization,public_page_request,web_extract,web_extract_performance,web_result_projection): 186 of 186.Process cost on tower2: 40-72 ms per extraction including process start (8 in a row). Five at once through the two slots took 166 ms.
Tests in
tests/pdf_reader.test.mjs(22). The PDF fixtures are generated inside the test (tests/fixtures/pdf-fixtures.mjs), so every byte can be reviewed. Besides the review tests above, they cover:web_fetchreceipts shaped like the tower1/tower2 rows: PDF and image are not reads; readable text served as octet-stream still is, and so are RTF and PostScript.web_fetchresults.Changed test. The round-061 replay now feeds row 276 as the PDF it actually was. The host's list of pages read has seven entries instead of eight.
Earlier checks (first version, before the review fixes). I broke ten parts of the change one at a time; each break was caught by at least one failing test. Text was extracted from real PDFs (local, statistics only, not committed): Chrome/Edge print-to-PDF, a 379-page Word document, a LaTeX spec, earnings-call transcripts, a 3.8 MB SEC proxy statement, ReportLab output. An image-only slide deck and a business card with outlined text came back as
no-text.Conflicts with open PRs
I checked with
git merge-treeagainst current heads, for the old head and the new one. The review fixes add no conflict with any open PR.1f0158f2d): conflicts only in the import lines ofplugin/completion-assurance.mjs. feat(pixel): search and read public pages in one call (pixel_ods_search_read) #6698 adds theexternalContentBodyand source-kind imports; this branch addswebFetchBinaryBody. Keep both. With that resolution the full suite passes (table above), and the binary exclusion inopenedSourceUrlsandsourceUrlssurvives the merge.Risks
timeoutbecause it waited for a free process. The host check reads up to four cited URLs in parallel; if all four are PDFs, two wait inside its 2 s extraction budget.process.execPath. The macOS gateway sandbox allows process-exec, and the plugin already starts processes (provider-lease-worker.mjs). If starting fails, the receipt saysunavailable.too-large.pixel_ods_web_extractare worded differently; they still contain "not a supported text document". A body with a text type that has a NUL or a PDF signature is now refused.binaryText's 20% threshold. That row is caught by its content type and by the%PDFat the start of the body. A binary body with no type and no PDF signature would be caught only by NULs. Unchanged here.no-textreceipt.🤖 Generated with Claude Code