package handler /* GET /api/v1/sessions/tree —— 会话级线索树(2026-10-04)。 # 可见性:逐节点过滤,且 admin 看全部 用户点开树时看到的是「**他能看到的**那部分树」。做法是**按节点过滤**而不是 按整棵树放行 —— 否则一条自己参与的线索会把它的**父节点**(可能是别人与别人 的对话)连标题一起带出来。 `UserCanAccessSession` 的口径与既有端点一致:会话 owner,或该用户在会话里有 过收发。这是「我参与过」,不是「我建过的」。 **admin 看全部**(用户 2026-10-04 明确要求分层):`IsAdmin()` 时跳过逐节点 过滤。理由是协作排查往往需要看**别人**的分叉 —— 那正是线索树的用途 (「A 交给 B 之后 B 去了哪」跨会话,只看自己那半边永远看不出来)。 ★ 这与 Agent 侧那条边界无关:`AgentMayReadSession`(Agent 只能读自己参与过的 会话)是 15e4fe9 / 095213b 两处越权修出来的,**本端点属于人类登录态**, 且 admin 全看是用户显式授权的。两者不要混为一谈。 # parent 链要**剪断** 一个用户能看见节点 A、看不见它的父 P —— 若照样把 P 的信息发出去就泄露了。 所以输出前按可见性**重算 parent**:不可见的祖先一律跳过,树在可见处重新起根。 */ import ( "net/http" "github.com/google/uuid" "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/repo" ) func ListSessionTree(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { Error(w, http.StatusUnauthorized, "not authenticated") return } all, err := repo.ListSessionTree(r.Context(), nil) if err != nil { Error(w, http.StatusInternalServerError, "列会话树失败") return } byID := make(map[string]repo.SessionNode, len(all)) for _, n := range all { byID[n.SessionID] = n } // 可见性:逐节点判;admin 全看。 visible := make(map[string]bool, len(all)) if user.IsAdmin() { for _, n := range all { visible[n.SessionID] = true } } else { for _, n := range all { ok, err := repo.UserCanAccessSession(r.Context(), user, uuidOf(n.SessionID)) if err != nil { // 判据查询失败 ⇒ fail-closed(当不可见),不放行。 continue } visible[n.SessionID] = ok } } // 剪断不可见的祖先(实现在 repo 层,与判据共用同一份) out := repo.PruneTree(all, visible) JSON(w, http.StatusOK, map[string]any{ "nodes": out, "admin": user.IsAdmin(), "total": len(all), "visible": len(out), }) } func uuidOf(s string) uuid.UUID { return uuid.MustParse(s) }