feat: 跨主机 Agent 验证 + 离线邮件补投 + 400 指向具体字段

7.8「跨主机 Agent 发现」原计划(Gateway + Registry 拆分、etcd/Consul 注册)
取消,改为验证现有协议已经够用。验证过程暴露两个真实缺陷,一并修掉。

## 为什么不做注册中心

它要解决「Gateway 怎么找到 Agent」,而这个问题在本架构里不存在:
连接方向是单向的 —— Agent 主动连 Gateway,Gateway 从不外呼。
远端 Agent 只需要一个公网 URL 加一把密钥,被叫方自己会打进来。
注册中心要解决的「被叫方在哪」根本没出现过。

同一个理由此前已经决定了平台会话同步走插件上报而不是 Gateway 拉取。

## 验证方式:一个纯标准库脚本

`deploy/remote-agent-demo.py` 在另一台主机(192.168.2.106)上跑,
不装 AgentMail 的任何代码。注册 / 心跳(带模型目录)/ SSE 长连 /
收件箱 / 标记已读 / 发信全通,Gateway 侧 status=online 且 last_seen 随心跳推进。
完整一轮往返跑通:admin 发给 remotebot@/tmp/remotebot-ws,脚本回信入库。

「协议层面已支持」的含义就是这个:跨主机不需要新组件,只需要三个环境变量。

## 缺陷一:SSE 只推连上之后的事件,没人补拉积压

写那个脚本时第一版只挂了 SSE,启动前发的邮件永远不会被处理。
查了才发现**两个正式插件也有这个洞** —— 原以为它们做了补拉,实际没有。
后果比明确的失败更难排查:邮件躺在收件箱里,而发件人以为 Agent 收到了。

新增共用模块 `lib/catchup.js`,两插件在首个成功心跳后补投一次。五条约束
都对应一种具体的坏行为:

- 只在**首个**心跳后补 —— 每轮都补会把「模型正在处理中、尚未标已读」的
  邮件重复投递
- 串行、一次最多 5 封 —— 每封都要起一轮模型,并发放出去等于对上游打 N 个
  并发请求,且最后几封要等前面全部跑完
- 与 SSE 共用 deliveredMails 去重 —— 心跳与 SSE 建连之间有个窗口,
  那期间到的邮件两条路都会到
- 按时间**正序**投(收件箱倒序返回)—— 倒着塞进去同一会话的上下文是乱的
- permission 类不补投 —— 原来的工具调用早随进程没了,没有可恢复的上下文

端到端两平台各验一次:停插件 → 发信 → 启插件 → 日志「补投 1 封离线期间的
邮件」→ 回信入库;随后在线再发一封确认只回一次。

## 缺陷二:400 只说 "Invalid JSON",不说是哪个字段

脚本把 `workspaces` 传成字符串数组(它要 `[{name, path}]`),
得到的只是一句固定文案,只能靠翻服务端结构体才能发现。
两个官方插件都传 `workspaces: []`,所以这个洞一直没暴露;
第三方客户端没有「翻服务端源码」这个条件。

新增 `handler.DecodeBody`,22 处 `Decode` + 固定文案的调用点全部换过去:

    {"error": "字段 \"workspaces\" 类型不对:期望 object,收到 string"}
    {"error": "JSON 语法错误(第 8 字节处)"}
    {"error": "请求体为空"}

刻意不回显 encoding/json 的原文 —— 它带 Go 类型名(models.Workspace),
那是本侧的实现细节,不该出现在公开 API 的响应里。期望类型用 JSON 的说法。
截断的 JSON 走 io.ErrUnexpectedEOF 而不是 json.SyntaxError,单独一条分支,
否则会落到笼统的兜底文案里(写测试时才发现)。

## 验证

