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

@ -14,8 +14,8 @@ import (
// ---------- /me当前登录人类用户的邮箱全部路由需 UserAuth ----------
type meSendMailRequest struct {
To string `json:"to"` // name@path.session
CC string `json:"cc"` // 多个 name@path.session
To string `json:"to"` // name@path.session
CC string `json:"cc"` // 多个 name@path.session
Subject string `json:"subject"`
Body string `json:"body"`
ReplyTo string `json:"reply_to"`
@ -30,6 +30,16 @@ type meSendMailRequest struct {
// 仅在本次投递【新建】会话时生效;续谈已有会话请用
// PUT /sessions/{id}/budget对话页里可随时改
MaxRounds *int `json:"max_rounds"`
// PermissionMode 声明本任务允许 Agent 动手到什么程度plan / workspace / full。
//
// 与 MaxRounds 同理,**仅在本次投递【新建】会话时生效**:续谈已有会话若也接受
// 这个字段,每封新信都会悄悄改掉对方正在遵守的规则 —— 而 plan 档的会话里
// 模型已经被告知「只许看」,第二封信把它改成 full 是在一段已有上下文里换规则。
// 续谈请用 PUT /sessions/{id}/permission对话页里可随时改
//
// 省略时用 models.DefaultPermissionModeworkspace
PermissionMode string `json:"permission_mode"`
}
// POST /api/v1/me/mail/send
@ -65,6 +75,11 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) {
return
}
// 附件可挂性必须在**建邮件之前**校验(与 Agent 侧同理,见 mail.go
if !checkAttachable(w, r, attachIDs, user.Username) {
return
}
// human@ 是兼容别名,人类发信时解析为自己
to = resolveHumanAlias(to, user.Username)
for i := range ccList {
@ -82,7 +97,24 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) {
return
}
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias, "")
// 纯输入校验必须在建会话【之前】做完。
//
// 原来两项校验都在 resolveTarget 之后:请求返回 400但 `.new` 已经建好了
// 会话、占掉了新建速率名额、并留下一条谁也不会再用的空线索。实测发 5 封
// 非法请求就攒下 5 条垃圾会话。校验不依赖会话,本来就该先做。
rounds := -1
if req.MaxRounds != nil {
if *req.MaxRounds < 0 {
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
return
}
rounds = *req.MaxRounds
}
if !validPermissionModeInput(w, req.PermissionMode) {
return
}
sessionID, parentMailID, created, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias, "")
if err != nil {
writeErr(w, err, "Failed to resolve session")
return
@ -95,21 +127,30 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) {
//
// 没显式给就用【收件 Agent 的默认值】。默认值挂在 Agent 上而不是全站一个数:
// 跑测试的小工具与重构整个模块的 Agent合理来回数差一个量级。
if parentMailID == nil {
rounds := 0
if req.MaxRounds != nil {
if *req.MaxRounds < 0 {
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
return
}
rounds = *req.MaxRounds
} else {
// 判据是 `created` 而不是 `parentMailID == nil`:后者在「省略 session 位复用
// 默认会话」时也成立,于是第二封信会把对方正在遵守的预算改写成默认值
//实测max_rounds=7 的会话被第二封省略该字段的信改成 20
if created {
if rounds < 0 {
rounds = repo.DefaultRoundsFor(r.Context(), to.Name)
}
if _, err := repo.SetSessionBudget(r.Context(), sessionID, rounds); err != nil {
Error(w, http.StatusInternalServerError, "Failed to set session budget")
return
}
// 权限档位同样只在新建时定。人可以直接指定(不继承)—— 人就是权限的源头,
// 而 Agent 侧的 SendMail 走 InheritedMode 不得自行抬档。
mode := models.NormalizePermissionMode(req.PermissionMode)
if _, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); err != nil {
Error(w, http.StatusInternalServerError, "Failed to set permission mode")
return
}
// 强制力是事实快照:按收件 Agent 当下自报的能力定死。
// 收件方是人类时也走这里 —— AgentModeEnforcement 查不到就返回 advisory
// 而人的收件箱本来不执行任何档位,这个值对他无意义也无害。
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
repo.AgentModeEnforcement(r.Context(), to.Name))
}
// 人类侧不产生改名提议(人直接有改名按钮,用不着向自己提议),
@ -124,6 +165,9 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) {
}
if !attachAll(w, r, mailID, attachIDs, user.Username) {
// 竞态窗口(见 mail.go 同位置):回滚那封已入库的邮件。
// 人类发信不扣会话预算、也不走 relay所以只需退邮件本身。
_ = repo.DeleteMailByID(r.Context(), mailID)
return
}