Files
codex-app-server/docs/web-search-mcp.md
host 3a56f83114 websearch: 默认 auto 付费冻结,SearXNG 优先 + 显式解冻开关
- 默认 auto 只走免费链 searxng -> duckduckgo;即使环境里有
  付费 key 也不调用 Perplexity/Exa/Tavily/Brave,SearXNG
  失败也不回落付费,默认使用零付费花费。
- 付费后端代码/测试/文档全部保留;显式 pin(tavily/exa/
  perplexity/brave)仍照常调用,是有意覆盖不是默认。
- 解冻开关 WEBSEARCH_ENABLE_PAID,仅精确值 "1" 才恢复旧链
  (perplexity/exa/tavily/brave/searxng/duckduckgo),默认关;
  开关经 worker search_env 透传进 MCP config env。
- HttpClient 超时/DNS 失败改报显式错误(timeout after Ns /
  URL error),绝不把超时写成“无结果”。
- SearXNG 默认参数核实:代码无 SEARXNG_URL 硬编码默认值;
  SEARXNG_ENGINES 默认 bing,360search 已在基线;WEBSEARCH_TIMEOUT
  默认 20 秒。主机 env 实测仅 SEARXNG_URL=http://searx.diante.me
  一行,故默认 pin 生效;文档 https 漂移改回 http(主机 nginx
  仅听 80、无 443 vhost,https 在 TLS 层即失败)。
- 旧测试更新原因:test_websearch_mcp 原断言默认 auto 为
  perplexity 优先,与新冻结语义直接冲突,故改为断言冻结后
  的 searxng -> duckduckgo 回退 + 不泄漏 key;旧 perplexity ->
  exa 回退断言移至解冻开关测试(WEBSEARCH_ENABLE_PAID=1 下)。
  未删断言、未加 skip、未放宽门禁。
- 文档另删 WEBSEARCH_PROVIDERS 说法:代码从未实现该变量。
- 未推、未建 PR、未合并、未部署、未改主机 env/nginx/filter/
  证书、未做付费后端 live 验证、未创建生产任务。
2026-11-01 14:15:54 +00:00

