Files
MailUI4Agents/server/internal/models/models.go
JianFeeeee 153985e8b1 补投路径漏传档位(full 档被误拦)—— 修因 + 兜底,并顺出同族另外四个字段
pi 报告:离线补投的邮件把 full 档会话当 workspace 档申请审批 → 服务端 409 →
桥按「永久失败」当场 block → 这一轮 bash/write/edit 全被拦(SSE 实时送达不受影响)。
jianf 让 pi 把这件转给我,我这边定位后**先跑变异再改**。

## 1 根因:`mailToEvent` 少搬字段(不是服务端不给)

`lib/catchup.js` 的 `mailToEvent()` 只搬了 8 个字段,没有 `permission_mode` /
`permission_enforcement`,于是 worker 的 `msg.data?.permission_mode || 'workspace'`
落到默认档。**pi 以为收件箱行不含档位、于是建议"要么动服务端载荷要么另取一次"——
实测不成立**:服务端一直就给了(`repo.ListInboxScoped` 的 SQL 里有
`JOIN sessions s` + `COALESCE(NULLIF(s.permission_mode,''),'workspace')`,
`models.Mail.PermissionMode` 的注释写明"补拉路径必须有它们")。所以修因只在插件侧:
补上这两个字段,键名与 SSE 逐字一致;缺字段时给空串(**不猜档**,猜宽了就是提权)。
`lib/catchup.js` 在四个桥里**逐字节相同**,一次改动四边同步(改后 md5 仍为一份)。

## 2 兜底:409 带档位时按档位处置

服务端在"档位不该问人"时也回 409,并在回包里带 `permission_mode`。两种 409 的正确反应
**相反**:无人可问 → 拦;**full 档 → 放行**(本档无需审批,拦了就是把能干的活干死)。
`src/worker.mjs` 的 409 分支先认 `permission_mode === MODE_FULL` 放行,
plan 档与"链上没有人类"照旧 fail closed —— 只有服务端明说 full 才放行。

## 3 顺出的同族字段(用"配对"扫出来的,不是猜的)

把四个桥**读投递事件的字段**与 `mailToEvent` 的产出对了一遍,邮件类字段还缺三个:

- `from_human`:dsh 的提示词靠它决定说不说"回信不用你自己发"。缺了它,
  **人发来的信在补投路径上被当成 Agent 来信、失去自动回信**(服务端注释早写明)。
- `in_reply_to`:SSE 那边等于 `ParentMailID`。缺了它,"这封是对我的回复"被当成新派的活,
  两边互相客套到撞 hop 上限(生产实测 6 轮)。行里叫 `parent_mail_id`,**只改名不推算**。
- `session_alias`:缺了它插件只有 session_id,而 `send_mail` 不接受 session_id。

`reply_address` 是**唯一**行里真的没有的字段(SSE 在 notify 里按收件人现算)。
服务端注释明确说"插件不必自己拼(拼错了就是静默开新会话)",所以由服务端补:
`models.Mail.ReplyAddress` + `ListInboxScoped` 填 `FormatAddress(from_name,"",alias)`,
插件只搬运。

## 4 判据(这次事故**单独看任何一个桥的测试都发现不了** —— 缺口在接口上)

- `test/catchup.test.mjs`:补投必须带档位(缺字段给空串而非猜档);
  ★ **四桥配对**:把 dsh/pi/zcode 读的邮件字段与补投产出配对,缺了就红
  (非邮件事件字段走显式 ALLOW 并各写理由,白名单不许膨胀)。这条正是本次缺口的形状。
- `test/permission-mode-409.test.mjs`:409 + full 必须放行且放行分支在 block 之前,
  非 full 仍拦;带**判据自检**(拿掉放行分支后必须判红)。
