可插拔
构建标签、符号数判据、减法与加法两个方向
可插拔是 ANet daemon 最独特的工程属性,也是唯一一条由构建产物而不是文档来兑现的承诺: 一个模块被拔掉,指的是二进制里没有它的代码,不是运行时把它关掉。
这条属性的判据是符号数,每次提交在 CI 上两个方向都验,并且下游拿到二进制之后 可以在自己的文件上重新数一遍。
命题:让"裁剪"从 git 操作变成构建配置
这句话是三代实现的教训综合,写在 ANet/docs/CONTRACTS-zh.md §0(2026-08-16 原文):
| 代 | 症状 | 代价 |
|---|---|---|
| v1 | 组织(org)功能长进 35 个 daemon 文件 | 想拔的时候拔不出来 |
| 1.3.0-v3 | 模块之间没有合同 | 开源时只能用斧头,删掉 683,852 行 |
| oss-v0.1 | 减法产品成立,但减法用的是 fork | 内核逐字拷贝,两个月即漂移 |
三次失败的形状不同,缺的东西是同一件:代码没有地方可以挂,就会挂得到处都是。 所以 anet4 的唯一命题是:
让"裁剪"从 git 操作变成构建配置。一套代码,N 种发行版。
一套代码意味着不再有 fork,也就不再有漂移;N 种发行版意味着每一种都能说清自己不能做什么, 而这句话必须是可核对的,否则它和"我们关掉了那个功能"没有区别。
判据是符号数,不是"运行时禁用"
对任意一个减法模块 <m>,判据是这两条命令:
以 x402(付费模块)为例,可以原样复制运行:
两个方向都要验。 只验 lean == 0 会让一个从未被链接进任何构建的模块看起来"可插拔" ——
它在两种构建里都是 0,而检查只看其中一边,于是通过。
full > 0 这一半证明的是"检查确实看得见它要检查的代码"。
符号数与"配置里关掉"是两种不同性质的事实。no_x402 的构建里没有 facilitator 客户端、
没有兑付路径、也没有那个公开监听口,所以"这个节点不可能被诱导付款"是一句关于文件的陈述;
如果代码还在、只是没走到,那句话就退化成一句关于运行时状态的期望。
no_x402 拿走的约 1,600 行(module/x402 的六个源文件加 cmd/anet 里那行 blank import,不含测试)
里包含 x402 模块唯一的公开 HTTP 监听面(配了 voucher_addr 时才开)。
这也是当初把付费从内核拆出去的理由:在它还是内核里 838 行无 tag 代码的时候,
这个承诺根本无法成立。
十个由构建标签管辖的子系统
| 名字 | 构建标签 | 方向 | 代码位置 |
|---|---|---|---|
p2p | no_p2p | 减法(默认在) | module/p2p |
anetlink | no_anetlink | 减法 | module/anetlink、provider/anetlink |
blackboard | no_blackboard | 减法 | module/blackboard |
org | no_org | 减法 | module/org |
cas | no_cas | 减法 | module/cas |
service | no_service | 减法 | module/service |
mcp | no_mcp | 减法 | internal/mcpserv、cmd/anet/mcp.go |
x402 | no_x402 | 减法 | module/x402 |
taskboard | no_taskboard | 减法 | module/taskboard |
shell | shell | 加法(默认不在) | module/shell |
默认构建里由 module.Register 登记的是其中八个,可以直接问二进制:
commit 与 built 在这里是 unknown,因为这是一次不带 -ldflags -X 的本地构建;
发布脚本会把 commit 与构建时间打进去,下载到的二进制这两项是具体值。第二行不受影响。
九个名字里有两个要单独说清:
mcp不走模块注册表。它是一个 northbound(anet mcp,stdio 上的 MCP 服务), 代码在internal/mcpserv,由no_mcp管辖。它出现在这一行里,是因为cmd/anet/module_mcp_declare.go带着同一个//go:build !no_mcp,里面一句module.DeclareCompiled("mcp")—— 声明文件与子系统受同一个 tag 管辖, 所以这个名字不可能比链接器留下的东西活得更久。no_mcp的构建里名字消失,anet mcp这条命令仍然存在,但会回答"这个构建是用-tags no_mcp编的" —— 比"未知命令"更能把操作者送到正确的地方。shell默认就不在。带上-tags shell之后它会出现在同一行里。
这一行读的是注册表,不是构建时打进去的字符串。理由写在 cmd/anet/main.go 里:
go build -tags shell 而不加 -X 会产出一个含有 shell 模块、却报告"没有 tag"的二进制,
一个能把"可以在宿主机执行命令"的构建说成"默认版"的字段,比没有这个字段更糟。
注册表不会说错 —— 一个模块出现在列表里,是因为它的 init() 跑了,而 init() 跑了,
是因为链接器留下了它。
daemon 启动时也会把同一份清单写进日志(internal/daemon/modules.go):
N 是占位,实际日志里是个数字。compiled in 与 started 是两个数:前者是二进制里有什么,
后者是本机配置里启用了几个。
两个方向:减法与加法
九个减法 tag 默认在、-tags no_<name> 去掉;一个加法 tag shell 默认不在、-tags shell 才有。
方向不对称是有意的,理由不是审美,是两种写错的代价不一样:
| 写错的方式 | 后果 |
|---|---|
| 减法 tag 漏了(该去掉的没去掉) | 某人想去掉的模块留在了他的构建里 |
| 加法 tag 漏了(该不在的进去了) | 命令执行能力进了所有没要过它的构建 |
shell 是套件里唯一把一次网络请求变成宿主机上一条命令的模块。需要靠"记得去掉"
来保护的人,恰好是没听说过这个 tag 的人 —— 所以默认必须是安全的那一边,由发布方
主动 opt in。规则可以写成一句:
默认构建应当是完整的,除非某个能力进错构建的后果比缺失更重。
加法方向的判据同样是符号数,只是断言反过来:
加法 tag 还有一个容易被忽略的连带后果:go test ./... 根本看不见 tag 后面的代码。
module/shell/shell_test.go 自己也带 //go:build shell,所以一条不写 tag 的测试命令
永远不会编译、更不会运行这个模块的测试 —— 而这是唯一一个执行命令的模块,是最不该没有覆盖的那个。
因此 CI 与本地脚本都必须另有一条 go test -tags shell ./...。
"编进去"不等于"开着"。带 shell 的构建在没有 modules.shell 配置块时不注册任何能力;
配了但调用方名单(allow 或 allow_file)为空时拒绝所有远程调用。三道闸门分别由发布方、
机器运营者、运营者写的调用方名单把守,详见 shell 发行变体。
shell 这个名字在不含它的构建里也要存在,否则一个操作者会去找 no_shell 这个从来
不存在的 tag。做法是 cmd/anet/optin_tags.go —— 一个没有构建标签的文件,
里面只有 module.DeclareOptIn("shell") 一行。于是当有人在配置里写了 modules.shell
而构建里没有它时,错误信息说的是"它需要 -tags shell,而默认构建不用这个 tag",
不是"是不是用 no_shell 编的"。
顺带说明另一条相关行为:配置了却没编进来的模块是错误,不是空操作。
module.Build 遇到这种情况直接失败,而不是让 daemon 读到 "p2p": {...} 之后
默默忽略、留下一个操作者对着"为什么没有在对等"发呆。
接缝长什么样
可插拔不是九个 tag 的集合,是一个接缝。子系统只能挂在 module.Host 上,而 Host
是一个接口,不是 *Daemon:
Host 给出的能力 | 说明 |
|---|---|
AID() | 本节点身份锚 |
Providers() | C1 能力注册表 —— 模块对外提供能力的唯一途径 |
RecordEvidence() | 往本节点自己的证据链追加。模块不另开私有账本 |
ResolveKEL() | 读本节点已验证过的对端密钥历史(共脑合并前需要知道"是谁签的") |
PaymentSeam() | 以本节点身份签一笔付款所需的最小面,无 hub 时不存在 |
HubSeam() | 以本节点身份对自己的 hub 发起请求所需的最小面(签名 + hub 地址) |
这张表就是"一个子系统被允许想要什么"的清单。想要更多的模块,是在说明它要么属于内核、 要么根本不该存在 —— 而说这句话的是编译器,在越界被写下的那一刻,不是两年以后。
PaymentSeam 与 HubSeam 都是签名授权,所以它们是这张表里最大的两项,也因此被
分成了两个大小不同的口子:一个只需要向自己的 hub 认证的模块拿 HubSeam(两个方法),
不必顺带拿到 hub 的密钥历史和本节点的证据。
内核是"拔掉就不是 anet daemon"的部分,没有构建标签:身份与多身份、能力注册表、 委派生命周期、委派验签、收据验证、第三方验证、证据链、中继客户端、传输列表、 对端 KEL 缓存、回环控制面、CLI、自动回复、本地控制台。
CI 怎么验
.github/workflows/ci.yml 里有两个专门的 job。
pluggable —— 18 行矩阵,逐个减法 tag 加若干组合。九个减法 tag 每一个都有独立一行,
另有覆盖多个 tag 的组合行,以及 DISTRIBUTIONS-zh.md 提出的四种发行版组合。
每一行做四件事:
符号匹配的 pattern 是 provider/<mod>|module/<mod>,mcp 单独特判为 internal/mcpserv ——
一个看不见自己要检查的代码的检查,会因为错误的理由通过。组合行只对第一个 tag 数符号,
这一点不影响覆盖:九个模块各自都有独立一行,单独被数过。
矩阵里为发行版专门列的组合,理由写在文件注释里:一个设计文档提出而没有人构建的组合, 会在没有人注意的情况下停止编译。
optin —— 加法 tag 的反向验证,单独一个 job 而不是矩阵里多加一行:
此外还有 test(gofmt / vet / go test ./...)、race(go test -race ./...)、
scenario(一个 hub 加三个节点的真实多进程联调)三个 job。
开发者本地做同一件事的入口是:
它会跑 gofmt、go vet ./...、go vet -tags shell ./...、go test ./...、
go test -tags shell ./...,然后构建一次默认二进制并断言 module/shell 的符号数为 0。
这个断言放在本地而不只放在 CI,是因为这是开发者推送之前跑的脚本,而"它默认不在"
是整个加法 tag 所依赖的全部内容。
在你自己下载到的二进制上核对
上面所有检查都发生在我们的机器上。要让"可插拔"对下游成立,下游必须能在自己下载到的文件 上重做一遍,所以 release 二进制保留符号表:
deploy/release/build-release.sh 用的是 -ldflags '-w',不是常见的 -s -w。
-w 只去掉 DWARF 调试信息;-s 会连符号表一起去掉,而那样 go tool nm 会回答
"没有符号"—— 读起来像检查通过,实际什么也没有证明。
代价是每个二进制大约多 1.4 MB(14,320 K → 15,760 K)。这个代价是有意付的: 不付,一条可核对的性质就退回成一句承诺。
发布脚本自己也把这条当成失败条件 —— 如果产物没有符号表,它直接报错说"下面的检查会 因为错误的理由通过",而不是让检查空转着通过。
下面这段可以在任意一台装了 Go 工具链的机器上跑:
在 v0.1.7 的 anet-linux-amd64 上实测:第 3 步 0,第 4 步 81;
anet-shell-linux-amd64 的第 3 步是 23。
判据是 0 与非 0,不是具体数字。81 与 23 会随实现变化而变,拿它们当断言只会得到 一堆脆弱的检查。
一行安装脚本的 --shell 拉取的是另一个文件,不是同一个二进制的一个开关:
所以一台从来不打算执行远程命令的机器,拿到的是一个做不到这件事的二进制, 而不是一个做得到但被配置拦住的二进制。
裁出来的形态
体积不是分发行版的理由:去掉全部九个减法模块只从 14,320 K 降到 12,096 K(15%),
其中 no_mcp 一项就占了可省总量的 85%(1,900 K),其余八个加起来 348 K、单个最多 124 K(no_x402);
-tags shell 加进去只有 28 K 和 1 个依赖包。
真正值得分档的维度是三个:攻击面(这个构建会打开什么、接受什么)、 可被要求做什么(没有付费模块的节点不可能被诱导付款)、运维复杂度。 六档减法形态加一个正交的加法开关,以及每一档的实测数据,在发行版章节里展开。
已知边界
写清楚这条属性不覆盖什么,和写清楚它覆盖什么同样重要。
- 符号数只回答"代码在不在",不回答"它做了什么"。
full > 0 / lean == 0证明链接器 去掉了这个包,不证明这个包的行为是正确的;那是单元测试与scripts/joint.sh、scripts/joint-shell.sh、scripts/scenario.sh、scripts/prodtest.sh这几条联调路径的事。 -s剥符号会让核对失效。 我们发的二进制保留符号表,但任何人再打包、再剥一次, 下游就数不出来了。拿到二进制先跑一次"符号表在不在"的检查。- 没有"发行版名字"这个字段。
anet version报的是模块列表,不报"这是 standard 档"。 想知道手上是哪一档,要自己对着模块列表比。anet status里的 "profile" 指的是 agent 的自述资料,不是发行版档位。 - 组合矩阵不是全集。 九个减法 tag 有 512 种组合,CI 跑的是 18 行 —— 九个单独、 若干递增组合、四种发行版组合、以及全部九个一起。没被列进矩阵的组合可能编译不过。 自建裁剪版本时,把你的 tag 组合加进你自己的 CI。
- 内核不可裁。 身份、委派、证据链、hub 客户端没有 tag。想要一个"不带委派的 daemon" 是做不到的,这是有意的设计决定(见 五条契约)。