Files
MailUI4Agents/server/internal/repo/permission_visibility_test.go
JianFeeeee 7634be8966 fix(inbox): 收件箱按**工作区**收窄(三维地址的 path 位此前从未被使用)
用户 12 天前就提过(`552fbc7` 只修了 session_id 那一维),这轮才真修。
用户原话:「难道让一个不在项目工作区的 agentsession 去修工程吗?」

# 缺陷(生产实测,2026-09-26)

在 `mc` 工作区干活的 pi 读收件箱拿到 **200 封,其中 191 封属于
`/home/program/agentmail`** —— 它照着那些信里的断言去改 agentmail 的代码,
把手上的 mc 活丢在一边。用户当场问它「你怎么干着干着修 agentmail 去了?」
(这条对话就在 mc 会话的 jsonl 里)

根因:`ListInboxScoped` 的 WHERE 只有 `m.to_name = $1`(+ 可选 session_id),
**没有任何 workspace 条件**。三维地址 `name@path.session` 的 path 位
在收件箱侧从未生效 —— 那不是"另一种语义",是没兑现契约。

# 三条守卫全部只覆盖自动转发,防不住这个

| 守卫 | 只覆盖 | 为何无效 |
| --- | --- | --- |
| 会话预算 | `relay != ""` 才扣 | 这批信 relay=0(模型主动发)⇒ 不扣 |
| maxRelayHops=5 | 同上,只数 relay | 同上 ⇒ 不进那个分支 |
| 插件自动转发守卫 | 插件代劳时 | 日志明说"本轮不自动转发" ⇒ 模型自己发的不受管 |

# 服务端

· `ListInboxScoped` / `CountUnreadScoped` / `MarkAllInboxReadForSession`
  三处统一加 `s.workspace = $N`(用会话的 workspace,不用 mails.to_workspace:
  后者是信封字段、可能是抄送或历史遗留;"线索属于哪个工作区"是会话属性)。
  ★ 三处必须是**同一个谓词** —— 列表看不到的信却被"全部标掉"标掉就是静默丢信
  (session_scope_test.go 记过这个形状)。
· **workspace 在 Agent 侧必需,缺了 400**(用户裁定:「不带 workspace 是错误
  发件格式,直接退回!」)。旧语义(不带=全部)正是缺陷本身,不留兼容回退。
· 人类侧**不过滤**(一个人跨工作区,WebUI 按 session_workspace 分组显示)——
  所以"必需"这条约束放在 Handler 而不是 repo 层:它是接口契约,不是数据层不变量。
· 新增 `UnreadWorkspaces`:心跳是**进程级**(一个桥服务所有工作区),没有
  "我的工作区"可言;但只有总数桥不知道去哪个工作区补投 ⇒ 心跳回
  `pending_workspaces` 清单,桥逐个消费。
· 决策载荷补 `workspace`(服务端知道 session→workspace,插件重启后推不出来)。
· `TouchAgentLastSeen` 从 HeartbeatAgent 拆出:middleware 在每个认证请求上都调它,
  而那时工作区还没解析(请求体没读),原来在白算一次 CountUnread。

# 三个插件(pi / opencode / dsh)

· 读类工具带 `workspace`;补投从"读一次全局收件箱"改为**逐工作区**读。
· pi:worker 信封的 `to_workspace` 经闭包递进工具(不是会话文件 header 的 cwd ——
  后者是"会话上次落在哪",前者是"这封信寄到哪个工作区")。
· opencode/dsh:插件常驻、信封在 deliverMail 那刻就消费掉了 ⇒ 新增
  `sessionWorkspace` 映射(键与既有 reverseMap 同一把)。
· 修一处真 bug:`UnreadWorkspaces` 原先会返回相对路径工作区(历史库里有
  `workspace='root'`),桥侧实测撞 400(`补投工作区 root 失败`)⇒ 只报可寻址的。

# 实测凭据