408 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 网页搜索 MCP v1(oh-my-pi 模式,Python 实现)
目标:给 codex 任务一个可用的 `web_search` / `web_fetch`,
不把 oh-my-pi 的 TypeScript 代码原样搬过来(codex 认不出
pi 的工具),只复用它的模式:统一工具 + provider 抽象 +
`auto` 链式回退 + 抓取转干净文本。
## 文件
- `cloud/websearch.py`:provider 链、抓取、codex-home 播种
助手。纯标准库,无第三方依赖,这样它能不经 pip 安装就
在宿主和 Docker 容器里跑。
- `cloud/websearch_mcp.py`:stdio JSON-RPC MCP 服务器,
只做协议帧,逻辑全在 websearch.py。日志只写 stderr,
stdout 是 JSON-RPC 流,绝不能混日志。
- `cloud/tests/test_websearch_mcp.py`:12 个单测,全部
mock HTTP,不联网、不需要真实 key。
- `cloud/tests/test_code_search.py`:编程作用域与抽取回退链
的单测,同样全部 mock HTTP、不联网、不需要真实 key。
- `cloud/tests/test_code_search_ext.py`:任务级
`allowed_hosts` 扩展的单测(指定 / 未指定 / 点边界 /
空值与非法值 / Perplexity 超 20 域 / MCP 透传)。
- `cloud/tests/test_websearch_paid_freeze.py`:付费冻结单测
(默认 auto 无付费后端、SearXNG 失败不回落付费、显式
pin 仍工作、解冻开关关时不变 / 开时恢复旧链、
DuckDuckGo 兜底失败报错清晰、超时报错清晰;全部
mock HTTP,不联网、不需要真实 key)。
- `cloud/tests/test_js_heavy_extract.py`:JS 重型页面
抽取评估的 mock 单测(直接抓取壳-only、provider 抽取
补与不补、回退语义;live SearXNG 烟囱默认跳过)。
## 工具
- `web_search(query, limit=5, provider="auto", scope="general")`:返回
`{provider, results:[{title, url, snippet, score?}], answer?, attempted, scope}`,
`scope="code"` 时另带 `code_domains`。
`answer` 只有 provider 本身给合成答案时才有(目前是
Perplexity Sonar);其它 provider 的总结仍由主模型做。
`scope` 详见下面“编程搜索 scope=code”一节。
- `web_fetch(url, char_limit=15000)`:走 `fetch_best` 抽取回退链
(Tavily extract → Exa contents → 直接抓取),返回
`{url, title, text, links, truncated, source}`(`source` 为
`tavily-extract` / `exa-contents` / `direct-fetch`)。
超长按头 75% + 尾 25% 截断并标 `[TRUNCATED]`;只接受 http(s),拒绝
localhost 与字面私有 IP;抽取与直接抓取都失败时报错,不伪造页面。
## Provider 链:默认冻结,显式 pin 与显式解冻除外
冻结语义(本轨起,默认 `auto` 只走免费路线,顺序固定):
1. `searxng`:`GET $SEARXNG_URL/search?format=json`,需自托管
实例且 JSON 格式已打开。默认参数:`engines=bing,360search`
(2026-10-30 主机实测:默认 general 类别 20.0s 撞超时,
pinned 两引擎 0.47s;`SEARXNG_ENGINES` 可覆盖,空值恢复
不 pin)。`SEARXNG_URL` 在代码里无默认值:未配时该后端
直接报 `searxng not configured (SEARXNG_URL unset)`,链
只剩 DuckDuckGo;生产值 `http://searx.diante.me` 来自主
机 env(见“生产接线”),不在代码里写死;主机 env 实测
仅有 `SEARXNG_URL` 这一行、无 `SEARXNG_ENGINES` 行,
所以默认 pin(bing,360search)生效。注意是 http 不是
https:主机 nginx 只听 80、无 443 vhost,https 会在 TLS
层证书主机名不匹配失败,根本到不了 SearXNG。
2. `duckduckgo`:解析 `https://html.duckduckgo.com/html/`,
免 key 兜底,布局漂移时只会返回空结果并继续走链,
不会把工具打崩;但它在两种出口下都不可作可用兜底:
本 VM 直连回 202 + 机器人挑战页、0 条,主机 Tinyproxy
路径约 25 秒超时。兜底失败时记为 `no results` / 超时并
如实报错,不伪造结果;因此 SearXNG 挂时冻结默认链可能
以诚实失败告终,这是已知边界,不是静默降级。
冻结保证:即使环境里配了付费 key,默认 auto 也绝不调用
Perplexity / Exa / Tavily / Brave、绝不产生付费花费;
SearXNG 失败后默认也不回落到付费后端,只走 DuckDuckGo。
付费后端的代码、测试、文档一律保留,未删除。
显式 pin 仍工作:`provider="tavily"` / `"exa"` /
`"perplexity"` / `"brave"` 显式指定时照常调用,这是用户
有意的覆盖,不是默认行为;pin 失败不回退(与冻结前一致)。
解冻开关:`WEBSEARCH_ENABLE_PAID=1`(且仅精确值 `1`,
`true` / `yes` / `0` 都不算开,默认关)时,默认 auto
恢复旧链,顺序为 Perplexity → Exa → Tavily → Brave →
SearXNG → DuckDuckGo(旧链里只试有 key 的付费后端 +
已配 URL 的 SearXNG + DuckDuckGo)。解冻是显式动作,
会产生付费花费,绝不能被默认链静默触发;开关走 worker
环境透传进 MCP config env(见“生产接线”)。
旧链各后端(解冻后 / 显式 pin 时)的请求形态不变:
1. `perplexity`:`POST https://api.perplexity.ai/chat/completions`,
模型 `sonar`,`Authorization: Bearer $PERPLEXITY_API_KEY`。
返回合成答案 + `search_results` / `citations`。
2. `exa`:`POST https://api.exa.ai/search`,头 `x-api-key`,
`type: auto`,`contents.text.maxCharacters: 2000` +
`highlights: true`。
3. `tavily`:`POST https://api.tavily.com/search`,
`Authorization: Bearer $TAVILY_API_KEY`,
`search_depth: basic`,`include_answer: false`(工具只返
结果,总结留给模型)。
4. `brave`:`GET https://api.search.brave.com/res/v1/web/search`,
头 `X-Subscription-Token`,`extra_snippets: true`。
规则:任一后端 HTTP 失败或 0 结果都继续下一个;全部失败
返回各后端错误(不含 key)。超时失败报
`timeout after Ns waiting for HTTP response`(N 为
`WEBSEARCH_TIMEOUT`,默认 20 秒),并记在该后端的
attempted 条目里,绝不把超时写成“无结果”或空答案。
`WEBSEARCH_TIMEOUT`(秒,默认 20)改单次超时。另注意:
旧文档曾写可用 `WEBSEARCH_PROVIDERS` 改默认链顺序,
代码里从未实现该变量,本轨已删此说法(不另新加该变量,
保持改动最小)。
## 接入 codex(测试项目做法)
codex 从 codex-home 的 `config.toml` 读 MCP 配置:
```toml
[mcp_servers.websearch]
command = "python3"
args = ["/path/to/codex-home/websearch_mcp.py"]
```
`ensure_codex_home_websearch(codex_home_dir, command_args=...)`
会把 `websearch.py` / `websearch_mcp.py` 拷进该 codex-home,
并在缺失时追加上面这段(已有 section 绝不重写;key 绝不
写进 config.toml,key 只走 MCP 进程的环境变量)。
注意:同一个 config.toml 不能同时装宿主路径和容器路径,
Docker 租户必须传容器内路径
`/var/lib/codex-app-server/codex-home/websearch_mcp.py`,
因为该目录正是租户 codex-home 的容器内挂载点。
## 生产接线(worker + egress + 自托管 SearXNG)
- worker 自动播种:`cloud/worker.py --websearch-mcp`(默认关)。
开启后 `_tenant_dirs` 在播种 config.toml 之后调播种助手,
写容器内路径 `/var/lib/codex-app-server/codex-home/websearch_mcp.py`。
老租户下一次任务自动合并(`_tenant_dirs` 每次派发都调到),
已有 section 不重写。autoscaler unit 已加 `--websearch-mcp`,
但 unit 文件不在 deploy tar 里,主机上须单独 scp +
daemon-reload + restart autoscaler 才生效(见部署教训)。
- egress 白名单:`deploy/egress/tinyproxy-filter` 已加
Perplexity、Exa、Tavily、Brave、DuckDuckGo 与
`searx.diante.me`(精确匹配,不用 diante.me 通配,否则
任务经代理也能摸到 api.diante.me 控制面;filter 行即
`^searx\.diante\.me$`,本轨不改这行)。主机上须把
该文件拷到 `/etc/tinyproxy/tinyproxy-filter` 并重载
tinyproxy,重载后先验中转域名还通,再验新域名。
- 自托管 SearXNG(公开子域名路):主机 Docker 跑
`searxng/searxng`,经 nginx(仅听 80,无 443 vhost、无
证书)反代到 `searx.diante.me`,MCP 侧
`SEARXNG_URL=http://searx.diante.me` 走 worker 进程
环境(`/etc/codex-app-server/env`)继承到 codex 再到
MCP,不写进 config.toml。`WEBSEARCH_ENABLE_PAID` 同理
走 worker 的 search_env 透传进 `[mcp_servers.websearch.env]`
(worker 代码已含该键);主机侧改 env 文件后需重启相关
unit 才生效——那是部署动作,不在本轨。公开实例的代价是可能被外人
刷:SearXNG limiter 保持开启,JSON 格式是 MCP 要用的
唯一格式。未加域名进白名单之前,Docker 任务的搜索
会被代理 403 挡住(fail closed,工具报错,不会静默绕过)。
- 付费后端实测:付费后端只以 mock 响应验证了请求
字段与结果映射,未用真实 key 跑过;上生产前须用真实
key 在测试项目跑一遍,再谈默认链顺序是否合理。
## 编程搜索 scope=code(v2 新增,只做编程任务搜索)
选型:`web_search` 加 `scope="code"` 参数,不另立
`code_search` 工具。理由:同一条 provider 链、同一套
回退与 key 规则,只多一层域名限定;另立工具会复制
schema、handler 与播种文档两套,且调用方要在两个
工具间二选一。代码(`cloud/websearch.py` 的
`CODE_SEARCH_DOMAINS` / `search(..., scope=...)`)、
MCP 工具描述(`cloud/websearch_mcp.py` 的
`web_search` 描述与 `scope` enum)与本文三处一致,
只有 `general` / `code` 两个值,未知 scope 在发任何
HTTP 之前就报 `ValueError`,绝不静默退化成无限定搜索。
### 允许域清单(实际落代码的 15 个,含子域名)
- 官方文档:`docs.python.org`、`nodejs.org`、`go.dev`、
`pkg.go.dev`、`docs.rs`、`doc.rust-lang.org`、
`developer.mozilla.org`
- 代码托管:`github.com`、`githubusercontent.com`
(GitHub 自己的 raw host,blob 页之外原始文件所用)
- 包仓库:`pypi.org`、`npmjs.com`、`crates.io`
- 问答与安全:`stackoverflow.com`、`nvd.nist.gov`、`cve.org`
匹配规则是域名本身或其子域名(点边界匹配:
`gist.github.com` 算、`evilgithub.com` 不算)。
changelog / release notes 只有落在上述域名时才在
范围内(例如项目在 GitHub 的 Releases 页);没有
“项目官方域名自动识别”——不在清单里的项目官网
(如某框架自己的 docs 站)本版不返回,这是明确的
缺口,不是泛化成任意站的口子。
### 不在范围清单(scope=code 下一律过滤掉)
通用新闻、购物、社交、地点、机票酒店、天气、行情、
视频/图片站、博客聚合与其它任意站。`scope="code"`
不是“优先编程来源”,是硬过滤:非清单域的结果不
返回、不降级展示,只在 `attempted` 里记
`domain_filtered_out` 数量。
### 各后端如何限定(先核实请求字段再写代码)
- 原生支持,直接传:Tavily `include_domains`、
Exa `include_domains`、Perplexity Sonar chat
completions 的顶层 `search_domain_filter`(上限
20 个域,本清单 15 个,整单可传)。
- 原生不支持,不编造参数:Brave 网页搜索端点没有
`include_domains` 参数(多域 `site:` 改写语义未经
证实,不改写查询),SearXNG / DuckDuckGo 同样不传;
这三家只靠 `search()` 返回后的统一事后过滤。
- 双保险:原生限定的后端返回后同样再过一次事后
过滤,所以各后端在 code 作用域下的语义一致。
- 链语义:某后端结果被全部滤掉时,该后端记为
“no results”(`code-domain filter removed all N
results`),链继续走下一个后端;全部后端都空才
报错。
### 清单外项目官网域:任务级显式扩展 `allowed_hosts`
这不是“任意站搜索”,仍是显式白名单机制,只是白名单
可以由调用方在当次调用里显式加域。选型是任务级参数
`(a)`,不是配置级扩展清单 `(b)`,理由:改动最小
(只动 `search()` 签名与 MCP 透传,不动 worker、
unit、env、播种与主机配置);按调用隔离(某任务要
某项目官网,不会把该域放宽给所有任务——配置级清单
一旦写入就是全局放宽,安全姿态更差);用完即弃
(不持久化,下次调用不带就不生效)。
用法:`search(..., scope="code",
allowed_hosts=["docs.example-project.dev"])`,MCP
侧 `web_search` 的 `allowed_hosts` 数组字段同名透传。
规则(全部由 `cloud/websearch.py` 的
`_normalize_allowed_hosts` 在发任何 HTTP 之前强制):
- 只在 `scope="code"` 下生效;`scope="general"` 下传
`allowed_hosts` 直接报 `ValueError`,绝不静默忽略;
不传时行为与本版之前完全一致(仍是 15 域)。
- 生效清单 = 内置 15 域 + 本次显式 extras(去重、保序);
返回结果的 `code_domains` 即为该生效清单。
- 匹配仍是点边界:子域名算(`api.docs.example-project.dev`
算),`evildocs.example-project.dev` 与
`docs.example-project.dev.evil.com` 都不算。
- 空值 / 非法值报错不静默退化:`[]`、裸字符串
(可迭代但不是域名列表)、`*` 通配、带 scheme /
端口 / 路径 / userinfo 的值、前导点、空标签、非字符串
元素、以及全部等于内置域(即“扩展了个寂寞”)都报
`ValueError`,且此时尚未发任何 HTTP。
- 扩展值绝不写进日志:错误串与 `attempted` 条目里不
出现扩展值;扩展值只出现在调用方自己那次结果的
`code_domains` 回显里(调用方自己指定的值回显给
自己,不是日志)。
- 与原生后端限定方式的关系,和现有逻辑一致:原生
支持的后端(Tavily / Exa 的 `include_domains`)直接传
生效清单;Perplexity 的 `search_domain_filter` 上限
20 个域,内置 15 + 扩展超 20 时跳过其原生过滤、只靠
事后过滤(事后过滤永远是权威,语义不变);原生不
支持的后端(Brave、SearXNG、DuckDuckGo)本来就不传
参数,只靠 `search()` 返回后的统一事后过滤。
- `web_fetch` 不受 `allowed_hosts` 影响:fetch 本来就是
按指定 URL 抓取,没有域名白名单可扩展。
### 抓取回退链与 403 语义(web_fetch)
`web_fetch` 实际调 `fetch_best`,顺序固定:
1. Tavily extract(`POST https://api.tavily.com/extract`,
`urls:[url]`,取 `results[0].raw_content`;空结果或
`failed_results` 命中都算失败)——只在配了
`TAVILY_API_KEY` 时试。
2. Exa contents(`POST https://api.exa.ai/contents`,
`ids:[url]` + `text.maxCharacters`,取 `results[0].text`,
无 text 时取 highlights 拼接;空算失败)——只在
配了 `EXA_API_KEY` 时试。
3. 直接抓取(旧 `fetch_url` 的 HTML 清洗与截断)。
抽取与直接都失败时,错误同时写明两边失败,不伪造
页面。无任何抽取 key 时 `fetch_via_provider` 单独调会
明确报“no extract provider configured”,但 `fetch_best`
会直接走直接抓取(与旧 web_fetch 行为一致),不是
报错。localhost / 私有 IP 在抽取之前就拒绝,不会经
抽取商去探内网。
403 语义:抽取走的是搜索商 API 域名(`tavily.com`、
`exa.ai`,已在 Tinyproxy 白名单里),不直接碰目标站,
所以文档站本身不在白名单时抽取仍可通;只有回退到
直接抓取时,目标域不在白名单才会被代理 403 挡住。
这是 fail-closed:报错、不静默绕过、不承诺“任意
文档站都能直接抓”。本轨**没有改**
`deploy/egress/tinyproxy-filter`(也不改主机上的文件):
若将来主机侧要让直接抓取也通,需另行走主机变更,
filter 行须用 POSIX BASIC 正则(`^\(.*\.\)*域名$`)、
文件 644,extended 锚定写法会把允许域也挡成 403
(见部署教训);该变更不在本轨授权内。
### 安全与隔离(不变)
- key 只走 MCP 进程环境变量,绝不写进 config.toml、
日志、测试、文档;本轨测试用的 key 全是
`*-SECRET-*` 假值。
- worker 开关默认关;租户播种幂等、已有 section
不重写;Docker 环境变量走 extra_env +
`[mcp_servers.websearch.env]` 路径。
- `web_fetch` 仍只接受 http(s)、拒 localhost 与私有
IP,且它不是沙箱边界(danger-full-access 任务本
来就能 curl)。
- 老租户注意:播种助手从不覆盖内容已变的租户脚本
(既有防覆盖规则),所以 scope 只对新播种的租户
立即生效;老租户拿到新脚本需另行决策,不在本轨
改动该规则。
## 诚实边界
- 本批只做代码 + 单测 + 测试租户目录的配置合并验证,
未推分支、未建 PR、未合并、未部署、未改生产 worker
与 egress 配置、未跑真实 codex 任务。
- 付费冻结本轨同样只在本地提交里:生产默认链要等部署
新代码后才变;解冻开关在主机上默认仍是关(除非主机
env 显式设为 `WEBSEARCH_ENABLE_PAID=1`,且那需要
主机侧改 env + 重启 unit,属部署动作)。
- DuckDuckGo 的 HTML 解析是兜底手段,布局一变就可能
0 结果;它存在的意义是“没 key 也能走通链路”,不是
长期依赖的搜索后端。实测(2026-10-28,VM 出口):
html / lite 端点均回 202 + 机器人挑战页(anomaly),
不是解析器 bug——在这个出口下免 key 搜索实际不通,
免 key 可用性最终要靠自托管 SearXNG 或付费后端。
- `web_fetch` 不是安全边界:danger-full-access 的任务
本来就能 curl,fetch 只挡了最明显的非 http(s) 与
本地私有地址,不应把它当沙箱用。
- 编程搜索本轨未做(见回执):真实 key 的 live 测试、
主机 unit / filter / env 接线、GitHub API token
(本设计走网页搜索后端限定域名,不调 GitHub API,
所以不需要 token;真要 GitHub 代码内容搜索 API 是
另一功能,不在本轨)、老租户脚本升级。清单外项目
官网域本轨已做(见上节 `allowed_hosts`,本地提交、
未推未部署),仍缺真实 key 的 live 验证。
## JS 重型站点抽取效果评估(本轨新增,mock 为主)
评估对象是已有抽取链,不改默认抽取顺序、不因评估换
后端:`fetch_best` 固定为 Tavily extract → Exa contents
→ 直接抓取。结论只写在这里,不改代码行为。
测试文件:`cloud/tests/test_js_heavy_extract.py`(全部
mock HTTP,只有一个显式开关的 live 烟囱测试,默认跳过)。
### 阶段 1:直接抓取能拿到什么、拿不到什么
直接抓取是标准库 HTML 解析:`<script>` / `<style>` /
`<noscript>` 内容一律剥离,只留静态文本与绝对 http(s)
链接(相对链接不进 `links` 列表)。所以:
- 能拿到:静态壳里的标题、占位文本(如 "Loading…")、
静态正文、绝对链接;首屏静态内容本身就是正文的页面
不受影响。
- 拿不到:正文靠 JS 渲染的段落(mock 壳里
`RENDERED-BODY-PARAGRAPH` 只存在于 `<script>` 数据块
中,直接抓取结果里没有它,也不把数据块原文泄漏进
文本);分页靠 JS 加载的后续项(mock 里第 2 页
`ITEM-PAGE-2` 只经 `/api/items?page=2` 到达,直接抓取
只有第 1 页静态项,也拿不到该 API 地址本身)。
- 为什么:解析器没有 JS 引擎,也从不执行脚本或追
JS 发起的请求;这是结构性缺失,不是解析器 bug,
调解析参数补不回来。直接抓取在 JS 重型站上只能
给出“壳 + 首屏静态部分”,不能谎称是全文。
### 阶段 2:provider 抽取在 mock 层面能补什么、补不了什么
mock 层面(伪造的 Tavily extract 响应返回完整渲染文本)
证明链路本身能补:`fetch_via_provider` 返回
`source=tavily-extract` 的完整文本;抽取与直接都失败时
`fetch_best` 回退到直接抓取的壳(`source=direct-fetch`),
仍拿不到渲染正文——回退保证的是“不断链”,不是“补全文”。
mock 补不了、也证明不了的部分:
- 真实 provider 是否真的渲染该站的 JS(取决于各家
extract 实现是否跑无头浏览器、等多久、反爬策略),
mock 响应是“我假设它渲染了”,不是实测证据。
- JS 分页 / 交互(点“加载更多”、登录后内容、滚动加载)
provider 抽取一般只取单 URL 的渲染结果,不会替调用方
翻页或交互;多页内容仍需逐 URL fetch。
- 抽取返回空时是失败,不是空页:`fetch_via_provider`
空结果报 `WebSearchError`,不伪造页面;但 `fetch_best`
随后回退直接抓取会返回壳文本——调用方须以 `source`
字段分辨“渲染全文”与“壳”,不能只看 `text` 非空就当全文。
### Live 对照(与 mock 分开写,未验证的不写成已验证)
- 无 key 的 SearXNG 路线:旧 VM 测试曾用
`SEARXNG_URL=https://searx.diante.me` 失败(`URLError:
Remote end closed connection without response`)——该失败
现已归因:https 是错的,主机 nginx 只听 80、无 443
vhost,https 在 TLS 层证书主机名不匹配失败,根本没到
SearXNG,不能据此断言服务挂了。2026-11-01 探针实测:
http 经同一 Tinyproxy 0.34 秒回 200、16 条结果,服务
活着;主机 env 仅 `SEARXNG_URL=http://searx.diante.me`
一行、无 `SEARXNG_ENGINES`,默认 pin 生效。容器内真实
任务派发仍是 blocker(不由本轨创建生产任务),live
对照在该项上仍记为**未完成**;测试文件里的 live 烟囱
测试默认跳过,只在 `WEBSEARCH_EVAL_LIVE_SEARXNG=1`
且配了 `SEARXNG_URL` 时才跑。
- 付费后端 live 效果(Tavily extract / Exa contents /
Perplexity / Brave 的真实 JS 渲染能力):无 key,本轨
**一律未验证**,不编造效果,逐项列为 blocker(见回执),
在拿到 key 之前,文档里关于 provider 渲染能力的任何
表述都只是 mock 层面的链路结论。