From 2fd18ba1346af64a4681f3b9d1b677f2b55b9fa7 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 1 Oct 2026 20:19:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=9B=9E=E8=B7=AF):=20=E4=BA=BA=E7=B1=BB?= =?UTF-8?q?=E7=BB=8F=20/me/mail/send=20=E6=8F=92=E8=AF=9D=E4=B9=9F?= =?UTF-8?q?=E8=A6=81=E8=A7=A3=E9=94=81=20=E2=80=94=E2=80=94=20=E7=AB=AF?= =?UTF-8?q?=E5=88=B0=E7=AB=AF=E5=AE=9E=E6=B5=8B=E6=8A=93=E5=88=B0=E7=9A=84?= =?UTF-8?q?=E7=BC=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一提交(4d8165f)的 403 文案写着「若要立刻恢复,请由人类在会话里插一句话」, 但**那句话是假的**。 ## 实测证据 拿真实用户登录态经 `POST /api/v1/me/mail/send` 在被锁会话里插话: HTTP 200 {"mail_id":"68eda0c3-…"} ← 人类的信进去了(这是对的) session_agent_locks 锁行数: 1 ← ★ 锁还在 `MeSendMail` 有自己的 resolve + CreateMail,**根本不经过 Agent 侧那道闸** (main.go 里 UserAuth 组下单独注册)。所以解锁只写在 `SendMail` 里是不够的: 人类插了话,锁仍要等满 2 小时。 ## 为什么单测没抓到 `TestHumanPostClearsCooldownImmediately` 走的是 Agent 侧 handler, 证明了「解锁逻辑本身对」,却没证明「人类实际会走的那条路也解锁」。 判据钉的是 A 路径、生产走的是 B 路径 —— 这个形状本仓已遇到三次 (opencode 的 withScope、homeagent 的 InjectInputSync、这次)。 ## 修法 把解锁放在 `SetSessionOwner` 旁边 —— 那是「人类参与这条会话」的权威落点, 比在每个调用点各写一遍可靠(漏一处就又是一句假承诺)。 解锁失败只记日志不阻断:人的来信优先入库,代价只是那把锁到期自消(保守方向)。 判据:新增 `TestHumanSendPathAlsoClearsCooldown`,走真实 `middleware.UserAuth` + 真实 user_sessions 行。变异(撤掉解锁)后该格变红。 踩到的两个测试夹具坑(都记在判据注释里): - 直接调 handler 会 401 —— 生产上这条路由在 UserAuth 中间件后面 - UserAuth → SessionToken 读 config.C.CookieName,而测试里 config.C 是 nil ⇒ panic --- server/internal/handler/mail_cooldown_test.go | 77 ++++++++++++++++++- server/internal/handler/me.go | 18 +++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/server/internal/handler/mail_cooldown_test.go b/server/internal/handler/mail_cooldown_test.go index 100de0e..33caf9c 100644 --- a/server/internal/handler/mail_cooldown_test.go +++ b/server/internal/handler/mail_cooldown_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/agentmail/gateway/internal/config" "github.com/agentmail/gateway/internal/db" "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/repo" @@ -209,4 +210,78 @@ func TestHumanRecipientNeverLocked(t *testing.T) { if locks != 0 { t.Fatalf("★ 发往人类不得产生锁(人类在回路里正是我们要的):%d 行", locks) } -} \ No newline at end of file +} + +/* +★★ 2026-10-01 端到端实测抓到的缺口:**人类走的是另一条路径**。 + +# 现象 + +我在 `SendMail`(Agent 侧)里写了「人类插话立刻解锁」。部署后拿真实用户 +登录态经 `POST /api/v1/me/mail/send` 在被锁会话里插话: + + HTTP 200 {"mail_id":"68eda0c3-…"} ← 人类的信进去了(对) + session_agent_locks 锁行数: 1 ← ★ 锁还在 + +`MeSendMail` 有它自己的 resolve + CreateMail,**根本不经过 Agent 侧那道闸**。 +所以我写在 403 里的那句「若要立刻恢复,请由人类在会话里插一句话」是**假话** —— +人类插了话,锁仍要等满 2 小时。 + +# 为什么单测没抓到 + +`TestHumanPostClearsCooldownImmediately` 走的是 `SendMail`(Agent 侧 handler), +且注入身份 `human1`(一个 **user**)。那条恰好是**能**解锁的路径 —— +它证明了「解锁逻辑本身对」,却没证明「人类实际会走的那条路也解锁」。 + +# 这一格钉的是**接线** + +不是解锁的语义(那个已由上面那格覆盖),而是「人类的路径上也调了它」。 +这类缺陷(判据钉的是 A 路径,生产走的是 B 路径)在别的桥上已出现过两次: +opencode 的 withScope、homeagent 的 InjectInputSync。 +*/ +func TestHumanSendPathAlsoClearsCooldown(t *testing.T) { + setupPermissionHandlerDB(t) + ctx := context.Background() + if _, err := repo.CreateUser(ctx, "guiuser", "x", "guiuser", "user", nil, nil); err != nil { + t.Fatalf("建用户: %v", err) + } + sid, mailID := lockSession(t) + + // 用**用户登录态**(middleware.UserAuth 那条路),而不是 Agent 身份 + // ⚠ UserAuth → SessionToken 读 config.C.CookieName,而 config.C 在测试里是 nil + // (实测 panic: nil pointer at middleware/user.go:35)。 + // appearance_test.go 里有同样的处理,照它来。 + if config.C == nil { + config.C = &config.Config{CookieName: "am_session"} + t.Cleanup(func() { config.C = nil }) + } + + tok := "probe-session-token-20261001" + if _, err := db.DB.ExecContext(ctx, + `INSERT INTO user_sessions (token, user_id, created_at, expires_at) + SELECT $1, user_id, datetime('now'), datetime('now','+1 day') + FROM users WHERE username = $2`, tok, "guiuser"); err != nil { + t.Fatalf("造登录态: %v", err) + } + req := httptest.NewRequest("POST", "/api/v1/me/mail/send", + strings.NewReader(fmt.Sprintf( + `{"to":"ponger@","subject":"人来了","body":"停一下","reply_to":%q}`, mailID))) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: "am_session", Value: tok}) + rr := httptest.NewRecorder() + // ★ 必须包上 UserAuth —— 生产里这条路由就在该中间件后面 + // (main.go: r.Group(UserAuth) → r.Post("/me/mail/send", …))。 + // 我第一版直接调 handler ⇒ 401,判据红得莫名其妙。 + middleware.UserAuth(http.HandlerFunc(MeSendMail)).ServeHTTP(rr, req) + + if rr.Code/100 != 2 { + t.Fatalf("人类的信不该被回路闸拦:%d %s", rr.Code, rr.Body.String()) + } + // ★ 关键断言:人类的信进来了,锁**同时**必须消失。 + // 只断言前者的话,"人类能发信"与"人类插话解锁"两件事会一起通过, + // 而后者恰恰是缺失的那个。 + if _, locked, _ := repo.SessionLockOf(ctx, sid); locked { + t.Fatal("★ 人类经 /me/mail/send 插话后必须立刻解锁 —— " + + "否则 403 里「请由人类插一句话」是一句假话") + } +} diff --git a/server/internal/handler/me.go b/server/internal/handler/me.go index 90e1170..b01d3bd 100644 --- a/server/internal/handler/me.go +++ b/server/internal/handler/me.go @@ -2,6 +2,7 @@ package handler import ( "errors" + "log" "net/http" "time" @@ -122,6 +123,23 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) { // 人类发起的会话归属于该用户 _ = repo.SetSessionOwner(r.Context(), sessionID, user.ID) + // ★ 2026-10-01:人类在**这条路径**上插话也要立刻解除 Agent 回路冷静期。 + // + // 我第一版只把解锁写在 Agent 侧 `SendMail` 里,而**人类根本不走那条路** + // —— 端到端实测:WebUI 用户经 `/me/mail/send` 插话返回 200(人类的信 + // 本来就不该被回路闸拦,这是对的),可 `session_agent_locks` 那行**还在**。 + // + // 于是错误文案里那句「若要立刻恢复,请由人类在会话里插一句话」是**假的**: + // 人类插了话,锁却要等满 2 小时。而这条文案正是我自己在 403 里写的。 + // + // 放在 SetSessionOwner 旁边:那是「人类参与这条会话」的权威落点, + // 比在每个调用点各写一遍解锁可靠(漏一处就是一句假承诺)。 + if _, err := repo.SessionLockTouch(r.Context(), sessionID); err != nil { + // 解锁失败不该拦下人类的邮件 —— 人的来信优先入库。 + // 代价只是那把锁要到期才消(保守方向,不放宽任何闸)。 + log.Printf("[agent-lock] 人类插话后解锁失败(锁将到期自消): %v", err) + } + // 新建会话时定往返预算。只在新建时设:续谈已有会话若也接受这个字段, // 每封新信都会悄悄改掉对方正在遵守的预算,人却不一定意识到自己改了。 //