Files
MailUI4Agents/server/internal/repo/workspace_scope_test.go
JianFeeeee 1b8cd43935 fix(auth): 工作区成为读权限的边界 —— Agent 侧读端点按会话工作区收窄
用户报的:「agentmail 工作区的邮件会话被 trueagent 工作区的 agent 看到了,
还需要我亲自去解释。」

## 根因不是漏了一个 WHERE,是隔离单位选错了

Agent 注册时 `workspaces` 是空的(B-1.2:cwd 由每封邮件的 `to_workspace` 决定),
所以**一个 Agent 同时服务所有工作区**。而可见性判据一直是
`AgentCanAccessSession(agentName, sid)` = "这个 Agent 名出现在这条会话的 from/to/cc 里"
—— 于是同一个 agent `pi`,在 TrueAgent 里干活的 worker 眼里,对 agentmail 的会话
也成立。

现场证据:`mail_reads` 里 08:11–09:19 有 8 次「同一瞬间读了多个不同工作区的会话」
(08:23:59 一次跨 agentmail / TrueAgent / webui4frpc 三条会话),最后一次是 09:19:11
—— 正好停在 `read_inbox` 按会话收窄那个提交(552fbc7,09:19:25)之前。
更要紧的是 `mail_reads` 只记 `reader_name`、**没有「读的人当时在哪个工作区」这一列**,
所以这类越界读在数据上与正常读**无法区分** —— 这也是为什么只能由用户自己去解释。

## 改法:补一维,而不是逐个端点打补丁

- 新增 `repo.AgentMayReadSession(agentName, scope, target)`:① 参与过(原有判据)
  ② 两条会话的 `workspace` 相同(新增)。`scope` = 调用方当前所在的那条会话。
- 服务端只认一条**会话 id**(`?session_id=`),由它反查 workspace ——
  **不接受调用方直接声明工作区**,否则等于让它自己给自己发通行证。
- 应用到四个读端点:`read_mail` / `read_thread` / `session_participants` /
  `list_contacts`,以及 `contacts/suggest` 的**会话候选**(name/path 两段不收窄:
  跨工作区**发信**是设计允许的,被挡的只是"浏览同行的线索")。
- 未声明 `session_id` 时保留旧语义(放行)并**记警告日志**:迁移要能分步走,
  但"还有谁没接线"必须可观测(另四家桥仍走这条路)。
- pi 桥:五个读工具全部带上自己那条邮件会话 id(由 worker 闭包注入,模型改不了)。

## 顺手修掉一个真 bug

联系人查询的未读计数子查询里一直有 `r.reader_name = $1`,而原写法是
"forUser 为空就不传参" ⇒ $1 悬空:Postgres 直接报 `no parameter $1`,
SQLite 把 `= $1` 当 `= NULL` 比、次次不成立(未读计数静默退化成"全部未归档")。
管理员 `?all=true` 走的正是这条路。现在 $1 恒传。

## 判据(两侧都验 + 变异)

- repo:同工作区放行 / 跨工作区拒且 reason 分得清 / 没参与过拒 /
  未声明 scope 的旧语义;列表类有反向对照(不带收窄两条都在);
  建议补全同工作区照常给候选、跨工作区查路径不给、不带收窄会给(对照组)。
- ★ 这条判据我第一版**写错了对照组**:拿 path=wsA 去比 —— 而 path 本来就收窄,
  于是"不带收窄"也只剩一条,判据等于空的。改成拿 path=wsB 比才有区分力。
- 变异 3 处(拿掉工作区判据 / ListContactsInWorkspace 不收窄 /
  SuggestSessionCandidatesInWorkspace 不收窄)⇒ 各自恰好红在对应那条断言。
- pi 桥 14 条:6 个读工具 × 带上/不带 scope 两侧 + worker 闭包 + 自检;
  变异 read_mail 去掉收窄 ⇒ 恰好那一条红。

(工作区是多会话共用的,本次只 add 了 server/ 与 plugins/pi-mail-bridge/ 的 7 个文件。)
2026-09-14 23:03:25 +08:00

