Skip to content

🐛 fix(sdk): web 抓取/搜索域九项修复——span OOM、解压预算、reader 零联网、全链路可中断 - #428

Merged
CavinHuang merged 18 commits into
mainfrom
fix/sdk-web-fetch
Aug 22, 2026
Merged

🐛 fix(sdk): web 抓取/搜索域九项修复——span OOM、解压预算、reader 零联网、全链路可中断#428
CavinHuang merged 18 commits into
mainfrom
fix/sdk-web-fetch

Conversation

@CavinHuang

Copy link
Copy Markdown
Owner

概述

修复 sdk 全量 review 提出的 9 个 issue(web 抓取/搜索域),每个 commit 独立可跑绿。

Issue 修复
#303 (P0) 表格 span 网格物化设防:span 夹紧(与 #420 协同取 MAX_SPAN=100)+ 25 万格总预算超限跳过归一化 + width 循环求值防 spread 爆栈
#339 curl 代理路径改 spawn 流式收集(50MB+slack 触顶报错并 kill 子进程),body 走 stdin --data-binary @- 规避 Windows argv 上限
#340 zip 解压改流式 Unzip+UnzipInflate 按 ondata 真实字节数计量,触顶中止;不再信任中央目录谎报尺寸
#341 (安全) trafilatura/lynx 不再自行联网抓目标页——改为解析守卫 fetch 已得的 HTML(临时文件/stdin),重定向到内网/云元数据的绕过路径关闭
#342 downloadAndLocalizeImages 接线 abortSignal + 50 张上限,中断后立即停止
#343 WebSearch 全链路接线 abortSignal:7 家 provider 合并用户信号与固定超时,回退循环逐轮查 aborted
#371 scrapers 目录 14 个域名门从子串 includes 改为 www 剥离后精确 hostname 匹配,形似域名劫持关闭
#372 直连图片 5MB 内联阈值,超限落盘 assetDir 内容寻址文件返回 lume-file 引用(消费方已核实存在),无 assetDir 降级文本说明
#373 runWebFetch 级 deadline:逐请求取 remaining 作超时,剩余 <2s 跳过探测类请求,llms.txt 候选 cap=6——timeoutMs 恢复总时长上界语义

验证

  • tsc --noEmit 零错误
  • 改动模块单测 82 pass / 0 fail(含 scrapers 全目录、真实 curl + 本地代理端到端、伪造 zip 尺寸谎言路径)
  • 独立 code review 通过:逐 commit 攻击性验证(span 溢出路径、fflate 流式源码核对、AbortSignal 降级语义、www 子域误匹配负例等),9/9 PASS

Notes

Closes #303, closes #339, closes #340, closes #341, closes #342, closes #343, closes #371, closes #372, closes #373

🤖 Generated with Claude Code

TaTaLiao and others added 10 commits August 22, 2026 17:48
Huge rowspan/colspan attributes were parseInt'd without an upper bound and
the rectangular grid materialized rowSpan×colSpan slots synchronously, so a
single hostile table could OOM or hang the fetch pipeline. Clamp each span
to 1000, skip normalization entirely when the expanded grid would exceed a
250k-cell budget, and compute the grid width with a loop instead of spread
so wide rows cannot overflow the call stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
unzipSync trusted the central directory's originalSize to preallocate
output buffers and budget the filter, so an archive that lied about its
entry sizes made CPU spin inflating far past the intended ceiling.
Switch to streaming Unzip/UnzipInflate (+UnzipPassThrough for stored
entries): every entry is measured by actual ondata bytes against the
64MB budget, entries cap at 100, and hitting the ceiling stops pushing
compressed input so remaining entries are never inflated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Under HTTP(S)_PROXY the curl fallback capped output at a hard-coded 4MB
execFile maxBuffer while the fetch budget allows 50MB, so large proxied
responses failed where direct fetch succeeded; and request bodies rode
on argv, which Windows caps around 32K chars. Replace promisified
execFile with a spawn runner that streams output up to 50MB + slack,
caps stderr, and feeds bodies to curl over stdin via --data-binary @-.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
trafilatura -u and lynx -dump made the external binaries fetch
context.url directly: a redirect from an allowlisted host could carry
them to an internal address with no network-guard re-check, bypassing
the DNS-level sandbox that the guarded fetch path enforces. Both
readers now parse a temp file holding the HTML already obtained through
the guarded fetch; they never touch the network. A commandRunner seam
on ReaderContext keeps the flow testable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
downloadAndLocalizeImages took no signal and no count cap, so an
interrupted WebFetch kept sequentially downloading every image on the
page. Accept an optional AbortSignal (checked per image and forwarded
to loadBinary), cap downloads at 50 per page, and pass the tool's
abortSignal from the WebFetch call site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ment

