Files
MailUI4Agents/server/internal/handler/permission.go
JianFeeeee 429149e118 chore(format): 撤销误入提交的整体重排,并关闭本仓库的格式化器
# 发生了什么

pi-lens 内置「安全格式化」:它会自动安装 biome 并对**编辑过的文件**跑
`biome format --write`。本机原先没有任何 biome 配置,于是 biome 用它自己的
默认值 —— tab 缩进 + 双引号 —— 把文件整体重写。

我在 19a3161 那次提交里用了 `git add -A`,把这批与功能无关的重排一起扫了进去:
约 7000 行改动散落在 20 个文件上,使那次提交无法审查,还掩盖了
server/internal/handler/permission.go 的一处删行(实为文件末尾空行,无代码丢失)。

# 为什么是「关掉」而不是「配置成我们的风格」

试过把缩进/引号/lineWidth 全部对齐本仓库习惯(biome.json + space/2/single/
lineWidth 120):`biome format --write` 仍然改动 17 个文件。原因是本仓库从未按
biome 的规则排版过 —— 注释按语义换行、数组与调用按可读性手工折行,
这些无法由格式化器还原。也就是说只要格式化器开着,每次编辑都会产生与内容无关的
大面积 diff,把真正的改动埋掉。

因此 biome.jsonc 里 formatter 与 linter 都关闭:本仓库的静态检查由
tsc / go vet / tree-sitter / ast-grep 与各自测试套件承担,不引入会改动无关行的
自动修复。

(pi-lens 这一版把 format 服务的 enabled 硬编码为 true,没有配置开关,
所以只能在仓库侧用 biome 配置让它不动文件;已验证 `biome format --write`
对这些文件零改动。)

# 本提交内容

把 19a3161 里除「有意改动」外的 20 个文件还原到重排前的样子。
19a3161 中真正有意的改动是 deploy/install.sh 的扩展注册与
plugins/pi-mail-bridge/extension/index.ts 新文件,两者原样保留。

验证:Go 全量、三桥插件(320/362/409)、前端 196 全绿;
`biome format --write` 对还原后的文件零改动。
2026-09-11 12:03:51 +08:00

408 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"errors"
"net/http"
"strings"
"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 是上游那条权限询问的稳定 idopencode 的 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 填成 adminIsHumanUser 于是通过,这里根本不会被调用。
// 实测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",
})
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
}