Files
MailUI4Agents/server/internal/repo/session_scope_test.go
JianFeeeee 153985e8b1 补投路径漏传档位(full 档被误拦)—— 修因 + 兜底,并顺出同族另外四个字段
pi 报告:离线补投的邮件把 full 档会话当 workspace 档申请审批 → 服务端 409 →
桥按「永久失败」当场 block → 这一轮 bash/write/edit 全被拦(SSE 实时送达不受影响)。
jianf 让 pi 把这件转给我,我这边定位后**先跑变异再改**。

## 1 根因:`mailToEvent` 少搬字段(不是服务端不给)

`lib/catchup.js` 的 `mailToEvent()` 只搬了 8 个字段,没有 `permission_mode` /
`permission_enforcement`,于是 worker 的 `msg.data?.permission_mode || 'workspace'`
落到默认档。**pi 以为收件箱行不含档位、于是建议"要么动服务端载荷要么另取一次"——
实测不成立**:服务端一直就给了(`repo.ListInboxScoped` 的 SQL 里有
`JOIN sessions s` + `COALESCE(NULLIF(s.permission_mode,''),'workspace')`,
`models.Mail.PermissionMode` 的注释写明"补拉路径必须有它们")。所以修因只在插件侧:
补上这两个字段,键名与 SSE 逐字一致;缺字段时给空串(**不猜档**,猜宽了就是提权)。
`lib/catchup.js` 在四个桥里**逐字节相同**,一次改动四边同步(改后 md5 仍为一份)。

## 2 兜底:409 带档位时按档位处置

服务端在"档位不该问人"时也回 409,并在回包里带 `permission_mode`。两种 409 的正确反应
**相反**:无人可问 → 拦;**full 档 → 放行**(本档无需审批,拦了就是把能干的活干死)。
`src/worker.mjs` 的 409 分支先认 `permission_mode === MODE_FULL` 放行,
plan 档与"链上没有人类"照旧 fail closed —— 只有服务端明说 full 才放行。

## 3 顺出的同族字段(用"配对"扫出来的,不是猜的)

把四个桥**读投递事件的字段**与 `mailToEvent` 的产出对了一遍,邮件类字段还缺三个:

- `from_human`:dsh 的提示词靠它决定说不说"回信不用你自己发"。缺了它,
  **人发来的信在补投路径上被当成 Agent 来信、失去自动回信**(服务端注释早写明)。
- `in_reply_to`:SSE 那边等于 `ParentMailID`。缺了它,"这封是对我的回复"被当成新派的活,
  两边互相客套到撞 hop 上限(生产实测 6 轮)。行里叫 `parent_mail_id`,**只改名不推算**。
- `session_alias`:缺了它插件只有 session_id,而 `send_mail` 不接受 session_id。

`reply_address` 是**唯一**行里真的没有的字段(SSE 在 notify 里按收件人现算)。
服务端注释明确说"插件不必自己拼(拼错了就是静默开新会话)",所以由服务端补:
`models.Mail.ReplyAddress` + `ListInboxScoped` 填 `FormatAddress(from_name,"",alias)`,
插件只搬运。

## 4 判据(这次事故**单独看任何一个桥的测试都发现不了** —— 缺口在接口上)

- `test/catchup.test.mjs`:补投必须带档位(缺字段给空串而非猜档);
  ★ **四桥配对**:把 dsh/pi/zcode 读的邮件字段与补投产出配对,缺了就红
  (非邮件事件字段走显式 ALLOW 并各写理由,白名单不许膨胀)。这条正是本次缺口的形状。
- `test/permission-mode-409.test.mjs`:409 + full 必须放行且放行分支在 block 之前,
  非 full 仍拦;带**判据自检**(拿掉放行分支后必须判红)。
