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:
@ -47,15 +47,26 @@ type heartbeatRequest struct {
|
||||
// 空数组 = 平台确实一个模型都拿不到。拿不到目录时必须省略:
|
||||
// 清空目录会让配置页变成空白,管理员以为该平台没有任何可用模型。
|
||||
Models []repo.CatalogModel `json:"models"`
|
||||
|
||||
// ModeEnforcement 是插件自报的权限档位强制能力:native / advisory。
|
||||
//
|
||||
// 为什么走心跳而不是注册:能力会在运行中变。DSH 的沙箱模式被改成
|
||||
// danger-full-access 时,它就从 native 退化成了 advisory(实测:
|
||||
// approval:"never" 会在 waterfall 之前短路,approval/request 根本不触发)。
|
||||
// 只在注册时报一次的话,发件人看到的是上次重启时的能力快照。
|
||||
//
|
||||
// 与模型目录同一条通道(I-1:平台自己说的才算)。
|
||||
// 省略 = 本次不上报,保留现有值(与 PlatformSessions / Models 同约定)。
|
||||
ModeEnforcement string `json:"mode_enforcement"`
|
||||
}
|
||||
|
||||
// POST /api/v1/agent/register
|
||||
//
|
||||
// 两种认证方式:
|
||||
// 1. Authorization: Bearer <agent_key_token> —— 密钥认证(推荐)。
|
||||
// 密钥未绑定时用本请求的 name 落定;已绑定时 name 必须与之一致,
|
||||
// 否则等于拿别人的密钥冒充新身份。
|
||||
// 2. body 里带 secret —— 旧方式,兼容保留。
|
||||
// 1. Authorization: Bearer <agent_key_token> —— 密钥认证(推荐)。
|
||||
// 密钥未绑定时用本请求的 name 落定;已绑定时 name 必须与之一致,
|
||||
// 否则等于拿别人的密钥冒充新身份。
|
||||
// 2. body 里带 secret —— 旧方式,兼容保留。
|
||||
func RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
@ -161,9 +172,14 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 可选的平台会话快照。解不开就当作没带:心跳的主职责是「我还活着」,
|
||||
// 不该因为上报体格式不对就把 Agent 判成离线。
|
||||
//
|
||||
// 但**未知字段必须回报**(resp["unknown_fields"]):这是全站唯一一处宽容
|
||||
// 解码的端点,若还静默忽略,插件把 `models` 拼成 `modles` 就永远没人知道 ——
|
||||
// 而那与 `attachments` vs `attachment_ids` 是同一种事故形状。
|
||||
var req heartbeatRequest
|
||||
var unknownFields []string
|
||||
if r.ContentLength > 0 {
|
||||
_ = Decode(r, &req)
|
||||
unknownFields, _ = DecodeLenient(r, &req)
|
||||
}
|
||||
syncedSessions := -1 // -1 = 本次未上报
|
||||
if req.PlatformSessions != nil {
|
||||
@ -183,6 +199,12 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 档位强制能力:省略时不动(保留现有值)。
|
||||
// 写失败不影响心跳本身 —— 心跳的主职责是「我还活着」。
|
||||
if req.ModeEnforcement != "" {
|
||||
_ = repo.SetAgentModeEnforcement(r.Context(), agentName, req.ModeEnforcement)
|
||||
}
|
||||
|
||||
// 心跳回传该 Agent 的累计统计与新任务默认预算。
|
||||
//
|
||||
// 不再回传「剩余额度」:额度属于具体任务(会话)而不属于 Agent,
|
||||
@ -212,6 +234,11 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
|
||||
resp["allowed_models"] = allowed
|
||||
resp["models_unrestricted"] = len(allowed) == 0
|
||||
}
|
||||
// 未知字段回报:只有真的出现时才带这一项,正常心跳的响应不多一个空数组。
|
||||
// 插件看到它就知道自己上报的某个字段服务端根本没收。
|
||||
if len(unknownFields) > 0 {
|
||||
resp["unknown_fields"] = unknownFields
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
|
||||
@ -127,7 +127,7 @@ func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor,
|
||||
subject := forwardSubject(req.Subject, src.Subject)
|
||||
|
||||
// 转发按目标地址寻址,不带 reply_to:它是一条新线索,不该并进原会话
|
||||
sessionID, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias, agentLimiterKey(isAgent, actor))
|
||||
sessionID, _, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias, agentLimiterKey(isAgent, actor))
|
||||
if err != nil {
|
||||
writeErr(w, err, "Failed to resolve session")
|
||||
return
|
||||
|
||||
@ -37,6 +37,16 @@ type sendMailRequest struct {
|
||||
// 它由平台生成,模型伪造不出,而唯一约束保证同一条上游消息只能免费转一次。
|
||||
Relay string `json:"relay"` // "" | "permission" | "summary"
|
||||
RelayKey string `json:"relay_key"` // 上游消息 id;relay 非空时必填
|
||||
|
||||
// FromSessionID 是发信时模型所处的邮件会话 id(即「这活是谁派给我的」)。
|
||||
//
|
||||
// **只用于权限档位继承**:Agent 新开一条会话时,新会话不得比它所在
|
||||
// 的那条会话更宽松。注意这里**没有** permission_mode 字段 —— 那是有意的:
|
||||
// 让 Agent 自己指定档位等于发一封 mode=full 的信就能提权。
|
||||
//
|
||||
// 省略时回落到默认档(不是 full)。插件担不担得起传这个值不影响安全下限:
|
||||
// 没传 = 拿默认档,不会因此拿到更大的权限。
|
||||
FromSessionID string `json:"from_session_id"`
|
||||
}
|
||||
|
||||
// resolveTarget 根据三维地址 name@path.session 决定投递的会话。
|
||||
@ -52,18 +62,28 @@ type sendMailRequest struct {
|
||||
// byAgent 非空时表示这是 Agent 发起的投递,新建会话要过速率限制:
|
||||
// 往返预算按会话计,Agent 用 .new 开一串会话就等于绕过预算。
|
||||
// 人类不受此限(手工点「新建邮件」的频率天然受限,加限制只会在批量派活时误伤)。
|
||||
func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string, byAgent string) (uuid.UUID, *uuid.UUID, error) {
|
||||
// resolveTarget 依据地址的 session 位定位(或新建)会话。
|
||||
//
|
||||
// 返回值:会话 id / 父邮件 id(仅 reply_to 路径非 nil)/ **created** / 错误。
|
||||
//
|
||||
// created 为真**仅**表示这次调用真的新建了一条会话。它存在的理由是:
|
||||
// `parentMailID == nil` 曾被当作「新建会话」的判据,而那是错的 ——
|
||||
// 省略 session 位复用默认会话时 parentMailID 也是 nil。实测后果:
|
||||
// 第一封信 `max_rounds=7`,第二封信省略该字段,会话预算被静默改成 20。
|
||||
// 「只在新建时生效」的字段(往返预算、权限档位)必须靠这个返回值判断,
|
||||
// 否则每封新信都在改写对方正在遵守的规则。
|
||||
func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string, byAgent string) (uuid.UUID, *uuid.UUID, bool, error) {
|
||||
if replyTo != "" {
|
||||
replyID, err := uuid.Parse(replyTo)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, errBadRequest("Invalid reply_to UUID")
|
||||
return uuid.Nil, nil, false, errBadRequest("Invalid reply_to UUID")
|
||||
}
|
||||
mail, err := repo.GetMailByID(r.Context(), replyID)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, errNotFound("Parent mail not found")
|
||||
return uuid.Nil, nil, false, errNotFound("Parent mail not found")
|
||||
}
|
||||
repo.TouchSession(r.Context(), mail.SessionID)
|
||||
return mail.SessionID, &replyID, nil
|
||||
return mail.SessionID, &replyID, false, nil
|
||||
}
|
||||
|
||||
switch addr.Mode() {
|
||||
@ -73,17 +93,17 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
|
||||
var aliasPtr *string
|
||||
if a := strings.TrimSpace(alias); a != "" {
|
||||
if err := validateSessionAlias(a); err != nil {
|
||||
return uuid.Nil, nil, err
|
||||
return uuid.Nil, nil, false, err
|
||||
}
|
||||
if _, err := repo.FindSessionByAlias(r.Context(), a); err == nil {
|
||||
return uuid.Nil, nil, errConflict(fmt.Sprintf(
|
||||
return uuid.Nil, nil, false, errConflict(fmt.Sprintf(
|
||||
"会话别名 %q 已被占用;若要接着该会话谈请用 %s@%s.%s", a, addr.Name, addr.Path, a))
|
||||
}
|
||||
aliasPtr = &a
|
||||
}
|
||||
// Agent 主动开新线索要过速率限制
|
||||
if ok, retry := repo.AllowNewSession(r.Context(), byAgent); !ok {
|
||||
return uuid.Nil, nil, errRateLimited(fmt.Sprintf(
|
||||
return uuid.Nil, nil, false, errRateLimited(fmt.Sprintf(
|
||||
"新建会话过于频繁(1 小时内已开 %d 条)。请在已有会话里继续,或 %d 秒后再试。",
|
||||
repo.SessionRateLimit(), retry))
|
||||
}
|
||||
@ -93,7 +113,7 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
|
||||
if err != nil {
|
||||
// 建失败要把名额还回去:那次新建实际上没有发生
|
||||
repo.ReleaseNewSession(r.Context(), byAgent)
|
||||
return id, nil, err
|
||||
return id, nil, false, err
|
||||
}
|
||||
// `.new` 是一次性动作:它建完会话就用完了,之后要再投进这条会话只能靠
|
||||
// `name@path.<别名>`。未命名会话既查不到(FindNamedSessionFor 的
|
||||
@ -105,29 +125,32 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
|
||||
// 只能用 reply_to 续谈,比整封退回轻。
|
||||
_, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject))
|
||||
}
|
||||
return id, nil, nil
|
||||
return id, nil, true, nil
|
||||
|
||||
case models.SessionDefault:
|
||||
// 默认会话「从未通信则建立」也会产生新会话,但一个 name@path 只有一条,
|
||||
// 不构成暴开的手段,因此不计入速率限制。
|
||||
id, err := repo.FindOrCreateDefaultSession(r.Context(), addr.Name, addr.Path, fromAgent, subject)
|
||||
//
|
||||
// created 必须区分「这次建了」与「复用了既有的那条」:两者在这里都返回
|
||||
// parentMailID == nil,靠它判断会把续谈误当新建(预算与档位被静默改写)。
|
||||
id, created, err := repo.FindOrCreateDefaultSessionCreated(r.Context(), addr.Name, addr.Path, fromAgent, subject)
|
||||
if err != nil {
|
||||
return id, nil, err
|
||||
return id, nil, false, err
|
||||
}
|
||||
// 默认会话同样需要可寻址的别名:省略 session 位能投进来,但要**指名**
|
||||
// 投进这一条(而不是「该 name@path 当前的默认会话」)仍然只能靠别名。
|
||||
// 已有别名时 EnsureSessionAlias 直接返回,复用旧会话不会被改名。
|
||||
_, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject))
|
||||
return id, nil, nil
|
||||
return id, nil, created, nil
|
||||
|
||||
default: // models.SessionNamed
|
||||
id, err := repo.FindNamedSessionFor(r.Context(), addr.Name, addr.Path, addr.Session)
|
||||
if err == nil {
|
||||
repo.TouchSession(r.Context(), id)
|
||||
return id, nil, nil
|
||||
return id, nil, false, nil
|
||||
}
|
||||
if !errors.Is(err, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, nil, err
|
||||
return uuid.Nil, nil, false, err
|
||||
}
|
||||
|
||||
// 本侧没有这条别名 —— 再看平台会话镜像。
|
||||
@ -138,13 +161,15 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
|
||||
//
|
||||
// 命中就**接管**它:本侧建一条会话并绑定 platform_id,插件收到投递
|
||||
// 事件时据此 resume 那条平台会话而不是新建。
|
||||
// 接管**是**新建本侧会话(绑定了 platform_id 的那条),
|
||||
// 所以 created 为真:它此前没有档位与预算,需要按这次投递定下来。
|
||||
if adopted, aErr := adoptFromPlatform(r, addr, fromAgent, subject, byAgent); aErr == nil {
|
||||
return adopted, nil, nil
|
||||
return adopted, nil, true, nil
|
||||
} else if !errors.Is(aErr, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, nil, aErr
|
||||
return uuid.Nil, nil, false, aErr
|
||||
}
|
||||
|
||||
return uuid.Nil, nil, errNotFound(fmt.Sprintf(
|
||||
return uuid.Nil, nil, false, errNotFound(fmt.Sprintf(
|
||||
"无法送达:会话 %q 不存在于 %s@%s。若要新建会话请用 %s@%s.new,投递默认会话请省略 session 位",
|
||||
addr.Session, addr.Name, addr.Path, addr.Name, addr.Path))
|
||||
}
|
||||
@ -236,18 +261,56 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 附件可挂性必须在**建邮件之前**校验。
|
||||
//
|
||||
// 原先只在 CreateMail 之后调 attachAll,于是附件不合法时返回 403/409,
|
||||
// 但那封邮件已入库、已通知收件人、已扣预算(生产实测两封探针邮件均如此)。
|
||||
// 发件方看到 4xx 会重试,收件方于是收到两封。
|
||||
if !checkAttachable(w, r, attachIDs, agentName) {
|
||||
return
|
||||
}
|
||||
|
||||
// 可达性:收件人必须存在且未停用。Agent 侧同样要查 ——
|
||||
// 模型拿到 200 就会当作「话已传到」并停手等对方,而那封信永远不会有人读。
|
||||
if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias, agentName)
|
||||
sessionID, parentMailID, created, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias, agentName)
|
||||
if err != nil {
|
||||
writeErr(w, err, "Failed to resolve session")
|
||||
return
|
||||
}
|
||||
|
||||
// Agent 新开的会话继承权限档位,**不得自行抬档**。
|
||||
//
|
||||
// req 里根本没有 permission_mode 字段 —— 这是有意的:Agent 能指定档位
|
||||
// 就等于发一封 mode=full 的信给自己提权。档位由发信方当前所处的会话
|
||||
// (也就是「这活是谁派给我的」)推导,且只能同档或更严。
|
||||
//
|
||||
// 这保证 plan 档的任务派不出 full 档的子任务 —— 与 hop_limit 防自激同形:
|
||||
// 约束必须沿着链条传递下去,否则一跳之后就失效了。
|
||||
// 判据是 `created`:省略 session 位复用默认会话时 parentMailID 也是 nil,
|
||||
// 用后者会让每一封续谈的信重新“继承”一次 —— 而那条会话的档位可能已经
|
||||
// 被人在对话页里改过,重继承等于把人的修改静默回滚。
|
||||
if created {
|
||||
// 发信方自己那条会话的档位是上限。插件没传 from_session_id 时
|
||||
// 回落到默认档 —— 不会因为没传而拿到更大的权限。
|
||||
var parent *uuid.UUID
|
||||
if req.FromSessionID != "" {
|
||||
if pid, pErr := uuid.Parse(req.FromSessionID); pErr == nil {
|
||||
parent = &pid
|
||||
}
|
||||
}
|
||||
mode := repo.InheritedMode(r.Context(), parent, models.DefaultPermissionMode)
|
||||
if _, sErr := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); sErr != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to set permission mode")
|
||||
return
|
||||
}
|
||||
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
|
||||
repo.AgentModeEnforcement(r.Context(), to.Name))
|
||||
}
|
||||
|
||||
// 配额在建邮件之前扣:否则邮件已入库再报 403,收件方会看到一封发件方以为发失败的邮件。
|
||||
// 只限制主动发信,不限制收信(卡住收信只会让邮件凭空消失)。
|
||||
//
|
||||
@ -373,6 +436,19 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if !attachAll(w, r, mailID, attachIDs, agentName) {
|
||||
// 走到这里说明碰上了 checkAttachable 之后的竞态窗口(另一个请求把同一个
|
||||
// 附件挂走了)。必须回滚已产生的副作用,否则收件方会拿到一封没有附件的
|
||||
// 邮件,而发件方以为整次请求失败了。
|
||||
//
|
||||
// 三件事都要退:邮件本身、本次往返预算、relay 幂等键。
|
||||
// 错误均忽略:响应已由 attachAll 写出,回滚失败只能记日志。
|
||||
_ = repo.DeleteMailByID(r.Context(), mailID)
|
||||
if !relayFree {
|
||||
repo.RefundSessionBudget(r.Context(), sessionID)
|
||||
}
|
||||
if relay != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -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.DefaultPermissionMode(workspace)。
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -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 填成 admin,IsHumanUser 于是通过,这里根本不会被调用。
|
||||
// 实测:pi 给自己新开会话派活跑 bash → 权限邮件 to_name=jianf。
|
||||
// 那段兜底已删(参见上面的档位判定)。
|
||||
//
|
||||
// 为什么不该转给管理员:管理员对这条 Agent 链的上下文一无所知,
|
||||
// 既不知道这个 bash 命令在做什么,也不知道拒绝后 Agent 该怎么绕过去。
|
||||
//
|
||||
// 正确做法:直接拒绝,让 Agent 收到明确的错误信息,由它自己决定下一步:
|
||||
|
||||
Reference in New Issue
Block a user