Files
MailUI4Agents/server/internal/mcp/rename_proposal.go
JianFeeeee 5e312c6f5f feat(mcp): 补齐与四桥的三个参数缺口 —— 改名提议 / 线索翻页 / 转发命名
## 起因

做 MCP 与各桥的**参数级**对照(不是数量级)时,发现三处缺口。上一轮我
说过其中两处「服务端没有」—— **那是错的**,是我没查就下的结论:

| 缺口 | 真相 |
|---|---|
| `send_mail` 缺 `propose_alias` / `propose_reason` | ✅ 真缺口,且**不需要服务端字段** |
| `read_thread` 缺 `offset` | 服务端**早已支持**(`thread.go` 的 `intQuery(r,"offset",…)`),我漏传 |
| `forward_mail` 缺 `session_alias` / `session_id` | 服务端**早已支持**(`forward.go:33`),我漏传 |

三处都是「接上就行」,没有一处需要改服务端。

## ① 改名提议:为什么不是加个字段

`/mail/send` **没有** `propose_alias` 字段 —— 提议是**搭在正文里**发出去的:

    <!-- agentmail:rename-session alias="fix-login-leak" reason="定位到泄漏点" -->

服务端用正则摘出来、把标记从入库正文剥掉、把规范化后的别名回填到响应的
`rename_proposed`。载体选 HTML 注释的三个理由见 `lib/rename-proposal.js`:
react-markdown 默认不解析 raw HTML(没剥掉也不破版)、纯文本客户端里一行不碍事、
不与 Markdown 语法冲突。

所以在 Go 侧复刻了 `lib/rename-proposal.js`(三方插件共用那份)的三段逻辑:
`isProposableAlias` / `appendRenameProposal` / `renameProposalNote`。

## ★ 这一层的真正风险:跨语言镜像

格式差一个空格(或把双引号写成单引号),服务端正则就匹配不上,而**失败是
静默**的:邮件照常发出、提议凭空消失、模型以为自己提过了、下一封拿那个不存在的
别名寻址 → 404。

判据因此钉两件事:

- **能被服务端那个正则真的解出来** —— 直接 import `renameProposalRe`,
  不是另写一个(复制一份就放弃了「镜像」的意义)。
- **与 JS 版逐字节相同** —— 三个用例(含「理由里的双引号要去掉」)逐字符对照。

## ②③ 线索翻页与转发命名

`read_thread` 透传 `offset`(长线索不再只能拿首段);`forward_mail` 透传
`session_alias`(给转发出的新会话命名)与 `session_id`(与其它读端点一样过
`agentScope` 收窄)。

## 一处**故意**与桥不同的差异

`connect_to_server` 在桥侧有 `gateway_url` / `key_token`,MCP 侧保持无参 ——
网关内建端点**已认证**,改坐标是部署动作,不该由一次工具调用触发(桥侧能改是
因为它是局外进程)。判据里为此写了注释,防止将来有人"顺手补齐"。

## 判据(rename_proposal_test.go)

参数级对照那格钉「与其它桥逐字一致」——**缺参数不会报错**,只会让模型以为
该能力不存在,属静默缺陷。

**变异验证**:

    删掉 propose_alias 两行(回到缺口态)    → 红 1 ✓
    标记少一对引号(跨语言镜像写错)         → 红 2 ✓
    非法别名也追加标记(静默丢弃的来源)     → 红 2 ✓

第三条最要紧:别名不合法时**必须**不追加标记,否则发出一个服务端匹配得上却被
`validateSessionAlias` 拒掉的标记 —— 失败仍然是静默的。

## 两次判据自身缺陷(都记下来)

1. 「与 JS 版逐字节相同」那格最初用 Go 字符串字面量写期望值,`\n` 成了字面两字符
   ⇒ 判据错报红。代码是对的,判据错了。
2. 变异脚本只切掉 `strProp(…)` 的**第一行**、续行留在原地 ⇒ schema 仍合法 ⇒
   「0 红」。**没有采信那个 0**,改用完整锚点重测才拿到正确的红 1。

全量 14 包绿。
2026-10-02 14:37:09 +08:00

