pi 2026-09-28 §五 指出 DecidePermission 与 MarkMailRead 是同一族的反向写入,
只堵后者等于堵一半。成立,本轮收口。
## 1) DecidePermission 补守卫(pi §五)
UPDATE mails SET permission_result=$1, status='read' WHERE mail_id=$2
↑ 无 archived 守卫
与 MarkMailRead 同一处形状,补 `AND status <> 'archived'`。
判据 TestDecidePermissionDoesNotUnarchiveMail,已实测去掉守卫即转红。
★ mail_reads 的 INSERT 仍**不加**守卫(与 MarkMailRead 同口径):
事实表(谁读过,不可撤销)与派生列(全局可见性,可重算)语义不同,
一起挡会让「谁读过」不可审计 —— 判据 TestDecidePermissionDoesNotUnarchiveMail
顺带断言「决策人 bob 的 mail_reads 行仍要写进去」,防止守卫误伤事实表。
## 2) 修正 e78888b 的一处误用(我自己发现的)
DecidePermission 的 handler 路径(handler/permission.go)我原先挂了
`SessionOpenFor`(存在 + 未归档 + **参与方**),而该路径上一行刚放行的是
「该邮件收件人本人 **或** 管理员」—— 管理员本来就可以给任何线索做决策。
叠上参与方会把管理员挡在门外。改为 `EnsureSessionOpen`(只判存在 + 未归档)。
★ 这个错是在写 e78888b 时想到了、说了「要改成 EnsureSessionOpen」,
但**当时没落进文件**就提交了。已补,并在注释里写明两个函数的差别,
免得下一个人「顺手统一」把管理员又挡掉。
## 读侧清册
仍为 repo.go=13:新增 1 处命中在注释散文里,改措辞而非改数字。
repo/handler 全绿;internal/notify 的 TestInReplyToCarriesParentSender
仍是既有欠账 in-reply-to-ignores-direction,非本轮引入。
523 lines
22 KiB
Go
523 lines
22 KiB
Go
package handler
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"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
|
||
}
|
||
// 键已占住,此后**每一条**早退路径都必须把它还回去,否则这条
|
||
// (agent_name, relay_key) 被永久占用:之后的重试只会拿到
|
||
// duplicate_relay(复用「已转发过」那条响应),而这次询问其实从未发出。
|
||
//
|
||
// 用 defer 而不是在各 return 前逐个补 ReleaseRelay:这条路上有多处早退
|
||
// (session_id 不是 UUID、建会话失败……),逐个补漏掉一处就是一个静默的
|
||
// 永久占键。野外已有一例:relay_key
|
||
// `no-such-session-0000:toolu-nohuman-1789193173578` 的 session 位不是
|
||
// UUID,走到 `Invalid session_id` 那条 return 时没退键,2026-09-12 起
|
||
// 一直留在表里(mail_id 为空)。
|
||
//
|
||
// 成功路径不需要额外开关:ReleaseRelay 只删 `mail_id IS NULL` 的占位行,
|
||
// 而下面 BindRelayMail 一旦成功,这行就不再匹配,defer 自然什么都不做。
|
||
defer func() {
|
||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||
}()
|
||
}
|
||
|
||
// 确定 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
|
||
}
|
||
// 三个判据:存在 / 未归档 / 发起方确实是这条线索的一方。
|
||
//
|
||
// 这一格原先**一个都不查**(只 parse UUID 就 TouchSession 然后建邮件),
|
||
// 而 session_id 完全来自请求体 ⇒ 携带别人的 id 就能往那条线索里投一封
|
||
// 权限询问。原先那格唯一挡住的是 status:TouchSession 会把归档会话
|
||
// 改成 active,于是"往已归档线索里发信"也一并做到了(两表分叉的成因)。
|
||
// 判据见 repo.SessionOpenFor 的注释。
|
||
if err := repo.SessionOpenFor(r.Context(), agentName, id); err != nil {
|
||
switch {
|
||
case errors.Is(err, repo.ErrSessionArchived):
|
||
Error(w, http.StatusNotFound, "该会话已归档,不再接受权限询问;请新建会话")
|
||
default:
|
||
Error(w, http.StatusNotFound, "会话不存在,或你未参与该会话")
|
||
}
|
||
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 := strings.TrimSpace(req.To)
|
||
|
||
// 显式指定的决策人只在**确属这条线索**时才采信。
|
||
//
|
||
// `to` 由 Agent 自由填写,原来只要它是合法的人类用户名就直接采用,于是
|
||
// 一个 Agent 可以把「是否允许执行 bash」丢给任意一个与这条任务无关的人。
|
||
// 这与上面 409 分支的理由直接冲突:既然「管理员对这条 Agent 链一无所知」
|
||
// 是拒绝转给管理员的依据,那 Agent 自己点名一个无关的人同样不成立。
|
||
// 权限应当追溯到最初分配任务的人,也就是这条线索上的人。
|
||
//
|
||
// 这里**丢弃提示而不是报错**:它只是一个偏好,下面常规解析仍会给出一个
|
||
// 合法人类(owner 或线索上最近的人),否则明确 409 —— 无论哪条,都不会
|
||
// 把审批送到错的人手上。硬失败则会让 Agent 一次乐观的提示断掉整个任务,
|
||
// 而它并没有做错什么。丢掉是静默的,所以这里必须留下日志。
|
||
if decider != "" && decider != "human" && !repo.IsHumanOnSessionThread(r.Context(), sessionID, decider) {
|
||
log.Printf("[permission] 忽略不属于本线索的决策人 %q(会话 %s,由 %s 指定)—— 改走常规解析",
|
||
decider, sessionID, agentName)
|
||
decider = ""
|
||
}
|
||
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
|
||
}
|
||
|
||
// 归档检查必须在 DecidePermission **之前**:否则决策已落库、回执邮件却因为
|
||
// 会话已归档建不出来 —— 发起方那边永远等不到回执(任务挂死),
|
||
// 而人在界面上看到的却是"已处理"。宁可不决策,让人看见失败。
|
||
//
|
||
// 这里刻意用 `EnsureSessionOpen`(只判存在+未归档)而不是
|
||
// `SessionOpenFor`(多一格参与方):上面刚放行本请求的是
|
||
// 「该邮件收件人本人 **或** 管理员」,而管理员**本来就可以**给任何线索做决策
|
||
// —— 那不是越权。再叠一格参与方会把管理员挡在门外。
|
||
if err := repo.EnsureSessionOpen(r.Context(), perm.SessionID); err != nil {
|
||
if errors.Is(err, repo.ErrSessionArchived) {
|
||
Error(w, http.StatusNotFound, "该会话已归档,无法回执决策")
|
||
} else {
|
||
Error(w, http.StatusNotFound, "会话不存在")
|
||
}
|
||
return
|
||
}
|
||
|
||
// 决策人 = 当前登录用户(已读只记到他名下,不影响这条线索上其他收件人)
|
||
if _, err := repo.DecidePermission(r.Context(), mailID, user.Username, 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(),
|
||
}
|
||
/*
|
||
★ workspace:插件用它去收窄"等人期间新到的邮件"(那是一次 /mail/inbox 读,
|
||
而收件箱接口要求 workspace,缺了 400)。
|
||
|
||
为什么服务端给而不是让插件推:插件重启后待决映射丢光(上面那段说的),
|
||
此时它手上只有这个事件 —— 从事件里推不出工作区。而服务端**知道**
|
||
(`perm.SessionID` → `sessions.workspace`),一处给对,三平台都能用。
|
||
*/
|
||
if ws := repo.SessionWorkspaceOf(r.Context(), perm.SessionID); ws != "" {
|
||
payload["workspace"] = ws
|
||
}
|
||
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
|
||
}
|