Files
MailUI4Agents/server/internal/repo/workspace_scope_test.go
JianFeeeee 81ec77ae1f fix(read)!: 会话即主体 —— 读路径不再做任何"谁有资格"的仲裁
用户(第二次、说明白了):「我要求的是不同 session 不同收件箱,而不是复杂的权限隔离……
每个 session 是相对独立的单位,他们不应该公用一个相对私有化的设施,相当于每个 session
概念上是一个独立的『用户』」

所以 AgentMayReadSession 只剩一句话:**target 必须就是 scope**(我当前所在的那条会话)。
删掉两样东西:
  · 工作区比较 —— 那是 cwd/沙箱那条轴的事,混进读信就制造了 zcode 那次误判
    (cross-workspace 的 403 让它推断"那五位 agent 不存在");
  · **参与性仲裁** —— 那是我加的"相对私有化"设施:把 agent 当成一个跨所有会话的人,
    于是它既太松(同工作区内能互翻收件箱)又太严(发起者读不到自己发起的会话)。
    会话是独立单位,不需要一个更高层身份来"授权"它读自己的收件箱。

信任边界在桥:session_id 由 worker 闭包注入(模型改不了),等同"邮件客户端替它持有的
每个账号行事"。未声明 scope 的旧语义保持放行(记警告)。

判据重写为:① 我就是这条会话 → 放行;② 同工作区另一条会话 → 拒;③ 别的工作区会话 → 拒
(同一个理由 not-your-session);④ 未声明 → 迁移期放行;⑤ **显式钉住"不做参与性仲裁"**
(声明自己是哪条会话即以其行事,即便该 agent 名义上没参与过)—— 这条是模型的一部分,
免得以后被"好心"加回来;将来若要做"每个 session 自带凭据",那是加凭据而不是加回这层仲裁。

生产实测:① 200;② 403「这封信不在你当前所在的那条会话里……」;③ 200(迁移期放行)。
2026-09-15 10:01:24 +08:00

233 lines
9.0 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 TestAgentMayReadSessionIsSessionScoped(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 的另一条会话")
// ① 我就是这条会话 → 放行。少了这条,判据"永远拒绝"也能绿。
if ok, reason, err := AgentMayReadSession(ctx, agent, &scope, scope); err != nil || !ok {
t.Fatalf("自己当前这条会话必须能读(ok=%v reason=%q err=%v)", ok, reason, err)
}
// ② 同工作区的**另一条**会话 → 拒。会话是私有单位,同工作区不构成通行证
// (旧判据在这里是放行的,那等于会话之间可以互相翻收件箱)。
ok, reason, err := AgentMayReadSession(ctx, agent, &scope, sameWS)
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("★ 同工作区但不同会话必须拒 —— 每个 session 是独立单位,收件箱不共用")
}
if reason != "not-your-session" {
t.Fatalf("拒绝原因应是 not-your-session(实际 %q)", reason)
}
// ③ 别的会话(工作区也不同)→ 同一条判据、同一个理由。
// ★ 不再用工作区解释:工作区是 cwd/沙箱那条轴的事。zcode 2026-09-15 正是被
// cross-workspace 那句误导,推断出"那五位 agent 不存在"。
ok, reason, err = AgentMayReadSession(ctx, agent, &scope, otherWS)
if err != nil {
t.Fatal(err)
}
if ok || reason != "not-your-session" {
t.Fatalf("别的会话必须拒且理由是 not-your-session(ok=%v reason=%q)", ok, reason)
}
// ④ 未声明当前会话 = 迁移期旧语义放行(尚未接线的桥走这条路;服务端另记警告)。
if ok, _, err := AgentMayReadSession(ctx, agent, nil, otherWS); err != nil || !ok {
t.Fatal("未声明 scope 时应保持旧语义放行(收紧要显式改这条判据)")
}
// ⑤ **不做参与性仲裁** —— 这是模型的一部分,钉住它免得以后被"好心"加回来。
// 用户的原话:「不是复杂的权限隔离……每个 session 相当于一个独立的『用户』」。
// 声明自己是哪条会话,就以那条会话的身份行事;信任边界在桥(session_id 由
// worker 闭包注入,模型改不了)。将来若要"session 自带凭据"(每个会话一把钥匙,
// 像每个用户一个账号),那是加**凭据**,不是加这层仲裁 —— 届时这条判据要重写。
outsider := sessionIn(t, "someone-else", wsA, "别人的会话")
if ok, reason, err := AgentMayReadSession(ctx, agent, &outsider, outsider); err != nil || !ok {
t.Fatalf("声明了某条会话即视为以它行事,不该按「该 agent 是否参与过」仲裁(ok=%v reason=%q)", ok, reason)
}
}
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
}