· 改前:`pi` 的收件箱 200 封混 3 个工作区(agentmail 191 / TrueAgent 7 / huawei 2)
· 改后:agentmail=100(total 228)、mc=16、TrueAgent=7 —— 各工作区独立
· 不带 workspace ⇒ **HTTP 400**,话术给出可执行步骤
· 桥日志:`rw=/home/newqqagent/plugindev/editdoc-upgrade` —— 终于是别的工作区了
  (改前 78 次 worker 启动**全部**是 `/home/program/agentmail`)

# 判据

· `server/internal/repo/workspace_scope_test.go`(3 条):
  两向收窄 + **反向对照**(不带时两条都看得到 ⇒ 证明是收窄不是清空)+
  未读数同口径 + 相对路径必须报错
· `plugins/pi-mail-bridge/test/inbox-workspace-scope.test.mjs`(4 条):接线 +
  取信封而非 cwd + 补投逐工作区 + 判据自检
· dsh 那条 `取不到会话时退回整体收件箱` **改了**:它钉的"退回整体"正是缺陷,
  现在钉"两维各自缺席时各自不带、服务端 400 让错误可见"
· 变异验证:服务端 2 处 + 插件 3 处,全部判红后恢复回绿

全量:server `go test ./...` 绿;三插件 513+340+403 全绿。
2026-09-26 07:44:33 +08:00

