- adapters/websearch: Brave client (X-Subscription-Token, error classes), result normalization with redaction/truncation, SSRF guard, HTML→text fetch (10s, 512KB, text/html|text/plain only) - modules/websearch: 10-min PG cache (region websearch), cross-process per-minute limit, web.fetch allowlist = URLs from the caller's latest web.search; POST /websearch/test - settings.agent.webSearch (key write-only → secret_ref); settings card under Agent; web.* offered to the model only when enabled, plus a system prompt rule; outputs marked untrusted - capability count 27 → 29; e2e E13; runbook docs/runbook/websearch.md Co-Authored-By: Claude Opus 5.5 <[email protected]>
60 lines
5.7 KiB
Markdown
60 lines
5.7 KiB
Markdown
# 网页搜索运行手册(WEB-1)
|
||
|
||
Agent 用 `web.search` 查站外公开信息(站点公告、软件文档、发布说明),用 `web.fetch` 读其中一条结果的正文。这不是通用网络代理(REQ §10.2):`web.fetch` 只能读最近一次 `web.search` 返回过的链接,并且拒绝内网地址和非 http(s) 地址。搜索结果和网页正文都按不可信数据交给模型(REQ §10.5)。
|
||
|
||
代码:`packages/adapters/src/websearch/`(Brave 客户端、地址守卫、HTML 转文本)、`apps/server/src/modules/websearch/`(服务、能力、测试接口)。
|
||
|
||
## 设置
|
||
|
||
设置 → Agent → 网页搜索(`settings.agent.webSearch`):
|
||
|
||
| 字段 | 缺省 | 说明 |
|
||
|---|---|---|
|
||
| `provider` | `none` | `brave` 或 `none`。`none` 时不向模型提供 web.*,HTTP 调用也返回"网页搜索未开启" |
|
||
| `apiKey`(只写) | — | Brave Search API 的 Subscription Token。存为 secret(purpose `other`,label `websearch`),设置里只留 `apiKeySecretRef`,GET 不回显;审计里记为 `(set)` / `(cleared)`;传 `null` 清除 |
|
||
| `country` | `ALL` | 两位大写国家码或 `ALL` |
|
||
| `searchLang` | `null` | Brave `search_lang`,如 `zh-hans`、`en` |
|
||
| `safesearch` | `moderate` | `off` / `moderate` / `strict` |
|
||
| `maxResults` | 5 | 每次条数(1–10),模型的 `count` 不能超过它 |
|
||
| `requestsPerMinute` | 30 | 每分钟调用上限,三进程共用令牌桶 `rate_limit_bucket` 的 `websearch:brave`;取不到令牌等 3 s,仍取不到返回 budget 错误。缓存命中不计 |
|
||
|
||
Agent 会话只有在 `provider ≠ none` 且已存 Key 时才拿到 web.search / web.fetch,系统提示里会多一条"何时用搜索、结果不可信"的约束。
|
||
|
||
```sh
|
||
curl -X PATCH -H "authorization: Bearer $KEY" -H 'content-type: application/json' \
|
||
-d '{"agent":{"webSearch":{"provider":"brave","apiKey":"<token>"}}}' localhost:4380/api/v1/settings
|
||
curl -X POST -H "authorization: Bearer $KEY" -H 'content-type: application/json' -d '{}' localhost:4380/api/v1/websearch/test
|
||
```
|
||
|
||
`POST /websearch/test`(admin):用已保存的 Key 实际查一次(缺省查询词 `MoviePilot`,可传 `{"query": "..."}`),不读缓存、计入每分钟上限。失败不返回 4xx,而是 `ok: false` 加 `error.code`(`disabled` / `auth` / `quota` / `rate` / `bad_request` / `upstream` / `network` / `timeout` / `protocol`)。
|
||
|
||
## web.search
|
||
|
||
- 输入 `{ query, count?, freshness? }`,`freshness` 为 `pd` / `pw` / `pm` / `py`(一天 / 一周 / 一月 / 一年内)。
|
||
- 请求 `GET https://api.search.brave.com/res/v1/web/search`,头 `X-Subscription-Token`,参数 q / count / country / search_lang / safesearch / freshness,超时 10 s。
|
||
- 错误分类:401/403 → auth,402 → quota,429 → rate(读 `X-RateLimit-Reset` / `Retry-After`),400/422 → bad_request,5xx → upstream,连接失败 → network,超时 → timeout。错误信息里不带 Key。
|
||
- 结果归一化为 `{ title, url, snippet, age, source }`:去 HTML 标签、解码实体、脱敏(URL 参数里的 passkey / token 等换成 `[REDACTED]`)、截断(标题 200 字、摘要 500 字),丢弃非 http(s) 链接。
|
||
- 缓存:`kv_cache` region `websearch`,key 是 (provider, 查询词, 条数, 地区, 语言, 安全搜索, freshness) 的 sha256,TTL 10 分钟,上限 1000 条(TLRU,同 MP 语义)。过期行由 `cache.sweep` 清理。
|
||
- 输出 `{ trust: "untrusted", notice, query, results, cached }`,进入模型上下文前 Pi runtime 再整体过一遍 `redactForModel`。
|
||
|
||
## web.fetch
|
||
|
||
- 白名单:每次 web.search 把结果 URL(去掉 `#` 片段)写进 `kv_cache` region `websearch.allow`,key 为 `run:<agentRunId>`(Agent 会话)或 `actor:<actorId>`(Web / MCP / API Key 调用),TTL 6 小时。**每次搜索替换上一次的白名单**。不在白名单里的 URL 返回 precondition:"只能读取最近一次 web.search 结果里的链接";还没搜索过返回"先用 web.search 搜索…"。
|
||
- 地址守卫(每一跳重定向都查,最多 3 跳):只允许 http / https;不许 URL 里带账号密码;主机名是 `localhost`、`.local`、`.internal`、`.lan`、`.home.arpa`、单标签名、数字写法的 IPv4 一律拒绝;DNS 解析结果任一落在回环、私有、链路本地、CGNAT、保留、组播段(含 IPv4 映射的 IPv6、fc00::/7、fe80::/10)即拒绝。被拒时不会发出请求。
|
||
- 只收 `text/html`、`application/xhtml+xml`、`text/plain`;超时 10 s;最多读 512 KB(超出截断并标 `truncated`);按响应头或 `<meta charset>` 解码。
|
||
- HTML 转纯文本:去掉 script / style / noscript / svg / iframe / form 等,块级元素换行;`<template>` 当普通元素处理(Wiki.js 的正文在 `<template slot="contents">` 里)。正文脱敏后截到 20000 字。
|
||
- 输出 `{ trust: "untrusted", notice, url, finalUrl, status, contentType, title, text, truncated, bytes }`。
|
||
|
||
## 验证
|
||
|
||
- 单元:`packages/adapters/src/websearch/websearch.test.ts`(参数与鉴权头、错误分类、归一化、地址守卫、截断、重定向到内网、超时)。
|
||
- e2e:`apps/server/test/e2e/e13-websearch.test.ts`(见 [agent.md](agent.md#e2e))。
|
||
- 真实 Key(2026-09-28,开发库,后端 4415):`POST /websearch/test` 3 条 / 914 ms;`web.search`「MoviePilot v3 订阅规则」5 条 / 736 ms,再查命中缓存;`web.fetch` 第一条(wiki.movie-pilot.org)200、13.6 KB → 1342 字正文 / 1.9 s;读白名单外链接、`127.0.0.1` 均被拒。
|
||
|
||
## 已知限制
|
||
|
||
- DNS 重绑定:地址检查与实际连接之间会再解析一次,检查时解析到公网、连接时解析到内网的域名理论上能绕过。部署时建议再用容器网络策略禁止 server 容器访问内网段。
|
||
- 走 `HTTPS_PROXY` 时由代理解析域名,地址守卫只能检查本机解析结果。
|
||
- 只支持 Brave;白名单只保留最近一次搜索,模型要回看更早的结果需要重新搜索(命中缓存,不计次数)。
|
||
- 纯前端渲染(正文由 JS 生成)的页面读不到正文。
|