From 344f970353b4d00974c5245a97accc50315d9264 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 5 Sep 2026 09:58:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=BB=E4=BF=A1=E9=BB=91=E6=B4=9E?= =?UTF-8?q?=EF=BC=9A=E5=8F=91=E4=BF=A1=E5=89=8D=E6=A0=A1=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E4=BB=B6=E4=BA=BA=E5=8F=AF=E8=BE=BE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 事故 发给已彻底删除的 Agent 返回 200:邮件入库、分配 20 个来回预算、 建好会话,而那一端永远不会有人读。发件人看到 200 和一个 session_id, 以为送出去了。 实测(修复前): POST /me/mail/send to=remotebot@/tmp → 200 mail_id 53e4c9ea… session dc8a41c3… budget_max 20 remotebot 的 agents 行在本会话早前已被 DELETE /admin/agents 删掉。 根因:三个发信入口的检查链只有「地址语法 / 调用权限 / 会话别名」, 从不问「这个名字存在吗」。`AgentDisabled` 那个函数只在注册路径被调用, 发信路径压根不查——它的注释甚至写着「Agent 不存在时返回 false」。 静默丢件比报错严重:报错能立刻改,静默丢件要等对方追问才发现。 这与之前修过的「relay_key 400 被当暂时失败导致静默挂死」同类。 ## 修法 `repo.RecipientDeliverable(ctx, name)` 作为唯一判据: - 人类用户 → 放行(人的收件箱一直在,不受 Agent 停用影响) - Agent 在册且未停用 → 放行 - Agent 不存在 → ErrRecipientUnknown → 404 - Agent 已停用 → ErrRecipientDisabled → 409 `handler.checkDeliverable` 把它接到三个入口,**收件人与抄送位一起查**: 不查 cc 的话抄送位就成了绕过口,而且因为不是主收件人更不容易被发现。 - me.go MeSendMail (人类发信) - mail.go SendMail (Agent 发信) - forward.go ForwardMail(人与 Agent 两条转发路径共用) 停用选择「当场拒收」而非「入库等恢复后补投」:停用的语义就是这个 Agent 现在不干活,让发件人以为信已送达更坏——它会照常等回信。 ## 测试 `internal/repo/deliverable_test.go` 8 例:人类 / 在线 Agent / 不存在 / 删除后不可达 / 停用 409 / 恢复后重新可达 / 空名放行 / 同名人类优先于已停用 Agent。 负向对照:让 RecipientDeliverable 无条件 return nil(还原事故前行为), UnknownName、AfterDelete、Disabled 三例如期失败。 ## 线上验证 发给已删除 remotebot → 404「收件人不存在」 发给在线 pi → 200,pi 回信「可达性 OK」 cc 位放已删除 remotebot → 404(绕过口已封) 停用 pi 后发信 → 409「已被管理员停用」 恢复 pi 后发信 → 200 ## 顺带 - 部署改用 sqlite3 .backup + install -m 0755(原子 rename,不写坏 运行中进程镜像),来自 git-release-discipline skill 的运维纪律 - 清理本会话测试残留:误登记的 opencode 密钥、remotebot 两把残留密钥、 死信测试邮件与会话 --- gateway/internal/handler/forward.go | 5 + gateway/internal/handler/mail.go | 6 + gateway/internal/handler/me.go | 32 +++++ gateway/internal/repo/deliverable_test.go | 135 ++++++++++++++++++++++ gateway/internal/repo/repo.go | 50 ++++++++ 5 files changed, 228 insertions(+) create mode 100644 gateway/internal/repo/deliverable_test.go diff --git a/gateway/internal/handler/forward.go b/gateway/internal/handler/forward.go index c804c6c..2c1170d 100644 --- a/gateway/internal/handler/forward.go +++ b/gateway/internal/handler/forward.go @@ -119,6 +119,11 @@ func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor, } } + // 可达性:转发目标必须存在且未停用。人与 Agent 两条转发路径共用这道检查。 + if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) { + return + } + subject := forwardSubject(req.Subject, src.Subject) // 转发按目标地址寻址,不带 reply_to:它是一条新线索,不该并进原会话 diff --git a/gateway/internal/handler/mail.go b/gateway/internal/handler/mail.go index 6eb003d..e5b5d9e 100644 --- a/gateway/internal/handler/mail.go +++ b/gateway/internal/handler/mail.go @@ -236,6 +236,12 @@ func SendMail(w http.ResponseWriter, r *http.Request) { return } + // 可达性:收件人必须存在且未停用。Agent 侧同样要查 —— + // 模型拿到 200 就会当作「话已传到」并停手等对方,而那封信永远不会有人读。 + if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) { + return + } + sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias, agentName) if err != nil { writeErr(w, err, "Failed to resolve session") diff --git a/gateway/internal/handler/me.go b/gateway/internal/handler/me.go index 1b731ec..486567d 100644 --- a/gateway/internal/handler/me.go +++ b/gateway/internal/handler/me.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "time" @@ -76,6 +77,11 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) { return } + // 可达性:收件人必须存在且未停用,否则邮件进黑洞 + if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) { + return + } + sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias, "") if err != nil { writeErr(w, err, "Failed to resolve session") @@ -302,3 +308,29 @@ func checkScope(r *http.Request, user *models.User, addrs []models.Address) stri } return "" } + +// checkDeliverable 校验每个收件人(含拄送)当前能不能收信,写好响应并返回 false 表示已拒绝。 +// +// 拄送位同样要查:不查的话 cc 就成了绕过口 —— 把已删除的 Agent 放到 cc 位 +// 依旧能把邮件送进黑洞,而且因为不是主收件人更不容易被发现。 +func checkDeliverable(w http.ResponseWriter, r *http.Request, addrs []models.Address) bool { + for _, a := range addrs { + err := repo.RecipientDeliverable(r.Context(), a.Name) + switch { + case err == nil: + continue + case errors.Is(err, repo.ErrRecipientUnknown): + Error(w, http.StatusNotFound, + "收件人不存在:"+a.Name+"。它既不是人类用户也不是已注册的 Agent(可能已被删除)。") + return false + case errors.Is(err, repo.ErrRecipientDisabled): + Error(w, http.StatusConflict, + "Agent \""+a.Name+"\" 已被管理员停用,现在不接收新任务。请先在管理页恢复它。") + return false + default: + Error(w, http.StatusInternalServerError, "无法校验收件人状态") + return false + } + } + return true +} diff --git a/gateway/internal/repo/deliverable_test.go b/gateway/internal/repo/deliverable_test.go new file mode 100644 index 0000000..39f99af --- /dev/null +++ b/gateway/internal/repo/deliverable_test.go @@ -0,0 +1,135 @@ +package repo + +import ( + "context" + "errors" + "testing" + + "github.com/agentmail/gateway/internal/db" +) + +// RecipientDeliverable 是「邮件会不会进黑洞」的唯一判据。 +// +// 事故背景:发信路径原来只校验地址语法、调用权限与会话别名,从不问 +// 「这个名字存在吗」。实测发给已彻底删除的 remotebot 返回 200,邮件入库、 +// 分配了 20 个来回预算、建好会话,而那一端永远不会有人读。发件人看到 200 +// 和一个 session_id,以为送出去了 —— 静默丢件比报错严重,报错能立刻改, +// 静默丢件要等对方追问才发现。 + +func TestRecipientDeliverable_Human(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + if _, err := db.DB.ExecContext(ctx, + `INSERT INTO users (username, password_hash) VALUES ('alice', 'x')`); err != nil { + t.Fatalf("seed user: %v", err) + } + + if err := RecipientDeliverable(ctx, "alice"); err != nil { + t.Fatalf("人类用户应当可达,得到: %v", err) + } +} + +func TestRecipientDeliverable_OnlineAgent(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "pi", 20) + + if err := RecipientDeliverable(ctx, "pi"); err != nil { + t.Fatalf("在册且未停用的 Agent 应当可达,得到: %v", err) + } +} + +func TestRecipientDeliverable_UnknownName(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + err := RecipientDeliverable(ctx, "ghost") + if !errors.Is(err, ErrRecipientUnknown) { + t.Fatalf("不存在的收件人应当返回 ErrRecipientUnknown,得到: %v", err) + } +} + +// 删除后立刻不可达 —— 这正是本次事故的场景。 +func TestRecipientDeliverable_AfterDelete(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "remotebot", 20) + + if err := RecipientDeliverable(ctx, "remotebot"); err != nil { + t.Fatalf("删除前应当可达,得到: %v", err) + } + + if _, err := DeleteAgent(ctx, "remotebot"); err != nil { + t.Fatalf("delete agent: %v", err) + } + + err := RecipientDeliverable(ctx, "remotebot") + if !errors.Is(err, ErrRecipientUnknown) { + t.Fatalf("删除后必须不可达,得到: %v", err) + } +} + +// 停用选择「当场拒收」而不是「入库等恢复后补投」:停用的语义就是这个 +// Agent 现在不干活,让发件人以为信已送达更坏 —— 它会照常等回信。 +func TestRecipientDeliverable_Disabled(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "opencode", 20) + + if _, err := SetAgentDisabled(ctx, "opencode", true); err != nil { + t.Fatalf("disable: %v", err) + } + + err := RecipientDeliverable(ctx, "opencode") + if !errors.Is(err, ErrRecipientDisabled) { + t.Fatalf("已停用的 Agent 应当返回 ErrRecipientDisabled,得到: %v", err) + } +} + +// 恢复后重新可达,否则停用就成了不可逆操作。 +func TestRecipientDeliverable_ReenabledAgain(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "opencode", 20) + + if _, err := SetAgentDisabled(ctx, "opencode", true); err != nil { + t.Fatalf("disable: %v", err) + } + if _, err := SetAgentDisabled(ctx, "opencode", false); err != nil { + t.Fatalf("re-enable: %v", err) + } + + if err := RecipientDeliverable(ctx, "opencode"); err != nil { + t.Fatalf("恢复后应当重新可达,得到: %v", err) + } +} + +// 空名交给上层的地址解析处理,这里放行 —— 不然人类给自己发信 +// (to 位省略 name)会被这道检查误伤。 +func TestRecipientDeliverable_EmptyName(t *testing.T) { + setupTestDB(t) + if err := RecipientDeliverable(context.Background(), ""); err != nil { + t.Fatalf("空名应当放行,得到: %v", err) + } +} + +// 人类用户名与 Agent 名共用命名空间。一个名字同时是人类用户时按人类算 —— +// 人的收件箱一直在,不受 Agent 停用影响。 +func TestRecipientDeliverable_HumanWinsOverDisabledAgent(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + if _, err := db.DB.ExecContext(ctx, + `INSERT INTO users (username, password_hash) VALUES ('dual', 'x')`); err != nil { + t.Fatalf("seed user: %v", err) + } + seedAgent(t, "dual", 20) + if _, err := SetAgentDisabled(ctx, "dual", true); err != nil { + t.Fatalf("disable: %v", err) + } + + if err := RecipientDeliverable(ctx, "dual"); err != nil { + t.Fatalf("同名人类用户应当优先放行,得到: %v", err) + } +} diff --git a/gateway/internal/repo/repo.go b/gateway/internal/repo/repo.go index 824418f..1efcdec 100644 --- a/gateway/internal/repo/repo.go +++ b/gateway/internal/repo/repo.go @@ -84,6 +84,56 @@ func AgentDisabled(ctx context.Context, agentName string) (bool, error) { return status == "disabled", nil } +// ErrRecipientUnknown 收件人既不是人类用户也不是在册 Agent。 +var ErrRecipientUnknown = errors.New("recipient unknown") + +// ErrRecipientDisabled 收件 Agent 已被管理员停用。 +var ErrRecipientDisabled = errors.New("recipient disabled") + +// RecipientDeliverable 校验一个收件人名当前能不能收信。 +// +// 为什么必须有这道检查:发信路径原来只校验地址语法、调用权限与会话别名, +// 从不问「这个名字存在吗」。于是发给已删除或已停用的 Agent 一律返回 200 —— +// 邮件入库、分配预算、建好会话,而那一端永远不会有人读。发件人看到 200 +// 和一个 session_id,以为送出去了。这是静默丢件,比报错严重: +// 报错能立刻改,静默丢件要等对方追问才发现。 +// +// 三种放行/拒绝: +// - 人类用户 → 放行(人的收件箱一直在) +// - Agent 在册且未停用 → 放行 +// - Agent 不存在 → ErrRecipientUnknown(对应 404) +// - Agent 已停用 → ErrRecipientDisabled(对应 409) +// +// 停用选择「当场拒收」而不是「入库等恢复后补投」:停用的语义就是这个 Agent +// 现在不干活,让发件人以为信已送达更坏 —— 它会照常等回信。 +func RecipientDeliverable(ctx context.Context, name string) error { + if name == "" { + return nil // 空名由上层的地址解析负责 + } + + isHuman, err := IsHumanUser(ctx, name) + if err != nil { + return err + } + if isHuman { + return nil + } + + var status string + err = db.DB.QueryRowContext(ctx, + `SELECT status FROM agents WHERE agent_name = $1`, name).Scan(&status) + if errors.Is(err, sql.ErrNoRows) { + return ErrRecipientUnknown + } + if err != nil { + return err + } + if status == "disabled" { + return ErrRecipientDisabled + } + return nil +} + // SetAgentDisabled 停用或恢复一个 Agent。 // // 停用时连带撤销它的全部密钥:留着密钥的话,那个平台的插件仍然能用它调