用户订正:「是应该发到对应 session 的信,因为 agent 多 session 架构,不同 session 的
记忆是隔离的。你之前那个修法简直荒谬」
我把两条轴混在一起了:**工作区**决定 cwd/沙箱/线索可见面,**会话**决定记忆与收件箱。
原先 AgentMayReadSession 判「参与过 + 同工作区」,两头都错:
· 太松:同工作区内、我参与过的**别的会话**也放行 —— 会话之间记忆隔离,读别的会话
就是绕过隔离(用户上午的要求本就是"不同 session 不同收件箱");
· 太严:我在 A 工作区的上下文里读不到**自己刚发起**、落在 B 工作区那条会话。
zcode 撞上这条,403 文案还写着 cross-workspace ⇒ 它推断出"那五位 agent 不存在"。
新判据两句:① target 必须就是 scope(我当前那条会话);② 我参与过 target(防伪造
session_id)。scope==nil 保持迁移期放行(五家桥实测都带 session_id)。
403 文案换成「这封信不在你当前所在的那条会话里:每个会话各有各的收件箱(会话之间的
记忆是隔离的)。你当前在会话 <id>。要读它,就在它那条会话里读 —— 每封来信都会把 worker
唤醒到它自己那条会话上」。报"我在哪"安全,目标会话在哪不报。
判据:workspace_scope_test 的该用例改名并重写为五条形态(自己那条会话放行 / 同工作区
另一条会话拒 / 别的工作区会话拒 / 伪造 session_id 拒 / 未声明 scope 迁移期放行)。
生产实测:① 200 ② 403 not-your-session(新文案)③ 403(伪造)。
250 lines
8.9 KiB
Go
250 lines
8.9 KiB
Go
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 的线索")
|
||
alien := sessionIn(t, "someone-else", wsA, "与我无关的线索")
|
||
|
||
// ① 就是我当前这条会话 → 放行。少了这条,判据"永远拒绝"也能绿。
|
||
ok, reason, err := AgentMayReadSession(ctx, agent, &scope, scope)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !ok {
|
||
t.Fatalf("自己当前这条会话必须能读,却被拒(reason=%q)", reason)
|
||
}
|
||
|
||
// ② 同工作区的**另一条**会话 → 拒。★ 2026-09-15 用户订正:
|
||
// 会话是记忆隔离的单位,"不同 session 不同收件箱" —— 旧判据在这里是放行的
|
||
// (它只看工作区),那等于绕过会话隔离。
|
||
ok, reason, err = AgentMayReadSession(ctx, agent, &scope, sameWS)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ok {
|
||
t.Fatal("★ 同工作区但不同会话必须拒 —— 会话之间记忆隔离,读别的会话就是绕过它")
|
||
}
|
||
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 {
|
||
t.Fatal("别的会话必须拒")
|
||
}
|
||
if reason != "not-your-session" {
|
||
t.Fatalf("拒绝原因应是 not-your-session(实际 %q)—— 别拿工作区当理由", reason)
|
||
}
|
||
|
||
// ④ 伪造 session_id:声明 target 就是自己所在会话,但并没参与过 → 拒。
|
||
ok, reason, err = AgentMayReadSession(ctx, "pi-not-in-it", &alien, alien)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ok {
|
||
t.Fatal("没参与过的会话必须拒(防伪造 session_id)")
|
||
}
|
||
if reason != "not-participant" {
|
||
t.Fatalf("拒绝原因应是 not-participant(实际 %q)", reason)
|
||
}
|
||
|
||
// ⑤ 未声明当前会话 = 迁移期旧语义放行。**这是妥协不是正确行为**:
|
||
// 尚未接线的桥走这条路,所以它必须有判据守着 —— 哪天收紧成"拒",
|
||
// 这条会红,逼人去看还有谁没接线。
|
||
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
|
||
}
|