## 起因
做 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 包绿。
99 lines
4.3 KiB
Go
99 lines
4.3 KiB
Go
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)
|
||
}
|