- `server/internal/repo/session_scope_test.go`:收件箱行带 `permission_mode`(含"没设过
  回落 workspace"的反向对照)与 `reply_address`(与 `FormatAddress` 同形、path 位为空)。

变异验证:mailToEvent 去掉档位 → 2 条红;worker 新读一个补投没产的字段 → 配对判据红**并点名该字段**;
409 分支拿掉 full 放行 → 自检红;SQL 把档位写死成 workspace → Go 判据红。

## 验证

`go test ./...` 全绿(新增 2 条);四个桥套件全绿(pi 439 / dsh 381 / opencode 331 / zcode 385)。
**未部署**:`/opt/agentmail` 与 `sudo ./deploy/install.sh` 都在我的工作区之外(本会话文件策略
workspace-write,放宽需审批而这条链上没有人类),所以修复已进仓但**线上仍是有缺陷的版本** ——
需要有人跑一次 `sudo ./deploy/install.sh`(脚本自己会跑齐各套件)。
2026-09-14 15:49:45 +08:00

239 lines
7.9 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 repo
import (
"context"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
/*
会话维度:一个 Agent 同时在多条会话里干活时,**读一条不能动另一条**。
# 用户报的缺陷
「不同 session 的 agent 都可以看到全部邮件」—— 原先 `read_inbox` 按 **Agent** 列,
并且把列出来的都标成已读 ⇒ A 会话的 worker 会把 B 会话的未读标掉。
平时看不出来SSE 事件在途时队列兜着),但桥重启/漏事件之后的补投判据是
`?status=unread` —— 被别人标掉的那封信**再也不会补投**。这不是"少看一封"
是静默丢信。现场实例:另一条会话的来信被我在读自己收件箱时标成已读
`mail_reads` 里那行的 reader=pi、时间就是那次读取的时刻
# 判据要两侧都验
只验"收窄后看不到别人的"是不够的:把列表直接返回空也能过。所以同时验
"不带会话时两条都看得到"(反向对照),以及"全标已读"两个变体。
*/
// seedMailInSession 在指定会话里造一封给 recipient 的未读邮件。
func seedMailInSession(t *testing.T, sessionID uuid.UUID, recipient string) uuid.UUID {
t.Helper()
var id uuid.UUID
if err := db.DB.QueryRowContext(context.Background(),
`INSERT INTO mails (session_id, from_name, to_name, subject, body)
VALUES ($1, 'sender', $2, 's', 'b') RETURNING mail_id`,
sessionID, recipient).Scan(&id); err != nil {
t.Fatal(err)
}
return id
}
func twoSessionsWithUnread(t *testing.T, agent string) (uuid.UUID, uuid.UUID, uuid.UUID, uuid.UUID) {
t.Helper()
ctx := context.Background()
s1, err := CreateSession(ctx, nil, "human", "会话一", "")
if err != nil {
t.Fatal(err)
}
s2, err := CreateSession(ctx, nil, "human", "会话二", "")
if err != nil {
t.Fatal(err)
}
return s1, s2, seedMailInSession(t, s1, agent), seedMailInSession(t, s2, agent)
}
func TestInboxListIsScopedBySession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
s1, s2, m1, m2 := twoSessionsWithUnread(t, "bot")
// 反向对照:不带会话 = 整个 Agent 的收件箱,两条都看得到
all, err := ListInbox(ctx, "bot", "unread", 50)
if err != nil {
t.Fatal(err)
}
if !hasMail(all, m1) || !hasMail(all, m2) {
t.Fatalf("不带会话时应当两条都列出来(%d 条)", len(all))
}
// ★ 收窄到会话一:只有它那条
scoped, err := ListInboxScoped(ctx, "bot", "unread", 50, s1)
if err != nil {
t.Fatal(err)
}
if !hasMail(scoped, m1) {
t.Fatal("会话一的邮件应当在会话一的列表里")
}
if hasMail(scoped, m2) {
t.Fatal("★ 会话二的邮件不该出现在会话一的列表里(用户报的缺陷)")
}
// 会话二同理(别只验一侧 —— 方向反了也能"过"
scoped2, err := ListInboxScoped(ctx, "bot", "unread", 50, s2)
if err != nil {
t.Fatal(err)
}
if hasMail(scoped2, m1) || !hasMail(scoped2, m2) {
t.Fatalf("会话二的列表应只含会话二的邮件")
}
}
func TestMarkAllReadCanBeScopedToSession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
s1, s2, _, _ := twoSessionsWithUnread(t, "bot")
// 只标会话一
n, err := MarkAllInboxReadForSession(ctx, "bot", s1)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("应当只标掉 1 封,实际 %d", n)
}
left, err := ListInboxScoped(ctx, "bot", "unread", 50, s2)
if err != nil {
t.Fatal(err)
}
if len(left) != 1 {
t.Fatalf("★ 会话二的未读必须还在(实际剩 %d 条)—— 被跨会话标掉就是静默丢信", len(left))
}
// 反向对照:不带会话时两条都会被标掉
if _, err := MarkAllInboxReadFor(ctx, "bot"); err != nil {
t.Fatal(err)
}
if again, _ := ListInbox(ctx, "bot", "unread", 50); len(again) != 0 {
t.Fatalf("不带会话的全标应当清空整个收件箱,实际剩 %d", len(again))
}
}
func TestScopedCountsMatchScopedList(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
s1, s2, _, _ := twoSessionsWithUnread(t, "bot")
// 会话内的未读数(会话列表徽标用)与"只列这条会话"的口径必须一致,
// 否则界面上会出现"徽标 2、列表 1"这种自相矛盾。
n1, err := CountUnreadInSession(ctx, "bot", s1)
if err != nil {
t.Fatal(err)
}
list1, err := ListInboxScoped(ctx, "bot", "unread", 50, s1)
if err != nil {
t.Fatal(err)
}
if n1 != len(list1) || n1 != 1 {
t.Fatalf("会话一:计数 %d、列表 %d期望都是 1", n1, len(list1))
}
_ = s2
}
func hasMail(list []models.Mail, id uuid.UUID) bool {
for _, m := range list {
if m.ID == id {
return true
}
}
return false
}
// ─── 补拉路径的档位契约2026-09-14 ───
//
// 桥的离线补投走 `GET /mail/inbox`repo 层就是 `ListInboxScoped`)。
// 事故:插件以为这条路径**拿不到**会话档位,于是 `mailToEvent` 只搬了 8 个字段,
// 补投的邮件让 worker 回落默认 workspace 档 —— 一条 full 档会话被误拦一整轮。
// 实际上服务端的行**一直带着**档位SQL 里有 `JOIN sessions s` +
// `COALESCE(NULLIF(s.permission_mode,”),'workspace')``models.Mail.PermissionMode`
// 的注释也写明补拉路径必须有它)。这条判据把"服务端确实给了"钉死:
// 谁哪天把这个 JOIN 去掉,插件侧就会静默降级,而单看插件测试是发现不了的。
func TestInboxRowsCarrySessionPermissionMode(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "bot", 50)
sid := createTestSession(t, ctx, "bot", "/tmp/ws")
if _, err := SetSessionPermissionMode(ctx, sid, "full"); err != nil {
t.Fatal(err)
}
seedMailInSession(t, sid, "bot")
rows, err := ListInboxScoped(ctx, "bot", "unread", 50, sid)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("应当恰好一条未读,实际 %d 条", len(rows))
}
if rows[0].PermissionMode != "full" {
t.Fatalf("补拉路径必须带上会话档位want full, got %q", rows[0].PermissionMode)
}
if rows[0].PermissionEnforcement == "" {
t.Fatal("permission_enforcement 也要带上(空值会让插件无从判断强制力)")
}
// 反向对照:档位没设过的会话落到 workspace**不是** full——
// 缺字段时"猜宽"就是提权,所以默认值这一侧也要验。
other := createTestSession(t, ctx, "bot", "/tmp/ws2")
seedMailInSession(t, other, "bot")
rows2, err := ListInboxScoped(ctx, "bot", "unread", 50, other)
if err != nil {
t.Fatal(err)
}
for _, m := range rows2 {
if m.PermissionMode != "workspace" {
t.Fatalf("没有档位记录的会话应当回落 workspacegot %q", m.PermissionMode)
}
}
}
// 回信地址:补拉路径也要有(与 PermissionMode 同一个理由 —— 少了它,
// 补投进来的邮件拿不到回信地址,插件只能自己拼,拼错就是静默开新会话)。
func TestInboxRowsCarryReplyAddress(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "bot", 50)
// 会话带别名(回信地址里应当出现别名;没有别名的会话只写发件人)
alias := "harmony-ui-alignment"
sid, err := CreateSession(ctx, nil, "sender-agent", "test subject", "/tmp/ws")
if err != nil {
t.Fatal(err)
}
if _, err := db.DB.ExecContext(ctx, `UPDATE sessions SET session_alias = $1 WHERE session_id = $2`, alias, sid); err != nil {
t.Fatal(err)
}
seedMailInSession(t, sid, "bot")
rows, err := ListInboxScoped(ctx, "bot", "unread", 50, sid)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("应当恰好一条未读,实际 %d 条", len(rows))
}
got := rows[0].ReplyAddress
want := models.FormatAddress(rows[0].FromName, "", alias)
if got != want {
t.Fatalf("回信地址要与 SSE 载荷同形want %q, got %q", want, got)
}
// 反面:地址里**不该**出现 path 位(与 notify 的注释同一取舍:人没有工作目录)
if strings.Count(got, "@") != 1 {
t.Fatalf("回信地址形状不对:%q", got)
}
}