上一提交(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
288 lines
12 KiB
Go
288 lines
12 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"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"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
/*
|
||
Agent↔Agent 回路的**冷静期**在 handler 上的行为(2026-10-01)
|
||
|
||
# 两句话需求,落在两处
|
||
|
||
① 「agent 互发应当有 2h 恢复机制」
|
||
→ 撞 maxAgentPingPong=8 时**开始**冷静期,到期自动放行,不依赖人。
|
||
|
||
② 「锁定期间的邮件不自动重投递」
|
||
→ 冷静期内 **relay 邮件直接丢弃**(不排队、到时不补投)。
|
||
|
||
# 为什么 ② 必须单独钉
|
||
|
||
"不自动重投递"有三种可能的实现形状,观测上**不等价**:
|
||
· 直接拒(丢弃) ← 本次要求的
|
||
· 拒但入队、到期补投 ← 看着一样,其实把回路攒到 2h 后一次性放出来
|
||
· 收下但不投递 ← 库里多一封信,统计与将来的人工检查都被污染
|
||
|
||
所以这一格钉的是**库里没有这封信**,而不只是"接口返回了错误"。
|
||
*/
|
||
|
||
// lockSession 造一个「两个 Agent 互发已达上限、会话已进冷静期」的会话。
|
||
func lockSession(t *testing.T) (uuid.UUID, string) {
|
||
t.Helper()
|
||
ctx := context.Background()
|
||
// Agent 用 repo.CreateOrUpdateAgent 建(真实列是 secret/host_url,
|
||
// 不是 agent_key —— 我第一版照想象写了列名,被 schema 打回)。
|
||
// ⚠ **不要**把 pinger/ponger 建成了 user。
|
||
//
|
||
// 我第一版顺手调了 mustPermissionUser,结果 IsHumanUser("ponger")=true
|
||
// ⇒ 闸按"收件方是人类"正确放行 ⇒ 判据红。
|
||
// 闸没问题,是我的靶子造错了人:这两个必须是 Agent。
|
||
for _, u := range []string{"pinger", "ponger"} {
|
||
if err := repo.CreateOrUpdateAgent(ctx, u, "k-"+u, "test", nil); err != nil {
|
||
t.Fatalf("建 agent %s: %v", u, err)
|
||
}
|
||
}
|
||
sid, err := repo.CreateSession(ctx, nil, "pinger", "回路", "")
|
||
if err != nil {
|
||
t.Fatalf("建会话: %v", err)
|
||
}
|
||
// ⚠ 必须有一封**已存在的信**才能 reply_to 进去。
|
||
//
|
||
// 我第一版直接用 `ponger@.new` 投递:`.new` 会**另建一条会话**,
|
||
// 于是锁在 sid 上、请求却落在另一条会话上 ⇒ 一路 200,
|
||
// 判据看着像"闸没生效"。真实闸没问题,是我的靶子打偏了。
|
||
if _, err := repo.CreateMail(ctx, sid, nil, "ponger", "", "pinger", "",
|
||
"起个头", "在吗", nil); err != nil {
|
||
t.Fatalf("预置一封信: %v", err)
|
||
}
|
||
var mailID string
|
||
if err := db.DB.QueryRowContext(ctx,
|
||
`SELECT mail_id FROM mails WHERE session_id = $1 ORDER BY created_at DESC LIMIT 1`,
|
||
sid).Scan(&mailID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := repo.SetSessionLock(ctx, sid, 8, "test"); err != nil {
|
||
t.Fatalf("进入冷静期: %v", err)
|
||
}
|
||
return sid, mailID
|
||
}
|
||
|
||
// countMails 数会话里的邮件数。判据用它对比**前后的变化**:
|
||
// lockSession 自己会预置一封「起头信」(没有它 reply_to 无处可落),
|
||
// 所以基线不是 0。
|
||
func countMails(t *testing.T, sessionID uuid.UUID) int {
|
||
t.Helper()
|
||
var n int
|
||
if err := db.DB.QueryRowContext(context.Background(),
|
||
`SELECT COUNT(*) FROM mails WHERE session_id = $1`, sessionID).Scan(&n); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return n
|
||
}
|
||
|
||
// postSend 以某个 agent 的身份发一封信,返回响应体。
|
||
func postSend(t *testing.T, agent, to, body, relayKey, replyTo string) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
ctx := context.Background()
|
||
if err := repo.CreateOrUpdateAgent(ctx, agent, "k-"+agent, "test", nil); err != nil {
|
||
t.Fatalf("注册 %s: %v", agent, err)
|
||
}
|
||
// relay 非空时服务端要求 relay_key 齐备("给了 relay_key 却没给 relay 类型"
|
||
// —— 我第一版只给了 relay_key,被这道前置校验先打回,看不到真正的闸)。
|
||
relay := ""
|
||
if relayKey != "" {
|
||
relay = "summary"
|
||
}
|
||
form := strings.NewReader(fmt.Sprintf(
|
||
`{"to":%q,"subject":"s","body":%q,"relay_key":%q,"relay":%q,"reply_to":%q}`,
|
||
to, body, relayKey, relay, replyTo))
|
||
req := httptest.NewRequest("POST", "/api/v1/mail", form)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req = req.WithContext(context.WithValue(context.Background(), middleware.AgentNameKey, agent))
|
||
rr := httptest.NewRecorder()
|
||
SendMail(rr, req)
|
||
return rr
|
||
}
|
||
|
||
// ① 冷静期内:Agent 的自主回信被拒,且**给出的是可执行的出路**。
|
||
func TestCooldownBlocksAgentSendAndStatesRecovery(t *testing.T) {
|
||
setupPermissionHandlerDB(t)
|
||
sid, mailID := lockSession(t)
|
||
baseCount := countMails(t, sid)
|
||
|
||
rr := postSend(t, "pinger", "ponger@", "还在互相确认吗", "", mailID)
|
||
if rr.Code != http.StatusForbidden {
|
||
t.Fatalf("冷静期内应 403,实际 %d:%s", rr.Code, rr.Body.String())
|
||
}
|
||
body := rr.Body.String()
|
||
// 文案必须说清「多久后自动恢复」——旧文案只教「请人类插话」,
|
||
// 而这条线索上常常根本没有人类(那正是原缺陷)。
|
||
if !strings.Contains(body, "冷静期") {
|
||
t.Fatalf("错误文案应说明这是冷静期:%s", body)
|
||
}
|
||
if !strings.Contains(body, "恢复") {
|
||
t.Fatalf("★ 文案必须给出恢复出路(自动恢复 or 人类插话):%s", body)
|
||
}
|
||
if n := countMails(t, sid); n != baseCount {
|
||
t.Fatalf("★ 被拒的信不得入库(否则它会被补投):%d → %d 封", baseCount, n)
|
||
}
|
||
}
|
||
|
||
// ② 冷静期内:relay(自动转发)**丢弃**,不排队不补投。
|
||
func TestCooldownDiscardsRelayWithoutQueueing(t *testing.T) {
|
||
setupPermissionHandlerDB(t)
|
||
sid, mailID := lockSession(t)
|
||
baseCount := countMails(t, sid)
|
||
|
||
rr := postSend(t, "pinger", "ponger@", "自动转发", "relay-key-1", mailID)
|
||
|
||
if rr.Code != http.StatusForbidden {
|
||
t.Fatalf("冷静期内 relay 应 403,实际 %d:%s", rr.Code, rr.Body.String())
|
||
}
|
||
if !strings.Contains(rr.Body.String(), "已丢弃") {
|
||
t.Fatalf("★ 文案必须明说「已丢弃」(否则发件方以为会补投而等待):%s", rr.Body.String())
|
||
}
|
||
|
||
// 关键:库里既没有邮件,**也没有留下待补投的占位**
|
||
if n := countMails(t, sid); n != baseCount {
|
||
t.Fatalf("★ relay 不得入库(否则 2h 后会被一次性灌回去):%d → %d 封", baseCount, n)
|
||
}
|
||
var keys int
|
||
if err := db.DB.QueryRowContext(context.Background(),
|
||
`SELECT COUNT(*) FROM relayed_mails WHERE relay_key = $1`, "relay-key-1").Scan(&keys); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if keys != 0 {
|
||
t.Fatalf("★ 不得占用 relay 幂等键(占着会让重试拿到 duplicate 却从未发出):%d 行", keys)
|
||
}
|
||
}
|
||
|
||
// ③ 人类插话立刻恢复 —— 冷静期不是死等 2 小时。
|
||
func TestHumanPostClearsCooldownImmediately(t *testing.T) {
|
||
setupPermissionHandlerDB(t)
|
||
ctx := context.Background()
|
||
mustPermissionUser(t, "human1")
|
||
sid, mailID := lockSession(t)
|
||
|
||
// 人类在**同一会话**里插一封(非 relay)—— 必须 reply_to 进去:
|
||
// 用 .new 会另建一条会话,解锁的就不是被锁的那条了(我第一版的错)。
|
||
req := httptest.NewRequest("POST", "/api/v1/mail",
|
||
strings.NewReader(fmt.Sprintf(
|
||
`{"to":"ponger@","subject":"人来了","body":"停一下","reply_to":%q}`, mailID)))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req = req.WithContext(context.WithValue(context.Background(), middleware.AgentNameKey, "human1"))
|
||
rr := httptest.NewRecorder()
|
||
SendMail(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("★ 人类参与后必须立刻解锁(不必等 2 小时)")
|
||
}
|
||
}
|
||
|
||
// ④ 收件方是人类时**永远不该进**回路计数/冷静期。
|
||
func TestHumanRecipientNeverLocked(t *testing.T) {
|
||
setupPermissionHandlerDB(t)
|
||
ctx := context.Background()
|
||
mustPermissionUser(t, "human1")
|
||
|
||
rr := postSend(t, "pinger", "human1@.new", "请教一下", "", "")
|
||
if rr.Code/100 != 2 {
|
||
t.Fatalf("发往人类不应被回路闸拦:%d %s", rr.Code, rr.Body.String())
|
||
}
|
||
var locks int
|
||
if err := db.DB.QueryRowContext(ctx,
|
||
`SELECT COUNT(*) FROM session_agent_locks`).Scan(&locks); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if locks != 0 {
|
||
t.Fatalf("★ 发往人类不得产生锁(人类在回路里正是我们要的):%d 行", locks)
|
||
}
|
||
}
|
||
|
||
/*
|
||
★★ 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 里「请由人类插一句话」是一句假话")
|
||
}
|
||
}
|