|
|
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 |
|
|
|
d1099526ad
|
fix(寻址): flatten 的候选**逐条**标注 —— 第一版把 §C 噪声放进了新端点
## 缺口(部署后实测才发现,是我自己引入的)
第一版 flatten 只在响应的 `paths[]` 数组里标注。实测:
222 条候选,其中 37 条(16%)落在桥内部目录(/root/.pi/mail-sessions/<uuid>)
而标注在**另一个数组** —— 模型必须自己把 candidates 与 paths 对照才认得出
那正是「§C 噪声淹没信号」换个位置复活。我在动手前的判断是「先修 C 再修 A,
否则新端点会把噪声一起放大」—— 做了 A,却让 C 的噪声原样跟进了 A。
只在真机跑过 `flatten=1` 才看见:单测全绿(它们只断言了 paths[] 有标注),
是生产数据的 16% 把它翻出来的。
## 修法
`AddressedCandidate` 逐候选带 `path_kind` / `path_note` / `is_absolute_path`,
MCP 渲染逐条打 `⚠`。
marker 收敛到 repo 层一份,handler 的 `classifyPath` 改为委托调用:
同一目录在 path 列表里标成「工作区」、在候选列表里却没标 ——
而那两个数组是**同一次调用**返回的。两处各写一份 marker 时,
改一处忘另一处就会出现这种自相矛盾,且没有任何报错。
## 判据(2 格)
TestFlattenAnnotatesEachCandidate 桥内部目录/相对路径能分类 + 带说明;
真工作区不得被误标(否则全是噪声)
TestClassifyPathAgreesWithRepo handler 与 repo 口径必须逐条一致
## 顺带
第一版 flatten 本身已验证有效(生产实测):
flatten=1 → 222 条候选、66 个工作区
/home/program/agentmail 125 条 · /root 16 条 · root 2 条
⇒ root 与 /root **同时可见**且各自带 path,不再需要「先猜 path 再枚举」
path 标注:66 条候选里 35 条桥内部目录 + 1 条相对路径被标出
|
2026-10-02 16:06:00 +08:00 |
|
|
|
1b810a4898
|
fix(寻址)★★: 补「按 name 直出全部可投递地址」+ 标注 path 候选里的坑
## 起因
DSH 侧 Agent 报了一份寻址缺口(2026-10-02,全部结论有 API 实测复现)。
三段式寻址 `name@path.session` 里 session 段是**人的寻址入口**,而枚举它
必须先知道 path —— 但 path 恰恰是调用方无从得知的:
给 name → 只给 path(要再调一次才知道有哪些会话)
给 name+path → 给会话别名(但 path 得先猜对)
于是一个闭合的环。报告实测的踩坑:投 `pi@root` 返回 **200**,落进一条标题
为「拓展坞实测硬件正常…」的无关会话 —— 投递成功,所以调用方不知道自己投错了。
## 修法
**① A 项:`flatten=1` 一次给出全部可投递地址**
`SuggestAddressesForPeer` + `suggest?name=&flatten=1`。每个候选自带
`path` 与可直接塞进 send_mail 的 `address` —— 调用方不必自己拼,
拼错就是那个「猜错比报错更糟」。
可见性口径**不放宽**,与原 name+path 那一支逐条一致(「我参与过 + 与该 name
匹配」)。报告本身也确认问题不在权限:同一批数据给了 path 就能列出 17 条。
按 path 分组平铺而非嵌套:嵌套时调用方要发一封「不知道在哪个 path」的信
仍得遍历全部组;平铺一次给全,模型不必做「先猜 path 再枚举」两步。
**② B/C 项:标注而非隐藏**
`paths[]` 每项带 `kind`(workspace / bridge-internal)与 `is_absolute`。
选标注不选过滤的理由:桥内部目录(`/root/.pi/mail-sessions/<uuid>`)
确实**是某些会话的真实 cwd**(实测那条 workspace='root' 的会话 uuid 正是
其中之一)—— 滤掉等于让那些会话彻底不可见;而留着不标,64 条候选里 33 条
是噪声,模型选中即静默投错(实测 64 条中 33 条是它)。
`suggestions` 保持原样与原顺序 —— SuggestPaths 按最近使用倒序
(刚用过的那个几乎总是下一封想用的),排序被打乱等于让模型取最老的那个。
## ★★ 顺带修掉一个生产级缺陷(实测撞出来的)
给 `SessionCandidate` 加 `LastActivity` 时用了:
COALESCE(s.updated_at, '0001-01-01 00:00:00+00')
COALESCE 让驱动返回 **string**,扫进 time.Time 报 `unsupported Scan`
⇒ 命中 `return out, err` ⇒ **整个候选列表变空**(实测一条都列不出)。
生产影响:`updated_at` 为 NULL 的历史会话会全部静默消失。
而那个错误信息里**没有任何线索**指向「是你加的 COALESCE 害的」——
本次是我自己加的列触发的,排查花了几步。
改为扫进 `sql.NullTime`(NULL 即零值),平台镜像那条同理。
注释里写明为什么不能 COALESCE 兜底,免得下次有人再加回去。
## MCP 侧同步
`suggest_address` 加 `flatten` 参数,且**渲染必须单独写**:
flatten 的响应没有 `suggestions` 字段,走原来的分支只会回一句
「(没有 session_flat 建议)」—— 模型拿不到任何地址,等于白问一次。
path 形状的渲染把两类坑直接顶到眼前:桥内部目录、相对路径
(`root` 与 `/root` 在数据里是两个不同工作区,实测 1 条 vs 17 条)。
## 判据(8 格)
含「address 必须与候选自身 path/alias 一致」(那正是静默投错的解药)、
「两个工作区都要出现」(原形状缺的就是这一维)、
「不带 flatten 时行为一字未变」(各桥与 WebUI 都走那一支)、
「flatten 不得把 new 混在候选里」(没有真实会话时它看起来像出路)。
**变异验证**:
COALESCE 兜底(那个真 bug) → 红 1 ✓
flatten 段放回 path=="" 之后(顺序 bug)→ 红 1 ✓(kind 变回 "path")
## 实测校准了一处报告里的数字
报告写「近似写法返回 0 条」,实测返回 **1 条,内容是 `new`** ——
服务端在任何 path 下都追加的新建占位。所以选错 path 时调用方看到的不是
「空」,而是「只有 new 可选」:**看起来像一条出路**,于是顺着它新建,
恰好落进猜错的那个工作区。比报 0 更危险(0 会让人停下,new 会让人继续)。
§E 无需修:`validateSessionAlias` 已拒绝别名含 `.`。
全量 14 包绿。
|
2026-10-02 15:59:56 +08:00 |
|
|
|
457d1608f0
|
feat(mcp): MCP 集成进网关本体 —— POST /api/v1/mcp(Streamable HTTP)
## 为什么要集成而不是独立进程
上一版(29ad8aa)是独立进程 `plugins/zcode-mail-bridge/mcp/server.mjs`,
用 HTTP 调本网关。四条真实成本:
1. **工具语义有两份**。桥里的 read_inbox / send_mail 是**手抄**网关的,
抄错就是行为分叉 —— 已抓到两次:`connect_to_server` 只发
`X-Agent-Secret` 头,而 `/agent/register` 只认 Bearer 或 body 里的
secret ⇒ secret-only 的 Agent 必然 400。
2. **鉴权与收窄要再实现一遍**。工作区收窄、会话收窄、冷静期、配额住在服务端。
3. **多一跳 + 多一个故障点**。
4. **接入端仍要装东西**(node + 桥 + 环境变量)。
现在:工具**包装现有 handler**,同一份代码、同一套鉴权与收窄;
接入端只填一个 URL。
## 传输与实现(用户裁定)
- **Streamable HTTP**(规范 2025-06-18):单端点 POST,通知回 202,
请求回 JSON-RPC。
- **包装 handler**(不是直调 repo):`newRequest` + `invoke` 造内部请求
交给 `handler.GetInbox` / `SendMail` / … 于是 `AgentMayReadSession`、
冷静期、配额、附件保护目录全部是同一条代码路径,不是复述。
- 手写零依赖 JSON-RPC(协议面只有 4 个方法),与本仓取向一致。
端点挂在 `AgentAuth` **之内**:必须与 /mail/send 同一套凭证,
否则就成了绕过收窄的旁门。
## 11 个工具,名字与参数与四桥逐字一致
`connect_to_server` 在这里只做一次真实读来确认连通性 —— 能调到它本身
就证明凭证已过(它是局内端点,不再需要 register)。
## ★ 端到端撞出并修掉的两个真 bug
**① `Tool.Run` 丢掉了身份**(本来写成 `context.Background()`)。
症状:每个工具调用都 Unauthorized,模型表现为「说连上了但读不到任何信」。
**② 路径参数没到位**:被包装的 handler 用 `chi.URLParam(r,"id")` 取 id,
而 `httptest.NewRequest` 造的请求**没过 chi 的路由** ⇒ `URLParam` 恒空
⇒ 任何带路径参数的工具都报「Invalid id」。
第②个的发现过程值得记:端到端测越权时,主人和越权者**都**返回
「Invalid id」。只看越权那一次会误判成「收得太紧」,进而把**正确的收窄改松**;
做对照才看出是参数没到位。
修法两处:`withRouteParams` 注入 chi RouteContext;`invoke` 里**不能**再
`WithContext(ctx)` —— 那会覆盖掉刚注入的 RouteContext。
**③ 发现并暴露了会话越权漏洞**(同批,单独提交 095213b):
`AgentMayReadSession` 只比 `scope == target`,不问「你是不是参与方」,
而 session_id 由请求方给。对照实验 + 生产复核证实可读他人正文。
## 判据(13 格)
`internal/mcp/mcp_test.go`。真正在钉三件**只有集成才可能坏**的事:
1. MCP 不能成为越权旁门(工具参数里没有身份字段)。
2. 参数映射不许偷偷放宽/收紧(`attachment_ids` 被吞 ⇒ 附件静默不随信发出)。
3. 协议语义不许退化(工具失败必须 result+isError,不是 JSON-RPC error)。
`TestEveryErrorResponseCarriesID` 是被真 bug 逼出来的:曾用
`ID json.RawMessage` + `omitempty`,nil 时**整个 id 字段从 JSON 里消失**,
客户端会一直等这条的响应。遍历全部错误出口逐条验。
**变异验证**:
Run 丢身份 → 红 4
工具失败回 JSON-RPC error → 红 4
read_inbox 丢 workspace 收窄 → 红 1
id 泄露(tag+idPtr 同时失效) → 红 1 ★(真 bug 需两处同时失效,故两处防御都要留)
去掉 withRouteParams → 红 1
invoke 里加回 WithContext → 红 1
## 端到端(真实网关进程,临时库,备用端口 8199,不动生产)
未认证 /mcp → 401
错误密钥 → 401
initialize → 回显 2025-06-18
notifications/initialized→ 202 且无响应体
tools/list → 11 个,带 annotations 与 required
send_mail → read_inbox → mcp-peer 通过 MCP 读到对方发来的信
read_mail(带 session_id)→ 主人读到自己的信
## 未做
- 未删除旧桥 `plugins/zcode-mail-bridge/mcp/server.mjs`。它是 zcode 插件
清单里声明的入口(`.zcode-plugin/plugin.json` 的 mcpServers),删掉会破坏
该插件的组装。两者并存无害:桥仍走 HTTP,服务端这份是接入端零安装的那条路。
- 未部署(本提交只含代码)。
|
2026-10-02 13:28:07 +08:00 |
|