feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分
L0 核心: - 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点 - DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields - 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag) - 日历 status 校验(create 补字段 + update 拦非法值) - 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例 A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按 解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了 A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*), SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查, 48h 年龄下限兜上传窗口。已接进每小时 sweep 循环 C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human (EXISTS users 判定),models.Mail 加 ToHuman。前端判据从 workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart 从 session_workspace 取 path(修 dsh@dsh 拼接 bug) 契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/ permission_mode/permission_enforcement),B-5 加 B-5.6 (Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement + unknown_fields,demo 死链修复 + from_human 检查 验收清单加 Agent→Agent 负向对照项
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@ -10,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
@ -25,9 +27,170 @@ func Error(w http.ResponseWriter, status int, msg string) {
|
||||
JSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// Decode 从请求体解析 JSON
|
||||
// Decode 从请求体解析 JSON。**拒绝未知字段。**
|
||||
//
|
||||
// # 为什么必须严格
|
||||
//
|
||||
// 宽容解码把「字段名写错」变成一种**静默成功**:请求返回 200,服务端却什么都
|
||||
// 没收到。生产实测过最坏的一种形状 —— homeagent 插件的 send_mail 传的是
|
||||
// `attachments: [{"attachment_id": …}]`,而服务端要的是 `attachment_ids: ["…"]`:
|
||||
//
|
||||
// $ curl -X POST /mail/send -d '{…,"attachments":[{"attachment_id":"598f100e…"}]}'
|
||||
// HTTP 200 {"mail_id":"2a64fdc8…", …}
|
||||
// $ sqlite3 "SELECT COUNT(*) FROM attachments WHERE mail_id='2a64fdc8…'"
|
||||
// 0
|
||||
//
|
||||
// 邮件发出去了、附件一个都没带、没有任何一层报错。那个 bug 在库里活了很久 ——
|
||||
// **正因为没人会去核对一个返回 200 的请求**。
|
||||
//
|
||||
// 严格解码把它变成一个当场可见的 400。这是 `I-5`(失败必须当场可见)在
|
||||
// 请求解析层的落点:宁可让调用方收到一句「字段 X 不认识」,
|
||||
// 也不要让它以为自己传的东西生效了。
|
||||
//
|
||||
// 需要宽容的地方只有一处(心跳,见 DecodeLenient),且必须显式说明理由。
|
||||
func Decode(r *http.Request, v interface{}) error {
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(v)
|
||||
}
|
||||
|
||||
// DecodeLenient 解析请求体但**容忍未知字段**,同时把认不出的字段名报回来。
|
||||
//
|
||||
// 只给心跳用,理由是那条路径的职责是「我还活着」:插件比服务端新、多带了一个
|
||||
// 服务端还不认识的字段时,代价不该是整个心跳体(含会话快照与模型目录)被丢掉。
|
||||
//
|
||||
// 但**容忍不等于咽下去**。返回的 unknown 列表必须被调用方回报给插件
|
||||
// (心跳响应里的 `unknown_fields`),否则又变成一次静默忽略 —— 那正是
|
||||
// `attachments` vs `attachment_ids` 能拖那么久的原因。
|
||||
//
|
||||
// 实现上要解两遍(宽容一遍取值、严格一遍找未知字段),所以先把 body 读进内存。
|
||||
func DecodeLenient(r *http.Request, v interface{}) (unknown []string, err error) {
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, maxLenientBodyBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 取值这一遍必须宽容:未知字段不能让整个心跳体作废。
|
||||
if uErr := json.Unmarshal(raw, v); uErr != nil {
|
||||
return nil, uErr
|
||||
}
|
||||
|
||||
// 再严格解一遍**只为找出未知字段**。json 每遇到一个未知字段就立即返回,
|
||||
// 所以要循环剥:不循环的话「多带了三个字段」只会报出第一个。
|
||||
probeType := reflect.TypeOf(v)
|
||||
for probeType != nil && probeType.Kind() == reflect.Ptr {
|
||||
probeType = probeType.Elem()
|
||||
}
|
||||
if probeType == nil {
|
||||
return nil, nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < maxUnknownFieldsReported; i++ {
|
||||
probe := reflect.New(probeType).Interface()
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
dErr := dec.Decode(probe)
|
||||
if dErr == nil {
|
||||
break
|
||||
}
|
||||
name := unknownFieldName(dErr)
|
||||
// 不是未知字段错误(宽容那遍已经成功,所以这里本不应出现其他错),
|
||||
// 或者同一个名字又出现一次 —— 都说明剥不下去了,停。
|
||||
if name == "" || seen[name] {
|
||||
break
|
||||
}
|
||||
seen[name] = true
|
||||
unknown = append(unknown, name)
|
||||
stripped, sErr := stripTopLevelKey(raw, name)
|
||||
if sErr != nil {
|
||||
break
|
||||
}
|
||||
raw = stripped
|
||||
}
|
||||
return unknown, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// maxLenientBodyBytes 是心跳体的读取上限。会话快照 200 条 + 模型目录 300 条,
|
||||
// 每条百来字节,2MB 有充足余量;超出的部分被截断后 json 解析会报错,
|
||||
// 那正是我们想要的(一个畸形巨大的心跳体不该被当成有效上报)。
|
||||
maxLenientBodyBytes = 2 << 20
|
||||
// maxUnknownFieldsReported 是回报的未知字段数上限。
|
||||
// 报头几个足够定位问题,无上限循环会让一个塞满垃圾键的请求变成 CPU 消耗。
|
||||
maxUnknownFieldsReported = 8
|
||||
)
|
||||
|
||||
// stripTopLevelKey 从一个 JSON 对象里删掉一个顶层键。
|
||||
//
|
||||
// 只动顶层:未知字段错误报的就是顶层键名。嵌套结构里的未知字段报的名字
|
||||
// 在顶层找不到,这里返回错误,循环随即停下 —— 那个字段仍会被报出来。
|
||||
func stripTopLevelKey(raw []byte, key string) ([]byte, error) {
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := m[key]; !ok {
|
||||
return nil, errors.New("key not at top level")
|
||||
}
|
||||
delete(m, key)
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// unknownFieldName 从 encoding/json 的未知字段错误里取出那个字段名。
|
||||
//
|
||||
// json 包没有为这种错误定义类型(返回的是 *errors.errorString),
|
||||
// 只能按文本匹配 `json: unknown field "xxx"`。
|
||||
// 匹配不上时返回空串,调用方回落到笼统文案。
|
||||
func unknownFieldName(err error) string {
|
||||
const prefix = `json: unknown field "`
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, prefix)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := msg[i+len(prefix):]
|
||||
j := strings.IndexByte(rest, '"')
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
return rest[:j]
|
||||
}
|
||||
|
||||
// jsonFieldNames 反射列出一个请求结构体接受的 JSON 键。
|
||||
//
|
||||
// 用途是把「字段 X 不认识」补成「应为 a / b / c 之一」——
|
||||
// 少了这半句,调用方只知道自己错了,仍要去翻服务端源码才知道对的是什么。
|
||||
// 那正是 `attachments` vs `attachment_ids` 当初拖了那么久的原因。
|
||||
func jsonFieldNames(v interface{}) []string {
|
||||
t := reflect.TypeOf(v)
|
||||
for t != nil && t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t == nil || t.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, t.NumField())
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
if f.PkgPath != "" {
|
||||
continue // 非导出字段不参与 JSON
|
||||
}
|
||||
name := f.Tag.Get("json")
|
||||
if idx := strings.IndexByte(name, ','); idx >= 0 {
|
||||
name = name[:idx]
|
||||
}
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = f.Name
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DecodeBody 解析请求体,失败时直接写 400 并返回 false。
|
||||
@ -38,7 +201,7 @@ func Decode(r *http.Request, v interface{}) error {
|
||||
// 就是那句固定文案,只能靠翻服务端结构体才发现。第三方客户端没有这个条件。
|
||||
func DecodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
|
||||
if err := Decode(r, v); err != nil {
|
||||
Error(w, http.StatusBadRequest, decodeErrMsg(err))
|
||||
Error(w, http.StatusBadRequest, decodeErrMsg(err, v))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@ -48,10 +211,21 @@ func DecodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
|
||||
//
|
||||
// 刻意不回显 json 包的原文:它带 Go 的类型名(如 models.Workspace),
|
||||
// 那是本侧的实现细节,对调用方没有意义,也不该出现在公开 API 的响应里。
|
||||
func decodeErrMsg(err error) string {
|
||||
func decodeErrMsg(err error, target interface{}) string {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return "请求体为空"
|
||||
}
|
||||
|
||||
// 未知字段:把认识的键一并列出来。只说「不认识 x」的话,调用方还得去翻
|
||||
// 服务端源码才知道对的拼法 —— 而拼错字段名恰恰是最容易犯、最难自查的错
|
||||
//(宽容解码时它连报错都没有,见 Decode 的注释)。
|
||||
if bad := unknownFieldName(err); bad != "" {
|
||||
msg := "不认识的字段 \"" + bad + "\""
|
||||
if names := jsonFieldNames(target); len(names) > 0 {
|
||||
msg += ";本端点接受:" + strings.Join(names, " / ")
|
||||
}
|
||||
return msg
|
||||
}
|
||||
// 截断的 JSON 走的不是 SyntaxError 而是 ErrUnexpectedEOF ——
|
||||
// 不单独处理的话会落到最后那句笼统的兜底文案里
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
@ -141,7 +315,7 @@ func writeKeyErr(w http.ResponseWriter, err error) {
|
||||
} else {
|
||||
Error(w, http.StatusInternalServerError, "密钥操作失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateSessionAlias 校验会话别名是否可安全出现在三维地址 name@path.<alias> 的末段。
|
||||
@ -217,3 +391,20 @@ func agentLimiterKey(isAgent bool, actor string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// validPermissionModeInput 校验人显式指定的权限档位。
|
||||
//
|
||||
// 与 repo 层的 Normalize 分工不同:**人显式传了一个认不出的档位时必须报错**,
|
||||
// 不能静默用默认档。他以为自己给了 plan,实际拿到 workspace —— 那是比报错
|
||||
// 更坏的结果(他会以为自己收紧了)。
|
||||
//
|
||||
// 而 repo 层的 Normalize 面向的是「库里的历史脏数据」与「省略该字段」,
|
||||
// 那两种情形下静默回落到默认档才是对的。
|
||||
func validPermissionModeInput(w http.ResponseWriter, mode string) bool {
|
||||
if mode == "" || models.ValidPermissionMode(mode) {
|
||||
return true
|
||||
}
|
||||
Error(w, http.StatusBadRequest,
|
||||
"permission_mode 非法:"+mode+"(应为 plan / workspace / full)")
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user