234 lines
8.2 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/google/uuid"
)
/*
工作区维度:一个 Agent 同时服务**所有**工作区,所以"参与过"不等于"该看"。
# 用户报的缺陷
「agentmail 工作区的邮件会话被 trueagent 工作区的 agent 看到了,还需要我亲自去解释。」
根因不是某处漏了一个 WHERE,而是**隔离单位选的是 Agent**:
- `AgentCanAccessSession(agentName, sid)` 判的是「这个 Agent 名出现在这条会话的
from/to/cc 里」;
- 而 Agent 注册时 `workspaces` 是空的(B-1.2:cwd 由每封邮件的 `to_workspace`
决定),于是同一个 agent `pi` 既"参与过" agentmail 的会话、也"参与过"
TrueAgent 的会话 —— 两个工作区之间没有任何边界。
现场证据:`mail_reads` 里 2026-09-14 08:11–09:19 有 8 次"同一瞬间读了多个不同工作区
的会话"(最典型 08:23:59 一次跨 agentmail / TrueAgent / webui4frpc 三条会话),
而那正是按 Agent 整表读的特征。更要紧的是 `mail_reads` 只记 `reader_name`,
**没有"读的人当时在哪个工作区"这一列** —— 这类越界读在数据上与正常读无法区分。
# 判据两侧都验
只验"跨工作区被拒"是不够的:把函数写成永远拒绝也能过。所以同时验
- 同工作区必须放行(否则等于把所有 Agent 都锁死);
- 未声明 scope 时的旧语义(迁移期妥协,必须明确写下来,不能靠"没人测"存在);
- 拒绝的**原因**要分得清(没参与过 vs 跨工作区)—— 否则调用方无法自查;
- 列表类接口的反向对照:不带收窄时两条会话都在(证明收窄真的在起作用)。
*/
// sessionIn 在工作区 ws 里造一条会话,并让它与该 Agent 有过一次往来。
func sessionIn(t *testing.T, agent, ws, title string) uuid.UUID {
t.Helper()
id, err := CreateSession(context.Background(), nil, "human", title, ws)
if err != nil {
t.Fatalf("create session(%s): %v", title, err)
}
seedMailInSession(t, id, agent)
return id
}
func TestAgentMayReadSessionIsWorkspaceScoped(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
const agent = "pi"
const wsA = "/home/program/agentmail"
const wsB = "/home/program/TrueAgent"
scope := sessionIn(t, agent, wsA, "我在 A 干活")
sameWS := sessionIn(t, agent, wsA, "A 的另一条线索")
otherWS := sessionIn(t, agent, wsB, "B 的线索")
alien := sessionIn(t, "someone-else", wsA, "与我无关的线索")
// ① 同工作区 → 放行。少了这条,判据"永远拒绝"也能绿。
ok, reason, err := AgentMayReadSession(ctx, agent, &scope, sameWS)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatalf("同工作区的会话必须能读,却被拒(reason=%q)", reason)
}
// ② 跨工作区 → 拒,且原因要说清是工作区不对(不是"没参与过")。
ok, reason, err = AgentMayReadSession(ctx, agent, &scope, otherWS)
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("★ 跨工作区必须拒 —— 用户报的就是这个(TrueAgent 的 worker 读到 agentmail 的线索)")
}
if reason != "cross-workspace" {
t.Fatalf("拒绝原因应是 cross-workspace(实际 %q):分不清原因,调用方就没法自查", reason)
}
// ③ 没参与过的会话 → 拒,且原因是 not-participant(两道闸门要能分开)。
ok, reason, err = AgentMayReadSession(ctx, agent, &scope, alien)
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("没参与过的会话必须拒")
}
if reason != "not-participant" {
t.Fatalf("拒绝原因应是 not-participant(实际 %q)", reason)
}
// ④ 未声明 scope = 旧语义放行。**这是迁移期的妥协,不是正确行为**:
// 尚未接线的桥会走这条路,所以它必须有判据守着 —— 哪天要收紧成"拒",
// 这条测试会红,逼人去看还有谁没接线(服务端同时也记警告日志)。
ok, _, err = AgentMayReadSession(ctx, agent, nil, otherWS)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("未声明 scope 时是旧语义(放行)—— 要收紧请连这条判据一起改")
}
}
func TestListContactsInWorkspace(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
const agent = "pi"
const wsA = "/home/program/agentmail"
const wsB = "/home/program/TrueAgent"
sessionIn(t, agent, wsA, "A 的线索")
sessionIn(t, agent, wsB, "B 的线索")
// 反向对照:不带工作区收窄时两条都在(证明下一段的收窄真的在起作用)。
all, err := ListContactsFor(ctx, agent, false)
if err != nil {
t.Fatal(err)
}
if len(all) != 2 {
t.Fatalf("不带收窄时应当两条都在(实际 %d 条)", len(all))
}
scoped, err := ListContactsInWorkspace(ctx, agent, wsA, false)
if err != nil {
t.Fatal(err)
}
if len(scoped) != 1 {
t.Fatalf("★ 收窄到 A 之后应当只剩 1 条(实际 %d 条)", len(scoped))
}
if scoped[0].Subject != "A 的线索" {
t.Fatalf("留下来的应当是 A 的线索(实际 %q)", scoped[0].Subject)
}
// 另一侧也要验:方向反了(总是返回第一条)同样能骗过上面那两条。
scopedB, err := ListContactsInWorkspace(ctx, agent, wsB, false)
if err != nil {
t.Fatal(err)
}
if len(scopedB) != 1 || scopedB[0].Subject != "B 的线索" {
t.Fatalf("收窄到 B 应当只剩 B 的线索(实际 %d 条)", len(scopedB))
}
}
// 管理员那条路(scope 为空 = 看全部)必须能跑通。
//
// ★ 这条是顺手修掉的真 bug:未读计数子查询里一直有 `r.reader_name = $1`,
// 而原先的写法是"forUser 为空就不传参" —— $1 于是悬空。Postgres 直接报
// `no parameter $1`;SQLite 把 `= $1` 当 `= NULL` 比,次次不成立,未读计数
// 静默退化成"全部未归档"。判据只钉"不报错 + 能列出会话"这两条硬事实。
func TestListContactsWithEmptyScopeStillWorks(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sessionIn(t, "pi", "/home/program/agentmail", "随便一条线索")
all, err := ListContactsFor(ctx, "", false)
if err != nil {
t.Fatalf("scope 为空(管理员看全部)不该报错:%v", err)
}
if len(all) != 1 {
t.Fatalf("应当列出 1 条(实际 %d 条)", len(all))
}
}
func TestSuggestSessionCandidatesInWorkspace(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
const agent = "pi"
const wsA = "/home/program/agentmail"
const wsB = "/home/program/TrueAgent"
// 别名是候选列表的可见内容,所以两条会话都要有别名。
aliasA, aliasB := "线索-a", "线索-b"
mustCreateNamed := func(alias, ws, title string) {
t.Helper()
id, err := CreateSession(ctx, &alias, "human", title, ws)
if err != nil {
t.Fatal(err)
}
seedMailInSession(t, id, agent)
}
mustCreateNamed(aliasA, wsA, "A 的线索")
mustCreateNamed(aliasB, wsB, "B 的线索")
// 同工作区:路径与调用方工作区一致 → 候选照常给出。
// 少了这条,"永远返回空"也能骗过下面那条。
sameWS, err := SuggestSessionCandidatesInWorkspace(ctx, agent, agent, wsA, wsA)
if err != nil {
t.Fatal(err)
}
if !hasAlias(sameWS, aliasA) {
t.Fatalf("本工作区的候选项该列出来(实际 %v)", aliasesOf(sameWS))
}
// 反向对照:**不带**工作区收窄时,去查 B 的路径是能列出 B 的会话别名的
// —— 那就是收窄前那个状态(跨工作区可浏览)。
raw, err := SuggestSessionCandidates(ctx, agent, agent, wsB)
if err != nil {
t.Fatal(err)
}
if !hasAlias(raw, aliasB) {
t.Fatalf("不带收窄时 B 的候选本该列出来(实际 %v)—— 对照组不成立,判据就是空的", aliasesOf(raw))
}
// ★ 带上调用方的工作区(人在 A,去查 B 的路径):B 的会话别名不能再出现。
cross, err := SuggestSessionCandidatesInWorkspace(ctx, agent, agent, wsB, wsA)
if err != nil {
t.Fatal(err)
}
if hasAlias(cross, aliasB) {
t.Fatalf("★ 别的工作区的会话别名不该被列出来(实际 %v)", aliasesOf(cross))
}
}
func hasAlias(list []SessionCandidate, alias string) bool {
for _, c := range list {
if c.Alias == alias {
return true
}
}
return false
}
func aliasesOf(list []SessionCandidate) []string {
out := make([]string, 0, len(list))
for _, c := range list {
out = append(out, c.Alias)
}
return out
}