# WebFetch 与 WebSearch

> 两个联网工具的实现：WebFetch 的 URL 规范化、出站护栏、重定向与各项上限、手写的 HTML 转 Markdown、交给当前模型提炼与 15 分钟缓存；预批准域名与审批；WebSearch 怎样借模型提供方的原生 web_search，哪些端点支持，结果与引用怎样整理。

- 作者：David（道雾轩）
- 专栏：ZCode 源码解读（https://daiw.org/manual/zcode.md）
- 最后更新：2026-09-21
- 原文：https://daiw.org/manual/zcode/web-tools
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

联网工具只有两个，实现都在 `apps/zcode-cli/packages/core/src/tool/handlers`。`WebFetch` 由 `webfetch.ts` 和十个 `webfetch-*.ts` 分工完成：从 Agent 所在的机器发 GET，把页面转成 Markdown，再交给当前模型按 `prompt` 提炼出答案。`WebSearch`（`websearch.ts`、`websearch-results.ts`、`websearch-support.ts`）自己不碰网络，而是另发一次模型请求，让模型提供方在服务端执行 Anthropic 协议的原生 `web_search` 工具。

预批准域名表在 `apps/zcode-cli/packages/core/src/tool/webfetch-preapproved.ts`，输入输出契约在 `apps/zcode-cli/packages/contracts/src/tools/webfetch.ts` 与 `apps/zcode-cli/packages/contracts/src/tools/websearch.ts`。WebFetch 的请求经 `HttpClientPort` 交给 `apps/zcode-cli/packages/adapters/src/http`，代理、证书与出口网络的统一处理见[执行边界：子进程、环境与网络](https://daiw.org/manual/zcode/exec-boundary)；工具怎样被调度、审批，见[执行器：调度、审批、超时与结果](https://daiw.org/manual/zcode/tool-executor)与[权限模式与规则](https://daiw.org/manual/zcode/permission)。

## 怎么用

| | `WebFetch` | `WebSearch` |
| --- | --- | --- |
| 参数 | `url`、`prompt` | `query`（至少 2 个字符），`allowed_domains` 与 `blocked_domains` 二选一 |
| 何时可见 | 始终 | 当前模型的 `supportsNativeWebSearch` 为真时 |
| 给模型的结果 | 当前模型按 `prompt` 写出的回答 | 摘要加至多 20 条链接 |
| build 模式 | 询问；预批准 URL 免询问 | 不询问 |
| 超时 | 60 秒 | 60 秒 |
| 缓存 | 按 URL 缓存 15 分钟 | 无 |

出处：`apps/zcode-cli/packages/contracts/src/tools/webfetch.ts:13`、`apps/zcode-cli/packages/contracts/src/tools/websearch.ts:12`、`apps/zcode-cli/packages/core/src/tool/handlers/webfetch.ts:195`、`apps/zcode-cli/packages/core/src/tool/handlers/websearch.ts:129`、`apps/zcode-cli/packages/core/src/runtime/methods/config.ts:141`。另外几件用户能感知的事：

- WebFetch 的描述除了一句总述，只有三条提示：私有 URL 会失败，HTTP 升级为 HTTPS 且跨主机重定向要自己再调一次，结果按 URL 缓存 15 分钟（`handlers/webfetch.ts:39`）。
- 桌面端审批框的摘要取 URL 而不是 `prompt`，注释说显示 `prompt` 会遮住真正需要确认的目标地址（`packages/ui/src/ToolCallBlocks/renderers/search.tsx:69`）。
- HTTP 客户端按配置里的 `network.httpProxy`、`network.noProxy`、`network.caCertFile` 创建（`apps/zcode-cli/packages/bootstrap/src/app/create-app.ts:409`）。每个请求都显式带 60 秒超时，所以 `network.timeout` 管不到 WebFetch。
- 自己配置的模型可以在模型设置里打开“原生联网搜索”，WebSearch 才会出现，但只有 Anthropic Messages 协议的模型真能用上，见下文（`packages/ui/src/i18n/locales/zh-CN.ts:2866`，可编辑字段见 `packages/provider/src/config/manual-model-config.ts:14`）。

## WebFetch：一次调用经过什么

```mermaid
flowchart TD
  A["WebFetch(url, prompt)"] --> N["normalizeWebFetchUrl"]
  N --> C{"缓存命中"}
  C -->|"否"| G["字面量出站检查"]
  G --> R["GET：手动重定向、60 秒、10 MiB"]
  R -->|"同主机重定向"| G
  R -->|"跨主机重定向"| RT["返回 REDIRECT DETECTED 文本"]
  R -->|"非 2xx"| HE["返回 HTTP 错误文本"]
  R -->|"2xx"| X["按类型取正文，HTML 转 Markdown"]
  X --> K["超 100000 字节落盘，写入缓存"]
  K --> P{"预批准且为 Markdown"}
  C -->|"是"| P
  P -->|"是"| D["原文直接返回"]
  P -->|"否"| M["当前模型按 prompt 提炼"]
```

**URL**。`normalizeWebFetchUrl`（`apps/zcode-cli/packages/core/src/tool/handlers/webfetch-url.ts:10`）依次检查：长度不超过 2000 字符（`apps/zcode-cli/packages/core/src/tool/handlers/webfetch-constants.ts:3`）、能被 `URL` 解析、协议只能是 http 或 https、不许带用户名密码；然后把 http 改成 https（`webfetch-url.ts:39`）。主机名的形态检查在 `webfetch-url.ts:103`：空主机名、`localhost`、`.localhost` 与 `.local` 结尾、没有点的单段主机名（如 `intranet`）都拒绝；IP 字面量在这一层放过，留给下面的出站检查。

**重定向**。请求以 `redirect: "manual"` 发出（`apps/zcode-cli/packages/core/src/tool/handlers/webfetch-network.ts:77`），遇到 301、302、303、307、308 由 `isPermittedRedirect` 判断能否自动跟随（`webfetch-url.ts:63`）：

```ts
export function isPermittedRedirect(from: URL, to: URL): boolean {
  if (to.username || to.password) {
    return false;
  }

  if (!isPublicHost(to)) {
    return false;
  }

  if (from.protocol !== to.protocol || effectivePort(from) !== effectivePort(to)) {
    return false;
  }

  if (!sameHostModuloWww(from.hostname, to.hostname)) {
    return false;
  }

  return true;
}
```

只有协议、端口相同，主机名去掉开头的 `www.` 后也相同，才自动跟随，最多 10 次，超过报 TooManyRedirects（`webfetch-constants.ts:9`、`webfetch-network.ts:151`）。其余重定向不跟，而是返回一段 “REDIRECT DETECTED” 文本，列出原 URL、目标 URL 和状态码，请模型用同一个 `prompt` 再调一次（`handlers/webfetch.ts:128`）。新的调用是一次新的工具调用，会重新过权限检查。缺少 `Location` 的 3xx 按 HTTP 错误处理（`webfetch-network.ts:188`）。

## 出站护栏

每次真正发 GET 之前，包括每一跳重定向，都要过 `assertWebFetchLiteralEgress`（`webfetch-network.ts:52`，实现在 `apps/zcode-cli/packages/core/src/tool/handlers/webfetch-egress-guard.ts:14`）：

```ts
export function assertWebFetchLiteralEgress(url: URL): void {
  const hostname = normalizeHostname(url.hostname);

  if (isLocalHostname(hostname)) {
    throw webFetchError("EgressBlocked", "WebFetch cannot access private or local hostnames", {
      hostname,
      url: url.toString(),
    });
  }

  // DNS preflight 在部分网络下 1s 内无法完成，会让公网 URL 在真实 fetch 前失败。
  // 当前只保留 URL 字面量层面的本地/私网目标阻断，不对普通域名做本地 DNS 解析。
  if (!isIpLiteral(hostname)) return;
  assertPublicIpAddress(hostname, { hostname, url });
}
```

- **主机名**：`localhost` 与 `.localhost` 结尾的一律拦下。
- **IP 字面量**：用 ipaddr.js 分类，只有归为 `unicast` 的地址才放行，回环、私网、链路本地（169.254.0.0/16，云主机元数据地址 169.254.169.254 就在其中）都不是；另外显式排除 198.18.0.0/15 基准测试网段和五个特殊用途的 IPv6 前缀（`webfetch-egress-guard.ts:4`、`:89`、`:94`）。
- **IPv6 里藏着的 IPv4**：IPv4 映射地址和 NAT64 前缀 `64:ff9b::/96` 先还原出低 32 位，再按 IPv4 规则判（`webfetch-egress-guard.ts:77`）。
- **出口代理**：响应头带 `x-proxy-error: blocked-by-allowlist` 时，按出口白名单拦截处理，返回一段 JSON 错误（`webfetch-network.ts:233`）。

<Callout type="warn">
  注释写得明白：普通域名不做 DNS 解析。一个解析到 10.x 或 169.254.169.254 的域名，或者做 DNS 重绑定的域名，都能通过这道检查。HTTP adapter 里其实有一套按 DNS 结果拦截私网地址的策略，只在请求带 `egressPolicy: "public"` 时启用（`apps/zcode-cli/packages/adapters/src/http/index.ts:70`、`apps/zcode-cli/packages/adapters/src/http/public-egress-policy.ts:79`），但 WebFetch 发请求时没有带这个字段（`webfetch-network.ts:70`），仓库里也没有别处设置它。`webfetch-network.ts:252` 为这种拦截准备的“不把解析出的内网 IP 透露给模型”的文案改写，目前走不到。
</Callout>

## 超时、大小与内容类型

- **时间**：每个 GET 60 秒，整个工具调用也是 60 秒且不许调用方改（`webfetch-constants.ts:2`、`webfetch-network.ts:75`、`handlers/webfetch.ts:239`）。工具内部的模型请求在进程级准入闸门前排队时，工具的 deadline 会暂停计时（`apps/zcode-cli/packages/core/src/tool/executor/timeout.ts:13`）。
- **大小**：响应体不超过 10 MiB，先看 `content-length`，再在流式读取中累计（`webfetch-constants.ts:4`，`apps/zcode-cli/packages/adapters/src/http/response-body.ts:18`、`:62`）。
- **请求头**：User-Agent 是 `ZCode-WebFetch/0.1 (+https://zcode.ai; coding-agent-cli)`，`Accept` 把 `text/markdown` 排在最前（`webfetch-constants.ts:11`、`webfetch-network.ts:274`）。
- **内容类型**：只收 `text/*`、JSON、XML、JavaScript、`+json`、`+xml` 以及缺省类型，其余报 “Unsupported WebFetch content type”，所以 PDF、图片链接抓不了（`apps/zcode-cli/packages/core/src/tool/handlers/webfetch-content.ts:8`、`:103`）。正文一律按 UTF-8 解码，不看 `charset`（`webfetch-content.ts:14`）。

HTML 转 Markdown 没有用任何库，是一串正则（`webfetch-content.ts:59`）：删掉注释、`script`、`style`、`noscript`；`h1` 到 `h6` 换成对应层级的 `#`；链接换成 Markdown 链接；列表项换成 `- `；`br` 与段落、表格行等块级结束标签换成换行；其余标签全部剥掉，只解码少数几个实体。最后每一行的连续空白压成一个空格并去掉首尾空白，所以 `pre` 里代码的缩进也会被压平。

转换后的文本超过 100000 字节时，全文写成会话级的工具结果附件（`webfetch-content.ts:21`），路径只出现在结构化输出里，模型看到的仍是提炼后的答案（`handlers/webfetch.ts:95`、`:257`）。

## 交给当前模型提炼

描述里说用 “a small fast model” 回答 `prompt`（`handlers/webfetch.ts:40`），实际用的是 `context.model`（`apps/zcode-cli/packages/core/src/tool/handlers/webfetch-processing.ts:30`），也就是执行器交给工具的本轮模型（`apps/zcode-cli/packages/core/src/runtime/methods/turn-tools.ts:186`，字段注释见 `apps/zcode-cli/packages/core/src/runtime/methods/turn-loop-state.ts:95`），代码里没有另配小模型。它取最低一档的推理强度，输出不超过 4096 token，不带任何工具（`webfetch-processing.ts:67`，`apps/zcode-cli/packages/core/src/model/auxiliary-model-options.ts:9`）。正文先截到 100000 字符，结尾附一句截断说明（`webfetch-content.ts:46`）。提示词在 `webfetch-processing.ts:106`：

```ts
function buildProcessingPrompt(content: string, prompt: string, preapprovedUrl: boolean): string {
  const instruction = preapprovedUrl
    ? "Provide a concise response based on the content above. Include relevant details, code examples, and documentation excerpts as needed."
    : [
        "Provide a concise response based only on the content above. In your response:",
        " - Enforce a strict 125-character maximum for quotes from any source document. Open Source Software is ok as long as we respect the license.",
        " - Use quotation marks for exact language from articles; any language outside of the quotation should never be word-for-word the same.",
        " - You are not a lawyer and never comment on the legality of your own prompts and responses.",
        " - Never produce or reproduce exact song lyrics.",
      ].join("\n");

  return `
Web page content:
---
${content}
---

${prompt}

${instruction}
`;
}
```

非预批准的页面要求引文不超过 125 个字符、不逐字复述、不评论合法性、不复现歌词；预批准的文档站则鼓励给出细节、代码示例和文档摘录。模型返回空文本时，结果换成一句固定说明（`webfetch-processing.ts:80`）。

**缓存**是模块级的 `Map`（`apps/zcode-cli/packages/core/src/tool/handlers/webfetch-cache.ts:8`），同一进程里的所有会话共用。键是模型传入的原始 URL 字符串（`handlers/webfetch.ts:56`），因此 `http://` 与 `https://` 两种写法各占一条，尽管实际请求相同。条目存活 15 分钟，总量不超过 50 MiB，命中时移到队尾，清理时先删过期再从最旧的删起（`webfetch-constants.ts:7`、`:8`，`webfetch-cache.ts:24`、`:45`）。只有成功抓到的正文进缓存，重定向与 HTTP 错误都不缓存（`handlers/webfetch.ts:71`）。缓存的是转换后的正文而不是答案：换一个 `prompt` 再问同一个 URL，不再联网，但仍会重新调一次模型，输出里 `cacheHit` 为真（`handlers/webfetch.ts:79`、`:93`）。

## 预批准域名

`webfetch-preapproved.ts` 里有 82 个整主机名和 4 个“主机加路径前缀”（`apps/zcode-cli/packages/core/src/tool/webfetch-preapproved.ts:1`、`:86`），全是公开的技术文档站：

| 类别 | 例子 |
| --- | --- |
| MCP 与技能 | `modelcontextprotocol.io`、`agentskills.io` |
| 语言与运行时 | `docs.python.org`、`go.dev`、`doc.rust-lang.org`、`www.typescriptlang.org`、`nodejs.org` |
| 前端 | `react.dev`、`vuejs.org`、`nextjs.org`、`tailwindcss.com` |
| 后端与数据 | `docs.djangoproject.com`、`fastapi.tiangolo.com`、`pandas.pydata.org`、`pytorch.org` |
| 数据库 | `www.postgresql.org`、`redis.io`、`www.sqlite.org` |
| 云与运维 | `docs.aws.amazon.com`、`cloud.google.com`、`kubernetes.io`、`www.docker.com` |
| 限定路径 | `wordpress.org/documentation`、`huggingface.co/docs`、`www.kaggle.com/docs`、`vercel.com/docs` |

主机名必须完全相等，子域名和父域名都不算。限定路径的四项要求路径正好是前缀或以“前缀加斜杠”开头，并且拒绝含 `%2f`、`%5c`、`%2e`（包括多重编码）的路径，防止用编码绕出前缀（`webfetch-preapproved.ts:109`）。

从代码看，预批准有两层作用：一是权限上免询问，在项目 deny、ask 规则与 plan 模式判断之后、按模式询问之前放行（`apps/zcode-cli/packages/core/src/permission/service.ts:189`）；二是内容处理上更宽松，服务器返回 `text/markdown` 且不足 100000 字符时原文直接返回、不经过模型，否则用上面那段宽松的提示词（`webfetch-processing.ts:95`）。

## 审批

WebFetch 的元数据是只读、`needsApproval: true`、副作用范围 `network`（`handlers/webfetch.ts:198`）；WebSearch 是只读、不需要审批（`handlers/websearch.ts:139`）。套进 `checkPermission` 的判定顺序（`service.ts:97`）：

| 模式 | WebFetch | WebSearch |
| --- | --- | --- |
| build | 预批准 URL 或 allow 规则命中则放行，否则询问 | 放行 |
| edit | 同 build | 放行 |
| plan | 按只读工具放行（`service.ts:412`） | 放行 |
| yolo | 放行（`service.ts:136`） | 放行 |
| auto | 拒绝，模式未实现（`service.ts:140`） | 拒绝 |

build 模式下 WebFetch 落到 “Tool has side effects and requires approval” 这一支（`service.ts:505`），WebSearch 走的是只读直通（`service.ts:460`）。规则匹配时，WebFetch 的比对对象是 `domain:<主机名>`（`apps/zcode-cli/packages/core/src/permission/rule-matching.ts:9`、`service.ts:288`）。而审批时默认给出的“始终允许”建议，是把输入里的完整 `url` 当作规则内容（`apps/zcode-cli/packages/core/src/tool/executor/permission-suggestions.ts:4`、`:39`）；从代码看，这样的规则与 `domain:` 主体对不上，以后的请求仍会询问，本书没有在运行中验证。

## WebSearch：借提供方的原生搜索

```mermaid
sequenceDiagram
  participant M as 主模型
  participant T as WebSearch handler
  participant P as 模型提供方
  M->>T: WebSearch(query, allowed_domains)
  T->>P: 新的流式请求，只带 web_search 工具
  P->>P: 服务端执行搜索，至多 8 次
  P-->>T: 文本流与用量
  T-->>M: Summary、Links 与引用提醒
```

WebSearch 只有在当前模型 `supportsNativeWebSearch` 为真时才出现在工具清单里（`config.ts:268`），handler 执行时再查一遍（`handlers/websearch.ts:71`）。它另起一次请求（`handlers/websearch.ts:82`）：

```ts
  const request: Parameters<typeof model.streamText>[0] = {
    messages: [
      {
        role: "system",
        content: "You are an assistant for performing a web search tool use.",
      },
      {
        role: "user",
        content: `Perform a web search for the query: ${input.query}`,
      },
    ],
    tools: [createProviderNativeWebSearchContract(input)],
    // BigModel 的 Anthropic 兼容端点会拒绝 named forced web_search tool_choice（1210）。
    // 这里保持自动选择，依靠单工具请求和 prompt 触发 provider-native 搜索。
    options: {
      ...auxiliaryModelOptions(model),
      maxOutputTokens: Math.min(4096, model.optionSpecs.maxOutputTokens.max),
    },
    abortSignal: context.abortSignal,
  };
```

请求里唯一的工具是 provider-native 的 `web_search`（`handlers/websearch.ts:156`）。之所以走流式，注释说 BigModel 的 Anthropic 兼容端点在非流式 JSON 里会把内部搜索结果返回成 assistant 一侧的裸 `tool_result`，AI SDK 校验时会报错（`handlers/websearch.ts:103`）。适配层只会为 Anthropic Messages 协议编码这个工具，映射成 `anthropic.tools.webSearch_20260209`，其他 API 形态直接报错（`apps/zcode-cli/packages/adapters/src/model/tool-transform.ts:259`、`:275`）。`maxUses` 默认 8、上限 8，运行时接受，但不在给模型的 JSON Schema 里（`apps/zcode-cli/packages/contracts/src/tools/websearch.ts:9`、`:26`、`:45`）。

内置规则里把 `supportsNativeWebSearch` 设为真的端点：

| 端点 | 出处 |
| --- | --- |
| Anthropic 官方 `api.anthropic.com/v1` 上的 `claude-*` | `config/provider/zcode-builtin.json:4052` |
| DeepSeek 的 `/anthropic` 端点 | `zcode-builtin.json:4072` |
| Z.ai 的 `api.z.ai/api/anthropic` | `zcode-builtin.json:4095` |
| 智谱 BigModel 的 `open.bigmodel.cn/api/anthropic` | `zcode-builtin.json:4106` |
| ZCode Coding Plan 的 `zcode-plan/anthropic` | `zcode-builtin.json:4117` |

闲时计划的 `off-peak/anthropic` 端点显式关掉（`zcode-builtin.json:4128`），其余模型的默认值为假（`zcode-builtin.json:884`）。账号与 Coding Plan 见[账号、Coding Plan 与闲时计划](https://daiw.org/manual/zcode/accounts-plans)，规则体系见[Provider 规则、模型目录与选项映射](https://daiw.org/manual/zcode/provider-config)。

**结果与引用**。收集流时只记文本、工具调用与结束事件（`apps/zcode-cli/packages/core/src/tool/handlers/websearch.ts:179`），流里的裸 `tool_result` 块又会被兼容层滤掉（`apps/zcode-cli/packages/adapters/src/model/anthropic-stream-compat.ts:258`），所以 `results` 通常为空，来源主要靠从摘要里抽取 Markdown 链接，注释也承认引用有时只出现在摘要里（`apps/zcode-cli/packages/core/src/tool/handlers/websearch-results.ts:110`）。给模型的内容是 “Web search results for query” 加摘要，再列至多 20 条链接，最后一句 “REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.”（`websearch-results.ts:14`、`:42`）。服务端实际搜了几次，取自用量里的 `server_tool_use.web_search_requests`（`apps/zcode-cli/packages/adapters/src/model/runner-normalization.ts:21`）。

工具描述每次读取时现算当前月份，并要求回答末尾附 “Sources:” 链接列表（`handlers/websearch.ts:49`、`:136`）。描述第一句写着 “US-only”，但从代码看，能不能搜只取决于模型配置，实际后端是各家提供方自己的搜索。

## 错误与给模型的提示

WebFetch 自己抛的错误都经 `webFetchError` 构造：类型是可恢复的 `ToolExecutionFailed`，上下文里带一个 `webFetchCode`，只有两类标记为可重试（`apps/zcode-cli/packages/core/src/tool/handlers/webfetch-errors.ts:20`、`:24`）：

| 代码 | 触发 | 可重试 |
| --- | --- | --- |
| `webfetch_invalid_url` | URL 过长、无法解析、主机名不合法 | 否 |
| `webfetch_unsupported_protocol` | 不是 http 或 https | 否 |
| `webfetch_credentials_in_url` | URL 带用户名或密码 | 否 |
| `webfetch_unsafe_redirect` | `Location` 不是合法 URL | 否 |
| `webfetch_egress_blocked` | 本地主机名、非公网 IP 字面量，或出口代理拦截 | 否 |
| `webfetch_too_many_redirects` | 同主机重定向超过 10 次 | 否 |
| `webfetch_response_too_large` | 响应超过 10 MiB | 否 |
| `webfetch_fetch_failed` | 网络错误、不支持的内容类型 | 是 |
| `webfetch_processing_failed` | 模型提炼失败 | 是 |

映射表里还有 `webfetch_missing_redirect_location`，但没有抛出点。不支持的内容类型也归入可重试的 `fetch_failed`，重试并不会有别的结果。网络错误的文案会顺着 `cause` 链找到最底层的原因和错误码拼上去，免得只剩一句 “fetch failed”（`webfetch-network.ts:281`、`:305`）。

非 2xx 响应不算错误：工具返回一段以 “The server returned HTTP” 开头的说明，数字形式的 `Retry-After` 会附上，并提示需要认证的页面改用 `gh` 或带认证的 MCP 工具（`handlers/webfetch.ts:161`、`webfetch-network.ts:220`）。每次 GET 前后还会发一条 `NetworkRequestStatus` 会话事件，带上是否走代理、是否用了自定义 CA 等出口信息（`webfetch-network.ts:58`、`:82`，`apps/zcode-cli/packages/adapters/src/http/index.ts:283`）。

WebSearch 的失败要少得多：没有模型、模型不支持原生搜索分别报配置错误，后者可恢复（`handlers/websearch.ts:64`、`:71`）；流里的错误原样抛出，非 `Error` 对象包成 “WebSearch stream failed”（`handlers/websearch.ts:215`）。

下一篇：[Bash：解析、只读判定与后台任务](https://daiw.org/manual/zcode/bash)——命令怎样被解析、哪些算只读、超时怎么定，以及长命令怎样转入后台。
