mcpField
H
HEIDES

HEIDES

本地服务
github.com/AbduljabbarBXR开发工具13 周前更新

关于 HEIDES

HEIDES 是本地运行的确定性代码分析工具,为编码 Agent 构建代码图谱、生成问题报告,并在补丁应用前评估风险。它提供静态检查和结构化 MCP 结果,不依赖云端或模型。

主要功能

  1. 01构建持久代码图谱
  2. 02检查污点流与代码风险
  3. 03分析调用关系和导入
  4. 04评估补丁及受影响调用方
  5. 05以门禁方式检查并返回退出码
  6. 06提供命令行与 MCP 工具

适用场景

  1. 01在提交补丁前评估影响
  2. 02在 CI 中阻止严重问题
  3. 03查询函数调用方与代码文档
  4. 04检查输入是否流向 SQL 或 Shell

MCP 相关

`heides mcp` 通过 stdio 提供 11 个工具,包括 `harmony.check` 和 `harmony.report`,返回带有严重级别、文件位置和安全门禁信息的结果。

配置

本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。

暂未收录安装方式

我们还没有获取到这个服务的安装包或远程地址,请按项目 README 中的说明安装。

代码仓库
安装包
mcpbheides-0.13.1-linux-x86_64.mcpb0.13.1
mcpbheides-0.13.1-darwin-arm64.mcpb0.13.1
mcpbheides-0.13.1-darwin-x86_64.mcpb0.13.1
mcpbheides-0.13.1-windows-x86_64.mcpb0.13.1

工具

还没有获取到工具列表

本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。

详细介绍

HEIDES

HEIDES,代码的神经系统

代码的神经系统

HEIDES 是一个确定性测试框架,为 AI 编码 Agent 提供它们自身所不具备的能力:一份持久的代码地图、基于该地图生成的警告,以及补丁应用前给出的判定。Agent 提出建议,HEIDES 决定是否安全。

一个二进制文件。无需云服务、模型或账户。它可以在笔记本电脑、服务器、CI 中运行,也可以在运行 Termux 的手机上运行。

heides scan                 # map the codebase once
heides check                # run every guard, exit non-zero on a blocker or critical
heides staged patch.diff    # judge a patch before it lands
heides mcp                  # expose it to any MCP client

check 是一道门禁,而不是一份报告。发现阻断项或严重问题时,它会以非零状态码退出,因此遇到真实的 SQL 注入时,CI 会失败,无需任何人解析输出。工作区干净时则以 0 退出。--exit-zero 可恢复旧版始终返回 0 的行为,而 --exit-threshold=warning 会让门禁更严格。

每道防护都在本地运行,这正是整个设计的核心

heides 只报告它能根据已读取文件证实的问题。每条发现都标明文件和行号,同一棵代码树无论在笔记本电脑、容器,还是在关闭网络的手机上,都会得到相同的判定。没有会过期的缓存,也没有可能出故障的注册表。

过去并非如此。heides 曾有一个依赖项防护,会查询固定版本是否属于已知 CVE;而在工具内回答这个问题的各种方式,都可能出错:

  • 通过网络查询时,门禁判定取决于网络状况,而非代码树。在同一个包含 5 个文件的测试样例中,启用该防护时 check 耗时 3242 ms,不启用时耗时 117 ms。
  • 使用缓存则更糟。离线缓存会在 24 小时后让原本干净的结果过期,因此早上发布的 CVE,在重新获取条目之前仍会显示“没有已知漏洞”。输出中没有任何内容能区分这两种情况。

如果一道防护无法区分“没有漏洞”和“尚未获知”,它报告的就是虚假的安全感,而虚假的安全感与通过无从区分。因此,这道防护被移除了,而不是改成可选项:即使默认安全的可选功能仍会拖慢默认流程,而即便缓存正确,它也还是缓存。

依赖项、密钥和暴露面由 GRIM 提供,该项目是公开发布的,并由持续更新的数据源支持,而非依赖此代码库的缓存。 heides deps 和 deps.* 工具仍然存在,并会如实说明这一点,这样,持有这些名称的 Agent 就能知道答案去了哪里,而不会以为工具坏了。

heides 仍能在本地验证的,是你即将进行的更改中真正重要的部分:

污点用户输入是否到达 SQL 字符串、shell 或提示词
密钥根据值的形态检测已提交的凭据,包括源代码和配置中的凭据
边缘情况输入不符合预期时会触发 panic 的解包和索引模式
架构外键循环、缺少主键、读取路径上的个人身份信息
配置.env、Dockerfile、Terraform 和清单文件中的凭据

这些都是关于你的代码库而非外部世界的事实,因此适合由同一个工具处理;CVE 问题则不然。

通过 MCP

harmony.check 和 harmony.report 会返回包含防护项、严重级别、消息、文件和行号的发现结果。harmony.report 会提供 security_gate 和 security_posture,让 Agent 无需解析文本就能根据判定进行门禁检查;覆盖情况回执则包含在 JSON 中,因为收到干净结果的 Agent 必须能够了解实际检查了多少内容,而无需再次调用。

这条经验在它所针对的防护移除后仍然适用。一个 MCP 服务会在同一个进程中处理每个客户端,因此会改变行为的设置必须限定在请求该设置的调用范围内,绝不能设置在进程级别。早期版本的 harmony.check 设置了一个进程全局变量,却从未将其恢复,导致一个客户端传入参数后,悄然改变了所有客户端后续请求的安全态势。

为什么存在

AI Agent 能力强大,却对代码一无所知。它可以写出完美的函数,却仍然会破坏三个自己从未读过的调用方,因为它没有代码的持久化地图。代码检查器和测试会在更改落地后才发现问题,而且只覆盖恰好运行到的路径。典型的失败案例是:Agent 修改了签名,未经过测试的调用点因此出错,测试套件却仍然全部通过,最终在凌晨两点导致生产环境故障。

HEIDES 在最关键的时刻——写入之前——弥补了这一缺口。它能即时回答其他工具都无法回答的问题:谁调用了这个函数?这个文件实际用了哪些导入?这个补丁是否与图谱冲突?用户输入是否流入了 SQL 字符串、Shell 或提示词?

主干

主干将代码库映射到一个嵌入式 SQLite 文件中

HEIDES 只遍历一次目录树,就会在一个嵌入式 SQLite 文件中构建持久化图谱:文件、包含所捕获文档注释的符号、调用边、导入边,以及两个 FTS5 表,一个索引名称和文档,另一个索引字面量和注释。此后的每个问题都只需查询索引,因此 Agent 查询图谱,而不是读取文件。

主干不是模型,也不是猜测。heides describe 一次性读取整个工作区清单,其中包括各语言的入口点、枢纽和文档覆盖情况。在工作区中无人调用某个函数,并不意味着它一定是死代码:Express、Flask、Django 和 Go 的路由注册都会被识别,因此处理程序不会再被报告为未调用的根节点。

成本约束由 CI 的规模阶段在每次推送时强制执行,而不是由本文件中固定的数字规定:索引时间与文件数呈线性关系,查询依赖索引,内存占用有界,分析过程绝不需要联网。

污点

污点跨越函数边界,追踪用户输入到达接收端的路径

污点分析是发现实际漏洞的部分。源包括各语言中的请求数据和环境读取。它会在块作用域内、跨函数边界以及跨模块级代码追踪流向,并设有跳数上限,超过上限后不再报告。接收端包括 SQL、SSRF、NoSQL、Shell、文件系统、提示词、eval 和 Django 的 mark_safe。

SSRF 和 NoSQL 刻意采用比 SQL 更严格的门槛。硬编码的健康检查 URL 和请求处理程序内的字面量查询对象都是安全的,因此这些规则要求被污染的值确实到达接收端所在行,而不只是处理程序中某处存在源:

app.get('/health', async (req, res) => {
  const id = req.query.id;                              // a source is in scope here
  const r = await fetch('https://api.internal/health'); // ...and this must stay silent
});

如果被污染的 fetch 指向云元数据地址,报告会将其标记为凭据窃取,而不是泛泛的 SSRF,因为这正是它的本质:

user controlled input reaches a cloud metadata fetch sink on this line. source at line 4

报告只会声称它能看到的情况。通过变量传入的被污染主机将被报告为 SSRF,因为该地址并未出现在代码行中,工具也不会猜测它指向哪里。

最重要的规则是保持克制。run、raw、literal 和 prepare 都是含义模糊的名称,因此只有在接收对象与数据库相关时,它们才会被视为 SQL:

db.run("DELETE FROM users WHERE id = " + id)   // critical SQL finding
run(taskName)                                  // silence, a task runner is not a database

文档查询也遵循相同规则:方法名称本身含义模糊,但接收对象并不模糊:

UserModel.find(JSON.parse(req.query.filter))  // critical NoSQL finding
items.find(i => i.id === id)                  // silence, that is an array method

这两种情况都有回归测试,因此这种捷径不会卷土重来。

语言

污点分析和索引是分开的,表格如实说明了各自的情况。 以下每一行的数据都经过已发布的二进制文件实测,并非根据语法列表推断得出。

语言是否建立符号索引是否进行污点扫描
JavaScript、TypeScript、TSX、Python、PHP、Go、Java、C#是是
C、C++是否
Ruby是是
HTML、CSS是否
Rust是否

直到 0.16.0,TypeScript 都被悄悄地跳过了污点扫描:解析器将 .ts 映射为 typescript,而规则表中没有 typescript 对应的行,因此所有 TypeScript 文件都被跳过,没有任何提示。本系列之前,Ruby 完全无法进行污点分析,原因有三个:没有 rb 扩展名、没有源代码行,以及块检测器只识别花括号,不识别 def 和 end。Rust 会建立索引,但不会进行污点扫描,因此 Rust 代码中的 shell 或 SQL 接收点不会被报告。

.tsx 使用 TSX 语法,而不是普通的 TypeScript 语法。两者不能互换:JSX 元素会打开普通语法无法识别的节点类型,遍历因此在此处停止,所以同一段代码使用 .ts 扩展名时,得到的符号数量会多于组件文件。C 和 C++ 会建立符号和调用边索引,但不会进行污点扫描:将 sprintf 的结果写入固定缓冲区,再传给 system,不会产生任何结果。

门禁

应用补丁前的门禁会检查差异

heides staged 是 Agent 循环实际使用的部分。它检查的是差异,而不是文件:签名扩宽(不只是移除)、补丁破坏的调用方和导入、补丁引入的污点,以及项目自身的测试(如果有)。判定结果采用结构化格式,因此 Agent 无需解析自然语言即可据此设置门禁;每条发现都会附带文件和行号,并以非零状态码退出。

安装

一行安装程序,可下载适用于 Linux、macOS、Windows 和 Termux Android 的预构建二进制文件。

curl -fsSL https://raw.githubusercontent.com/AbduljabbarBXR/heides/main/scripts/install.sh | bash

使用 HEIDES_VERSION 固定版本。它接受不带前缀的版本号,并会去掉开头的 v,因此 0.16.0 和 v0.16.0 都可以使用。npm 安装程序读取同一个变量,因此一个版本固定设置即可覆盖两种安装方式。

# any published tag, bare or with a leading v
HEIDES_VERSION=v0.16.0 curl -fsSL https://raw.githubusercontent.com/AbduljabbarBXR/heides/main/scripts/install.sh | bash

或者通过 npm 安装,无需 Rust 工具链:

npm install -g heides

如果运行 heides 时缺少二进制文件,启动器会立即下载并告知你。这种回退机制本不该用到:npm 11.16 及更新版本默认会阻止依赖项生命周期脚本;在 npm 11.17 中,--allow-scripts 标志会直接中止安装,因此负责获取二进制文件的 postinstall 钩子根本不会运行。启动器不会让你重新安装(而这恰恰是刚刚失败的操作),而是运行与钩子相同的安装程序。平台表位于 install.js 中,因此只有一份。

或者使用 cargo 安装:

cargo install heides

也可以从发布页面下载适用于你平台的预构建二进制文件,或通过 cargo build --release 从源代码构建。

所有渠道的版本都相同;如果 npm 封装程序和二进制文件出现版本不一致,测试会使构建失败。

快速开始

heides scan                       # build the spine index
heides check                      # every guard, human readable
heides query callers send_order   # who calls it
heides query search refresh       # names, docs, literals and comments
heides describe                   # the workspace manifest in one shot
heides plan "add a health endpoint"   # is this plan grounded in what exists
heides staged patch.diff          # judge a patch before applying it
heides mcp                        # serve the eleven tools over stdio

以下是一个完整示例:在一个植入了两个 Bug 的小型 Express 服务上运行。

$ heides check
0 blocker(s), 3 critical, 0 warning(s), 1 info

[critical] user controlled input reaches a SQL sink ... at src/knexish.js:6
[critical] user controlled input reaches a SQL sink ... at src/knexish.js:7
[critical] user controlled input reaches a SQL sink ... at src/orders.js:9
[info] no dependency manifests found

$ heides query search users
2 hit(s)
SELECT * FROM users WHERE email = (literal) at src/knexish.js:5
DELETE FROM users WHERE id = (literal) at src/orders.js:9

$ heides plan "add a health endpoint"
feasible true
  no identifier in this plan exists in the spine. this plan introduces new definitions.
  existing functions to build on or replace: findUser, greet at src/clean.js:1
  spine holds 4 file(s) and 5 symbol(s). index version 8.

命令参考

所有命令,一览无余。heides <command> --help 会打印任一命令的用法行。

命令功能
scan将当前代码库映射到持久化索引中。其他命令会读取此命令构建的内容。
status一行显示:索引保存的文件、符号、调用边和导入信息。
describe工作区清单:语言、数量、入口点、枢纽、文档覆盖率,以及哪些守卫背后有真实语料。
querycallers、imports、definition、calls、neighbors、search。查询谁调用了某个符号、定义位于何处、它调用了什么,还可在名称、签名、文档、字面量和注释中进行自由文本搜索。
check运行所有守卫。质量关卡。如果发现项属于阻断级或严重级,则以非零状态码退出。
staged应用统一差异之前对其进行检查。
verify项目可以对自身作出的断言:项目自身的测试以及所有本地守卫,并提供一个可用于分支判断的布尔值。
dbtables、columns、reads、writes、orphans、missingindex、policies、cycles、schema、routes、touch。从代码中读取的数据库图。
deps已移除。它过去用于查询固定版本是否为已知 CVE。现在会拒绝执行,并指向 GRIM。
config扫描清单和配置文件,查找凭据及重要设置。
export将代码映射写入 Markdown 文件。此命令没有对应的 MCP 工具,因为它是文件导出,而服务没有可供导出的目标。
confirm在依赖某个软件包之前,向 crates.io 和 npm 查询其真实名称。这里唯一会主动使用网络的命令。
plan根据代码库评估计划,并展示所用证据。接受自由文本。
scaffold根据计划生成项目并建立索引。接受自由文本。
changed-since索引中修改时间晚于 Unix 时间戳的文件。返回结果与 spine.changed_since 工具相同。
watch有变更时重新建立索引并重新检查。
version打印版本。
mcp通过 stdio 运行 MCP 服务。

MCP 工具参考

该服务通过 stdio 向任何 MCP 客户端提供 21 个工具。除特别注明外,每个工具都有对应的命令行版本。

  • spine.scan。将当前代码库映射到持久化索引中。(scan)
  • spine.query。查询谁调用了某个符号、谁导入了某个模块、定义位于何处、某个函数调用了什么,或对名称、签名、文档、字面量和注释进行全文搜索。(query)
  • spine.describe。一次调用即可获取工作区清单:语言、计数、入口点、枢纽和文档覆盖率。(describe)
  • spine.neighbors。查看符号的各个关联:定义及其捕获的文档、调用者和调用。(query neighbors)
  • spine.changed_since。列出在 Unix 时间戳之后修改过的已索引文件。(changed-since)
  • harmony.check。运行所有守卫,并提供带证据的检查结果。(check)
  • harmony.report。以结构化 JSON 返回相同的判定结果,并包含严重性计数和 clean 标志。
  • harmony.staged。在应用统一差异之前对其进行检查。(staged)
  • harmony.verify。将项目自身的测试和所有守卫合并为一个布尔值。(verify)
  • grounding.plan。根据代码库评估计划,并提供所用证据。(plan)
  • grounding.scaffold。根据计划搭建新项目并立即为其建立索引。(scaffold) 三个 deps.* 工具已随守卫一起移除。调用其中一个会返回错误,说明该功能已移至 GRIM,而不是返回会让人误以为服务已损坏的未知工具错误。
  • db.tables、db.columns、db.reads、db.writes、db.routes、db.schema、db.touch。从代码中读取数据库图。(db <subcommand>)
  • config.scan。扫描清单和配置文件中的凭据与设置。(config)
  • web.confirm。根据软件包注册表确认事实。(confirm)

harmony.report 没有单独的命令,因为 check 会输出相同的判定结果,而 export 会导出映射;再次拆分只会为同一个答案增加第二种写法。

安全模型

  • 设计上具有确定性。每条检查结果都包含文件、行号、原因和规则 ID。没有黑箱。
  • 默认在本地运行。除明确发起的依赖检查和 grounding 网络调用外,任何内容都不会离开本机。
  • 无遥测、无分析、无需账户、无端口、无守护进程协议。单个进程通过 stdio 通信。
  • 单个静态二进制文件,无运行时依赖。
  • MCP 服务读取原始字节,因此二进制输入不会导致其崩溃,并且会限制消息大小。

用例

  • 在终端中的 Agent 编码会话里提供护栏。
  • 在 CI 中或通过 MCP 对 AI 生成的拉取请求进行合并前审查。
  • 无需阅读整个代码树,即可让 Agent 熟悉陌生代码库。
  • 审查 Agent 应用的安全性,尤其是提示词注入。
  • 改善旧项目的依赖管理。

尚未完成的部分

直言不讳,并在 ROADMAP.md 中通过引用和解决状态列进行跟踪。关于这份清单,有两点值得明确说明。列在这里的缺口是你可以据此规划的限制;悄无声息地什么也不做则不是。0.15.2 中就曾出现过这种情况:对从未索引过的工作区进行检查时,报告称工作区干净。现在有两项测试明确锁定了这个特定缺陷:一项断言新构建的图可以读回其中列出的每个文件,另一项断言首次检查包含未索引问题文件的工作区时会报告该问题。

  • 还没有数据库层。没有 .sql 解析,没有架构图,也无法查询哪些代码会操作某个表。这是目前已知最大的缺口。请注意,缺少的是 数据库 层,而不是存储:核心已经是一个嵌入式 SQLite 文件,因此这是在现有存储上扩展,而不是引入新的存储。
  • 没有 API 表面图,因此无法通过一次调用回答“POST /users 最终会写入什么”。这是重点项目。
  • 对文件系统写入点之外的路径遍历,以及框架特定 shell API 的命令注入规则,尚未支持。
  • 没有开放重定向、XXE、不安全反序列化或加密误用规则。
  • 有少数生态系统尚未读取依赖项:Ruby、.NET、Swift、Gradle、Dart;也不会从锁文件解析传递依赖。
  • 尚未索引的语言:SQL、shell、Kotlin、Swift、Scala、Dart、Dockerfile。C 和 C++ 已索引符号和调用边,但没有污点记录。Rust 已索引,但未进行污点扫描。
  • 任何守卫都不会访问网络。 每项发现都可以通过文件来证明。唯一会访问网络的命令是在 grounding.plan 中进行网络确认;这是调用方按名称请求的有意查询,而不是作为 check 一部分运行的守卫。
  • staged 只验证冲突;它不会对修补后的树运行污点分析,因此目前无法通过关卡拦截引入数据流的补丁。

阅读输出

对这个仓库运行检查后,输出了 185 行,其中 152 行是同一句话。 这实际上是一项计数为 152 的发现;将它打印成 152 行,正是导致重要发现被淹没在信息墙中的原因。

0 blocker(s), 1 critical, 157 warning(s), 41 info

[warning] unwrap can panic when the value is not present. handle the case
instead. x152 (edge.cases) at src/deps.rs:893

advice, unproven and unranked:
[info] diagnostic console call left in the code. x10 (best.practice) at npm/bin/heides.js:17

三条规则。相同的发现会折叠,并保留计数以及第一个文件和行号,因此不会丢失任何信息。--all 会打印每一项发现。证据绝不会与建议混在一起:污点发现是一条可供你查看的代码路径;“函数跨越 N 行”则是风格意见,Agent 无法像处理前者那样对后者采取行动。顶部的计数始终描述所有发现,因此折叠绝不会粉饰结果。

--no-advice 会完全移除建议部分,适用于只需要证据、不需要其他内容的关卡。

Agent 要为此付出什么

heides 专为模型阅读而构建,因此一次调用返回的是预算,而不仅仅是答案。在这个仓库上的测量结果如下:

调用字节数令牌数
将每个 Rust 文件读取一遍1,105,462276,365
describe6,4971,624
describe --brief5,1161,279
带建议的 harmony.check(MCP)4,8271,206
默认的 harmony.check(MCP)718179

概览所需内容比阅读源代码少 169 倍。通过 MCP,harmony.check 只返回证据,而 advice: true 会请求返回风格意见,因为 Agent 无法据此采取行动,关卡也无法据此判定失败。CLI 会保留完整记录: 人在终端前会有意阅读全部内容。

两个标志可防止结果成为对话的终点:

  • describe 的 --brief 会省略规则测量、文档覆盖率和未记录符号列表:这是一份供人判断关卡的记录,而不是 Agent 会据此采取行动的数据。
  • harmony.check 的 max_bytes 会将结果裁剪到你设定的预算。裁剪会落在行边界,并报告省略了哪些内容,因此不会将受限结果误认为完整结果。
insight

heides 自相矛盾之处,以及一项更改会影响到什么。

heides insight contradictions   # signals that cannot both be true
heides insight impact <symbol>  # what editing this could reach
heides insight coverage         # where no rule can fire at all
heides insight baseline save    # record today's findings as known
heides insight baseline         # what is new since that baseline

contradictions 会比较独立计算出的信号,因此分歧是证据,而非猜测:某个路由的处理函数找不到匹配的定义;某个处理函数也被死代码根检查判定为不可达;某个文件中被调用次数最多的函数却没有注释;某个文件既没有被任何对象访问,也没有调用任何对象。

impact 回答 Agent 在每次编辑前都会提出的问题。它会报告传递调用者数量、随更改而变动的文件、受影响的路由,以及是否有测试会触及该代码,因为“四个调用者”和“四个调用者且没有测试会触及该代码”是不同的答案。

coverage 会指出干净的判定在哪些情况下毫无意义。rust: 44 file(s) indexed, no taint rule can fire on any of them 与判定结果为干净是不同的说法,而只有其中一种值得采取行动。

baseline 会将一长串发现转化为增量。新出现的发现比一直存在的发现更重要,而且发现键不会考虑行号,因此仅仅向下移动的文件不会给基线带来噪声。

回执

每次 check 结束时,都会说明实际检查了什么,即使运行结果干净也不例外:

analysed 12 of 12 indexed file(s)
languages: javascript, typescript, python

这并非装饰。没有发现的工作区和从未经过分析的工作区,打印出的内容完全相同;无法说明自己属于哪一种情况的检查,无论结果如何都不值得信任。回执还会列出无法完全覆盖的语言,并区分语言不完整的两种情况:

languages: rust
no taint rules for: rust (indexed, taint not scanned)

languages: ruby
taint scanned, no grammar: ruby (no symbols in the graph)

第一种是已建立索引但未扫描数据流。第二种是已扫描数据流,但未向图中贡献任何符号。这是两种不同的缺口;假装它们没有区别,只会让工具失去用户。

如果某次运行无法读取所有已建立索引的文件,或无法访问依赖项注册表,就会如实说明,而不会将部分结果报告为完整结果。

贡献

打开拉取请求前,请阅读 CONTRIBUTING.md。RULES.md 是约定:其中明确规定了检查工具运行的每条规则及其确切触发条件、严重性和保证,因此行为可供审查,而非偶然发生。

开发

cargo build --release
cargo test

每次推送和拉取请求都会在 CI 中运行相同的门禁:格式检查、将警告视为错误的 clippy 检查、构建和完整测试套件。测试套件包括干净语料库门禁(若任何规则在惯用代码上触发就会失败)、向解析器输入随机字节和大量花括号的对抗性测试套件、断言性能预算的规模测试阶段,以及要求输出逐字节相同且索引一致的确定性测试。

许可证

MIT。请参阅 LICENSE。

评论

支持 Markdown

第一次接触 MCP?

从协议是什么,到在客户端里装上第一个服务,这几条回答覆盖了大多数入门问题。

一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。