- `server/internal/repo/session_scope_test.go`:收件箱行带 `permission_mode`(含"没设过
  回落 workspace"的反向对照)与 `reply_address`(与 `FormatAddress` 同形、path 位为空)。

变异验证:mailToEvent 去掉档位 → 2 条红;worker 新读一个补投没产的字段 → 配对判据红**并点名该字段**;
409 分支拿掉 full 放行 → 自检红;SQL 把档位写死成 workspace → Go 判据红。

## 验证

`go test ./...` 全绿(新增 2 条);四个桥套件全绿(pi 439 / dsh 381 / opencode 331 / zcode 385)。
**未部署**:`/opt/agentmail` 与 `sudo ./deploy/install.sh` 都在我的工作区之外(本会话文件策略
workspace-write,放宽需审批而这条链上没有人类),所以修复已进仓但**线上仍是有缺陷的版本** ——
需要有人跑一次 `sudo ./deploy/install.sh`(脚本自己会跑齐各套件)。
2026-09-14 15:49:45 +08:00

392 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package models
import (
"strings"
"time"
"github.com/google/uuid"
)
// Agent 代表一个已注册的 Agent 实例
type Agent struct {
ID uuid.UUID `json:"agent_id"`
Name string `json:"agent_name"`
Secret string `json:"-"`
HostURL string `json:"host_url"`
Workspaces []Workspace `json:"workspaces"`
Platform string `json:"platform"`
Status string `json:"status"`
// DefaultRounds 是派给该 Agent 的新任务默认多少个来回0 = 不限)。
// 真正的额度在每条会话上sessions.max_rounds这里只是默认值。
DefaultRounds int `json:"default_rounds"`
// UsedRounds 是累计发信数,纯统计,不拦请求。
// 它原本是「终身额度」——那种额度跑满要人工重置才能再干活,
// 而 Agent 是长期在线的,所以已降级为观测数据。
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 管理的项目工作区
type Workspace struct {
Name string `json:"name"`
Path string `json:"path"`
}
// Session 是有明确边界的任务会话
type Session struct {
ID uuid.UUID `json:"session_id"`
Alias *string `json:"session_alias"`
FromAgent string `json:"from_agent"`
Subject string `json:"subject"`
Status string `json:"status"`
OwnerUserID *uuid.UUID `json:"owner_user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MailCount int `json:"mail_count,omitempty"`
// RenameDismissed 是用户驳回过的改名提议。
// 记下来才能让提示条不再反复弹同一个建议。
RenameDismissed string `json:"rename_dismissed,omitempty"`
// AliasSource 记录别名是谁定的:
// platform = Agent 平台自动同步来的,后续同步可以覆盖
// manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
// 没有这个区分,平台下一次 session.updated 会把人刚定的名字冲掉。
AliasSource string `json:"alias_source,omitempty"`
// MaxRounds/UsedRounds 是本任务的往返预算0 = 本会话不限)。
// 配额的语义是「这件事值得多少个来回」,那是任务的属性而非 Agent 的属性,
// 所以在写信时给、在对话页里随时调。
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 是人类用户(多用户账号体系)
type User struct {
ID uuid.UUID `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
PasswordHash string `json:"-"`
Role string `json:"role"` // admin / user
Status string `json:"status"` // active / disabled
CreatedAt time.Time `json:"created_at"`
LastLogin *time.Time `json:"last_login"`
// 权限边界:空切片 = 不限
AllowedAgents []string `json:"allowed_agents"`
AllowedPaths []string `json:"allowed_paths"`
}
// IsAdmin 判断是否管理员
func (u User) IsAdmin() bool { return u.Role == "admin" }
// CanUseAgent 判断用户是否可向指定 Agent 发信
// 空白名单(或为空) = 不限;管理员不受限;收件方是人类用户时不走此限制
func (u User) CanUseAgent(agentName string) bool {
if u.IsAdmin() || len(u.AllowedAgents) == 0 {
return true
}
for _, a := range u.AllowedAgents {
if a == agentName {
return true
}
}
return false
}
// CanUsePath 判断用户是否可访问指定工作区。
// 空白名单 = 不限;管理员不受限;空 path人类地址总是允许。
// 匹配规则:完全相等,或白名单项作为目录前缀(/program 允许 /program/sub
func (u User) CanUsePath(path string) bool {
if u.IsAdmin() || len(u.AllowedPaths) == 0 || path == "" {
return true
}
for _, p := range u.AllowedPaths {
if p == "" {
continue
}
if path == p {
return true
}
prefix := p
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
// Mail 是会话中的一封邮件
type Mail struct {
ID uuid.UUID `json:"mail_id"`
SessionID uuid.UUID `json:"session_id"`
ParentMailID *uuid.UUID `json:"parent_mail_id"`
FromName string `json:"from_name"`
FromWorkspace string `json:"from_workspace"`
ToName string `json:"to_name"`
ToWorkspace string `json:"to_workspace"`
CCList []Address `json:"cc_list"`
Subject string `json:"subject"`
Body string `json:"body"`
MailType string `json:"mail_type"`
PermOptions []string `json:"permission_options,omitempty"`
PermResult string `json:"permission_result,omitempty"`
PermissionKind string `json:"permission_kind,omitempty"`
PermissionMulti bool `json:"permission_multi_select,omitempty"`
// PermissionExpiresAt 是这条权限待办的失效时刻(仅仍未决策的 permission_request 有)。
//
// 超过它之后,提出询问的插件很可能已不再阻塞等待(见 models.PermissionWaitWindow
// 刻意只给**时刻**而不给「已失效」布尔值:布尔值是「发出那一刻」的快照,
// 经 SSE 缓存后会永久停在旧值;时刻则任何客户端都能随时比出现在过没过期。
//
// 由读路径按 CreatedAt 推导后填充,**不落库** —— 它是时间的函数,存下来会失真。
PermissionExpiresAt *time.Time `json:"permission_expires_at,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
HopLimit int `json:"hop_limit"`
SessionAlias string `json:"session_alias,omitempty"`
// SessionWorkspace 是**这条会话**的工作目录sessions.workspace
//
// 为什么不能用 FromWorkspace / ToWorkspace 代替:
// - 人 → Agentto_workspace 是真路径from_workspace 为空(人没有工作目录)
// - Agent → 人to_workspace 为空,而 **from_workspace 存的是 Agent 名
// 而不是路径**(历史遗留,见 db/migrate.go 的 sessions.workspace 注释)
//
// 于是「Agent 发来的这封信,那个 Agent 在哪个目录干活」只能从会话上取 ——
// 前端要靠它拼出 `name@path.alias` 这个可投递地址(界面上曾显示成
// `dsh@dsh`,就是拿 from_workspace 当路径拼出来的)。
SessionWorkspace string `json:"session_workspace,omitempty"`
BodyPreview string `json:"body_preview,omitempty"`
// Attachments 仅在读取单封邮件/会话线程时填充;列表接口为省带宽留空
Attachments []Attachment `json:"attachments,omitempty"`
// RenameAlias / RenameReason 是 Agent 在本封正文里提议的新会话别名。
// 存在邮件上而非会话上:邮件是不可篡改的历史记录,
// 「谁在哪一封里提了什么」应当留痕。
RenameAlias string `json:"rename_alias,omitempty"`
RenameReason string `json:"rename_reason,omitempty"`
// FromHuman 表示发件方是人类用户而不是 Agent。
//
// 插件靠它判「要不要自动转发本轮结论」Agent 之间不自动回,
// 否则两边都以为对方的插件会代它开口,持续互相唤醒(生产实测 6 轮)。
//
// **补拉路径必须有它**SSE 事件里叫 `from_human`,而插件重启后走
// `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"`
// ReplyAddress 是「把回信发回这条会话」的现成地址,与 SSE 载荷里的
// `reply_address` 同义同形。
//
// **补拉路径同样必须有它**(第三次同一个理由):插件重启后走
// `GET /mail/inbox` 补投,而 dsh 桥的补投提示词会读 `data.reply_address`
// —— 缺了它,补投进来的那封邮件拿不到回信地址,插件要么拼错(静默开新会话),
// 要么在提示词里留下一句没有地址的"请回信"。
//
// SSE 那边是 `FormatAddress(replyTo, "", alias)`replyTo 取 ReplyToName
// 空则取发件人);行级信息足够算出来:发件人名 + 会话别名都在行上,
// 所以由 ListInboxScoped 直接填(而非让插件自己拼 —— 服务端注释明确说
// 插件不该拼这个地址)。
ReplyAddress string `json:"reply_address,omitempty"`
}
// PermissionRequest 是 Agent 向人类发起的权限请求
type PermissionRequest struct {
ID uuid.UUID `json:"request_id"`
MailID uuid.UUID `json:"mail_id"`
SessionID uuid.UUID `json:"session_id"`
AgentName string `json:"agent_name"`
Question string `json:"question"`
Options []string `json:"options"`
Context string `json:"context"`
Kind string `json:"kind"` // permission | question
MultiSelect bool `json:"multi_select"` // 仅 question 使用
Result *string `json:"result"`
DecidedAt *time.Time `json:"decided_at"`
CreatedAt time.Time `json:"created_at"`
// ExpiresAt 是等待窗口的截止时刻(见 PermissionWaitWindow
// 客户端用它自行判断「这条待办是否可能已经没人等了」,服务端不替它下结论。
ExpiresAt time.Time `json:"expires_at"`
}
// SSE 事件类型
const (
EventNewMail = "new_mail"
EventPermissionDecision = "permission_decision"
EventSessionUpdate = "session_update"
EventAgentOnline = "agent_online"
)
// ---------- 密钥认证 ----------
// 密钥类型:签发时决定其生命周期
const (
// KeyPermanent 永不过期,可重复使用(正式部署的 Agent 用这个)
KeyPermanent = "permanent"
// KeyOneTime 首次验证后即失效(用于把 Agent 首次接入的窗口压到最小)
KeyOneTime = "one_time"
// KeyTimed 到 ExpiresAt 之后失效
KeyTimed = "timed"
)
// ValidKeyType 判断密钥类型是否受支持
func ValidKeyType(t string) bool {
return t == KeyPermanent || t == KeyOneTime || t == KeyTimed
}
// AgentKey 是管理员签发的 Agent 接入密钥。
// AgentName 为空表示「待绑定」——密钥有效但还没指定属于哪个 Agent
// 首次注册时由注册请求里的 name 落定。
type AgentKey struct {
ID uuid.UUID `json:"key_id"`
Token string `json:"key_token,omitempty"` // 仅创建时回显一次
TokenHint string `json:"token_hint"` // 前 8 位 + 省略号,用于列表展示
AgentName *string `json:"agent_name"`
KeyType string `json:"key_type"`
Label string `json:"label"`
ExpiresAt *time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at"`
CreatedBy *uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
// UserKey 是用户自助签发的客户端连接密钥,只能用于 /me/* 人类邮箱接口。
type UserKey struct {
ID uuid.UUID `json:"key_id"`
Token string `json:"key_token,omitempty"` // 仅创建时回显一次
TokenHint string `json:"token_hint"`
UserID uuid.UUID `json:"user_id"`
Label string `json:"label"`
KeyType string `json:"key_type"`
ExpiresAt *time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at"`
CreatedAt time.Time `json:"created_at"`
}
// TokenHint 返回密钥的展示形式:只露前 8 位。
// 密钥全文仅在创建响应里出现一次,之后任何列表接口都只给 hint。
func TokenHint(token string) string {
if len(token) <= 8 {
return token
}
return token[:8] + "…"
}
// ---------- 附件 ----------
// Attachment 是一封邮件的附件元数据。文件内容存磁盘,按 sha256 内容寻址。
//
// MailID 为空表示「已上传、尚未挂到邮件上」:上传与发信是两步操作
// Agent 侧工具走 JSON无法在发信请求里带 multipart中间态必须允许存在。
type Attachment struct {
ID uuid.UUID `json:"attachment_id"`
MailID *uuid.UUID `json:"mail_id"`
Uploader string `json:"uploader"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
SHA256 string `json:"sha256"`
CreatedAt time.Time `json:"created_at"`
}
// Appearance 是**账号级**的用户外观(主题 + 壁纸)。
//
// 背景的取值范围刻意收窄kind 三值、dim/blur 有上限),因为服务端要
// 兜住"客户端被改坏"的情况:这些值最终会写进 CSS 变量,越界值会让界面不可读。
type Appearance struct {
Theme string `json:"theme"` // light / dark / system
BgKind string `json:"bg_kind"` // none / preset / image
BgPresetID string `json:"bg_preset_id"`
BgDim int `json:"bg_dim"` // 遮罩强度 %0-90
BgBlur int `json:"bg_blur"` // 照片模糊 px0-40
ImageSHA256 string `json:"-"`
ImageType string `json:"-"`
ImageBytes int64 `json:"image_bytes"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// DefaultAppearance 是"从没设置过"时的外观 —— 与客户端 backgroundStore /
// themeStore 的默认值一致dim 12、blur 4 是 2026-09-13 壁纸修复后的取值)。
func DefaultAppearance() Appearance {
return Appearance{Theme: "system", BgKind: "none", BgPresetID: "aurora", BgDim: 12, BgBlur: 4}
}
// NormalizeAppearance 把客户端传来的值夹进合法范围。
// 认不出的 kind/theme 一律退回默认 —— 静默接受非法值会让界面白屏且无从排查。
func NormalizeAppearance(a Appearance) Appearance {
switch a.Theme {
case "light", "dark", "system":
default:
a.Theme = "system"
}
switch a.BgKind {
case "none", "preset", "image":
default:
a.BgKind = "none"
}
if a.BgPresetID == "" {
a.BgPresetID = "aurora"
}
if len(a.BgPresetID) > 64 {
a.BgPresetID = a.BgPresetID[:64]
}
if a.BgDim < 0 {
a.BgDim = 0
}
if a.BgDim > 90 {
a.BgDim = 90
}
if a.BgBlur < 0 {
a.BgBlur = 0
}
if a.BgBlur > 40 {
a.BgBlur = 40
}
return a
}