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 }