anet-p2p
p2p 档:能做什么、不能做什么、姿态如何
p2p 是 standard 加上一个传输模块:委派不再必须经过
hub 中继,能直连的两个节点直接把载荷交给对方。
这一档最容易被误读的地方是它的名字。它不是"脱离 hub"。 发现、密钥历史、 以及直连失败时的信箱,三件事都仍然由 hub 提供;p2p 改变的只是载荷走哪条线。 本页把这条边界写在"不能做"一节,而不是留给读者自己撞上。
一 · 给谁
一组彼此可直连的节点(同一内网、同一机房、或双方都有公网地址), 不希望任务载荷与结果经过第三方服务器,同时接受"仍然要有一个 hub 负责发现"这个前提的部署。
如果两侧都在 NAT 后且没有端口映射,这一档拿不到任何好处 ——
anetpeer 没有 NAT 穿透、没有中继、没有目录之外的发现(ANet/tools/anetpeer/main.go 的包注释
把这三条列为明确的非目标)。代价取决于有没有发布地址:发布了一个实际拨不通的地址,
每次投递会先尝试直连、等到 dial_timeout_ms 超时、再回落 hub;没有发布地址时
Reachable 直接答"不可达",relaySend 跳过这条传输,不产生额外等待 ——
这一档就只是白带一个模块。
二 · 构建
tag 串
保留 service、p2p、taskboard 三个模块。tag 串取自
.github/workflows/ci.yml 的 pluggable 矩阵 —— 这一串每次提交都被编译并跑测试。
这一档的串里没有 no_taskboard,所以二进制带着 hub 共享任务板的客户端。
min / standard / paid 三档的串里有。这不是设计意图的表述,是当前 tag 串的实际差异,
见发行版总览第四节的说明。
daemon
或者用带 commit 戳的构建脚本(推荐 —— 不带戳的二进制 anet version 只会报 unknown):
对等进程
p2p 模块本身不联网。它是一个客户端:通过 unix socket 连到一个独立的对等进程,
把对方当成一个普通的 module.Transport。这个进程要单独编:
一行安装装不到这一档,也装不到 anetpeer。
deploy/release/build-release.sh 每平台只出两个件:anet-<os>-<arch>(不带 tag,即 full)
与 anet-shell-<os>-<arch>(-tags shell,即 full+shell)。脚本里没有 anetpeer。
所以:
装到的是 full 档,里面含有 p2p 模块(以及 service、taskboard、x402、cas、
blackboard、org、anetlink 七个,加上 MCP northbound),但没有对等进程,
配了 modules.p2p 也连不上任何 socket。
要用 p2p 必须从源码编 anetpeer,这一步没有捷径。发现方式:核对 build-release.sh
的 build_one 调用与 dist/install.sh 的资产名。
配置
config.json 里加一个 modules.p2p 块。字段来自 ANet/module/p2p/p2p.go 的 Config:
| 字段 | 必填 | 默认 | 含义 |
|---|---|---|---|
socket | 是 | — | 对等进程监听的 unix socket。为空时模块构造失败,daemon 拒绝启动 |
dial_timeout_ms | 否 | 3000 | 一次投递的上限。短,因为 hub 在后面等着接手 |
reach_timeout_ms | 否 | 300 | "能不能直连到这个 AID"的查询上限。每次发送前都问一次,所以必须便宜 |
两侧都要配。一条直连路径只有两端都有才成立 —— 只有提供方配了 p2p, 请求方仍然经 hub 发过去。
起一遍
启动对等进程
四个 flag 全部来自 tools/anetpeer/main.go 的 flag 声明:
--socket本机 daemon 的modules.p2p.socket,必填--peer其他anetpeer连进来的地址。带/当 unix 路径,带:当 TCP, 也可以写显式的tcp:///unix://前缀(addrKind)。必填--rendezvous会合点:http://或https://开头当 hub,否则当本地目录。必填--advertise别人应该拨的地址,默认取--peer。监听地址与对外可达地址不同时 (端口映射、容器)必须显式给
anetpeer 用 hub 做会合点时不会替你发布地址。它在启动日志里打印一行提示,
让你用 anet p2p-advertise 自己发。原因写在 announce() 的注释里:发布是一条关于
daemon 身份的签名声明,而对等进程不持有任何私钥 —— 这是刻意的,它是那个替别人搬运载荷的进程。
用本地目录做会合点时它才自己写文件。
三 · 能做
standard 的全部,加上直连投递。逐条:
| 能做 | 命令 |
|---|---|
| 注册、改名、公布能力清单 | anet hub-register <url> [--name N] [--caps a,b] |
| 找人 | anet find [query] |
| 委派一个自然语言任务 | anet delegate <provider-aid> <goal> |
| 调用一个注册过的能力 | anet delegate <aid> --capability <id> [--args '<json>'](--pay 在这一档上不可用,见下节) |
| 收活、读会话、回话、协商结束 | anet inbox [--pending] / anet thread <interaction_id> / anet message <interaction_id> <text...> / anet end <interaction_id> |
| 取回自己委派出去的结果与收据 | anet results |
| 评价 | anet review <interaction_id> <rating 1-5> [comment] |
| 以能力 id 对外提供 HTTP 服务 | 配 modules.service,见 standard 档 |
| 读 / 建 / 领 hub 共享任务板上的卡片 | taskboard 模块在这一档里。它向本节点注册 task.board / task.create / task.claim 三个能力(module/taskboard/taskboard.go),配置块 modules.taskboard 只有一个可选字段 url,空为本节点自己的 hub |
| 发布 / 撤回直连地址 | anet p2p-advertise [<addr>] |
| 直连投递 | 无独立命令。delegate / message 等一切投递自动选路 |
| 看直连有没有生效 | anet logs,以及对等进程日志里的 delivered <kind> <ix> → <aid> |
选路是自动的
internal/daemon/transport.go 的 transports() 返回直连在前、hub 在后的列表,
relaySend 依次尝试:
- 对每条传输先问
Reachable(ctx, toAID)。不可达的直接跳过,不浪费一次投递。 p2p 的这一问会转成对等进程的op:"reach",后者查会合点有没有这个 AID 的地址。 - 可达的调
Send。成功即返回。 - 失败则记下错误,试下一条。hub 永远在列表末尾,且永远不会被移除。
- 全部失败时返回最后一个错误,而不是"所有传输都失败了"这种汇总 —— 排查投递的人要知道 hub 说了什么。
传输健康只在状态变化时记一行日志(noteTransport),不是每条消息一行。
注释里给了理由:一个 benchmark 让回落路径打出 20 万行日志且没能跑完。
所以你会看到
一次,恢复时看到 anet: transport p2p: recovered 一次,中间是安静的。
直连的端到端确认
anetpeer 不在自己收到时就报"已投递"。它把载荷交给接收方 daemon,
等接收方 daemon 回 op:"ack" 才告诉发送方成功;10 秒等不到就明确报失败
(handOff 的超时分支),于是发送方回落到 hub。
理由写在包注释里:一个在自己收到时就 ack 的传输,会把一次丢掉的委派变成一次 看起来成功的委派,而整条回落链的前提正是"失败的 Send 会往下走"。
四 · 不能做
这一节比上一节重要。每条给出它是怎么表现的。
1. 收费。构建里没有付费面
no_x402 的意义不是"付费功能关掉了",是这个节点不可能收款也不可能付款。表现有两种:
别人调这个节点上标价的能力时,返回诚实效果状态 UNAVAILABLE,
且消息里点名 no_x402,活不会被免费干掉。
internal/daemon/paylink_test.go 的 TestPricedWorkIsRefusedWhenThisBuildCannotCharge
断言了三件事:work.invoked 必须是 0(活一次都没跑),res.Status 必须是
effect.Unavailable,消息必须含 no_x402(调用方被告知了原因)。
UNAVAILABLE(做不了)与 FAILED(试了没成)是两种状态,不合并。见
证据与效果。
四个付费控制面入口给同一句话(internal/daemon/paylink.go 的 errNoPayments()):
四个 CLI 入口都走同一个 d.payer() == nil 判断:anet balance 与 anet redeem 在
paylink.go 的 Balance / RedeemCredit 里,anet reconcile 与 anet audit-hub 在
internal/daemon/control_api.go 的 hReconcile / hAuditHub 里。
TestThePayingSurfacesSayWhenThereIsNoPaymentSupport 覆盖的是四个方法:
Balance、RedeemCredit、PayAndRetry、DelegateAndPay。reconcile 与 audit-hub
两条 HTTP 路径由代码保证,没有对应的断言。
主动付费调用也一样。 anet delegate <aid> --capability <id> --pay 走
DelegateAndPay,而它的第一句就是 if d.payer() == nil { return errNoPayments() }
—— 在报价出现之前就拒绝,不会先排队再失败。
要收费就换 paid 或 full。
2. MCP。IDE 里挂不上
anet mcp 这个子命令仍然存在,但立即返回:
cmd/anet/mcp_stub.go 的注释说明了为什么保留命令而不是让它消失:
在 MCP 客户端里配了 anet mcp 的运营者,应当拿到一句"你装的是哪一档",
而不是 unknown command。
要在 Claude Code / Cursor 里用就换 agent 或 full。
3. 设备能力、内容寻址存储、共脑、组织凭证。配了会拒绝启动,并点名 tag
module.Build(ANet/module/module.go)对"配置里有、二进制里没有"的模块报错而不是忽略。
注释给了理由:一个读到 "p2p": {...} 却静默跳过的 daemon,会让运营者对着一个不 peering
的节点想不明白。
在这一档上配 modules.cas,daemon 起不来:
org、blackboard 同理。anetlink 还多一条路径:providers.anetlink.socket
会被 moduleConfig 折成 modules.anetlink 块(internal/daemon/modules.go),
所以照着设备文档写 providers 块也会撞上同一条拒绝。
4. 远程执行本机命令。加法 tag,提示语反过来
shell 是唯一一个加法 tag,默认构建里没有它 —— 包括 full。
在这一档上配 modules.shell,拒绝启动的提示语指向的是"加上",不是"去掉":
这条提示由 module.Build 里的 optInName(name) 分支产生,名字在
cmd/anet/optin_tags.go 里用 module.DeclareOptIn("shell") 声明 ——
这个文件没有构建 tag,所以一个不含 shell 代码的构建仍然叫得出这个名字。
详见 +shell。
5. 完全脱离 hub。这一档做不到,别的档也做不到
三件事仍然要 hub:
- 发现。 对等进程只知道 AID,不知道往哪拨。会合点要么是 hub
(
GET /agents/{aid}/p2p),要么是一个共享目录 —— 而目录形式要求两端共享文件系统, 也就是同一台机器,恰好是不需要 p2p 的情况。 - 密钥历史。 验签要对方的 KEL,hub 是它的来源(
GET /agents/{aid}/kel)。 - 投递兜底。 hub 持有信箱,所以对端可以离线、可以不可路由、可以从没见过。
hubTransport.Reachable直接返回"只要配了 hub 就可达",这正是它作为兜底的性质。
hub 因此知道"两个节点互相查过地址"。它不知道它们之后做了什么 —— 载荷、证据、结果都不经过它。这一档换到的是"介绍之后不经 hub",不是"没有 hub"。
6. 强制只走 p2p。没有这个开关
当前策略是"能直连就直连,不能就回落 hub",写死在 relaySend 的遍历里。
module/p2p 的 Config 只有 socket / dial_timeout_ms / reach_timeout_ms 三个字段,
没有任何"禁止回落"的配置。
**影响:**要求"载荷绝不能碰第三方服务器"的部署,在这一档上拿不到这个保证 ——
对端拨不通时它会安静地走 hub,而 hub 中继本身是设计行为。
ANet/docs/DISTRIBUTIONS-zh.md 把这一条列为未决项。发现方式:读 Config 结构体,无对应字段。
7. NAT 穿透、中继、DHT 发现
tools/anetpeer 的包注释逐条列明:没有 NAT 穿透、没有中继、没有目录之外的发现。
它是"那份契约的完整陈述",不是 libp2p peer 的替代品。表现就是回落:
拨不通 → 3 秒(dial_timeout_ms 默认)超时 → 走 hub。
五 · 姿态
监听
| 进程 | 监听 | 谁能连 |
|---|---|---|
anet | 127.0.0.1:39811(DefaultConfig,control_addr 可改) | 只有本机。bearer token 守卫,GET /console、GET /ping、GET /attachment 与 GET / 的重定向除外(浏览器导航送不出 Authorization 头,靠回环兜底;见 control_api.go 的 top mux) |
anetpeer | --socket 指的 unix socket | 只有本机的 daemon |
anetpeer | --peer 指的地址 | 写成 TCP 时,这是这一档唯一的公开可达面 |
anet 进程在这一档里不 net.Listen 任何公开口。 全套件里唯一会这么做的模块是
x402(module/x402/voucher.go 的 startRedeemFace,只在配了 voucher_addr 时执行),
而 no_x402 已经把它整个拿掉了。
所以"跑 p2p 档的机器接受入站连接"这句话成立,但要拔掉这个能力,停的是 anetpeer 进程,
不是改 anet 的配置。两个进程的生命周期是分开的:对等进程崩了、在升级、还没起,
daemon 照常运行,投递照常经 hub —— Start 的注释把这一点写成了明确设计
("Connecting is not required to start")。
出站
- 向自己注册的 hub 发 HTTPS(注册、中继收发、KEL、评价、任务板)
- 向
modules.service里写明的本机/内网 HTTP URL POST 调用参数 anetpeer主动拨对端的--peer地址anetpeer向会合点 hub 发GET /agents/{aid}/p2p,超时 5 秒,结果缓存 60 秒 (hubLookupTimeout/hubCacheTTL)
拔掉的模块少了什么
| 拔掉 | 少了 |
|---|---|
no_x402 | 那个可选的公开 HTTP 兑付口;向 hub 发结算、拉发放链、给发放头签见证的全部出站;以及"这个节点可能被诱导付款"这件事本身 |
no_mcp | MCP stdio 服务,以及 34 个依赖包(MCP Go SDK 与它的 jsonschema 依赖树) |
no_anetlink | 连 ANetLink 运行时 C1 unix socket 的出站 |
no_cas / no_blackboard / no_org | 无网络行为,这三个包里没有任何 net/http 或 net.Dial。少的是代码,不是端口 |
净结果:相对 full,少了一个可能的公开监听口和一整条付费出站链;
相对 standard,多了一个由另一个进程打开的公开对等口。
六 · 实测数字
数字测于 2026-09-03,go 1.26.7,linux/amd64,CGO_ENABLED=0,-trimpath -ldflags "-s -w"。
体积单位 KiB,依赖包数是 go list -tags "<tags>" -deps ./cmd/anet 的行数。
| 档 | 体积 | 依赖包数 | anet version 报的模块 |
|---|---|---|---|
standard | 12,124 K | 266 | service |
p2p | 12,176 K | 268 | p2p,service,taskboard |
p2p+shell | 12,228 K | 269 | p2p,service,shell,taskboard |
full | 14,320 K | 308 | anetlink,blackboard,cas,mcp,org,p2p,service,taskboard,x402 |
p2p 比 standard 只多 52 K 和 2 个依赖(p2p 与 taskboard 各一个)。
这一档与 standard 的区别不是 52 K,是这台机器上多了一个接受入站连接的进程。
anetpeer 自身是另一个二进制,不计入上表。
自己核一遍
答 0。反方向:
答大于 0。两个方向都要验 —— 只验一边会让一个从未被链接的模块看起来"可插拔"。 完整做法见自己核一遍。
上面这两条命令要求二进制保留符号表。第二节里那条裸 go build 的产物是保留的;
scripts/build.sh 的产物不是 —— 它的 ldflags 是 -s -w,-s 会把符号表一起去掉,
于是 go tool nm 回答的是"没有符号表",看起来像通过、实际什么都没证明。
要自己数符号,就用不带 -s 的构建;GitHub 上的发布件出于同样的理由只用 -w。
运行中的节点还可以问它自己(无需 token,回环):
返回里的 tags 字段是构建时钉进去的 tag 串。注意:只有经 scripts/build.sh
或自己带 -X …version.Tags= 的构建才有值,裸 go build -tags … 会留空。
modules 那一行(anet version)不会留空,因为它读的是注册表。
七 · 换到别的档
身份和数据不受影响
身份、交互日志、证据链、控制令牌都在数据目录里(默认 ~/.anet,环境变量 ANET_DATA_DIR),
和二进制无关。换档就是换一个可执行文件:停 daemon、替换文件、再起。AID 不变,
hub 上的注册不变,已有的证据链继续往后追加。
换档之前先处理配置块
module.Build 对"配置里有、二进制里没有"的模块拒绝启动。所以降档前要先删配置块,
否则 daemon 起不来:
| 换到 | 必须先从 config.json 删掉 | 否则的报错 |
|---|---|---|
min / standard / paid | modules.p2p | module "p2p" is configured but not compiled into this build (built with no_p2p?) |
min / standard / paid | modules.taskboard(若配过) | 同形,点名 no_taskboard |
agent | modules.p2p | 同上 |
反方向(p2p → full)不需要删任何东西:full 是八个注册模块全在
(anetlink,blackboard,cas,mcp,org,p2p,service,taskboard,x402,anet version 读回的就是这一行),
配置块只会多不会少。注意 full 里没有 shell —— 它是加法 tag,默认构建不含它。
换到没有 p2p 的档时,记得撤回已发布的地址。
anet p2p-advertise 在内核里、不带任何 tag(internal/daemon/hub_client.go 的
AdvertisePeerAddress),所以 -tags no_p2p 的构建仍然能发布和撤回地址。
这既是方便也是陷阱:换档不会自动撤回,hub 上那条地址会一直留着。
后果是可测量的,而且落在别人身上:想给你发东西的那一方,每次投递前问自己的
anetpeer "拨得到吗",对等进程从 hub 查到你那条陈旧地址(结果还会缓存 60 秒),
于是答"可以";发送方随即尝试直连,拨不通,等到 dial_timeout_ms(默认 3 秒)
超时才回落 hub。每一次投递因此最多多等 3 秒(这是 Transport.Send 的超时上限,
不是实测值;连接被明确拒绝时更快,被丢包时吃满),一次委派往返有多次投递。
不带参数即撤回。撤回与注销 hub 是两件事 —— 撤回之后仍然经 hub 正常收活。
八 · 这一档特有的注意事项
1. 地址目录是公开的
hub 把它持有的全部地址表公开在:
返回 {"peers": {...}, "count": N}。ANetHub/internal/aghub/rendezvous.go 的
P2PDirectory 注释给了理由:这个特性的代价是"hub 现在多持有一份地址表",
而付出这个代价的人应当能看见它到底装了什么。
这意味着你发布的直连地址是全网可读的。 不接受就别发布 —— 不发布只是不能被直连,不影响经 hub 收活。
2. 发布必须是签名的自述,hub 不能替你写
要带 ts / key_state_seq / sig,用和其他自述同一套挑战(relayauth.ActionProfile)验签。
hP2PPublish 的注释:"地址是一条关于自己的声明,hub 必须不能列出一个没有要求被列出的 agent"。
地址上限 256 字节(MaxP2PAddr),够写一个主机名加端口或一条 socket 路径,
不够把目录当存储用。
3. 查不到时是 404,不是空地址
- 有地址:
200,{"aid": "...", "addr": "...", "at": "..."} - 没发布:
404,{"error": "this agent has not published a direct address here"}
两者不能合并。hP2PLookup 的注释说明了区别:404 让调用方回落到 hub,
一个空地址会被拿去拨号。
4. 跨 hub 要靠转介,而且只跳一次
地址发布在验证了发布者签名的那个 hub 上。所以 A 问自己的 hub 要 B 的地址,
而 B 在另一个 hub 上时,得到的是 404 加一个 home_hub 字段:
hub 不会代查。HomeHubOf 的注释:一个 hub 转述另一个 hub 的地址,
等于为道听途说背书。anetpeer 的 lookupHub 因此自己跟一跳,只跟一跳 ——
注释里的理由是"一串转介就是一个等着发生的重定向循环"。
这条路径在实网测过:scripts/prodtest.sh 第 9n 节要求 emax 对 dmax 报出 home hub、
fmax 对 cmax 报出 home hub,两个方向都断言。
5. 直连是单向能成立的,回程可能仍然走 hub
一次委派有去有回。A 能拨通 B、B 拨不通 A(A 在防火墙后)是很常见的形状: 去程走 p2p,回程走 hub。
prodtest.sh 把这条写成一行明确的已知限制而不是断言:
"回程需要提供方能反向拨通发起方。一侧在防火墙后时回程走 hub"。
一个要求两侧都公网可达的点对点传输,在防火墙后会退化成中继,而这是常态。
同一节还写明:如果那一次没走成 p2p,脚本报告的是"已回落到 hub —— 回落本身是设计行为", 而不是把它记成失败;但它也明说这次检查因此什么都没证明。
6. 会合点用本地目录时,覆盖面只有本机
--rendezvous 给一个不带 http:// 前缀的路径时,anetpeer 自己往目录里写
<aid> → 地址的文件,退出时删掉。这要求两端共享文件系统 ——
也就是同一台机器,而那正是不需要 p2p 的情况。
本地目录形式适合的场景只有一个:本机多进程联调,例如 scripts/joint.sh 里那两个
共享 run/rv 的 anetpeer。跨机器一律用 hub 会合点。
7. 载荷端到端可验,不因为"走的是直连"而被信任
deliverInbound 的注释写死了这条:从对等进程收到的载荷,daemon
用和 hub 信箱完全相同的方式验。一个说谎的对等进程产出的是一条验签失败的消息,
不是一条因为"经直连到达"而被信任的消息。
inbound 把任何传输收到的消息路由进和从 hub 信箱拉取完全相同的路径 ——
证据链因此不区分载荷走了哪条线。
8. 发布筛查不覆盖 p2p 投递,这是有意的
internal/daemon/modules.go 的 screenPublication 守的是公开发布这条边界
(注册到 hub 的内容会同步给联邦对端、从可浏览的目录里被读走),
INV-1 问"这个类型能不能出去",INV-2 问"这些值能不能出去"。
同一段注释明说:点对点传输不是这样一条路径,而且是刻意不守的。 理由是两条:它寻址的是一个指名的对端、走一条直连,暴露面与 hub 中继相同 (后者按设计就在搬运 CogUnit);而且守它会拦下合法投递并且什么都证明不了 —— 那里的载荷已经是字节,守卫读的是静态类型。
**含义要说清楚:**直连投递不会因为"没经过 hub"而多一层内容检查,也不会少一层验签。 它和 hub 中继在这两件事上是等价的。
九 · 已知缺口
按位置、行为、影响、发现方式记录。
1. anetpeer 没有发布件。 位置:ANet/deploy/release/build-release.sh。
行为:只出 anet 与 anet-shell 两个前缀,四平台共八个件。
影响:这一档的必需组件只能自己从源码编,一行安装装不到,
full 档用户即使有 p2p 模块也无法使用它。发现方式:核对发布脚本的 build_one 调用。
2. p2p 档本身没有发布件。 位置:同上。行为:发的是 full 与 full+shell。
影响:要这一档只能自己编。发现方式:同上。
3. anet help 对 p2p-advertise 的说明与代码不符。 位置:
cmd/anet/main.go 第 289 行的用法串,写"needs the p2p module"。
行为:AdvertisePeerAddress 在内核里、不带 tag,-tags no_p2p 的构建照样能发布和撤回。
影响:读帮助的人会以为换档后地址会自动失效,实际不会 —— 见第七节的警告。
发现方式:对照 hub_client.go 里 AdvertisePeerAddress 的注释与 cmd/anet/main.go 的
case "p2p-advertise",后者没有任何构建约束。
4. 没有"只用 hub 发现、投递强制走 p2p"的开关。 位置:ANet/module/p2p。
行为:自动回落写死在 relaySend 里,Config 无对应字段。
影响:要求载荷绝不经第三方服务器的部署做不到。发现方式:读 Config 结构体;
设计稿把它列为未决项。
5. scripts/onboard.sh 不覆盖这一档。 位置:scripts/onboard.sh 第 0 节的
PROFILE 表,只有 min / standard / paid。行为:这一档只有 CI 的编译与单元测试覆盖,
外加 joint.sh(本机双 peer)与 prodtest.sh 第 9l / 9n 节(实网双 hub),
没有端到端的新用户路径核对。发现方式:读脚本的档位表。
6. taskboard 进了这一档而没有文档说明这是有意的。 位置:
.github/workflows/ci.yml 的 pluggable 矩阵。行为:p2p 与 agent 两档的 tag 串缺
no_taskboard。影响:多 24 K 和一个依赖,并且这一档的节点可以领 hub 任务板上的活。
发现方式:逐档编译后读 anet version 的模块行。