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)
- 生产已部署
282 lines
8.5 KiB
Go
282 lines
8.5 KiB
Go
package handler
|
||
|
||
import (
|
||
"errors"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/agentmail/gateway/internal/middleware"
|
||
"github.com/agentmail/gateway/internal/repo"
|
||
"github.com/agentmail/gateway/internal/sse"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// ---------- Permission ----------
|
||
|
||
type permissionRequestRequest struct {
|
||
Question string `json:"question"`
|
||
Options []string `json:"options"`
|
||
Context string `json:"context"`
|
||
SessionID *string `json:"session_id"`
|
||
// 可选:显式指定决策人(人类用户名)。省略时由会话 owner 决定。
|
||
To string `json:"to"`
|
||
// RelayKey 是上游那条权限询问的稳定 id(opencode 的 permission.id)。
|
||
//
|
||
// 权限请求本来就不扣配额(人不点头 Agent 就动不了,收费等于收「求人费」),
|
||
// 这里要的只是**幂等**:permission.updated 事件会重复触发,插件也会重连重放,
|
||
// 没有幂等键就会给同一次询问生成好几封邮件。
|
||
RelayKey string `json:"relay_key"`
|
||
}
|
||
|
||
type permissionDecideRequest struct {
|
||
MailID string `json:"mail_id"`
|
||
Decision string `json:"decision"`
|
||
Note string `json:"note"`
|
||
}
|
||
|
||
// POST /api/v1/permission/request
|
||
func RequestPermission(w http.ResponseWriter, r *http.Request) {
|
||
agentName := middleware.GetAgentName(r)
|
||
if agentName == "" {
|
||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||
return
|
||
}
|
||
|
||
var req permissionRequestRequest
|
||
if !DecodeBody(w, r, &req) {
|
||
return
|
||
}
|
||
if req.Question == "" {
|
||
Error(w, http.StatusBadRequest, "Missing question")
|
||
return
|
||
}
|
||
|
||
options := req.Options
|
||
if len(options) == 0 {
|
||
options = []string{"同意", "拒绝"}
|
||
}
|
||
|
||
// 幂等:同一条上游询问只生成一封邮件。
|
||
// 重复不是故障(插件重试/事件重放的正常结果),因此幂等地返回已存在的结论而非报错。
|
||
relayKey := strings.TrimSpace(req.RelayKey)
|
||
if relayKey != "" {
|
||
if len(relayKey) > 160 {
|
||
Error(w, http.StatusBadRequest, "relay_key 过长(上限 160 字节)")
|
||
return
|
||
}
|
||
if err := repo.ClaimRelay(r.Context(), agentName, relayKey, "permission"); err != nil {
|
||
if errors.Is(err, repo.ErrRelayDuplicate) {
|
||
JSON(w, http.StatusOK, map[string]any{
|
||
"status": "duplicate_relay",
|
||
"relay_key": relayKey,
|
||
"detail": "该权限询问已转发过,本次调用未产生新邮件",
|
||
})
|
||
return
|
||
}
|
||
Error(w, http.StatusInternalServerError, "Failed to claim relay")
|
||
return
|
||
}
|
||
}
|
||
|
||
// 确定 session
|
||
var sessionID uuid.UUID
|
||
if req.SessionID != nil && *req.SessionID != "" {
|
||
id, err := uuid.Parse(*req.SessionID)
|
||
if err != nil {
|
||
Error(w, http.StatusBadRequest, "Invalid session_id")
|
||
return
|
||
}
|
||
sessionID = id
|
||
repo.TouchSession(r.Context(), sessionID)
|
||
} else {
|
||
// workspace 空串:权限询问不经三维寻址,没有 path 位可归属。
|
||
id, err := repo.CreateSession(r.Context(), nil, agentName, "权限请求: "+req.Question, "")
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to create session")
|
||
return
|
||
}
|
||
sessionID = id
|
||
}
|
||
|
||
// 决策人:显式指定优先,否则取会话 owner
|
||
decider := req.To
|
||
if decider == "" || decider == "human" {
|
||
owner, err := repo.SessionOwnerUsername(r.Context(), sessionID)
|
||
if err == nil && owner != "" {
|
||
decider = owner
|
||
}
|
||
}
|
||
if decider == "" {
|
||
// 会话无归属(Agent 自发起)时退回默认管理员
|
||
admin, err := repo.FirstAdminUsername(r.Context())
|
||
if err != nil || admin == "" {
|
||
Error(w, http.StatusConflict, "无法确定决策人,请在请求中指定 to")
|
||
return
|
||
}
|
||
decider = admin
|
||
}
|
||
|
||
body := req.Context
|
||
if body == "" {
|
||
body = req.Question
|
||
}
|
||
mailID, err := repo.CreatePermissionMail(r.Context(), sessionID, agentName, decider, req.Question, body, options)
|
||
if err != nil {
|
||
// 归还幂等键,否则这次询问永远转不出来了
|
||
if relayKey != "" {
|
||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||
}
|
||
Error(w, http.StatusInternalServerError, "Failed to create permission mail")
|
||
return
|
||
}
|
||
if relayKey != "" {
|
||
_ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID)
|
||
}
|
||
if err := repo.CreatePermissionRequest(r.Context(), mailID, sessionID, agentName, req.Question, options, req.Context); err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to create permission request")
|
||
return
|
||
}
|
||
|
||
// 只推给该决策人
|
||
sse.Default.SendToUser(decider, "new_mail", map[string]interface{}{
|
||
"mail_id": mailID.String(),
|
||
"session_id": sessionID.String(),
|
||
"from_name": agentName,
|
||
"subject": "权限请求: " + req.Question,
|
||
"mail_type": "permission_request",
|
||
"role": "to",
|
||
})
|
||
|
||
JSON(w, http.StatusOK, map[string]string{
|
||
"mail_id": mailID.String(),
|
||
"session_id": sessionID.String(),
|
||
"permission_mail_id": mailID.String(),
|
||
"decider": decider,
|
||
})
|
||
}
|
||
|
||
// POST /api/v1/permission/decide —— 需登录;只有该权限请求的收件人或管理员可决策
|
||
func DecidePermission(w http.ResponseWriter, r *http.Request) {
|
||
user := middleware.GetUser(r)
|
||
if user == nil {
|
||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||
return
|
||
}
|
||
|
||
var req permissionDecideRequest
|
||
if !DecodeBody(w, r, &req) {
|
||
return
|
||
}
|
||
if req.MailID == "" || req.Decision == "" {
|
||
Error(w, http.StatusBadRequest, "Missing mail_id or decision")
|
||
return
|
||
}
|
||
|
||
mailID, err := uuid.Parse(req.MailID)
|
||
if err != nil {
|
||
Error(w, http.StatusBadRequest, "Invalid mail_id UUID")
|
||
return
|
||
}
|
||
|
||
perm, err := repo.GetPermissionByMailID(r.Context(), mailID)
|
||
if err != nil {
|
||
Error(w, http.StatusNotFound, "Permission request not found")
|
||
return
|
||
}
|
||
if perm.Result != nil && *perm.Result != "" {
|
||
Error(w, http.StatusConflict, "该请求已被处理")
|
||
return
|
||
}
|
||
|
||
// 鉴权:必须是这封权限邮件的收件人,或管理员
|
||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||
if err != nil {
|
||
Error(w, http.StatusNotFound, "Mail not found")
|
||
return
|
||
}
|
||
if !user.IsAdmin() && mail.ToName != user.Username {
|
||
Error(w, http.StatusForbidden, "无权决策他人的权限请求")
|
||
return
|
||
}
|
||
|
||
// 决策选项必须在候选内
|
||
if !contains(perm.Options, req.Decision) {
|
||
Error(w, http.StatusBadRequest, "决策必须是候选项之一")
|
||
return
|
||
}
|
||
|
||
if _, err := repo.DecidePermission(r.Context(), mailID, req.Decision); err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to decide permission")
|
||
return
|
||
}
|
||
|
||
decisionMailID, err := repo.CreateDecisionMail(
|
||
r.Context(), perm.SessionID, mailID, user.Username, perm.AgentName, req.Decision, req.Note)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to create decision mail")
|
||
return
|
||
}
|
||
|
||
// 通知发起 Agent 恢复执行
|
||
// 带上上游 permission id:插件要拿它回复 opencode 的原生权限询问。
|
||
// 两边 id 空间不同,光给 AgentMail 的 mail_id 插件对不上;
|
||
// 而插件重启后内存映射会丢,所以这个映射由服务端持久化并在此回传。
|
||
payload := map[string]interface{}{
|
||
"mail_id": mailID.String(),
|
||
"decision_mail_id": decisionMailID.String(),
|
||
"decision": req.Decision,
|
||
"note": req.Note,
|
||
"decided_by": user.Username,
|
||
// 会话 id:插件重启丢了待决映射时,会退化成「把决策当一封通知投进会话」,
|
||
// 那条路径要靠这个字段找到原会话,否则会凭空另开一个。
|
||
"session_id": perm.SessionID.String(),
|
||
}
|
||
if key, kind := repo.RelayKeyForMail(r.Context(), mailID); key != "" {
|
||
payload["relay_key"] = key
|
||
payload["relay_kind"] = kind
|
||
}
|
||
sse.Default.SendToAgent(perm.AgentName, "permission_decision", payload)
|
||
// 只刷新决策人自己的界面
|
||
sse.Default.SendToUser(user.Username, "session_update", map[string]interface{}{
|
||
"session_id": perm.SessionID.String(),
|
||
"status": "active",
|
||
})
|
||
|
||
JSON(w, http.StatusOK, map[string]string{
|
||
"status": "decided",
|
||
"decision_mail_id": decisionMailID.String(),
|
||
})
|
||
}
|
||
|
||
// GET /api/v1/permission/pending —— 需登录;普通用户只看发给自己的
|
||
func ListPendingPermissions(w http.ResponseWriter, r *http.Request) {
|
||
user := middleware.GetUser(r)
|
||
if user == nil {
|
||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||
return
|
||
}
|
||
|
||
forUser := user.Username
|
||
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
|
||
forUser = ""
|
||
}
|
||
|
||
reqs, err := repo.ListPendingPermissionsFor(r.Context(), forUser)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "Failed to list pending permissions")
|
||
return
|
||
}
|
||
JSON(w, http.StatusOK, map[string]interface{}{
|
||
"requests": emptySlice(reqs),
|
||
})
|
||
}
|
||
|
||
func contains(list []string, v string) bool {
|
||
for _, s := range list {
|
||
if s == v {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|