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:
@ -95,6 +95,21 @@ var sqliteAddColumns = []struct{ table, column, ddl string }{
|
||||
// 旧库也给 20:之前的 max_rounds 默认是 10 但那是终身额度,语义不同,
|
||||
// 不能直接搬过来当单任务预算。
|
||||
{"agents", "default_rounds", "ALTER TABLE agents ADD COLUMN default_rounds INTEGER NOT NULL DEFAULT 20"},
|
||||
// 会话级权限档位(plan / workspace / full)。
|
||||
//
|
||||
// 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
|
||||
// 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
|
||||
// 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
|
||||
{"sessions", "permission_mode", "ALTER TABLE sessions ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'workspace'"},
|
||||
// 接收平台实际做到的强制力(native / advisory),由插件心跳自报后落到会话上。
|
||||
//
|
||||
// 旧库默认 'advisory':没自报过的插件,我们不能替它宣称「档位在这里是被强制的」。
|
||||
// 保守方向是承认做不到,而不是假装做到了。
|
||||
{"sessions", "permission_enforcement", "ALTER TABLE sessions ADD COLUMN permission_enforcement TEXT NOT NULL DEFAULT 'advisory'"},
|
||||
// Agent 自报的档位强制能力(native / advisory),随心跳更新。
|
||||
// 与 sessions.permission_enforcement 的区别:这里是平台的能力,那里是
|
||||
// 某条会话建立时的事实快照 —— 插件升级后能力会变,已结束的会话不该被改写。
|
||||
{"agents", "mode_enforcement", "ALTER TABLE agents ADD COLUMN mode_enforcement TEXT NOT NULL DEFAULT 'advisory'"},
|
||||
}
|
||||
|
||||
// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。
|
||||
|
||||
@ -94,6 +94,22 @@ ALTER TABLE sessions ADD COLUMN IF NOT EXISTS alias_source TEXT NOT NULL DEFAULT
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS max_rounds INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS used_rounds INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- 会话级权限档位(plan / workspace / full)。
|
||||
--
|
||||
-- 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
|
||||
-- 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
|
||||
-- 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS permission_mode TEXT NOT NULL DEFAULT 'workspace';
|
||||
|
||||
-- 接收平台实际做到的强制力(native / advisory),由插件心跳自报后落到会话上。
|
||||
-- 旧库默认 'advisory':没自报过的插件,不能替它宣称「档位在这里是被强制的」。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS permission_enforcement TEXT NOT NULL DEFAULT 'advisory';
|
||||
|
||||
-- Agent 自报的档位强制能力(native / advisory),随心跳更新。
|
||||
-- 与 sessions.permission_enforcement 的区别:这里是平台当下的能力,
|
||||
-- 那里是某条会话建立时的事实快照 —— 插件升级后能力会变,已结束的会话不该被改写。
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS mode_enforcement TEXT NOT NULL DEFAULT 'advisory';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id);
|
||||
|
||||
-- Mails table
|
||||
|
||||
@ -63,6 +63,17 @@ CREATE TABLE IF NOT EXISTS agents (
|
||||
-- max_rounds 保留列但不再参与判断。
|
||||
max_rounds INTEGER NOT NULL DEFAULT 0,
|
||||
used_rounds INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- mode_enforcement 是该平台插件自报的权限档位强制能力(native / advisory),
|
||||
-- 随心跳上报(与模型目录同一条通道 —— I-1:平台自己说的才算)。
|
||||
--
|
||||
-- 为什么要存:发件人在派活前得知道 plan 档在对方那儿到底算不算。
|
||||
-- homeagent 的核心没有工具调用拦截点,档位只能写进提示词 ——
|
||||
-- 把这个事实藏起来比做不到本身更危险。
|
||||
--
|
||||
-- 默认 advisory 而不是 native:没自报过的插件,我们不能替它宣称
|
||||
-- 「档位在这里是被强制的」。
|
||||
mode_enforcement TEXT NOT NULL DEFAULT 'advisory',
|
||||
last_seen DATETIME,
|
||||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
);
|
||||
@ -115,7 +126,18 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
-- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话,
|
||||
-- 每条都是全新预算,全局上限就形同虚设。
|
||||
max_rounds INTEGER NOT NULL DEFAULT 0,
|
||||
used_rounds INTEGER NOT NULL DEFAULT 0
|
||||
used_rounds INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- 会话级权限档位(plan / workspace / full)。
|
||||
-- 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
|
||||
-- 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
|
||||
-- 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
|
||||
permission_mode TEXT NOT NULL DEFAULT 'workspace',
|
||||
|
||||
-- 接收平台实际做到的强制力(native / advisory)。
|
||||
-- 旧库默认 'advisory':没自报过的插件,我们不能替它宣称
|
||||
-- 「档位在这里是被强制的」。保守方向是承认做不到,而不是假装做到了。
|
||||
permission_enforcement TEXT NOT NULL DEFAULT 'advisory'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias);
|
||||
|
||||
@ -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 收到明确的错误信息,由它自己决定下一步:
|
||||
|
||||
@ -25,6 +25,14 @@ type Agent struct {
|
||||
UsedRounds int `json:"used_rounds"`
|
||||
LastSeen *time.Time `json:"last_seen"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// ModeEnforcement 是该平台插件自报的权限档位强制能力(native / advisory),
|
||||
// 随心跳上报(与模型目录同一条通道,见 I-1:平台自己说的才算)。
|
||||
//
|
||||
// 为什么要存:发件人在派活前得知道 plan 档在对方那儿到底算不算。
|
||||
// homeagent 的核心没有工具调用拦截点,档位只能写进提示词 ——
|
||||
// 把这个事实藏起来比做不到本身更危险。
|
||||
ModeEnforcement string `json:"mode_enforcement"`
|
||||
}
|
||||
|
||||
// Workspace 是 Agent 管理的项目工作区
|
||||
@ -60,6 +68,14 @@ type Session struct {
|
||||
// 所以在写信时给、在对话页里随时调。
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
UsedRounds int `json:"used_rounds"`
|
||||
|
||||
// PermissionMode 声明本任务允许 Agent 动手到什么程度:
|
||||
// plan / workspace / full。空值按 DefaultPermissionMode 处理。
|
||||
PermissionMode string `json:"permission_mode"`
|
||||
|
||||
// PermissionEnforcement 记录接收平台是否真正强制了权限档位:
|
||||
// native / advisory。它描述执行事实,不与 PermissionMode 混为一谈。
|
||||
PermissionEnforcement string `json:"permission_enforcement"`
|
||||
}
|
||||
|
||||
// User 是人类用户(多用户账号体系)
|
||||
@ -173,6 +189,25 @@ type Mail struct {
|
||||
// `GET /mail/inbox` 补投 —— 那条路径上没有这个字段的话,补投的邮件会被
|
||||
// 保守当成 Agent 来信,于是人发的那封失去自动回信。
|
||||
FromHuman bool `json:"from_human"`
|
||||
|
||||
// ToHuman 表示收件方是人类用户而不是 Agent(判据与 FromHuman 同源:
|
||||
// to_name 是否存在于 users 表)。
|
||||
//
|
||||
// 前端拼地址时靠它决定「要不要带 path 与会话位」:人只写名字,
|
||||
// Agent 才拼 `name@path.session`。此前靠 `to_workspace` 是否为空的启发式 ——
|
||||
// 但对 Agent 而言 to_workspace 存的是 Agent 名而不是路径(历史遗留),
|
||||
// 那条启发式在「Agent 名恰好为空」时会猜错。显式布尔胜过猜。
|
||||
ToHuman bool `json:"to_human"`
|
||||
|
||||
// PermissionMode / PermissionEnforcement 是所属会话的权限档位与实际强制力。
|
||||
//
|
||||
// **补拉路径必须有它们**(与 FromHuman 同一个理由):SSE 事件里叫
|
||||
// `permission_mode` / `permission_enforcement`,而插件重启后走
|
||||
// `GET /mail/inbox` 补投 —— 那条路径上没有这两个字段的话,补投的邮件
|
||||
// 会拿不到档位,插件只能回落默认档 —— 于是一条 plan 档的任务在重启后
|
||||
// 惄惄变成了 workspace 档。
|
||||
PermissionMode string `json:"permission_mode"`
|
||||
PermissionEnforcement string `json:"permission_enforcement"`
|
||||
}
|
||||
|
||||
// PermissionRequest 是 Agent 向人类发起的权限请求
|
||||
|
||||
142
gateway/internal/models/permission_mode.go
Normal file
142
gateway/internal/models/permission_mode.go
Normal file
@ -0,0 +1,142 @@
|
||||
package models
|
||||
|
||||
// ─── 权限档位 ───
|
||||
//
|
||||
// 三档描述「这条任务允许 Agent 动手到什么程度」。**AgentMail 声明,平台执行,
|
||||
// 插件只做翻译** —— 不能让插件按工具名自己猜着拦,那会同时违反 I-1(平台原生
|
||||
// 信号是唯一真相来源)与 I-4(插件只搬运不决策),而且四个插件对「workspace
|
||||
// 到底管什么」必然各猜一套。
|
||||
//
|
||||
// 档位与 DSH 原生的三档沙箱一一对应(read-only / workspace-write /
|
||||
// danger-full-access,见 @deepseek-ai/dsh-sandbox-policy)—— 那不是巧合,
|
||||
// 是同一个问题的同一个答案。
|
||||
const (
|
||||
// ModePlan 只读:查资料、读代码、出方案,一个字都不许写。
|
||||
//
|
||||
// 危险操作**直接拒绝**,不产生权限邮件 —— plan 档的语义就是「这轮不动手」,
|
||||
// 没什么可问人的。模型该做的是把方案写在回信里。
|
||||
ModePlan = "plan"
|
||||
|
||||
// ModeWorkspace 本目录内可动手,越界要问人。默认档。
|
||||
//
|
||||
// 「本目录」= 会话的 workspace(三维地址的 path 位)。越界的定义是
|
||||
// 写到那个目录之外,或跑一条无法判定影响范围的命令。
|
||||
ModeWorkspace = "workspace"
|
||||
|
||||
// ModeFull 自动放行,不问人。
|
||||
//
|
||||
// 不产生权限邮件:既然已经声明了全权,再问一遍只是噪音。
|
||||
ModeFull = "full"
|
||||
)
|
||||
|
||||
// DefaultPermissionMode 是没有显式指定时的档位。
|
||||
//
|
||||
// 选 workspace 而不是 full:默认值应当是「多数任务够用且出错代价可控」的那一档。
|
||||
// 一个默认全权的系统里,「我忘了收紧」与「我确实需要全权」在数据上无法区分。
|
||||
const DefaultPermissionMode = ModeWorkspace
|
||||
|
||||
// PermissionModes 是全部合法档位,按宽松程度递增排列。
|
||||
//
|
||||
// 顺序有意义:ModeAtMost 靠它做「向更严取整」。
|
||||
var PermissionModes = []string{ModePlan, ModeWorkspace, ModeFull}
|
||||
|
||||
// ValidPermissionMode 判断是不是合法档位。
|
||||
func ValidPermissionMode(m string) bool {
|
||||
for _, v := range PermissionModes {
|
||||
if v == m {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizePermissionMode 把外部输入收敛成合法档位。
|
||||
//
|
||||
// 空串 → 默认档;非法值 → 默认档(**不是** ModeFull)。
|
||||
// 拼错一个档位名不该换来比预期更大的权限。
|
||||
func NormalizePermissionMode(m string) string {
|
||||
if ValidPermissionMode(m) {
|
||||
return m
|
||||
}
|
||||
return DefaultPermissionMode
|
||||
}
|
||||
|
||||
// modeRank 是档位的宽松程度序号,越大越宽松。
|
||||
//
|
||||
// 只接已经归一化过的档位 —— 调用方负责先跑 NormalizePermissionMode。
|
||||
// 让它自己处理非法值会造出两套语义:曾经这里把未知值当 rank 0(plan),
|
||||
// 而 NormalizePermissionMode 把它归到 workspace,于是同一个脏值在不同函数里
|
||||
// 含义不同,ModeAtMost 也因此不可交换(单元测试当场抓到)。
|
||||
func modeRank(m string) int {
|
||||
for i, v := range PermissionModes {
|
||||
if v == m {
|
||||
return i
|
||||
}
|
||||
}
|
||||
// 归一化后不可能走到这里;防御性地返回默认档的序号。
|
||||
return modeRank(DefaultPermissionMode)
|
||||
}
|
||||
|
||||
// ModeAtMost 返回 a 与 b 里更严的那一档。
|
||||
//
|
||||
// 两个用途:
|
||||
// - 子会话继承:Agent 派活时子会话不得比父会话宽松(plan 档派不出 full 档子任务)
|
||||
// - 平台取整:平台表达不出精确档位时向更严的方向取整
|
||||
//
|
||||
// 为什么必须是同一个函数:这两处若各写一遍,早晚有一处会写成「取更宽松」。
|
||||
//
|
||||
// **先归一化再比较**:两个脏值都变成默认档,于是结果与参数顺序无关(可交换),
|
||||
// 也与 NormalizePermissionMode / ModeNeedsHuman 对同一个脏值的理解一致。
|
||||
func ModeAtMost(a, b string) string {
|
||||
na := NormalizePermissionMode(a)
|
||||
nb := NormalizePermissionMode(b)
|
||||
if modeRank(na) <= modeRank(nb) {
|
||||
return na
|
||||
}
|
||||
return nb
|
||||
}
|
||||
|
||||
// ModeNeedsHuman 这一档会不会产生权限邮件(即需不需要人来点头)。
|
||||
//
|
||||
// 只有 workspace 档需要人。这一点直接决定了「找不到人类时怎么办」:
|
||||
// plan 档当场拒绝、full 档自动放行,两者都不问人,所以**只有 workspace 档
|
||||
// 会走到「这条链上有没有人类」这个问题**,找不到就是 409。
|
||||
//
|
||||
// 这也是为什么 permission.go 里那段「退回第一个管理员」的兜底必须删掉:
|
||||
// 它让 409 分支永远不可达(实测:pi 给自己派活跑 bash,权限邮件发给了 jianf),
|
||||
// 而那段 409 的注释本身就在论证兜底是错的 —— 管理员对这条 Agent 链一无所知。
|
||||
func ModeNeedsHuman(m string) bool {
|
||||
return NormalizePermissionMode(m) == ModeWorkspace
|
||||
}
|
||||
|
||||
// ─── 强制力 ───
|
||||
//
|
||||
// 档位是「要求什么」,强制力是「平台实际做到了什么」。两者必须分开记录并且
|
||||
// 都对人可见(I-5:失败必须可见)—— 否则发件人以为 plan 档管住了 homeagent,
|
||||
// 而 homeagent 的核心根本没有工具调用拦截点。
|
||||
const (
|
||||
// EnforcementNative 平台有原生拦截点,档位被真正执行。
|
||||
EnforcementNative = "native"
|
||||
|
||||
// EnforcementAdvisory 平台没有拦截点,档位只写进提示词。
|
||||
//
|
||||
// 模型至少知道「这活只让你看不让你动」,但没有任何机制阻止它动手。
|
||||
// 这不是缺陷掩饰 —— 是把「做不到」如实标出来,让发件人自己决定要不要派。
|
||||
EnforcementAdvisory = "advisory"
|
||||
)
|
||||
|
||||
// ValidEnforcement 判断强制力取值是否合法。
|
||||
func ValidEnforcement(e string) bool {
|
||||
return e == EnforcementNative || e == EnforcementAdvisory
|
||||
}
|
||||
|
||||
// NormalizeEnforcement 收敛强制力取值。
|
||||
//
|
||||
// 空串或非法值 → advisory。**保守方向是 advisory 而不是 native**:
|
||||
// 没自报过的插件,我们不能替它宣称「档位在这里是被强制的」。
|
||||
func NormalizeEnforcement(e string) string {
|
||||
if ValidEnforcement(e) {
|
||||
return e
|
||||
}
|
||||
return EnforcementAdvisory
|
||||
}
|
||||
160
gateway/internal/models/permission_mode_test.go
Normal file
160
gateway/internal/models/permission_mode_test.go
Normal file
@ -0,0 +1,160 @@
|
||||
package models
|
||||
|
||||
// 权限档位的判据测试。
|
||||
//
|
||||
// 为什么值得单独一组测试:`ModeAtMost` 被两处调用(子会话继承 / 平台向更严取整),
|
||||
// 两处若各写一遍必有一处写成「取更宽松」。而 `NormalizePermissionMode` 的保守
|
||||
// 取向(非法值 → workspace 而非 full)是安全属性,拼错一个档位名不该换来更大权限。
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidPermissionMode(t *testing.T) {
|
||||
for _, m := range []string{ModePlan, ModeWorkspace, ModeFull} {
|
||||
if !ValidPermissionMode(m) {
|
||||
t.Fatalf("%q 应当合法", m)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"", "PLAN", "readonly", "danger-full-access", "workspace-write"} {
|
||||
if ValidPermissionMode(m) {
|
||||
t.Fatalf("%q 不该合法", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非法值必须落到 workspace,不能落到 full。
|
||||
// 拼错一个档位名换来全权是最不该有的失败方向。
|
||||
func TestNormalizePermissionMode_FailsClosed(t *testing.T) {
|
||||
for _, in := range []string{"", "full-access", "plan ", "FULL", "无", "workspace-write"} {
|
||||
got := NormalizePermissionMode(in)
|
||||
if got != DefaultPermissionMode {
|
||||
t.Fatalf("NormalizePermissionMode(%q) = %q,应当是默认档 %q", in, got, DefaultPermissionMode)
|
||||
}
|
||||
}
|
||||
if DefaultPermissionMode == ModeFull {
|
||||
t.Fatal("默认档不能是 full —— 「我忘了收紧」与「我确实需要全权」会无法区分")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePermissionMode_KeepsValid(t *testing.T) {
|
||||
for _, m := range []string{ModePlan, ModeWorkspace, ModeFull} {
|
||||
if got := NormalizePermissionMode(m); got != m {
|
||||
t.Fatalf("合法档位应原样返回:%q → %q", m, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ModeAtMost 取更严的一档 —— 子会话继承与平台取整共用这一个判据。
|
||||
func TestModeAtMost(t *testing.T) {
|
||||
cases := []struct{ a, b, want string }{
|
||||
{ModePlan, ModeFull, ModePlan},
|
||||
{ModeFull, ModePlan, ModePlan},
|
||||
{ModeWorkspace, ModeFull, ModeWorkspace},
|
||||
{ModeFull, ModeWorkspace, ModeWorkspace},
|
||||
{ModePlan, ModeWorkspace, ModePlan},
|
||||
{ModeWorkspace, ModePlan, ModePlan},
|
||||
{ModeFull, ModeFull, ModeFull},
|
||||
{ModePlan, ModePlan, ModePlan},
|
||||
{ModeWorkspace, ModeWorkspace, ModeWorkspace},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ModeAtMost(c.a, c.b); got != c.want {
|
||||
t.Fatalf("ModeAtMost(%q,%q) = %q,want %q", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 未知值归到默认档(workspace),而不是最严的 plan。
|
||||
//
|
||||
// 为什么不是 plan:脏数据的含义应该在整个包里只有一个 ——
|
||||
// NormalizePermissionMode / ModeNeedsHuman 都把它当默认档,ModeAtMost
|
||||
// 若单独把它当 plan,同一个脏值就有两种语义,且 ModeAtMost 不可交换
|
||||
// (单元测试当场抓到过)。一致比“局部更严”重要:默认档本身已经是安全的。
|
||||
func TestModeAtMost_UnknownFallsToDefault(t *testing.T) {
|
||||
if got := ModeAtMost("garbage", ModeFull); got != DefaultPermissionMode {
|
||||
t.Fatalf("未知档位应归默认档,得到 %q", got)
|
||||
}
|
||||
if got := ModeAtMost(ModeFull, "garbage"); got != DefaultPermissionMode {
|
||||
t.Fatalf("未知档位应归默认档,得到 %q", got)
|
||||
}
|
||||
// 脏值不得抬升权限:与 plan 相遇时仍然是 plan 胜出。
|
||||
if got := ModeAtMost("garbage", ModePlan); got != ModePlan {
|
||||
t.Fatalf("脏值不该把 plan 抬成更宽松的档,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ModeAtMost 必须可交换:两处调用点传参顺序不同,结果不能不同。
|
||||
func TestModeAtMost_Commutative(t *testing.T) {
|
||||
all := append([]string{"garbage", ""}, PermissionModes...)
|
||||
for _, a := range all {
|
||||
for _, b := range all {
|
||||
if ModeAtMost(a, b) != ModeAtMost(b, a) {
|
||||
t.Fatalf("ModeAtMost 不可交换:(%q,%q)=%q 但 (%q,%q)=%q",
|
||||
a, b, ModeAtMost(a, b), b, a, ModeAtMost(b, a))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只有 workspace 档需要人 —— 这一条直接决定「找不到人类时怎么办」。
|
||||
//
|
||||
// plan 档当场拒绝、full 档自动放行,两者都不问人,所以只有 workspace 档会
|
||||
// 走到「这条链上有没有人类」这个问题,找不到就是 409。permission.go 里那段
|
||||
// 「退回第一个管理员」的兜底正因此必须删掉:它让 409 分支永远不可达。
|
||||
func TestModeNeedsHuman(t *testing.T) {
|
||||
if ModeNeedsHuman(ModePlan) {
|
||||
t.Fatal("plan 档不该问人:语义就是这轮不动手,直接拒绝即可")
|
||||
}
|
||||
if !ModeNeedsHuman(ModeWorkspace) {
|
||||
t.Fatal("workspace 档必须问人:越界时需要人点头")
|
||||
}
|
||||
if ModeNeedsHuman(ModeFull) {
|
||||
t.Fatal("full 档不该问人:已声明全权,再问一遍只是噪音")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeNeedsHuman_NormalizesInput(t *testing.T) {
|
||||
// 脏数据走默认档(workspace)→ 需要人。宁可多问一次,不可静默放行。
|
||||
if !ModeNeedsHuman("garbage") {
|
||||
t.Fatal("认不出的档位应当按默认档处理,即需要人")
|
||||
}
|
||||
if !ModeNeedsHuman("") {
|
||||
t.Fatal("空档位应当按默认档处理,即需要人")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 强制力 ───
|
||||
|
||||
func TestValidEnforcement(t *testing.T) {
|
||||
if !ValidEnforcement(EnforcementNative) || !ValidEnforcement(EnforcementAdvisory) {
|
||||
t.Fatal("native / advisory 都应合法")
|
||||
}
|
||||
for _, e := range []string{"", "NATIVE", "none", "enforced"} {
|
||||
if ValidEnforcement(e) {
|
||||
t.Fatalf("%q 不该合法", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保守方向是 advisory:没自报过的插件,不能替它宣称档位在那里是被强制的。
|
||||
func TestNormalizeEnforcement_FailsClosed(t *testing.T) {
|
||||
for _, in := range []string{"", "garbage", "NATIVE", "native "} {
|
||||
if got := NormalizeEnforcement(in); got != EnforcementAdvisory {
|
||||
t.Fatalf("NormalizeEnforcement(%q) = %q,应当是 advisory", in, got)
|
||||
}
|
||||
}
|
||||
if got := NormalizeEnforcement(EnforcementNative); got != EnforcementNative {
|
||||
t.Fatalf("显式 native 应原样保留,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// PermissionModes 的顺序是 ModeAtMost 的依据,不能被随手改动。
|
||||
func TestPermissionModesOrder(t *testing.T) {
|
||||
if len(PermissionModes) != 3 {
|
||||
t.Fatalf("档位应当是三个,得到 %d 个", len(PermissionModes))
|
||||
}
|
||||
if PermissionModes[0] != ModePlan ||
|
||||
PermissionModes[1] != ModeWorkspace ||
|
||||
PermissionModes[2] != ModeFull {
|
||||
t.Fatalf("PermissionModes 必须按宽松程度递增排列(plan < workspace < full),得到 %v", PermissionModes)
|
||||
}
|
||||
}
|
||||
@ -121,6 +121,21 @@ func Recipients(ctx context.Context, m Mail) {
|
||||
// 生产实测 pi 与 dsh 互相客套 6 轮直到撞上 hop 上限。
|
||||
fromHuman, _ := repo.IsHumanUser(ctx, m.From)
|
||||
|
||||
// 会话级权限档位与强制力。
|
||||
//
|
||||
// 为什么跑在 payload 外:一封邮件可能推给十几个参与方(收件人 + 拄送),
|
||||
// 而档位是**会话**的属性,每个人都一样 —— 放进闭包里就是每个参与方
|
||||
// 查一次库。platformFor 那个坑(会话级的值推给所有人)教过的是反面:
|
||||
// 会话级与参与方级的字段必须分清楚。档位确实是会话级的。
|
||||
perm, permErr := repo.GetSessionPermission(ctx, m.SessionID)
|
||||
if permErr != nil {
|
||||
// 查不到时给默认档 + advisory:不能因为一次查询失败就让插件以为自己拿到了全权。
|
||||
perm = repo.SessionPermission{
|
||||
Mode: models.DefaultPermissionMode,
|
||||
Enforcement: models.EnforcementAdvisory,
|
||||
}
|
||||
}
|
||||
|
||||
payload := func(role, workspace, forName string) map[string]interface{} {
|
||||
p := map[string]interface{}{
|
||||
"mail_id": m.MailID.String(),
|
||||
@ -160,6 +175,18 @@ func Recipients(ctx context.Context, m Mail) {
|
||||
// 插件据此不再对 Agent → Agent 的信说「回信不用你自己发」:
|
||||
// 那句话在那种情形下是假的,而它让模型以为自己只需要「把话说完」。
|
||||
"from_human": fromHuman,
|
||||
// permission_mode 声明本任务允许动手到什么程度:plan / workspace / full。
|
||||
//
|
||||
// 插件必须把它**翻译成平台原生的沙箱/审批配置**,而不是自己按工具名猜着拦:
|
||||
// 那会同时违反 I-1(平台原生信号是唯一真相来源)与 I-4(插件只搬运不决策),
|
||||
// 而且四个插件对「workspace 到底管什么」必然各猜一套。
|
||||
"permission_mode": perm.Mode,
|
||||
// permission_enforcement 是建会话时快照的**事实**:native / advisory。
|
||||
//
|
||||
// 与 permission_mode 成对下发:前者是要求,后者是对方平台实际做得到。
|
||||
// 只给前者会让人以为 plan 档把 homeagent 管住了 —— 它的核心没有
|
||||
// 工具调用拦截点,档位在那里只能写进提示词。
|
||||
"permission_enforcement": perm.Enforcement,
|
||||
}
|
||||
if m.Origin != "" {
|
||||
p["origin"] = m.Origin
|
||||
|
||||
148
gateway/internal/repo/permission_mode.go
Normal file
148
gateway/internal/repo/permission_mode.go
Normal file
@ -0,0 +1,148 @@
|
||||
package repo
|
||||
|
||||
// 会话级权限档位的读写。
|
||||
//
|
||||
// ## 为什么档位挂在会话上而不是每封邮件上
|
||||
//
|
||||
// 与配额同一个理由(见 quota.go 的注释):档位是**任务**的属性。
|
||||
// 「这件事只许你看不许你动」描述的是任务性质,不是某一封信的性质。
|
||||
//
|
||||
// 如果续谈的邮件也能带档位,每封新信都会悄悄改掉对方正在遵守的规则 ——
|
||||
// 而 plan 档的会话里模型已经被告知「只许看」,第二封信把它改成 full,
|
||||
// 是在一段已有上下文里换规则。人不一定意识到自己改了。
|
||||
//
|
||||
// 所以:**新建会话时设,续谈时忽略该字段,在对话页里显式编辑。**
|
||||
//
|
||||
// ## 为什么 Agent 不能自己指定档位
|
||||
//
|
||||
// 否则 Agent 发一封 mode=full 的信就给自己提权了。Agent 派活时子会话的档位
|
||||
// 由 InheritedMode 从父会话推导,且**只能同档或更严**(models.ModeAtMost)。
|
||||
// 这保证 plan 档的任务派不出 full 档的子任务 —— 与 hop_limit 一个形状。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SessionPermission 是一条会话的档位与实际强制力。
|
||||
//
|
||||
// 两个字段必须一起返回:档位是「要求什么」,强制力是「平台实际做到了什么」。
|
||||
// 只给前者会让人以为 plan 档管住了 homeagent(它的核心没有工具调用拦截点)。
|
||||
type SessionPermission struct {
|
||||
Mode string `json:"permission_mode"`
|
||||
Enforcement string `json:"permission_enforcement"`
|
||||
}
|
||||
|
||||
// GetSessionPermission 读一条会话的档位与强制力。
|
||||
//
|
||||
// 读出来的值一律过 Normalize:库里可能有历史脏数据(手工改库、旧版本写入),
|
||||
// 而调用方拿到一个认不出的档位时的行为无法预期。归一化在这里做一次,
|
||||
// 后续所有判断就都能假定值是合法的。
|
||||
func GetSessionPermission(ctx context.Context, id uuid.UUID) (SessionPermission, error) {
|
||||
var p SessionPermission
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(NULLIF(permission_mode, ''), 'workspace'),
|
||||
COALESCE(NULLIF(permission_enforcement, ''), 'advisory')
|
||||
FROM sessions WHERE session_id = $1`, id).Scan(&p.Mode, &p.Enforcement)
|
||||
if err != nil {
|
||||
return SessionPermission{}, err
|
||||
}
|
||||
p.Mode = models.NormalizePermissionMode(p.Mode)
|
||||
p.Enforcement = models.NormalizeEnforcement(p.Enforcement)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// SessionPermissionMode 只取档位,读不到时回落默认档。
|
||||
//
|
||||
// 供投递路径使用:那里拿不到档位也得继续走(不能因为查询失败就拒收邮件),
|
||||
// 但回落必须是默认档而不是 full —— 查询失败不该换来更大的权限。
|
||||
func SessionPermissionMode(ctx context.Context, id uuid.UUID) string {
|
||||
p, err := GetSessionPermission(ctx, id)
|
||||
if err != nil {
|
||||
return models.DefaultPermissionMode
|
||||
}
|
||||
return p.Mode
|
||||
}
|
||||
|
||||
// SetSessionPermissionMode 设置会话档位。
|
||||
//
|
||||
// 非法档位一律收敛成默认档而不是报错:这个函数的调用方包括人在界面上操作,
|
||||
// 而界面传来一个拼错的值时,静默用默认档比让整次操作失败更合理 ——
|
||||
// 默认档本身是安全的。
|
||||
func SetSessionPermissionMode(ctx context.Context, id uuid.UUID, mode string) (SessionPermission, error) {
|
||||
m := models.NormalizePermissionMode(mode)
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET permission_mode = $2, updated_at = NOW() WHERE session_id = $1`,
|
||||
id, m)
|
||||
if err != nil {
|
||||
return SessionPermission{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return SessionPermission{}, fmt.Errorf("会话 %s 不存在", id)
|
||||
}
|
||||
return GetSessionPermission(ctx, id)
|
||||
}
|
||||
|
||||
// SetSessionEnforcement 记录接收平台实际做到的强制力。
|
||||
//
|
||||
// 由投递路径在建会话时按收件 Agent 的自报能力写入 —— 它是**事实快照**
|
||||
// 而不是配置:插件升级后能力会变,但已结束的会话不该被改写成「其实当时
|
||||
// 是被强制的」。所以不跟着 agents.mode_enforcement 走,而是建会话时定死。
|
||||
func SetSessionEnforcement(ctx context.Context, id uuid.UUID, enforcement string) error {
|
||||
e := models.NormalizeEnforcement(enforcement)
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET permission_enforcement = $2 WHERE session_id = $1`, id, e)
|
||||
return err
|
||||
}
|
||||
|
||||
// AgentModeEnforcement 取某个 Agent 自报的档位强制力。
|
||||
//
|
||||
// Agent 不存在或没自报过时返回 advisory:不能替一个没说过话的插件宣称
|
||||
// 「档位在它那里是被强制的」。保守方向是承认做不到。
|
||||
func AgentModeEnforcement(ctx context.Context, agentName string) string {
|
||||
var e string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(NULLIF(mode_enforcement, ''), 'advisory')
|
||||
FROM agents WHERE agent_name = $1`, agentName).Scan(&e)
|
||||
if err != nil {
|
||||
return models.EnforcementAdvisory
|
||||
}
|
||||
return models.NormalizeEnforcement(e)
|
||||
}
|
||||
|
||||
// SetAgentModeEnforcement 落库 Agent 心跳自报的档位强制力。
|
||||
//
|
||||
// 走心跳而不是注册:注册只在插件启动时发生一次,而能力可能因为配置变化
|
||||
// (比如 DSH 的 sandbox 被换成 danger-full-access)而改变。与模型目录上报
|
||||
// 同一条通道 —— I-1:平台自己说的才算。
|
||||
func SetAgentModeEnforcement(ctx context.Context, agentName, enforcement string) error {
|
||||
e := models.NormalizeEnforcement(enforcement)
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agents SET mode_enforcement = $2 WHERE agent_name = $1`, agentName, e)
|
||||
return err
|
||||
}
|
||||
|
||||
// InheritedMode 推导子会话应当继承的档位。
|
||||
//
|
||||
// parentSessionID 为 nil(人直接发起、或没有父会话可依据)时返回 requested
|
||||
// 归一化后的值;有父会话时取**父档位与请求档位里更严的那一个**。
|
||||
//
|
||||
// 为什么必须取更严:Agent 派活时若能给子会话一个更宽松的档位,plan 档的
|
||||
// 任务就能通过「派给自己一条 full 档子会话」来提权,档位形同虚设。
|
||||
// 这与 hop_limit 防自激的形状一样 —— 约束必须沿着链条传递下去。
|
||||
func InheritedMode(ctx context.Context, parentSessionID *uuid.UUID, requested string) string {
|
||||
req := models.NormalizePermissionMode(requested)
|
||||
if parentSessionID == nil {
|
||||
return req
|
||||
}
|
||||
parent, err := GetSessionPermission(ctx, *parentSessionID)
|
||||
if err != nil {
|
||||
// 父会话查不到时按默认档与请求档取更严 —— 不能因为查询失败而放宽。
|
||||
return models.ModeAtMost(models.DefaultPermissionMode, req)
|
||||
}
|
||||
return models.ModeAtMost(parent.Mode, req)
|
||||
}
|
||||
@ -266,11 +266,14 @@ func GetSessionByID(ctx context.Context, id uuid.UUID) (*models.Session, error)
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT session_id, session_alias, from_agent, subject, status, owner_user_id,
|
||||
created_at, updated_at, rename_dismissed, COALESCE(alias_source, 'platform'),
|
||||
COALESCE(max_rounds, 0), COALESCE(used_rounds, 0)
|
||||
COALESCE(max_rounds, 0), COALESCE(used_rounds, 0),
|
||||
COALESCE(NULLIF(permission_mode, ''), 'workspace'),
|
||||
COALESCE(NULLIF(permission_enforcement, ''), 'advisory')
|
||||
FROM sessions WHERE session_id = $1`, id,
|
||||
).Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status, &s.OwnerUserID,
|
||||
&s.CreatedAt, &s.UpdatedAt, &dismissed, &s.AliasSource,
|
||||
&s.MaxRounds, &s.UsedRounds)
|
||||
&s.MaxRounds, &s.UsedRounds,
|
||||
&s.PermissionMode, &s.PermissionEnforcement)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -299,40 +302,6 @@ func UpdateSessionAlias(ctx context.Context, id uuid.UUID, alias string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func ListSessions(ctx context.Context, statusFilter string, limit int) ([]models.Session, error) {
|
||||
q := `SELECT s.session_id, s.session_alias, s.from_agent, s.subject, s.status,
|
||||
s.owner_user_id, s.created_at, s.updated_at,
|
||||
(SELECT COUNT(*) FROM mails m WHERE m.session_id = s.session_id)
|
||||
FROM sessions s`
|
||||
args := []any{}
|
||||
if statusFilter != "" {
|
||||
q += ` WHERE s.status = $1`
|
||||
args = append(args, statusFilter)
|
||||
}
|
||||
q += ` ORDER BY s.updated_at DESC`
|
||||
if limit > 0 {
|
||||
q += fmt.Sprintf(` LIMIT %d`, limit)
|
||||
}
|
||||
|
||||
rows, err := db.DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sessions := []models.Session{}
|
||||
for rows.Next() {
|
||||
var s models.Session
|
||||
if err := rows.Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status,
|
||||
&s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt,
|
||||
&s.MaxRounds, &s.UsedRounds, &s.MailCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- Mail ----------
|
||||
|
||||
func CreateMail(ctx context.Context, sessionID uuid.UUID, parentMailID *uuid.UUID,
|
||||
@ -393,6 +362,21 @@ func CreateDecisionMail(ctx context.Context, sessionID uuid.UUID, parentMailID u
|
||||
return id, err
|
||||
}
|
||||
|
||||
// DeleteMailByID 删一封邮件。
|
||||
//
|
||||
// **只用于回滚一次刚失败的发信**,不是给人用的「删邮件」功能 ——
|
||||
// 邮件是不可篡改的历史记录,没有任何人面入口能删它。
|
||||
//
|
||||
// 场景:附件挂载在建邮件之后才发现冲突(竞态窗口),此时这封邮件不应存在:
|
||||
// 发件方收到的是 4xx,它会重试,而一封无附件的残余邮件会让收件方收到两封。
|
||||
//
|
||||
// attachments 表的外键是 ON DELETE CASCADE,所以已经挂上去的那几条会跟着消失;
|
||||
// relayed_mails 的 mail_id 无 CASCADE,由调用方用 ReleaseRelay 归还幂等键。
|
||||
func DeleteMailByID(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := db.DB.ExecContext(ctx, `DELETE FROM mails WHERE mail_id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) {
|
||||
var m models.Mail
|
||||
var alias *string
|
||||
@ -402,14 +386,17 @@ func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) {
|
||||
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
|
||||
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
|
||||
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
|
||||
m.status, m.created_at, s.session_alias, s.workspace, m.rename_alias, m.rename_reason
|
||||
m.status, m.created_at, s.session_alias, s.workspace, m.rename_alias, m.rename_reason,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human
|
||||
FROM mails m
|
||||
JOIN sessions s ON m.session_id = s.session_id
|
||||
WHERE m.mail_id = $1`, id,
|
||||
).Scan(&m.ID, &m.SessionID, &m.ParentMailID,
|
||||
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
|
||||
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
|
||||
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &renameAlias, &renameReason)
|
||||
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &renameAlias, &renameReason,
|
||||
&m.FromHuman, &m.ToHuman)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -443,7 +430,9 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode
|
||||
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
|
||||
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
|
||||
m.status, m.created_at, s.session_alias, s.workspace,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
|
||||
COALESCE(NULLIF(s.permission_mode, ''), 'workspace') AS permission_mode,
|
||||
COALESCE(NULLIF(s.permission_enforcement, ''), 'advisory') AS permission_enforcement
|
||||
FROM mails m
|
||||
JOIN sessions s ON m.session_id = s.session_id
|
||||
WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
|
||||
@ -472,7 +461,8 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode
|
||||
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
|
||||
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
|
||||
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
|
||||
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman); err != nil {
|
||||
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman,
|
||||
&m.PermissionMode, &m.PermissionEnforcement); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ccJSON) > 0 {
|
||||
@ -513,7 +503,9 @@ func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, e
|
||||
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
|
||||
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
|
||||
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
|
||||
m.status, m.created_at, s.session_alias, s.workspace
|
||||
m.status, m.created_at, s.session_alias, s.workspace,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human
|
||||
FROM mails m
|
||||
JOIN sessions s ON m.session_id = s.session_id
|
||||
WHERE m.session_id = $1
|
||||
@ -531,7 +523,7 @@ func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, e
|
||||
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
|
||||
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
|
||||
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
|
||||
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace); err != nil {
|
||||
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman, &m.ToHuman); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ccJSON) > 0 {
|
||||
@ -632,13 +624,15 @@ func GetSessionMailByID(ctx context.Context, sessionID, mailID uuid.UUID) (*mode
|
||||
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
|
||||
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
|
||||
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
|
||||
m.status, m.created_at, s.session_alias, s.workspace
|
||||
m.status, m.created_at, s.session_alias, s.workspace,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human
|
||||
FROM mails m JOIN sessions s ON m.session_id = s.session_id
|
||||
WHERE m.session_id = $1 AND m.mail_id = $2`, sessionID, mailID,
|
||||
).Scan(&m.ID, &m.SessionID, &m.ParentMailID,
|
||||
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
|
||||
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
|
||||
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace)
|
||||
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman, &m.ToHuman)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -704,6 +698,18 @@ func FindNamedSessionFor(ctx context.Context, name, path, alias string) (uuid.UU
|
||||
// mails.to_workspace 反推。只看 to_workspace:Agent 回信时 from_workspace 存的是
|
||||
// Agent 名而不是路径,拿它比路径永远匹配不上(旧实现就挂在这里)。
|
||||
func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subject string) (uuid.UUID, error) {
|
||||
id, _, err := FindOrCreateDefaultSessionCreated(ctx, name, path, fromAgent, subject)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// FindOrCreateDefaultSessionCreated 与 FindOrCreateDefaultSession 相同,但额外返回
|
||||
// **这次调用是否真的新建了会话**。
|
||||
//
|
||||
// 为什么需要这个返回值:调用方此前用 `parentMailID == nil` 判断「是不是新建会话」,
|
||||
// 而复用已有默认会话时 parentMailID 也是 nil —— 于是「仅在新建时生效」的字段
|
||||
// (往返预算、权限档位)在每一封省略 session 位的信上都被重写了。
|
||||
// 实测:第一封 max_rounds=7 → 第二封省略该字段 → 预算被静默改成默认的 20。
|
||||
func FindOrCreateDefaultSessionCreated(ctx context.Context, name, path, fromAgent, subject string) (uuid.UUID, bool, error) {
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT s.session_id
|
||||
@ -729,12 +735,13 @@ func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subj
|
||||
`, name, path).Scan(&id)
|
||||
if err == nil {
|
||||
TouchSession(ctx, id)
|
||||
return id, nil
|
||||
return id, false, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return uuid.Nil, err
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
return CreateSession(ctx, nil, fromAgent, subject, path)
|
||||
newID, cErr := CreateSession(ctx, nil, fromAgent, subject, path)
|
||||
return newID, cErr == nil, cErr
|
||||
}
|
||||
|
||||
// SessionAliasOf 返回会话别名,未命名或查询失败时返回空串。
|
||||
@ -889,6 +896,12 @@ type Contact struct {
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
UsedRounds int `json:"used_rounds"`
|
||||
|
||||
// PermissionMode 与 PermissionEnforcement 必须成对出现在列表上:
|
||||
// 前者是「这条任务要求什么」,后者是「对方平台实际做到了什么」。
|
||||
// 只显示前者会让人以为 plan 档把 homeagent 管住了(它没有拦截点)。
|
||||
PermissionMode string `json:"permission_mode"`
|
||||
PermissionEnforcement string `json:"permission_enforcement"`
|
||||
|
||||
// LastFrom/LastPreview 是最后一封邮件的发件人与正文摘要,
|
||||
// 卡片视图用它显示「最新进展」——列表视图只显示地址时,
|
||||
// 人必须逐条点开才知道哪条有新动静。
|
||||
@ -961,6 +974,8 @@ func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Cont
|
||||
s.subject,
|
||||
COALESCE(s.max_rounds, 0),
|
||||
COALESCE(s.used_rounds, 0),
|
||||
COALESCE(NULLIF(s.permission_mode, ''), 'workspace'),
|
||||
COALESCE(NULLIF(s.permission_enforcement, ''), 'advisory'),
|
||||
COALESCE((SELECT from_name FROM mails WHERE mail_id = `+lastMail+`), ''),
|
||||
COALESCE((SELECT body FROM mails WHERE mail_id = `+lastMail+`), '')
|
||||
FROM sessions s`+firstMail+`
|
||||
@ -978,6 +993,7 @@ func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Cont
|
||||
if err := rows.Scan(&c.SessionID, &c.AgentName, &c.Path, &c.SessionAlias,
|
||||
&c.Status, &c.MailCount, &c.UnreadCount, &c.LastActivity,
|
||||
&c.Subject, &c.MaxRounds, &c.UsedRounds,
|
||||
&c.PermissionMode, &c.PermissionEnforcement,
|
||||
&c.LastFrom, &c.LastPreview); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -1141,7 +1157,9 @@ func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail,
|
||||
SELECT m.mail_id, m.session_id, m.parent_mail_id,
|
||||
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
|
||||
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
|
||||
m.status, m.created_at, s.session_alias, s.workspace
|
||||
m.status, m.created_at, s.session_alias, s.workspace,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human
|
||||
FROM mails m
|
||||
JOIN sessions s ON m.session_id = s.session_id
|
||||
WHERE m.from_name = $1 AND s.status <> 'archived'
|
||||
@ -1161,7 +1179,7 @@ func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail,
|
||||
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
|
||||
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
|
||||
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
|
||||
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace); err != nil {
|
||||
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman, &m.ToHuman); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ccJSON) > 0 {
|
||||
@ -1223,6 +1241,8 @@ func ListSessionsFor(ctx context.Context, forUser string, limit int) ([]models.S
|
||||
q := `SELECT s.session_id, s.session_alias, s.from_agent, s.subject, s.status,
|
||||
s.owner_user_id, s.created_at, s.updated_at,
|
||||
COALESCE(s.max_rounds, 0), COALESCE(s.used_rounds, 0),
|
||||
COALESCE(NULLIF(s.permission_mode, ''), 'workspace'),
|
||||
COALESCE(NULLIF(s.permission_enforcement, ''), 'advisory'),
|
||||
(SELECT COUNT(*) FROM mails m WHERE m.session_id = s.session_id)
|
||||
FROM sessions s
|
||||
WHERE s.status <> 'archived'`
|
||||
@ -1255,7 +1275,8 @@ func ListSessionsFor(ctx context.Context, forUser string, limit int) ([]models.S
|
||||
// 结果 /me/sessions 整个 500,联系人栅拉不到任何数据。
|
||||
if err := rows.Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status,
|
||||
&s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt,
|
||||
&s.MaxRounds, &s.UsedRounds, &s.MailCount); err != nil {
|
||||
&s.MaxRounds, &s.UsedRounds,
|
||||
&s.PermissionMode, &s.PermissionEnforcement, &s.MailCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, s)
|
||||
|
||||
@ -260,6 +260,22 @@ func SendCalendarMail(ctx context.Context, eventID, toAddr, subject, body, creat
|
||||
return err
|
||||
}
|
||||
|
||||
// 人建的日程 → 把他设为会话 owner。
|
||||
//
|
||||
// 为什么必须设:权限询问的决策人解析是「会话 owner → 线索里最近的人类 → 409」。
|
||||
// 日历提醒的发件人是 `calendar`(不是人也不是 Agent),所以一旦 Agent 在
|
||||
// 这条会话里要跑需要授权的命令,线索上根本找不到人类 —— 而那个日程
|
||||
// 就是人自己在界面上设的,他当然是合理的决策人。
|
||||
//
|
||||
// 不设的后果(删掉管理员兜底之后暴露):人建的提醒触发后,Agent 的权限询问
|
||||
// 直接得 409「这条链上没有人类」。
|
||||
//
|
||||
// created_by 是 Agent(Agent 自己建的日程)时 owner 保持为空 ——
|
||||
// 那条链上确实没有人类,409 是对的。
|
||||
if u, uErr := repo.GetUserByName(ctx, createdBy); uErr == nil && u != nil {
|
||||
_ = repo.SetSessionOwner(ctx, sessionID, u.ID)
|
||||
}
|
||||
|
||||
// 发件人固定为 calendar:它不是任何一个 Agent,也不是人。
|
||||
// 用创建者的名字会让 Agent 以为人在实时找它,而人此刻可能在睡觉 ——
|
||||
// 模型据此判断「要不要马上追问」,来源写错会让它问一个不在线的人。
|
||||
|
||||
Reference in New Issue
Block a user