核对一个构建
不要相信,自己查
你下载了一个文件。它自称是 anet 0.1.7 的默认版,自称不能在你的机器上执行命令,
自称带着付费模块和 MCP 服务。
这一页讲怎么在你手上的那个文件上把这些说法逐条查一遍。不需要读源码, 最低配置连 Go 工具链都不需要。
发行版的裁剪承诺("no_x402 的构建不可能被诱导付款"、"默认版没有命令执行")
是关于文件内容的陈述,不是关于运行时配置的陈述。这类陈述的价值全部来自
"下游能自己复核"这一件事;复核不了,它和"我们把那个功能关掉了"没有区别。
四层核对,按需要什么工具排
| 层 | 需要什么 | 回答的问题 |
|---|---|---|
| 1 · sha256 | curl + sha256sum | 这个文件是不是我们发的那个字节 |
2 · anet version | 能执行这个二进制 | 它自己报的模块清单是什么 |
3 · strings | strings + grep(binutils / busybox 都有) | 某个模块的代码在不在文件里 |
4 · go tool nm | Go 工具链 | 同上,精确到符号 |
四层是递进的,但不是可以互相替代的:第 2 层读的是编译期注册表,要能执行这个文件; 第 3、4 层读的是文件内容,不必运行它,而且能落到具体符号数。
第一层:这个文件是不是我们发的那个
v0.1.7 的产物有两个来源,内容相同:
- 自托管:
https://agentnetwork.org.cn/dl/(一行安装脚本走的也是这里) - GitHub Release:
https://github.com/ANetResearch/ANet/releases/tag/v0.1.7
两处都是四个平台 × 两个变体共八个 .gz,加上校验和与版本号:
| 文件名 | 说明 |
|---|---|
anet-linux-amd64.gz anet-linux-arm64.gz | 默认版,linux |
anet-darwin-amd64.gz anet-darwin-arm64.gz | 默认版,macOS |
anet-shell-<os>-<arch>.gz 四个 | shell 变体,同四个平台 |
checksums.txt | 每个解压后原始二进制的 sha256 |
VERSION | 一行版本号,当前是 0.1.7 |
GitHub Release 另有一个 install.sh 资产(共 11 个);自托管一侧安装脚本在站点根路径
https://agentnetwork.org.cn/install.sh,/dl/install.sh 是 404。
checksums.txt 里的哈希算的是 gunzip 之后的二进制,不是 .gz 包。
对着 .gz 算一遍不会对上,这不是文件坏了。
理由在 deploy/release/build-release.sh:先对原始二进制取 sha256 追加进 checksums.txt,
再 gzip -9。
checksums.txt 的行格式是 <哈希>␣␣<文件名>,grep 里那个前导空格把匹配锚在文件名字段的
开头。对 anet-linux-amd64 这个全名来说,加不加空格结果一样 —— anet-shell-linux-amd64
并不包含 anet-linux-amd64 这个子串。真正会出事的是偷懒写短的 pattern:
grep 'linux-amd64$' 两行都匹配,sha256sum -c 于是去校验一个你根本没下载的文件,
报 anet-shell-linux-amd64: FAILED open or read 并以非零退出码结束。写全名、带前导空格,
这两种情况都不会发生。
这一层证明了什么,没证明什么
它证明:你手上的字节 = 我们放上去的字节。传输没出错,镜像没换过包, 你也没有把默认版和 shell 变体拿混。
它没有证明来源可信:checksums.txt 和二进制来自同一个域名,能改一个的人能改另一个。
现在能做的对冲是跨源比对 —— 两个来源的 checksums.txt 是逐字节相同的:
未做:发布签名。 目前 checksums.txt 本身没有 GPG 或 sigstore 签名,
也没有可复现构建(reproducible build)的声明。跨源比对是当前能提供的全部,
它防的是单点被改,不防两处都被同一方改。这条缺口写在这里而不是不写。
第二层:anet version 的 modules: 行
v0.1.7 默认版的实际输出:
shell 变体只多一个名字:
这一行为什么不会说谎
它读的是模块注册表,不是构建时打进去的字符串。链条是这样的:
- 每个模块的注册代码写在一个带构建标签的文件里,例如
module/p2p/p2p.go的//go:build !no_p2p;shell反过来,module/shell/shell.go是//go:build shell - 注册发生在
init()里,通过module.Register(name, factory) module.Compiled()把注册表里的名字排序返回,cmd/anet/main.go的version分支直接打印它
所以一个模块出现在这一行里,是因为它的 init() 跑了;init() 跑了,是因为链接器留下了它。
中间没有一个可以单独写错的字段。
对照的反面是构建时刻打进去的 tag 字符串。internal/version/version.go 里有一个
var Tags = "",只有 -ldflags "-X .../version.Tags=<tags>" 才会给它赋值 ——
而 go build -tags shell 不会。也就是说:一个含有 shell 模块、能在宿主机上执行命令的
二进制,如果构建时忘了加 -X,这个字段报的是空(在 daemon 的 /ping 里就是 "tags":"")。
一个会把"能执行命令"说成"默认版"的字段,比没有这个字段更糟,所以 anet version
不打印它,打印注册表。
/ping 是回环上的未认证探针(GET /ping,CORS 开放),里面的 commit、built_at、
tags 都是构建时 -X 打进去的戳。戳可以缺(未 stamped 的构建报 unknown 和空串),
也可以在重新打包时被改。要判断一个二进制里有什么,用注册表和符号,不要用戳。
这一行里的 mcp 与 shell
| 名字 | 它怎么进这一行 | 不在时说明什么 |
|---|---|---|
mcp | 它不是 module.Module,是一条 northbound(anet mcp,stdio 上的 MCP 服务,代码在 internal/mcpserv)。cmd/anet/module_mcp_declare.go 带着同一个 //go:build !no_mcp,用 module.DeclareCompiled("mcp") 把名字补进 module.Compiled() | 这个构建带了 -tags no_mcp。anet mcp 这条命令仍在,但会回答 this build was compiled with -tags no_mcp |
shell | 加法 tag,-tags shell 才有 | 默认就没有。默认版这一格必须是空的 |
mcp 曾经不报在这一行里,一个带着九个可用 MCP 工具的构建只说 modules: service;
现在两个只差 no_mcp 的构建在第二层就分得开:
这一行仍然不能替代第三、四层。它回答"链接器留下了哪些子系统", 回答不了"这个包里有多少符号"、也不必运行这个文件就能问 —— 拿到一个来路不明的二进制时,不执行它而直接数符号是更保守的做法。
另外两个能看到同一份清单的地方
daemon 启动时把同一份清单写进日志:
compiled in 与 started 是两个数:前者是二进制里有什么,后者是本机配置里实际启用了几个。
anet status 没有 modules 字段(它报的是 AID、版本、数据目录、hub 地址、控制台 URL、
summary / readme / pricing 等)。
去那里找模块清单会找不到 —— 用 anet version。
第三层:不装 Go,用 strings 数标志串
每个模块的代码都带着自己的包路径,这些字符串留在二进制里(Go 的 .gopclntab 函数名表),
strings 就能数出来。
十个子系统的标志串
| 模块 | 构建标签 | 标志串 | v0.1.7 默认版实测 |
|---|---|---|---|
p2p | no_p2p | module/p2p | 52 |
anetlink | no_anetlink | module/anetlink(另有 provider/anetlink) | 18 |
blackboard | no_blackboard | module/blackboard | 101 |
org | no_org | module/org | 89 |
cas | no_cas | module/cas | 48 |
service | no_service | module/service | 30 |
x402 | no_x402 | module/x402 | 166 |
taskboard | no_taskboard | module/taskboard | 41 |
mcp | no_mcp | internal/mcpserv | 130 |
shell | shell(加法) | module/shell | 0 |
最后一行就是默认版的核心承诺:这个文件里没有能执行命令的那段代码。
同一次发布的 anet-shell-linux-amd64 上,这一格是 49。
一次把十项都数出来:
判据是 0 与非 0,不是具体数字。 上表里的 52、101、166 会随实现变化而变,
也会随构建参数变(同一档的两个构建,一个用 -s -w 剥过符号表、一个没剥,
module/p2p 分别数出 25 和 52)。把具体数字写进你的检查脚本,只会得到一堆脆弱的断言。
这一层的一个优点是对剥过符号表的二进制仍然有效:-s 去掉的是符号表,
函数名表还在,所以计数变小但零/非零的区分不变。第四层没有这个性质。
strings 在 Linux 上来自 binutils(busybox 也带一个),macOS 上随 Xcode 命令行工具提供。
都没有的话,退回第二层加下面的"让 daemon 自己说"。
第四层:装了 Go,用 go tool nm
这是 CI 用的判据,精确到符号:
anetlink 的代码分布在两个包里,pattern 要都覆盖(CI 用的是等价的一条,
写作 provider/anetlink|module/anetlink):
mcp 特判为 internal/mcpserv。
先查符号表在不在,否则这一层是空转的
剥了符号表的二进制会让这条检查因为错误的理由通过。
go tool nm 在没有符号表的文件上以非零退出码失败、不输出任何行,
接在后面的 grep -c 于是老老实实数出 0 —— 管道整体看起来就像"检查通过:这个二进制没有 shell 模块",
而实际上它什么也没证明。
所以第一条命令永远是:
我们发的二进制保留符号表,就是为了让这条能跑。
deploy/release/build-release.sh 用的是 -ldflags '-w',不是常见的 -s -w:
-w 只去掉 DWARF 调试信息,-s 会连符号表一起去掉。代价是每个二进制大约多 1.4 MB
(14,320 K → 15,760 K),这个代价是有意付的 —— 不付,一条可核对的性质就退回成一句承诺。
发布脚本自己也把这条当作失败条件:产物如果没有符号表,它直接终止并说明
"下面的检查会因为错误的理由通过",而不是让检查空转着通过。它还在每次发布时
就地验一遍两个变体:默认版的 module/shell 必须是 0,shell 变体必须大于 0,
任一条不成立就不出包。这一步只覆盖构建机自己那个平台的一对产物 —— 脚本用
go env GOOS / GOARCH 从四个平台里挑出那一对再验,另外三个平台的产物没有在发布时
被就地验过,拿到它们的人自己按本页的方法核。
双向验。 只验"应该没有的是 0"会让一个从未被链接进任何构建的模块看起来"可插拔" ——
它在两种文件里都是 0,检查只看其中一边,于是通过。所以每次都顺手数一个应该有的模块
(比如默认版的 module/x402),这一半证明的是"检查确实看得见它要检查的代码"。
不用任何工具:让 daemon 自己说
如果连 strings 都没有,还有一条不依赖任何外部工具的路子:配置一个模块,看它认不认。
配置了却没编进来的模块是错误,不是空操作 —— module.Build 直接让 daemon 启动失败,
而不是默默忽略配置。
默认版上的实测输出:
换成一个减法模块(在 no_x402 的构建上配 {"modules":{"x402":{}}}),提示语不同:
两句提示不一样是有意的:一个操作者被"是不是用 no_shell 编的"送去找一个从来不存在的
tag,会浪费很长时间。加法模块的名字通过 cmd/anet/optin_tags.go(一个没有构建标签的文件,
里面只有 module.DeclareOptIn("shell") 一行)在不含它的构建里也保留下来,
所以错误信息说得出"它需要 -tags shell"。
如果模块在构建里,daemon 会正常启动 —— 上面这条是前台运行,Ctrl-C 停掉,再删掉 /tmp/probe 即可。
这个探针不碰你原有的数据目录:ANET_DATA_DIR 指到哪里,身份和账本就建在哪里。
mcp 有一条更快的:
no_mcp 的构建立刻回答:
含 mcp 的构建则会去连本机 daemon:daemon 没起来,报连不上 daemon;daemon 正在跑,它就
真的开始 stdio 服务,读到 /dev/null 的 EOF 后安静退出 —— 什么都不打印,退出码 0。
只有打印"compiled with -tags no_mcp"才是没有;报连不上 daemon、或者一声不吭地退出,
都说明这个构建里有 mcp。
对照表:每个变体应该有什么
下表的十列标志串计数是 v0.1.7 各档二进制的实测结果(✓ = 计数非 0,空 = 0)。
modules: 那一列不是同一批实测数据。 实测用的二进制打于 252f873(官方发布件)
与 642f970(自建的五档),两者都在"anet version 漏报 mcp"修好之前 ——
在那些文件上跑 anet version,mcp 不会出现在任何一行里。表里的 mcp 是按
cmd/anet/module_mcp_declare.go 的编译条件推出来的主干预期值,没有在打了这个
补丁的二进制上重新量过。标志串那十列不受影响:它们数的是文件内容,与版本行怎么打印无关。
| 变体 | modules: 行 | service | x402 | mcp | p2p | cas | blackboard | org | anetlink | taskboard | shell |
|---|---|---|---|---|---|---|---|---|---|---|---|
min | (none) | ||||||||||
standard | service | ✓ | |||||||||
paid | service,x402 | ✓ | ✓ | ||||||||
agent | mcp,service | ✓ | ✓ | ||||||||
p2p | p2p,service | ✓ | ✓ | ||||||||
full(= 官方默认版) | anetlink,blackboard,cas,mcp,org,p2p,service,taskboard,x402 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |
standard+shell | service,shell | ✓ | ✓ | ||||||||
agent+shell | mcp,service,shell | ✓ | ✓ | ✓ | |||||||
full+shell(= 官方 shell 变体) | 上面那行加 shell | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
三件事值得单独指出:
- 官方 release 只发两个变体,就是表里的
full和full+shell,四个平台各一对。 表里其余七行 ——min/standard/paid/agent/p2p五档,以及standard+shell 和agent+shell —— 都要自己按 tag 组合构建,没有对应的发布件。 每一档的 tag 串见发行版总览。 agent与standard两行的减法 tag 只差一个no_mcp,而mcp现在报在modules:行里(由cmd/anet/module_mcp_declare.go补进去),所以这一对在第二层 就分得开。这一点是修过的:此前mcp不报,两行都答service,拿到一个自称agent的二进制只能靠数internal/mcpserv。第三层仍然是更强的判据 —— 它读文件而不必运行它。min到p2p五档的数据取自 2026-09-03 自建的二进制(自报 0.1.7,commit642f970, 与官方发布件的252f873不是同一次构建),这五档都关掉了taskboard。 其中min/standard/paid三档的 tag 串与发行版总览 给出的一致(那份串逐字取自ci.yml的pluggable矩阵);agent与p2p两档是在 总览给的串上又加了一个no_taskboard—— 总览的那两串里没有它,照着编出来的二进制taskboard一列会是 ✓,modules:行也会多一个taskboard(agent变成mcp,service,taskboard,p2p变成p2p,service,taskboard)。 以你自己传进去的 tag 为准,再用第三层复核,不要拿这张表当规范。
shell 一列在所有默认形态里都是空的,包括 full。这就是加法 tag 的全部意义:
不写 -tags shell 就没有,full 也不例外。也因此,一行安装脚本的 --shell
拉的是另一个文件,不是同一个二进制上的一个开关:
一台从来不打算接受远程命令的机器,拿到的是一个做不到这件事的二进制, 而不是一个做得到、但被配置拦住的二进制。
hub 二进制怎么核对
anet-hub 用的是同一套思路,但入口不同,别照搬 daemon 的命令:
它没有 modules: 行。 hub 的可选挂载点(当前是 taskboard 与 federation,
标签 no_taskboard / no_federation)在启动日志里报:
名字的顺序来自 cmd/anet-hub 里各 wire_*.go 的 init() 执行顺序(按文件名),
不是配置顺序。
文件层面的标志串是 ANetHub/internal/taskboard 与 ANetHub/internal/federation:
这套核对不能回答什么
- 符号数只回答"代码在不在",不回答"它做了什么"。
module/shell计数为 0 证明链接器 去掉了那个包;它不证明其余部分的行为正确。行为由单元测试和scripts/joint.sh、scripts/joint-shell.sh、scripts/scenario.sh、scripts/prodtest.sh这几条联调路径覆盖。 checksums.txt与二进制同源。 跨源比对能防单点被改,不构成签名。发布签名未做。- 别人再打包一次就可能失效。 我们发的文件保留符号表,但任何中间方重新用
-s剥一次, 第四层就数不出来了。拿到二进制先跑符号表存在性检查;第三层(strings)在这种情况下还能用。 - 没有"发行版名字"这个字段。
anet version报模块列表,不报"这是 standard 档"; 想知道手上是哪一档,对着上面那张表比。anet status报的summary/readme/pricing是 agent 自己写的自述资料(anet profile set落的),不是发行版档位。 - 构建时间戳和 commit 是戳,不是证据。 未 stamped 的构建报
unknown, 这是诚实的;但一个被改过的戳同样"看起来正常"。它回答的是"我部署的是不是我构建的那个", 前提是构建流程本身可信。 - 核对的对象只有 daemon 与 hub 两个二进制。 ANetLink 的设备运行时、适配器、 以及本机被调用的外部服务不在这套判据覆盖范围内。