Files
MailUI4Agents/server/internal/mcp/rename_proposal_test.go
JianFeeeee a6b7994a45 feat(mcp)★★: 自动转发通道 —— relay/relay_key,并修掉一个从未生效的幂等文案
用户 2026-10-04:「不只是 Agent 主动回,还有自动转发 Agent 的最后一条消息」。

## 为什么 MCP 侧需要显式上报,而插件侧不用

  插件(pi / dsh / homeagent)有 `session.idle` 钩子:
    宿主进程在自己会话 idle 时读到最后一条回复 → 自动 send_mail(relay=…)
  MCP 宿主(Codex / Claude Desktop)**没有这个概念**:
    不知道「这一轮处理完了」,也不会主动调 send_mail。
    ⇒ 宿主必须显式上报,服务端才可能自动转发。

  所以新增 `send_mail(relay="summary", relay_key="<宿主消息 id>")`:
  relay=summary 免配额(服务端既有逻辑),relay_key 提供幂等。

## 顺带修掉一个从未生效的幂等文案(既有缺陷)

    MCP 侧读:str(payload, "duplicate_relay")
    服务端返:{"status": "duplicate_relay", "detail": "该上游消息已转发过…"}

  **字段名对不上 ⇒ 那段逻辑从来没生效过。**

  后果对 MCP 接入端尤其致命:它的自动转发正是靠 relay_key 幂等兜底,
  而宿主重试时看到的是「已发送」(无 mail_id、无任何异常),
  于是继续重试 —— 每一轮都在消耗一次往返。

  现在文案是:
    该上游消息(relay_key: xxx)已转发过,本次调用未产生新邮件 —— 不需要重试。

## 判据(3 格,2 个变异都转红)

- send_mail 暴露 relay / relay_key(去掉透传 → 转红)
- relay 非空而 relay_key 为空必须被拒
- 同一 relay_key 第二次转发报重复,且**只有一封真邮件**

## 判据自己错了两次

1. `TestRelayRequiresKey` 红着但**原因不对**:没建收件人,400 发生在
   「收件人不存在」那一步,掩盖了 parseRelay 的校验。
2. 幂等那格被我自己 `Skip` 了 —— `.relay-idem` 会话不存在,
   于是「幂等没被击穿」的假绿。改为 `.new` 并**禁止 Skip**:
   幂等是自动转发不重投的唯一保证,它绿着才敢让宿主上报。
   (判据不可达 = 描述的状态不存在。)

## 双向对齐判据立刻证明了价值

加了 relay/relay_key 却没登记,它当场红:

    ★ send_mail 的参数 "relay" 既不在插件版、也没登记为 MCP 额外

若它不存在,加了新参数就会静默通过,而「这个能力是有意加的还是漏登记的」
正是以后没人能回答的问题。relay 登记为「额外」而非「缺失」——
插件侧走 session.idle,不需要这条参数。

14 包全绿。
2026-10-04 17:08:15 +08:00