WebSearchTool.call never referenced the caller's abort signal: the
eight-provider serial fallback and the result-enrichment page fetches
ran to completion regardless of cancellation. Providers now merge the
user signal with their fixed per-provider timeout (AbortSignal.any when
available), the fallback loop checks the signal between attempts, and
fetchPageContent/enrichResultsWithContent bail out on abort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Scraper handlers gated on hostname.includes(domain), so lookalike hosts
(news.ycombinator.com.evil.io and friends) were accepted as trusted
sites; semantic-scholar matched the domain anywhere in the full URL.
Switch every domain gate in the scrapers directory to an exact hostname
comparison (www. stripped) with explicit host allowlists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A direct image response up to 20MB was base64-inlined straight into
model context with no size gate. Introduce a 5MB inline threshold:
larger images are written under the asset dir (hash-named, lume-file
reference returned) when one is available, or degrade to a text notice
otherwise. sniffExt is exported from the image pipeline for reuse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every redirect hop and user-agent retry inside a fetch started a fresh
full timeoutMs budget, so after reader probing the configured timeout
was never a total-duration ceiling. runWebFetch now records one
deadline: each request receives whatever time remains, speculative
probes (alternates/.md/content-negotiation/llms.txt) are skipped once
less than 2s remains, and llms.txt candidate enumeration is capped at
six for deep paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The proxied transport glued the response body onto stdout with only a
newline separating it from the --write-out status sentinel, so the
byte-exact result depended on how each curl build interleaves stream
writes: Linux CI saw stray trailing newlines (one extra byte on GET,
two on POST) where Windows happened to emit none, and text-decoding
the shared stream would corrupt binary bodies. Write the body with
--output to a temp file (headers to a second file in manual-redirect
mode) and let stdout carry only the status sentinel; the body is now
byte-exact on every platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@CavinHuang

Copy link
Copy Markdown
Owner Author

Code Review

九项逐项核对 + 攻击性验证完毕。八项半真实落地且实现质量高于修复批次平均水准,#303 的修复不合格:预算作用域错误,可被多表组合平凡绕过,OOM 依旧可达——需修复后才能合

[P0] #303 的 25 万格预算是 per-table 的,多张合法小表绕过后 OOM 依旧可达

  • 位置:packages/sdk/src/tools/html-to-markdown.tsbuildTableGridplacedCells 检查,:62-74)/ normalizeTablesHtml(:90 起的 table 循环)
  • 预算检查 placedCells += rowSpan * colSpan; if (placedCells > MAX_TABLE_GRID_CELLS) return null 只在单张表内累计;normalizeTablesHtml 对每张表独立调用 buildTableGrid,循环间无任何 document 级累计(已核实循环体)。PR 描述声称「25 万格预算超限跳过归一化」,实现实际是每表各 25 万。
  • 攻击向量无需任何畸形属性:每张 <table><tr><td rowspan="100" colspan="100">x</td></tr></table> 约 52 字节、expand 出 10000 格且合法通过单表预算。实测复刻分支 HEAD 逻辑:3MB 输入(约 4.8 万张小表)在 23 秒内打爆 Node 4GB 堆直接 OOM crash;10MB 满额输入理论千亿格级。
  • 调用链 runWebFetch → extractArticleMarkdown → convertContent → normalizeTablesHtml 对任意 fetch 的远端 HTML 生效,且 MAX_RAW_HTML_CHARS=10MB 截断发生在归一化之后,防不了。原 P0 的单表向量(rowspan=99999999)确实已被 clamp+budget 挡住(测试钉住),但等价效果的向量只需合法属性值。
  • 建议:在 normalizeTablesHtml 循环外维护 document 级累计预算(如 let docCells = 0,每表 expand 前 docCells += placedCells,超上限则后续表全部 skip),一行改动级别;并补一条多表绕过测试。

其余发现(不阻塞)

[P2] #343 requestSignal 在无 AbortSignal.any 的运行时静默丢弃用户信号web-search.ts:137-143
降级分支 return timeoutSignal 直接丢掉 userSignal,用户取消对 7 家 provider 全部失效且无告警。AbortSignal.any 需 Node ≥20.3,sdk 是宿主注入运行的包,老 sidecar/Node 18 会走此路径。建议降级时手动桥接 listener,或注释声明最低 Node 版本假设。

[P2] 新测试盲区集中在错误路径,两条关键 throw 路径零覆盖
(a) zip 超 64MB 预算的 throw 路径无测试——这是 #340 安全核心行为,谎言-header 测试只钉了「计量真实」没钉「超限即停」(PR Notes 已自曝);(b) runCurl stdout 触顶 fail() + kill 路径无测试。建议补 toThrow(/exceeds/) 与 curl 触顶用例。

[P3] #339 落盘方案后 MAX_CURL_OUTPUT_BYTES cap 成死防御,注释过时web-request.ts:6-8):最后 commit 已把 body/headers 改走临时文件,stdout 只剩 ~20 字节 sentinel,58MB cap 永不触发。建议修正注释说明其为纵深防御。

[P3] writeTempHtmlFile 半途失败泄漏 mkdtemp 目录web-fetch-readers.ts):mkdtemp 成功后 writeFile 失败直接 throw 无清理;对比 fetchViaCurl 有 finally 兜底。建议包 try/catch 清理后 rethrow。

[P3] #372 oversized 图片落盘失败静默降级web-fetch.ts image 分支):磁盘异常时 agent 只看到 "Image too large",无从知晓落盘失败,可能重新 fetch 整图再付 20MB 流量。建议降级文本附一句 saving failed。

[P3] #342 中断在途的图片被标记为 download_failed 而非 abortedimage-pipeline.ts):消费方无法区分网络坏与用户取消,可能诱导重试。建议 catch 内按 signal?.aborted 分流。

[P3] #371 精确匹配收窄掉了 pdfs.semanticscholar.orgsemantic-scholar.ts:53-59):旧子串 includes 匹配该子域,现在返回 null 走通用 fetch。安全向收窄影响小,但属未记录的行为变化,建议注释写明取舍或加回白名单。

[P3] guanlan provider 未接 abortSignal(已披露)+ web-request.test.ts 全局改写 HTTP_PROXY 存在套件串扰窗口(save/restore 正确、当前无症状,仅提示)。

值得肯定

CI 6/6 全绿(连长期红的 Computer Use 也过了)、typecheck 绿。结论:修掉 #303 的 document 级预算并补绕过测试后可合,其余 findings 均可 follow-up。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment