package handler import ( "errors" "fmt" "net/http" "strings" "time" "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/models" "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"` // Kind 区分待办类型:"permission"(危险工具审批,默认)或 "question" // (Agent 主动询问)。主动询问不套权限档位判定 —— plan/full 档也可能需要 // 补充信息,审批档不能拦它。 Kind string `json:"kind"` // MultiSelect 仅 question 使用:ask_user_question 的多选语义。 MultiSelect bool `json:"multi_select"` } 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 !DecodeBody(w, r, &req) { return } if req.Question == "" { Error(w, http.StatusBadRequest, "Missing question") return } // 校验请求类型。空串按 permission 处理(历史客户端不传也不会被拒)。 kind := strings.TrimSpace(req.Kind) if kind == "" { kind = "permission" } if kind != "permission" && kind != "question" { Error(w, http.StatusBadRequest, `kind 只能是 ""、"permission" 或 "question"`) return } options := req.Options if len(options) == 0 && kind != "question" { // 审批型询问必须给两个可点选项,否则人在界面上无以为答。 // // 主动询问(question)不同:它可能根本没有预设选项 —— // 那是「请把你的名字告诉我」「请把报错贴给我」这类自由文本问题。 // 给它们塞「同意/拒绝」会让人只能选一个毫无意义的答案, // 而模型拿到的 selected 里也会是这种噪音。 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, mailSubjectFor(kind, req.Question), "") if err != nil { Error(w, http.StatusInternalServerError, "Failed to create session") return } sessionID = id } // 权限档位决定审批型询问该不该存在。**主动询问(question)不受此约束**: // 无论 plan/full,模型都可能需要向人补充信息,拦住就是阻塞整个任务。 // // 审批型只有 workspace 档需要人: // - plan 档 → 409。该档的语义就是「这轮不动手」,没什么可问人的, // 模型该做的是把方案写在回信里。 // - full 档 → 409。已经声明全权,再问一遍只是噪音;插件本不该发这封信, // 发了说明它没按档位翻译,报错比静默接受好。 // // 这也是为什么下面不再有「退回第一个管理员」的兜底: // 既然只有一档需要人,那一档里找不到人就是 409,没有中间形态。 mode := repo.SessionPermissionMode(r.Context(), sessionID) needHuman := kind != "question" && !models.ModeNeedsHuman(mode) if needHuman { if relayKey != "" { _ = repo.ReleaseRelay(r.Context(), agentName, relayKey) } detail := "本会话的权限档位是 " + mode + ",不产生权限询问。" suggestion := "" if mode == models.ModePlan { suggestion = "plan 档只允许读与查。请不要尝试写入或执行命令," + "把方案、需要人工执行的步骤写在回信里。如需动手,请请发件人把档位改成 workspace。" } else { suggestion = "full 档下工具调用无需审批,插件不应该转发权限询问。" + "这通常意味着插件没按会话档位配置平台的审批策略。" } JSON(w, http.StatusConflict, map[string]interface{}{ "error": "本会话不接受权限询问(档位 " + mode + ")", "detail": detail, "suggestion": suggestion, "permission_mode": mode, }) return } // 决策人:显式指定优先,否则取会话 owner,再否则沿线索找最近的人类。 // // **不再退回第一个管理员**。那段兜底让下面的 409 分支永远不可达: // decider 空 → 填上管理员 → IsHumanUser 通过 → NearestHumanInThread 根本不会被调用。 // 实测:pi 给自己新开会话派活跑 bash,权限邮件 to_name=jianf,而那条链上 // 没有任何人类参与过。而且那段 409 自己的注释就在论证兜底是错的: // 「管理员对这条 Agent 链的上下文一无所知」。两条策略互相矛盾, // 先执行的那条把后写的那条变成了死代码。 decider := req.To if decider == "" || decider == "human" { owner, err := repo.SessionOwnerUsername(r.Context(), sessionID) if err == nil && owner != "" { decider = owner } } // 关键防线:decider 必须是人类用户。 // // Agent 无法通过 Web UI 决策权限 —— SendToUser 投递到不存在的用户通道, // 而桥的 await Promise 永不 resolve,会话永久阻塞。这在 Agent 给自己发信时 // 必然发生:pi 分配任务给自己的另一个会话 → 该会话触发权限询问 → 邮件发给 pi // → pi 不是人类用户 → 整条会话卡死。 // // 修复:沿会话树上溯找最近的人类节点 —— 权限应追溯到最初分配任务的人。 if isHuman, _ := repo.IsHumanUser(r.Context(), decider); !isHuman { human, err := repo.NearestHumanInThread(r.Context(), sessionID, decider) if err == nil && human != "" { decider = human } else { // 整条任务链上没有人类:Agent → Agent → Agent,中间没有任何人介入。 // // 这条分支曾经**永远不可达**:上游有一段「退回第一个管理员」的兜底, // 把 decider 填成 admin,IsHumanUser 于是通过,这里根本不会被调用。 // 实测:pi 给自己新开会话派活跑 bash → 权限邮件 to_name=jianf。 // 那段兜底已删(参见上面的档位判定)。 // // 为什么不该转给管理员:管理员对这条 Agent 链的上下文一无所知, // 既不知道这个 bash 命令在做什么,也不知道拒绝后 Agent 该怎么绕过去。 // // 正确做法:直接拒绝,让 Agent 收到明确的错误信息,由它自己决定下一步: // 换用不需要权限的方式(subprocess、文件操作等),或在邮件里说明情况让上游转给人类。 if relayKey != "" { _ = repo.ReleaseRelay(r.Context(), agentName, relayKey) } JSON(w, http.StatusConflict, map[string]interface{}{ "error": "权限询问无法送达:该任务链上没有人类用户", "detail": "整条任务都是 Agent 之间的邮件往来,没有人类参与决策。请换用不需要权限的方式完成此操作,或在回复中说明情况让上游转达给人类。", "suggestion": "考虑用 subprocess/file 工具替代需要权限的工具,或通过邮件向上游请求人类协助。", "decider_was": decider, }) return } } body := req.Context if body == "" { body = req.Question } mailID, err := repo.CreatePermissionMail(r.Context(), sessionID, agentName, decider, req.Question, body, options, kind, req.MultiSelect) 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, kind, req.MultiSelect); err != nil { Error(w, http.StatusInternalServerError, "Failed to create permission request") return } // 只推给该决策人。 // // 这一处不走 notify.Recipients:那个函数推给「三维地址解析出的参与方」, // 而权限询问的投递对象是逐会话树找出来的人类决策人(NearestHumanInThread), // 不是一个地址 —— 抄送也不应当收到它(权限是待办,不是广播)。 // // 但 payload 必须带足字段:前端的授权页靠 session_alias + 会话 workspace // 拼出「哪个 Agent、在哪个目录、哪条线索」。只给 from_name 的话人 // 看到的只是一个光秃的 Agent 名,无法判断该不该批。 alias := repo.SessionAliasOf(r.Context(), sessionID) sse.Default.SendToUser(decider, "new_mail", map[string]interface{}{ "mail_id": mailID.String(), "session_id": sessionID.String(), "from_name": agentName, "subject": mailSubjectFor(kind, req.Question), "mail_type": "permission_request", "role": "to", "session_alias": alias, // 待办类型与多选语义必须随推送下发:前端靠它们决定渲染 // 「批准/拒绝」还是「回答问题」(勾选 + 自由文本)。 // 不下发的话前端只能重查一次,而授权页是靠这条推送实时更新的。 "permission_kind": kind, "permission_multi_select": req.MultiSelect, "permission_options": options, }) JSON(w, http.StatusOK, map[string]string{ "mail_id": mailID.String(), "session_id": sessionID.String(), "permission_mail_id": mailID.String(), "decider": decider, }) } // mailSubjectFor 给待办邮件起主题。 // // 审批型与主动询问是两类不同的待办,主题必须一眼能区分:「权限请求」意味着 // 有人要被放行一个危险操作,「需要回答」只是模型缺信息。混用一套措辞会让人 // 在授权页里把「回答问题」当成「批准执行」。 func mailSubjectFor(kind, question string) string { if kind == "question" { return "需要回答: " + question } return "权限请求: " + question } // 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 !DecodeBody(w, r, &req) { 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 } // 决策选项必须在候选内 —— 仅限审批型。主动询问允许自由文本回答, // 多选时 decision 是多个原始标签(前端用换行分隔),同样不套暂时选项表。 if perm.Kind != "question" && !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, "kind": perm.Kind, "multi_select": perm.MultiSelect, // 会话 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", }) // 已越过等待窗口的决策必须当场说清「这次批准不会恢复原调用」。 // // 实测(2026-09-11):pi 桥等不到决策时,会在回合超时(默认 10 分钟)拆掉 // worker 与它的决策路由表;此后再来的决策只会作为**通知**投递给 Agent, // 不会恢复当时那次工具调用(该轮已经结束了)。 // // 而接口原本照旧回 200 {"status":"decided"} —— 人在界面上看到批准成功, // 实际那件事什么都没发生。这正是 I-5 要消灭的「静默成功」。 // 具体组装见 decideResponse。 JSON(w, http.StatusOK, decideResponse(perm.CreatedAt, time.Now(), decisionMailID.String())) } // decideResponse 组装权限决策的响应体。 // // 抽成纯函数是为了可测:那条「越过等待窗口」的分支只有等满窗口才会走到, // 不能只靠人工点一遍;而它正是「人看到批准成功、实际什么都没发生」的根源。 // // 越窗时加 expired + warning 而**不**改 HTTP 状态码:决策本身仍是人的真实意愿、 // 仍然有效(桥会把它当通知投给 Agent,Agent 重起一轮),所以不能拒掉; // 但必须把发生了什么讲明白。 // // 这里用布尔值而不是让客户端自己比:响应本身就是「此刻」的一次性快照, // 不像邮件那样会被缓存反复展示。(邮件上给的则是失效**时刻**,见 models.Mail。) func decideResponse(createdAt, now time.Time, decisionMailID string) map[string]any { resp := map[string]any{ "status": "decided", "decision_mail_id": decisionMailID, } if deadline := models.PermissionDeadline(createdAt); now.After(deadline) { resp["expired"] = true resp["expires_at"] = deadline resp["warning"] = fmt.Sprintf( "该请求已超过等待窗口(%.0f 分钟),发起它的 Agent 很可能已不再阻塞等待。"+ "决策已记录并会投递给它,但不会恢复当时那次工具调用 —— "+ "它会把这次决策当作一条通知,重新起一轮。", models.PermissionWaitWindow.Minutes()) } return resp } // 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 }