package repo import ( "context" "testing" "github.com/agentmail/gateway/internal/db" "github.com/agentmail/gateway/internal/models" "github.com/google/uuid" ) // 权限询问的决策人白名单。 // // `/permission/request` 的 `to` 字段由 Agent 自由填写,原来只要它是合法的人类 // 用户名就直接采用 —— 于是一个 Agent 可以把「是否允许执行 bash」丢给任意一个 // 与这条任务无关的人。这与 handler 里那段 409 的理由直接冲突:既然「管理员对 // 这条 Agent 链的上下文一无所知」是拒绝转给管理员的依据,那 Agent 自己点名一个 // 无关的人同样不成立。权限应当追溯到最初分配任务的人,也就是这条线索上的人。 func mustHuman(t *testing.T, username string) uuid.UUID { t.Helper() id := uuid.New() if _, err := db.DB.ExecContext(context.Background(), `INSERT INTO users (user_id, username, display_name, password_hash, role) VALUES ($1, $2, $3, 'x', 'user')`, id, username, username); err != nil { t.Fatalf("建用户 %s: %v", username, err) } return id } func TestIsHumanOnSessionThread(t *testing.T) { setupTestDB(t) ctx := context.Background() sid, _ := CreateSession(ctx, nil, "pi", "权限决策人", "/home/program/agentmail") ownerID := mustHuman(t, "laoban") mustHuman(t, "dispatcher") mustHuman(t, "reviewer") mustHuman(t, "passerby") // 存在,但与这条线索无关 // 会话里有人类 dispatcher / reviewer 往来;owner 由平台指派、从未收发过邮件。 mustMail(t, sid, "dispatcher", "", "reviewer", "/home/program/agentmail", nil) if err := SetSessionOwner(ctx, sid, ownerID); err != nil { t.Fatalf("指派 owner: %v", err) } cases := []struct { name string who string want bool why string }{ {"线索上发过信的人类", "dispatcher", true, "发件人就是最初分配任务的人,权限第一该问他"}, {"线索上收过信的人类", "reviewer", true, "人类收件人也是参与者"}, {"线索上的 Agent 不是决策人", "pi", false, "出现在邮件里只是必要条件;只有人类用户才能在 WebUI 决策"}, {"会话 owner(没发过邮件)", "laoban", true, "owner 与参与方是两处来源,缺一不可:会话由 Agent 建立、owner 由平台指派时,只看邮件会把合法 owner 判成外人"}, {"存在但与线索无关的人", "passerby", false, "这就是要堵的那个洞:Agent 不能把审批丢给与任务无关的人"}, {"根本不存在的人", "nobody", false, "白名单从正面判定,不存在的名字自然不通过"}, {"空串", "", false, "空串的语义是「没指定」,走常规解析,不该被当成线索上的人"}, } for _, c := range cases { if got := IsHumanOnSessionThread(ctx, sid, c.who); got != c.want { t.Errorf("%s:IsHumanOnSessionThread(%q) = %v,应为 %v —— %s", c.name, c.who, got, c.want, c.why) } } } // 抄送方也算线索上的人:一次抄送就把人带进了这条线索。 func TestIsHumanOnSessionThreadCountsCC(t *testing.T) { setupTestDB(t) ctx := context.Background() sid, _ := CreateSession(ctx, nil, "pi", "抄送也算", "/home/program/agentmail") mustHuman(t, "dispatcher") mustHuman(t, "observer") mustMail(t, sid, "dispatcher", "", "pi", "/home/program/agentmail", nil) // 抄送方由第二封信带入 —— 一次抄送就把人带进了这条线索。 cc := []models.Address{{Name: "observer", Path: "/home", Session: "new", Raw: "observer@/home.new"}} if _, err := CreateMail(ctx, sid, nil, "dispatcher", "/home/program/agentmail", "pi", "/home/program/agentmail", "带抄送", "正文", cc); err != nil { t.Fatalf("建带抄送的邮件: %v", err) } if !IsHumanOnSessionThread(ctx, sid, "observer") { t.Error("人类抄送方应算作线索上的人:一次抄送就把他带进了这条线索") } } // 会话不存在时不得 panic,且必须返回 false(fail closed)。 func TestIsHumanOnSessionThreadUnknownSession(t *testing.T) { setupTestDB(t) if IsHumanOnSessionThread(context.Background(), uuid.New(), "admin") { t.Error("会话不存在时应返回 false(fail closed),不能因为查不到就往放行方向倒") } }