458 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package mcp
/*
改名提议的判据。
# 为什么这一层要单独钉
标记格式是**服务端正则的镜像**(`server/internal/handler/rename_proposal.go` 的
`renameProposalRe`)。格式不一致的后果是**静默**的:
邮件照常发出 → 服务端正则匹配不上 → 提议凭空消失
→ 模型以为自己提过了 → 下一封邮件用那个不存在的别名寻址 → 404
所以这里不只验「能拼出标记」,还验**与 JS 版逐字节相同**、且**能被服务端正则
真的解出来**(直接调用服务端那个正则,不是另写一个)。
*/
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/agentmail/gateway/internal/middleware"
)
// 服务端的正则,在这里 import 而不是复制 —— 复制一份就等于放弃了「镜像」的意义。
// 这里直接用 handler 包里的那个(它是导出的)。
func serverExtract(body string) (alias, reason string, ok bool) {
for _, m := range renameProposalRe.FindAllStringSubmatch(body, -1) {
if len(m) >= 2 {
return m[1], func() string {
if len(m) > 2 {
return m[2]
}
return ""
}(), true
}
}
return "", "", false
}
func TestRenameProposalRoundTripsThroughServerRegex(t *testing.T) {
body, ok := appendRenameProposal("干完了,根因是登录态泄漏。", "fix-login-leak", "定位到泄漏点在 cookie 过期判断")
if !ok {
t.Fatal("合法别名必须被接受")
}
alias, reason, found := serverExtract(body)
if !found {
t.Fatalf("★ 服务端正则必须能解出标记(解不出 = 提议静默消失):body=%q", body)
}
if alias != "fix-login-leak" {
t.Errorf("alias=%q", alias)
}
if reason != "定位到泄漏点在 cookie 过期判断" {
t.Errorf("reason=%q", reason)
}
}
// 与 JS 版逐字节相同 —— 这是「跨语言实现同一份协议」的核心风险。
func TestRenameProposalMatchesJSByteForByte(t *testing.T) {
const NL = "\n\n" // 标记前是两个换行
cases := []struct{ body, alias, reason, want string }{
{"正文", "fix-a", "理由", "正文" + NL + `<!-- agentmail:rename-session alias="fix-a" reason="理由" -->`},
{"正文", "fix-a", "", "正文" + NL + `<!-- agentmail:rename-session alias="fix-a" -->`},
// 理由里的双引号要去掉(HTML 注释里没有转义机制)
{"正文", "fix-a", `他说"好"`, "正文" + NL + `<!-- agentmail:rename-session alias="fix-a" reason="他说好" -->`},
}
for _, c := range cases {
got, ok := appendRenameProposal(c.body, c.alias, c.reason)
if !ok {
t.Fatalf("case %+v 应被接受", c)
}
if got != c.want {
t.Errorf("★ 与 JS 版不一致\n Go: %q\n JS: %q", got, c.want)
}
}
}
// 非法别名:原样返回、不追加标记,并如实告诉模型「没提交」。
func TestRenameProposalRejectsIllegalAlias(t *testing.T) {
for _, alias := range []string{"", " ", "new", "has.dot", "has space", "has/slash", "has@at", `has"quote`} {
body, ok := appendRenameProposal("正文", alias, "理由")
if ok {
t.Errorf("★ 非法别名 %q 被接受了(会发一个服务端匹配得上却被 validateSessionAlias 拒掉的标记)", alias)
}
if body != "正文" {
t.Errorf("别名 %q 非法时正文必须原样返回,实际 %q", alias, body)
}
// 空别名 = 根本没提议 ⇒ 不该有说明(与 JS 版一致)。
// 非空但不合法 ⇒ 必须说明「未提交」,否则模型以为自己提过了。
note := renameProposalNote("", alias, false)
trimmed := strings.TrimSpace(alias)
if trimmed != "" && note == "" {
t.Errorf("★ 别名 %q 不合法时必须给模型一句「未提交」的说明", alias)
}
if trimmed == "" && note != "" {
t.Errorf("别名是空的(没提议)时不该有说明,实际 %q", note)
}
}
}
// 回显必须用**服务端给的**别名:normalizeAlias 改过之后,本地值是不存在的名字。
func TestRenameProposalNoteUsesServerAlias(t *testing.T) {
// 服务端规范化了(new → session-new 之类)
note := renameProposalNote("fixed-login", "fix登录", true)
if !strings.Contains(note, "fixed-login") {
t.Errorf("★ 必须回显服务端返回的别名,实际 %q", note)
}
if !strings.Contains(note, "规范化") {
t.Errorf("本地值与服务端值不同时必须说明被规范化了,实际 %q", note)
}
// 必须说「等用户确认」—— 不说模型会以为已经生效,接着用新别名发信(那个别名还不存在)
if !strings.Contains(note, "确认") {
t.Errorf("必须说明要等人确认,实际 %q", note)
}
}
// 三种情形各有各的话,不能串。
func TestRenameProposalNoteThreeOutcomes(t *testing.T) {
if n := renameProposalNote("", "", false); n != "" {
t.Errorf("没有提议时不该有说明,实际 %q", n)
}
if n := renameProposalNote("", "fix-a", true); !strings.Contains(n, "未被服务端接受") {
t.Errorf("标记发出但服务端没回值 ⇒ 应说未被接受,实际 %q", n)
}
if n := renameProposalNote("fix-a", "fix-a", true); strings.Contains(n, "规范化") {
t.Errorf("两端一致时不该提规范化,实际 %q", n)
}
}
// ★ 与 JS 版的合法判据一致:超长别名拒绝。
func TestRenameProposalRejectsOverlongAlias(t *testing.T) {
long := strings.Repeat("a", 129)
if _, ok := appendRenameProposal("正文", long, ""); ok {
t.Error("★ 超 128 字节的别名必须拒绝(服务端是 VARCHAR(128))")
}
if _, ok := appendRenameProposal("正文", strings.Repeat("a", 128), ""); !ok {
t.Error("正好 128 字节应当接受")
}
}
// ---- 工具层接线 ----
// ★ 这一格钉的是「参数真的接上了」:上一版三个参数都缺(propose_alias、
//
// offset、session_alias),而工具照常工作 —— 缺参数不会报错,只会让模型
// 以为能力不存在。
/*
工具面必须与插件版**双向**对齐(2026-10-04)。
★ 为什么要改成真读源码:
初版这里是一份**手抄清单**(map[string][]string)。两个问题:
1. 会漂移 —— 桥加了参数,这里不会跟着变,判据照样全绿;
2. 只查"不缺",不查"不多" ⇒ MCP 多出来的能力没人管。
⇒ 改为**每次现读** plugins/pi-mail-bridge/src/tools.mjs,
与 MCP 的 schema 做双向集合比对。
已知的三处**有意**差异,各有理由,逐一显式豁免并说明:
- connect_to_server 的 gateway_url/key_token:
网关内建端点已由 AgentAuth 认证;改坐标是**部署动作**,
不该由一次工具调用触发(桥侧是局外进程才需要)。
- session_id(4 个工具):15e4fe9/095213b 越权修复的显式化产物。
插件侧靠「当前所在会话」隐式,MCP 宿主没有这个隐式上下文 ⇒ 必须显式。
- MCP 侧的 attachment_ids/propose_alias/propose_reason/flatten:
**MCP 比插件多**,属能力增强,不要求插件对齐。
*/
func TestToolsAlignWithPluginBridgeBidirectionally(t *testing.T) {
s := NewServer(nil)
NewTools().RegisterAll(s)
plugin := parsePiBridgeToolParams(t)
// 插件有而 MCP 没有的:必须逐个在豁免名单里有理由
intentional := map[string]map[string]string{
"connect_to_server": {
"gateway_url": "改坐标是部署动作,不该由工具调用触发",
"key_token": "身份只从 AgentAuth 的 context 取,不从参数取",
},
}
for tool, want := range plugin {
props, ok := s.tools[tool]
if !ok {
t.Errorf("★ 插件有工具 %q,MCP 没有", tool)
continue
}
sch, _ := props.Schema().InputSchema["properties"].(map[string]any)
for _, k := range want {
if _, has := sch[k]; has {
continue
}
if why, ok := intentional[tool][k]; ok {
_ = why // 有意豁免,见函数注释
continue
}
t.Errorf("★ %s 缺参数 %q —— 插件版有、MCP 没有,模型会以为该能力不存在", tool, k)
}
}
// 反向:MCP 独有的参数必须显式登记
mcpExtra := map[string][]string{
"forward_mail": {"session_id"},
"read_inbox": {"session_id", "workspace"},
"read_mail": {"session_id"},
"read_thread": {"session_id"},
"send_mail": {"attachment_ids", "propose_alias", "propose_reason",
"relay", "relay_key"},
"suggest_address": {"flatten"},
}
seen := map[string]bool{}
for tool, extra := range mcpExtra {
seen[tool] = true
props, ok := s.tools[tool]
if !ok {
t.Errorf("MCP 没有工具 %q", tool)
continue
}
sch, _ := props.Schema().InputSchema["properties"].(map[string]any)
for _, k := range extra {
if _, has := sch[k]; !has {
t.Errorf("★ 登记说 %s 有额外参数 %q,实际没有 —— 登记值过期了", tool, k)
}
}
}
// 反向还要反向查:MCP 有而没登记的参数(= 有人加了能力忘了登记)
for _, tl := range allTools() {
tool := tl.Schema().Name
sch, _ := tl.Schema().InputSchema["properties"].(map[string]any)
for k := range sch {
if k == "type" || k == "description" {
continue
}
if contains(append(append([]string{}, plugin[tool]...), mcpExtra[tool]...), k) {
continue
}
t.Errorf("★ %s 的参数 %q 既不在插件版、也没登记为 MCP 额外 —— "+
"新增能力忘了登记(判据会随实现漂移)", tool, k)
}
}
}
// parsePiBridgeToolParams 真读 pi 桥源码,取每个工具 parameters.properties 的键。
//
// ★ 每次现读、绝不缓存:模块级缓存会让「改了桥不重读」变成假绿
//
// (这在本项目已犯过一次 —— 判据模块级缓存外部源码)。
//
// ★ 用**花括号深度**而不是「行正则找 `},`」来判块结束。
//
// 初版用 `^\s*\},?$` 判闭合,而 properties 里的**每一行参数**长得就是
// ` xxx: { type: … },` —— 首行参数就把块关掉了。实测:给桥加一个参数,
// 判据**依然全绿**(`out` 有 11 个 key、每个都是空数组,
// 而 `len(out)==0` 那个守卫恰恰检查不出来)。
//
// ⇒ 少一个「抓到几个参数」的断言,判据就会长期假绿而不自知。
func parsePiBridgeToolParams(t *testing.T) map[string][]string {
t.Helper()
path := filepath.Join("..", "..", "..", "plugins", "pi-mail-bridge", "src", "tools.mjs")
raw, err := os.ReadFile(path)
if err != nil {
t.Skipf("读不到 pi 桥源码(%v)—— 对齐判据无法运行", err)
}
out := map[string][]string{}
toolRe := regexp.MustCompile(`^\s*name:\s*'([a-z_]+)',?\s*$`)
propRe := regexp.MustCompile(`properties:\s*\{\s*$`)
paramRe := regexp.MustCompile(`^\s+([a-z_]+):\s*\{\s*type:`)
skipKeys := map[string]bool{
"type": true, "description": true, "enum": true,
"items": true, "default": true, "required": true,
}
var cur string
depth := 0
for _, ln := range strings.Split(string(raw), "\n") {
if m := toolRe.FindStringSubmatch(ln); m != nil {
cur = m[1]
out[cur] = []string{}
depth = 0
continue
}
if cur == "" {
continue
}
if depth == 0 && propRe.MatchString(ln) {
depth = 1 // 进入 properties
continue
}
if depth > 0 {
if m := paramRe.FindStringSubmatch(ln); m != nil && !skipKeys[m[1]] {
out[cur] = append(out[cur], m[1])
}
depth += strings.Count(ln, "{") - strings.Count(ln, "}")
if depth <= 0 {
depth = 0
}
continue
}
}
// ★ 守卫要看**参数总数**,不是 key 数。
// 上面的 bug 正是「11 个 key、0 个参数」却通过了 len(out)==0 的检查。
total := 0
for _, v := range out {
total += len(v)
}
if total < 20 {
t.Skipf("pi 桥源码结构变了(只解析出 %d 个参数)—— 对齐判据会假绿", total)
}
return out
}
// ─── GET /mcp 事件流(投递/拉起对话)────────────────────────
/*
★ 方言必须是 JSON-RPC 通知。
这一格存在的理由:Frame 决定了接入方能不能解析。写错了不会报错 ——
连接是活的、字节在流动,只是客户端**解不开**而默默丢弃每一个事件。
那种失败看起来完全像「没有人给我发信」。
*/
func TestFrameRendersJSONRPCNotification(t *testing.T) {
out := Frame("7", "new_mail", []byte(`{"mail_id":"m1","subject":"你好"}`))
// ★ 三行 SSE 格式(2026-10-04 补)。
// 原实现只写裸 JSON,而 Content-Type 是 text/event-stream ⇒
// 标准 MCP 客户端(EventSource / eventsource-parser)按 `data:` 取
// payload,取不到就**静默丢掉每个事件**。
//
// 这一格初版只查「以 `{"jsonrpc"` 开头」—— 裸 JSON 恰好也满足,
// 于是**真正的坏帧一直绿灯**。教训与之前那次相同:
// 判据只断言了「能识别的那部分」,没断言「该有的结构」。
if !strings.HasPrefix(out, "id: 7\n") {
t.Errorf("★ 缺 SSE 的 id: 行(规范靠它维护 Last-Event-ID):%q", out)
}
if !strings.Contains(out, "\nevent: message\n") {
t.Errorf("★ 缺 SSE 的 event: 行:%q", out)
}
dl := strings.Index(out, "\ndata: ")
if dl < 0 {
t.Fatalf("★ 缺 data: 行 —— 标准 SSE 客户端会解析不到 payload:%q", out)
}
data := strings.TrimPrefix(out[dl+len("\ndata: "):], "")
data = strings.TrimRight(data, "\n")
// ★ 整帧必须是**合法 JSON**。
// 真实 bug(2026-10-04):`"params":` 后面少一个 `{`,
// 产出 `..."params":"event_id":"7",...}` —— params 变成字符串。
// 而上面的前缀检查、HasSuffix("\n\n")、payload 检查**全都通过**。
// 唯一能抓到它的办法是把整帧当 JSON 解一遍。
var probe struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
Payload json.RawMessage `json:"payload"`
} `json:"params"`
}
if err := json.Unmarshal([]byte(data), &probe); err != nil {
t.Fatalf("★ data: 行不是合法 JSON(%v):%s", err, data)
}
if probe.JSONRPC != "2.0" || probe.Method != "notifications/message" {
t.Errorf("必须是 JSON-RPC 2.0 通知:%s", data)
}
if probe.Params.EventID != "7" || probe.Params.EventType != "new_mail" {
t.Errorf("params 必须解出 event_id/event_type(说明 params 是对象不是字符串):%s", data)
}
if !strings.HasSuffix(out, "\n\n") {
t.Errorf("SSE 帧必须以空行结尾(否则客户端不认为事件结束):%q", out)
}
// payload 必须**原样嵌入**而不是被转义成字符串 ——
// 否则客户端要解析两次,长度还翻倍。
if !strings.Contains(out, `"payload":{"mail_id":"m1","subject":"你好"}`) {
t.Errorf("payload 应原样嵌入(不是 JSON 字符串):%q", out)
}
// 反向对照:转义版本(错误实现)不该出现
if strings.Contains(out, `"payload":"{`) {
t.Errorf("★ payload 被转义成字符串了:%q", out)
}
}
func TestFrameCarriesEventTypeAndID(t *testing.T) {
out := Frame("42", "session_update", []byte(`{}`))
if !strings.Contains(out, `"event_id":"42"`) {
t.Errorf("必须带 event_id(客户端据此做 Last-Event-ID 续传):%q", out)
}
if !strings.Contains(out, `"event_type":"session_update"`) {
t.Errorf("必须带 event_type(否则客户端分不清新邮件与会话更新):%q", out)
}
}
// 单行 + 空行分隔:SSE 的帧完整性靠它,多一个换行会把一条拆成两条。
func TestFrameIsSingleEvent(t *testing.T) {
out := Frame("1", "new_mail", []byte(`{}`))
if strings.Count(out, "\n\n") != 1 {
t.Errorf("一个事件只能有一个空行分隔:%q", out)
}
if strings.Contains(strings.TrimSuffix(out, "\n\n"), "\n\n") {
t.Errorf("事件体内不得出现空行:%q", out)
}
}
// Accept 判定:必须真的要求 text/event-stream。
func TestAcceptsEventStream(t *testing.T) {
cases := []struct {
accept string
want bool
}{
{"text/event-stream", true},
{"text/event-stream, application/json", true},
{"text/event-stream;q=0.9", true},
{"TEXT/EVENT-STREAM", true},
{"application/json", false},
{"", false},
{"text/plain", false},
}
for _, c := range cases {
r := httptest.NewRequest(http.MethodGet, "/api/v1/mcp", nil)
if c.accept != "" {
r.Header.Set("Accept", c.accept)
}
if got := acceptsEventStream(r); got != c.want {
t.Errorf("Accept=%q 期望 %v 收到 %v", c.accept, c.want, got)
}
}
}
// 未认证的 GET 必须拒绝(不能变成一处静默的匿名订阅)。
func TestGetRequiresAgentIdentity(t *testing.T) {
s := newTestServer()
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/v1/mcp", nil)
r.Header.Set("Accept", "text/event-stream")
// 故意不注入身份
s.HandleGET(rec, r)
if rec.Code != http.StatusUnauthorized {
t.Errorf("匿名订阅必须 401,收到 %d", rec.Code)
}
}
// 不带 Accept 的 GET 必须 406(接错的客户端会静默收不到东西)。
func TestGetRequiresEventStreamAccept(t *testing.T) {
s := newTestServer()
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/v1/mcp", nil)
r = r.WithContext(context.WithValue(r.Context(), middleware.AgentNameKey, "probe"))
s.HandleGET(rec, r)
if rec.Code != http.StatusNotAcceptable {
t.Errorf("缺 Accept 应 406,收到 %d", rec.Code)
}
}