- Go:13 个新测试(decode_test.go 含「不得泄漏 Go 类型名」断言)
- 插件:两侧各 10 个补投测试,共 200 个
- 共用模块同源校验通过(catchup 已纳入 check-shared-libs.sh)
- 生产已部署
This commit is contained in:
2026-09-02 22:47:31 +08:00
parent 89356d4a9b
commit 9e5c557cdf
26 changed files with 953 additions and 55 deletions

View File

@ -7,14 +7,14 @@ set -euo pipefail
A=plugins/opencode-mail-bridge
B=plugins/dsh-mail-bridge
fail=0
for f in relay-dedup inbox-format session-snapshot workspace model-scope; do
for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup; do
if ! diff -q "$A/lib/$f.js" "$B/lib/$f.js" >/dev/null 2>&1; then
echo "共用模块已分叉:lib/$f.js" >&2
diff "$A/lib/$f.js" "$B/lib/$f.js" | head -20 >&2
fail=1
fi
done
for f in inbox-format session-snapshot workspace model-scope; do
for f in inbox-format session-snapshot workspace model-scope catchup; do
if ! diff -q "$A/test/$f.test.mjs" "$B/test/$f.test.mjs" >/dev/null 2>&1; then
echo "共用测试已分叉:test/$f.test.mjs" >&2
fail=1

166
deploy/remote-agent-demo.py Executable file
View File