99 lines
4.3 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
import (
"fmt"
"regexp"
"strings"
)
// 会话改名提议 —— 与 `plugins/*/lib/rename-proposal.js` 逐字同源的 Go 副本。
//
// # 为什么要复刻一份而不是让 handler 做
//
// `/mail/send` **没有** `propose_alias` 字段:提议是**搭在正文里**发出去的,
// 服务端用正则摘出来、剥掉标记、把值回填到响应的 `rename_proposed`。
// 所以这不是「服务端少了个字段」,而是**传输格式定义在客户端** ——
// MCP 要支持它,就得自己拼这段标记。
//
// 标记格式是服务端正则的镜像(`server/internal/handler/rename_proposal.go` 的
// `renameProposalRe`)。格式必须逐字符对应:少个空格、把双引号写成单引号,
// 服务端就匹配不上 —— 而失败是**静默**的:邮件照常发出,提议凭空消失,
// 模型以为自己提过了,下次接着用那个不存在的别名寻址并 404。
//
// 选 HTML 注释作载体的三个理由(与 JS 版一致):
// - react-markdown 默认不解析 raw HTML,服务端没剥掉也只是一行转义文本,不破版
// - 纯文本邮件客户端里是一行不碍事的注释
// - 不与 Markdown 语法冲突,格式化工具不会改写它
var renameProposalRe = regexp.MustCompile(
`(?s)<!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->`)
// isProposableAlias 判一个别名能不能作为提议值。
//
// 与 JS 版同规则:`.` 空白 `/` `@` 与三维地址解析冲突,`new` 是寻址保留字,
// 双引号是标记的定界符,长度受服务端 VARCHAR(128) 约束(按字节)。
//
// 这里**不做规范化**(不把非法字符换成 `-`)—— 规范化是服务端 normalizeAlias
// 的职责;插件擅自改写会让模型看到的「我提议的名字」与实际入库的不一致。
func isProposableAlias(alias string) bool {
a := strings.TrimSpace(alias)
if a == "" || a == "new" {
return false
}
if strings.ContainsAny(a, ".\n\r\t /@\"") {
return false
}
if strings.ContainsAny(a, " \t\n\r") {
return false
}
return len(a) <= 128
}
// appendRenameProposal 把改名提议标记追加到正文末尾。
//
// 返回拟加后的正文与是否真的拟加了。别名不合法时**原样返回**、不追加:
// 与其发一个服务端匹配得上却被 validateSessionAlias 拒掉的标记,
// 不如当它没提 —— 调用方据此把「没提交」告诉模型。
func appendRenameProposal(body, alias, reason string) (string, bool) {
text := body
if !isProposableAlias(alias) {
return text, false
}
a := strings.TrimSpace(alias)
// 理由里的双引号会截断标记,去掉而不是转义:HTML 注释里没有转义机制。
r := strings.TrimSpace(strings.ReplaceAll(reason, "\"", ""))
reasonAttr := ""
if r != "" {
reasonAttr = fmt.Sprintf(" reason=%q", r)
}
return fmt.Sprintf("%s\n\n<!-- agentmail:rename-session alias=%q%s -->", text, a, reasonAttr), true
}
// renameProposalNote 拟提交后回给模型的那句话。空串表示没有需要追加的说明。
//
// **别名取服务端回的 `rename_proposed`,不是本地提议的那个。** 服务端会跑
// normalizeAlias(非法字符换 `-`、`new` 变 `session-new`、超长按 UTF-8 边界截断);
// 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。
//
// 必须说明「等人确认」:不说的话模型会以为改名已生效,接着用新别名发信 ——
// 而那个别名此刻还不存在,投递会失败。
func renameProposalNote(serverAlias, requestedAlias string, proposed bool) string {
server := strings.TrimSpace(serverAlias)
wanted := strings.TrimSpace(requestedAlias)
if server != "" {
changed := ""
if wanted != "" && wanted != server {
changed = fmt.Sprintf("(你提的 %q 被规范化成了这个)", wanted)
}
return fmt.Sprintf("已附上改名提议 %q%s,等用户在界面上确认后生效 —— 在那之前继续用原别名寻址。", server, changed)
}
if wanted == "" {
return ""
}
if !proposed {
return fmt.Sprintf("(改名提议 %q 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)", wanted)
}
// 标记发出去了但服务端没回 rename_proposed:它那侧的校验也拒了
return fmt.Sprintf("(改名提议 %q 未被服务端接受,会话别名不变。)", wanted)
}