package handler import ( "errors" "net/http" "strings" "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/repo" "github.com/agentmail/gateway/internal/sse" "github.com/google/uuid" ) // ---------- Permission ---------- type permissionRequestRequest struct { Question string `json:"question"` Options []string `json:"options"` Context string `json:"context"` SessionID *string `json:"session_id"` // 可选:显式指定决策人(人类用户名)。省略时由会话 owner 决定。 To string `json:"to"` // RelayKey 是上游那条权限询问的稳定 id(opencode 的 permission.id)。 // // 权限请求本来就不扣配额(人不点头 Agent 就动不了,收费等于收「求人费」), // 这里要的只是**幂等**:permission.updated 事件会重复触发,插件也会重连重放, // 没有幂等键就会给同一次询问生成好几封邮件。 RelayKey string `json:"relay_key"` } type permissionDecideRequest struct { MailID string `json:"mail_id"` Decision string `json:"decision"` Note string `json:"note"` } // POST /api/v1/permission/request func RequestPermission(w http.ResponseWriter, r *http.Request) { agentName := middleware.GetAgentName(r) if agentName == "" { Error(w, http.StatusUnauthorized, "Unauthorized") return } var req permissionRequestRequest if err := Decode(r, &req); err != nil { Error(w, http.StatusBadRequest, "Invalid JSON") return } if req.Question == "" { Error(w, http.StatusBadRequest, "Missing question") return } options := req.Options if len(options) == 0 { options = []string{"同意", "拒绝"} } // 幂等:同一条上游询问只生成一封邮件。 // 重复不是故障(插件重试/事件重放的正常结果),因此幂等地返回已存在的结论而非报错。 relayKey := strings.TrimSpace(req.RelayKey) if relayKey != "" { if len(relayKey) > 160 { Error(w, http.StatusBadRequest, "relay_key 过长(上限 160 字节)") return } if err := repo.ClaimRelay(r.Context(), agentName, relayKey, "permission"); err != nil { if errors.Is(err, repo.ErrRelayDuplicate) { JSON(w, http.StatusOK, map[string]any{ "status": "duplicate_relay", "relay_key": relayKey, "detail": "该权限询问已转发过,本次调用未产生新邮件", }) return } Error(w, http.StatusInternalServerError, "Failed to claim relay") return } } // 确定 session var sessionID uuid.UUID if req.SessionID != nil && *req.SessionID != "" { id, err := uuid.Parse(*req.SessionID) if err != nil { Error(w, http.StatusBadRequest, "Invalid session_id") return } sessionID = id repo.TouchSession(r.Context(), sessionID) } else { // workspace 空串:权限询问不经三维寻址,没有 path 位可归属。 id, err := repo.CreateSession(r.Context(), nil, agentName, "权限请求: "+req.Question, "") if err != nil { Error(w, http.StatusInternalServerError, "Failed to create session") return } sessionID = id } // 决策人:显式指定优先,否则取会话 owner decider := req.To if decider == "" || decider == "human" { owner, err := repo.SessionOwnerUsername(r.Context(), sessionID) if err == nil && owner != "" { decider = owner } } if decider == "" { // 会话无归属(Agent 自发起)时退回默认管理员 admin, err := repo.FirstAdminUsername(r.Context()) if err != nil || admin == "" { Error(w, http.StatusConflict, "无法确定决策人,请在请求中指定 to") return } decider = admin } body := req.Context if body == "" { body = req.Question } mailID, err := repo.CreatePermissionMail(r.Context(), sessionID, agentName, decider, req.Question, body, options) if err != nil { // 归还幂等键,否则这次询问永远转不出来了 if relayKey != "" { _ = repo.ReleaseRelay(r.Context(), agentName, relayKey) } Error(w, http.StatusInternalServerError, "Failed to create permission mail") return } if relayKey != "" { _ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID) } if err := repo.CreatePermissionRequest(r.Context(), mailID, sessionID, agentName, req.Question, options, req.Context); err != nil { Error(w, http.StatusInternalServerError, "Failed to create permission request") return } // 只推给该决策人 sse.Default.SendToUser(decider, "new_mail", map[string]interface{}{ "mail_id": mailID.String(), "session_id": sessionID.String(), "from_name": agentName, "subject": "权限请求: " + req.Question, "mail_type": "permission_request", "role": "to", }) JSON(w, http.StatusOK, map[string]string{ "mail_id": mailID.String(), "session_id": sessionID.String(), "permission_mail_id": mailID.String(), "decider": decider, }) } // POST /api/v1/permission/decide —— 需登录;只有该权限请求的收件人或管理员可决策 func DecidePermission(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { Error(w, http.StatusUnauthorized, "not authenticated") return } var req permissionDecideRequest if err := Decode(r, &req); err != nil { Error(w, http.StatusBadRequest, "Invalid JSON") return } if req.MailID == "" || req.Decision == "" { Error(w, http.StatusBadRequest, "Missing mail_id or decision") return } mailID, err := uuid.Parse(req.MailID) if err != nil { Error(w, http.StatusBadRequest, "Invalid mail_id UUID") return } perm, err := repo.GetPermissionByMailID(r.Context(), mailID) if err != nil { Error(w, http.StatusNotFound, "Permission request not found") return } if perm.Result != nil && *perm.Result != "" { Error(w, http.StatusConflict, "该请求已被处理") return } // 鉴权:必须是这封权限邮件的收件人,或管理员 mail, err := repo.GetMailByID(r.Context(), mailID) if err != nil { Error(w, http.StatusNotFound, "Mail not found") return } if !user.IsAdmin() && mail.ToName != user.Username { Error(w, http.StatusForbidden, "无权决策他人的权限请求") return } // 决策选项必须在候选内 if !contains(perm.Options, req.Decision) { Error(w, http.StatusBadRequest, "决策必须是候选项之一") return } if _, err := repo.DecidePermission(r.Context(), mailID, req.Decision); err != nil { Error(w, http.StatusInternalServerError, "Failed to decide permission") return } decisionMailID, err := repo.CreateDecisionMail( r.Context(), perm.SessionID, mailID, user.Username, perm.AgentName, req.Decision, req.Note) if err != nil { Error(w, http.StatusInternalServerError, "Failed to create decision mail") return } // 通知发起 Agent 恢复执行 // 带上上游 permission id:插件要拿它回复 opencode 的原生权限询问。 // 两边 id 空间不同,光给 AgentMail 的 mail_id 插件对不上; // 而插件重启后内存映射会丢,所以这个映射由服务端持久化并在此回传。 payload := map[string]interface{}{ "mail_id": mailID.String(), "decision_mail_id": decisionMailID.String(), "decision": req.Decision, "note": req.Note, "decided_by": user.Username, // 会话 id:插件重启丢了待决映射时,会退化成「把决策当一封通知投进会话」, // 那条路径要靠这个字段找到原会话,否则会凭空另开一个。 "session_id": perm.SessionID.String(), } if key, kind := repo.RelayKeyForMail(r.Context(), mailID); key != "" { payload["relay_key"] = key payload["relay_kind"] = kind } sse.Default.SendToAgent(perm.AgentName, "permission_decision", payload) // 只刷新决策人自己的界面 sse.Default.SendToUser(user.Username, "session_update", map[string]interface{}{ "session_id": perm.SessionID.String(), "status": "active", }) JSON(w, http.StatusOK, map[string]string{ "status": "decided", "decision_mail_id": decisionMailID.String(), }) } // GET /api/v1/permission/pending —— 需登录;普通用户只看发给自己的 func ListPendingPermissions(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { Error(w, http.StatusUnauthorized, "not authenticated") return } forUser := user.Username if user.IsAdmin() && r.URL.Query().Get("all") == "true" { forUser = "" } reqs, err := repo.ListPendingPermissionsFor(r.Context(), forUser) if err != nil { Error(w, http.StatusInternalServerError, "Failed to list pending permissions") return } JSON(w, http.StatusOK, map[string]interface{}{ "requests": emptySlice(reqs), }) } func contains(list []string, v string) bool { for _, s := range list { if s == v { return true } } return false }