@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""最小跨主机 Agent —— 只用 python 标准库,证明跨主机不需要新组件。
用途:在一台**没有装 AgentMail 任何代码**的机器上收发邮件。
如果这个脚本能跑通,「跨主机 Agent」在协议层面就已经成立 ——
不需要注册中心,因为连接方向是单向的:Agent 主动连 Gateway,Gateway 从不外呼。
用法:
export AGENTMAIL_GATEWAY_URL=https://mail.example.com/api/v1
export AGENTMAIL_AGENT_KEY=<在 Gateway 上建的 Agent 密钥>
export AGENTMAIL_AGENT_NAME=remotebot
export AGENTMAIL_WORKSPACE=/tmp/remotebot-ws # 可选
python3 remote-agent-demo.py [运行秒数]
它做四件事:注册 → 心跳(带模型目录)→ SSE 长连 → 收到邮件就回一封。
真正的插件还要做权限转发、会话命名回写、附件等,见 docs/PLUGIN-GUIDE.md。
"""
import json
import os
import sys
import threading
import time
import urllib.error
import urllib.request
GW = os.environ.get("AGENTMAIL_GATEWAY_URL", "http://127.0.0.1:8180/api/v1").rstrip("/")
KEY = os.environ.get("AGENTMAIL_AGENT_KEY", "")
NAME = os.environ.get("AGENTMAIL_AGENT_NAME", "remotebot")
WORKSPACE = os.environ.get("AGENTMAIL_WORKSPACE", "/tmp/%s-ws" % NAME)
if not KEY:
sys.exit("需要 AGENTMAIL_AGENT_KEY(在 Gateway 的管理页或 admin API 上建)")
def api(path, body=None, method=None):
"""打一次 Gateway。密钥走 Authorization 头,与插件完全一致。"""
req = urllib.request.Request(
GW + path,
data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": "Bearer " + KEY, "Content-Type": "application/json"},
method=method or ("POST" if body is not None else "GET"),
)
try:
with urllib.request.urlopen(req, timeout=20) as r:
return json.loads(r.read() or b"{}")
except urllib.error.HTTPError as e:
# Gateway 的错误体是 {"error": "..."},把它读出来 ——
# 不读的话只剩一个 "HTTP Error 400",看不出是哪个字段不对
detail = e.read().decode(errors="replace")[:300]
raise SystemExit("%s %s -> HTTP %s %s" % (req.method, path, e.code, detail))
def register():
"""注册。两个容易踩的地方:
- 路由是 `/agent/register`(**单数**),复数会 404
- `workspaces` 是对象数组 `[{name, path}]`,不是字符串数组;
传字符串会得到 400 "请求体 JSON 解析失败"
"""
return api("/agent/register", {
"name": NAME,
"platform": "stdlib-demo",
"workspaces": [{"name": "demo", "path": WORKSPACE}],
})
def heartbeat():
"""心跳。models 是这台机器上「有」的模型目录;
响应回传管理员划定的范围,真正的插件据此决定按什么顺序尝试。"""
return api("/agent/heartbeat", {
"models": [
{"provider": "stdlib", "model": "echo-1", "display_name": "Echo(演示)"},
],
})
def reply_to_unread(event):
"""收到 new_mail 就把未读的都回一封,然后标记已读。
标记已读不能省:不标的话下一轮会把同一批邮件再捞一次。
"""
box = api("/mail/inbox?status=unread&limit=5")
mails = box.get("mails", box if isinstance(box, list) else [])
for m in mails:
print(" 收到:", m.get("subject"), "| 工作目录:", event.get("to_workspace"))
api("/mail/send", {
"to": m.get("from_name"),
"subject": "Re: " + (m.get("subject") or ""),
"body": (
"这封回信来自另一台主机上的一个纯标准库脚本,"
"它没有安装 AgentMail 的任何代码。\n\n"
"- 收到的工作目录: `%s`\n"
"- 原邮件 ID: `%s`\n\n"
"跨主机在协议层面已经成立:Agent 主动连 Gateway,"
"只需要一个公网 URL 加一把密钥。"
% (event.get("to_workspace"), m.get("mail_id"))
),
"reply_to": m.get("mail_id"),
})
api("/mail/read", {"mail_ids": [m.get("mail_id")]})
print(" 已回信")
def stream():
"""SSE 长连。真正的插件还要处理断线重连与 Last-Event-ID 补投。"""
req = urllib.request.Request(
GW + "/events/stream",
headers={"Authorization": "Bearer " + KEY, "Accept": "text/event-stream"},
)
with urllib.request.urlopen(req, timeout=300) as r:
etype = None
for raw in r:
line = raw.decode(errors="replace").rstrip("\n")
if line.startswith("event: "):
etype = line[7:]
elif line.startswith("data: ") and etype:
data = json.loads(line[6:])
print("SSE <-", etype, json.dumps(data, ensure_ascii=False)[:100])
if etype == "new_mail":
try:
reply_to_unread(data)
except Exception as e: # noqa: BLE001 —— 一封失败不该断掉长连
print(" 回信失败:", e)
etype = None
def main():
os.makedirs(WORKSPACE, exist_ok=True)
print("Gateway:", GW, "| 身份:", NAME)
print("注册:", json.dumps(register(), ensure_ascii=False)[:120])
hb = heartbeat()
print("心跳: pending=%s models_synced=%s allowed=%s unrestricted=%s" % (
hb.get("pending_mails"),
hb.get("models_synced"),
[f"{m['provider']}/{m['model']}" for m in hb.get("allowed_models", [])],
hb.get("models_unrestricted"),
))
threading.Thread(target=stream, daemon=True).start()
# 启动时先补拉一次:SSE 只推连上之后的事件,
# 离线期间到的邮件只能从心跳的 pending_mails 得知。
# 不补这一次的后果:重启前发的邮件永远不会被处理。
if hb.get("pending_mails"):
print("启动补拉 %s 封未读" % hb["pending_mails"])
try:
reply_to_unread({"to_workspace": WORKSPACE})
except Exception as e: # noqa: BLE001
print(" 补拉失败:", e)
seconds = float(sys.argv[1]) if len(sys.argv) > 1 else 120
deadline = time.time() + seconds
# 30 秒一次心跳:Gateway 靠 last_seen 判在线
while time.time() < deadline:
time.sleep(min(30, max(1, deadline - time.time())))
try:
heartbeat()
except Exception as e: # noqa: BLE001
print("心跳失败:", e)
print("结束")
if __name__ == "__main__":
main()