feat: 权限档位体系(三档 plan/workspace/full + 四桥 from_session_id)

L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列
(sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置,
homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id
供 relay 去重与会话回溯。

FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
This commit is contained in:
2026-09-06 15:16:49 +08:00
parent 13fcb00acc
commit a44fd6949b
32 changed files with 3462 additions and 177 deletions

View File

@ -6,6 +6,7 @@ import (
"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"
@ -98,7 +99,47 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
sessionID = id
}
// 决策人:显式指定优先,否则取会话 owner
// 权限档位决定这次询问该不该存在。
//
// 只有 workspace 档需要人:
// - plan 档 → 409。该档的语义就是「这轮不动手」没什么可问人的
// 模型该做的是把方案写在回信里。
// - full 档 → 409。已经声明全权再问一遍只是噪音插件本不该发这封信
// 发了说明它没按档位翻译,报错比静默接受好。
//
// 这也是为什么下面不再有「退回第一个管理员」的兜底:
// 既然只有一档需要人,那一档里找不到人就是 409没有中间形态。
mode := repo.SessionPermissionMode(r.Context(), sessionID)
if !models.ModeNeedsHuman(mode) {
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)
@ -106,15 +147,6 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
decider = owner
}
}
if decider == "" {
// 会话无归属Agent 自发起)时退回默认管理员
admin, err := repo.FirstAdminUsername(r.Context())
if err != nil || admin == "" {
Error(w, http.StatusConflict, "无法确定决策人,请在请求中指定 to")
return
}
decider = admin
}
// 关键防线decider 必须是人类用户。
//
@ -130,7 +162,13 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
decider = human
} else {
// 整条任务链上没有人类Agent → Agent → Agent中间没有任何人介入。
// 此时把权限请求转给管理员毫无意义 —— 管理员对这条 Agent 链的上下文一无所知,
//
// 这条分支曾经**永远不可达**:上游有一段「退回第一个管理员」的兜底,
// 把 decider 填成 adminIsHumanUser 于是通过,这里根本不会被调用。
// 实测pi 给自己新开会话派活跑 bash → 权限邮件 to_name=jianf。
// 那段兜底已删(参见上面的档位判定)。
//
// 为什么不该转给管理员:管理员对这条 Agent 链的上下文一无所知,
// 既不知道这个 bash 命令在做什么,也不知道拒绝后 Agent 该怎么绕过去。
//
// 正确做法:直接拒绝,让 Agent 收到明确的错误信息,由它自己决定下一步: