|
|
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 |
|
|
|
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 |
|
|
|
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 |
|