192 lines
5.8 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"
"testing"
"github.com/agentmail/gateway/internal/models"
)
// 待办类型(permission / question)与多选语义必须在**每一条读路径**上透出。
//
// # 为什么值得单独钉一个测试
//
// 这几个字段(mails.permission_kind / permission_multi_select)曾经只存在于
// 结构体与写入路径:模型里有字段、INSERT 里有列,但五个读路径的 SELECT/Scan
// 都没带上它们。
//
// 这类遗漏不会报错 —— SQL 照常返回,只是那个字段永远为零值。前端于是拿到
// permission_kind="" 与 permission_multi_select=false,把「模型问了一个问题」
// 渲染成「批准 / 拒绝」两个按钮,人点出来的答案对模型毫无意义。
//
// 所以判据必须落在「五个不同的读函数都看得见」上,而不是只看写入那一侧。
func TestPermissionFieldsVisibleOnEveryReadPath(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, err := CreateSession(ctx, nil, "agent-q", "问与答", "")
if err != nil {
t.Fatal(err)
}
// 一个自由文本问题:无预设选项、不允许多选。
mailID, err := CreatePermissionMail(ctx, sid, "agent-q", "alice",
"配置文件名是什么", "请直接回答", nil, "question", false)
if err != nil {
t.Fatal(err)
}
if err := CreatePermissionRequest(ctx, mailID, sid, "agent-q",
"配置文件名是什么", nil, "请直接回答", "question", false); err != nil {
t.Fatal(err)
}
// 一个多选问题:单独造一封,验证 multi_select 也能透出。
multiID, err := CreatePermissionMail(ctx, sid, "agent-q", "alice",
"要哪些环境", "勾选", []string{"dev", "prod"}, "question", true)
if err != nil {
t.Fatal(err)
}
if err := CreatePermissionRequest(ctx, multiID, sid, "agent-q",
"要哪些环境", []string{"dev", "prod"}, "勾选", "question", true); err != nil {
t.Fatal(err)
}
// 一封普通审批:kind 必须是 permission,multi 必须是 false。
approvalID, err := CreatePermissionMail(ctx, sid, "agent-q", "alice",
"删除 build/", "rm -rf build/", []string{"同意", "拒绝"}, "permission", false)
if err != nil {
t.Fatal(err)
}
assertQuestion := func(what string, m *models.Mail) {
t.Helper()
if m == nil {
t.Fatalf("%s 返回 nil", what)
}
if m.PermissionKind != "question" {
t.Errorf("%s:PermissionKind = %q,期望 question"+
"(字段没进 SELECT/Scan 时会静默变成空串,前端就会渲染成批准/拒绝)",
what, m.PermissionKind)
}
}
// 1) GetMailByID —— 单封详情(前端点开邮件走这条)。
got, err := GetMailByID(ctx, mailID)
if err != nil {
t.Fatal(err)
}
assertQuestion("GetMailByID", got)
// 2) ListInbox —— 授权页的列表来源。
inbox, err := ListInbox(ctx, "alice", "all", "", 50)
if err != nil {
t.Fatal(err)
}
var found *models.Mail
for i := range inbox {
if inbox[i].ID == mailID {
found = &inbox[i]
}
}
if found == nil {
t.Fatal("ListInbox 没返回那封询问邮件")
}
assertQuestion("ListInbox", found)
// 3) GetSessionMails —— 会话视图整树。
thread, err := GetSessionMails(ctx, sid)
if err != nil {
t.Fatal(err)
}
var inThread *models.Mail
for i := range thread {
if thread[i].ID == mailID {
inThread = &thread[i]
}
}
if inThread == nil {
t.Fatal("GetSessionMails 没返回那封询问邮件")
}
assertQuestion("GetSessionMails", inThread)
// 4) GetSessionMailByID —— 会话内单封。
one, err := GetSessionMailByID(ctx, sid, mailID)
if err != nil {
t.Fatal(err)
}
assertQuestion("GetSessionMailByID", one)
// 5) ListSentBy —— 发件箱(Agent 自查「我问过什么」)。
sent, err := ListSentBy(ctx, "agent-q", 50)
if err != nil {
t.Fatal(err)
}
var inSent *models.Mail
for i := range sent {
if sent[i].ID == mailID {
inSent = &sent[i]
}
}
if inSent == nil {
t.Fatal("ListSentBy 没返回那封询问邮件")
}
assertQuestion("ListSentBy", inSent)
// multi_select 也要透出:勾选语义决定前端渲染单选还是多选。
multi, err := GetMailByID(ctx, multiID)
if err != nil {
t.Fatal(err)
}
if !multi.PermissionMulti {
t.Error("多选问题:PermissionMulti = false,期望 true(字段漏读会让多选退化成单选)")
}
// 审批型不能被误标成 question。
approval, err := GetMailByID(ctx, approvalID)
if err != nil {
t.Fatal(err)
}
if approval.PermissionKind != "permission" {
t.Errorf("审批邮件:PermissionKind = %q,期望 permission", approval.PermissionKind)
}
if approval.PermissionMulti {
t.Error("审批邮件不该带多选语义")
}
}
// 无选项的 question 不能被塞上「同意 / 拒绝」默认选项。
//
// 那是 handler 层的默认值逻辑:审批型没有选项时确实该给两个按钮,
// 但自由文本问题给它们只会让人点出一个毫无意义的答案。
// 这里从 repo 侧确认「传什么就存什么」,handler 的默认值规则另有测试。
func TestQuestionKeepsEmptyOptions(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, err := CreateSession(ctx, nil, "agent-q2", "自由文本", "")
if err != nil {
t.Fatal(err)
}
mailID, err := CreatePermissionMail(ctx, sid, "agent-q2", "alice",
"你的名字", "请回答", nil, "question", false)
if err != nil {
t.Fatal(err)
}
// GetPermissionByMailID 读的是 permission_requests 表(不是 mails),
// 所以必须把那一行也插上 —— 否则拿到的是「没有这条请求」。
if err := CreatePermissionRequest(ctx, mailID, sid, "agent-q2",
"你的名字", nil, "请回答", "question", false); err != nil {
t.Fatal(err)
}
pr, err := GetPermissionByMailID(ctx, mailID)
if err != nil {
t.Fatal(err)
}
if pr.Kind != "question" {
t.Errorf("Kind = %q,期望 question", pr.Kind)
}
if len(pr.Options) != 0 {
t.Errorf("无选项问题被写入了选项 %v,期望空", pr.Options)
}
}