## 事故现场 用户选中补全里的「项目定位」→ 邮件投进另一条会话,界面显示的名字也不是 自己选的那个。授权页只显示 Agent 名,看不出哪个目录哪条线索。 ## 四处因果链 **① 调度器自己的 new_mail payload(起点)。** `notifyRecipients`(handler) 与 `SendCalendarMail`(scheduler)是两份代码。加 `platform_session_id` 时只改了 handler 那份 → 日历提醒投进接管会话时插件不知道是接管 → 另开一条新会话 → 命名同步冲掉接管会话的别名。 修法:抽出 `internal/notify` 包,唯一入口 `notify.Recipients`。 handler / scheduler / permission.go 都走它。新增字段时不存在「另一处忘了改」。 **② SyncSessionAlias 覆盖接管别名。** 别名是人从补全里选中的平台 slug, 任何平台命名同步都不该动它。加守卫 `platform_id <> ''` → 有绑定就返回当前值。 **③ SuggestSessionCandidates 按别名字符串去重。** 别名一被冲掉,同一条会话 出现两次(一次被冲的名字、一次镜像 slug),而另一条真实会话被吃掉。 改按 `platform_id` 去重。 mail 侧查 `s.platform_id`,镜像侧查 `platform_id`。 **④ FindOrCreateDefaultSession 不排除接管会话。** 日历提醒省略 session 位 → FindOrCreateDefaultSession 挑中人显式指定的接管会话。加 `platform_id = ''` 条件。 ## 权限页 **CreatePermissionMail 不写 from_workspace。** `from_workspace` 存空串 → 前端 `g.path && ...` 不渲染 → 人只看到光秃的 Agent 名,不知道哪个目录 哪条线索在请求权限。修法:INSERT 时从 sessions.workspace 取。 **SSE payload 缺 session_alias。** permission.go 的 SSE 不走 notify 包(决策人 不是地址解析出的参与方),但 payload 也要带 `session_alias` → 前端拼出 `pi@/home/program/agentmail.别名`,而不是光秃的 `pi`。 **mailGroups.ts:path ← session_workspace。** `from_workspace` 对 Agent 存的是 Agent 名(历史遗留),不能当路径用。PermissionList 显示完整三段地址 `agent@path.alias`。 ## NarrowStack z-index 窄屏日历的星期表头(`sticky top-0 z-10`)穿透到二级页面之上。覆盖层 auto z-index 输给 z-10 → 底层组件的层叠穿透到覆盖层。 修法:底层容器加 `isolate`(isolation: isolate),自成层叠上下文; 覆盖层加 `z-10`。只给覆盖层加 z-index 只能治当前一处,底层再写更大的 z-index 又会复现。 ## homeagent SSE 自激振荡 根因:五处缺陷叠加,SSE 每 60 秒断一次 → Gateway 全量重放 → 再断 → 再重放。 1. `p.client`(60s Timeout)跑 SSE 长连接 → 新增 `sseClient`(无超时) 2. `InjectInputSync` 在读循环里同步调用 → 改为 `go p.handleNewMail(evt)` 3. `lastEventID` 无条件赋值,Gateway 重放时发旧 ID → 单调递增 `sseMaxID` 4. 无邮件级去重 → 补 `deliveredMails map[string]bool` 5. 手动 `[]byte` 管理:每次 `buf[lineStart:]` 缩小 cap → 最终 len==cap → Read 零长切片 → 满速空转。改 `bufio.Reader`。 ## 清库 保留 jianf + 4 个 Agent 密钥 + 模型范围配置。清掉 mails/sessions/ calendar_events/attachments/agent_platform_sessions/relayed_mails/ permission_requests/rate_limits。测试数据已全部清零。 ## 测试 - gateway 7 包全过;repo + 7 例(adopt_alias_test.go) - web 182 例(mailGroups 新增 session_workspace 断言) - 前端构建通过
324 lines
11 KiB
Go
324 lines
11 KiB
Go
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 !DecodeBody(w, r, &req) {
|
||
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
|
||
}
|
||
|
||
// 关键防线: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,中间没有任何人介入。
|
||
// 此时把权限请求转给管理员毫无意义 —— 管理员对这条 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)
|
||
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
|
||
}
|
||
|
||
// 只推给该决策人。
|
||
//
|
||
// 这一处不走 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": "权限请求: " + req.Question,
|
||
"mail_type": "permission_request",
|
||
"role": "to",
|
||
"session_alias": alias,
|
||
})
|
||
|
||
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 !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
|
||
}
|
||
|
||
// 决策选项必须在候选内
|
||
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
|
||
}
|