diff --git a/server/internal/blob/store_test.go b/server/internal/blob/store_test.go index 4c50d47..a328bb5 100644 --- a/server/internal/blob/store_test.go +++ b/server/internal/blob/store_test.go @@ -117,7 +117,7 @@ func TestPathTraversalRejected(t *testing.T) { "../../etc/passwd", "..", "/etc/passwd", - "ABCDEF", // 大写非法 + "ABCDEF", // 大写非法 strings.Repeat("g", 64), // 非十六进制 strings.Repeat("a", 63), // 长度不足 "", diff --git a/server/internal/db/migrations/init.sql b/server/internal/db/migrations/init.sql index 43d4c97..edce797 100644 --- a/server/internal/db/migrations/init.sql +++ b/server/internal/db/migrations/init.sql @@ -83,6 +83,9 @@ DROP INDEX IF EXISTS idx_sessions_alias_uniq; -- 已存在的库补列(必须先于依赖该列的索引) ALTER TABLE sessions ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(user_id); +-- 会话级线索树的父指针(2026-10-04,见 migrate.go sqliteAddColumns 处的说明)。 +-- 默认空 = 根节点;不据历史数据猜父子(猜错会让线索拓扑失真)。 +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS parent_session_id TEXT REFERENCES sessions(session_id); -- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。 ALTER TABLE sessions ADD COLUMN IF NOT EXISTS rename_dismissed TEXT; diff --git a/server/internal/handler/events.go b/server/internal/handler/events.go index 88058fd..0dc55a2 100644 --- a/server/internal/handler/events.go +++ b/server/internal/handler/events.go @@ -11,10 +11,11 @@ import ( // GET /api/v1/events/stream // // 四种凭证,都必须真正验证过身份才能订阅: -// Authorization: Bearer → Agent 通道(密钥认证) -// X-Agent-Name + X-Agent-Secret → Agent 通道(旧方式,兼容) -// 登录 Cookie 或 Bearer → 人类用户通道 -// ?access_token= → 浏览器 EventSource 专用回退 +// +// Authorization: Bearer → Agent 通道(密钥认证) +// X-Agent-Name + X-Agent-Secret → Agent 通道(旧方式,兼容) +// 登录 Cookie 或 Bearer → 人类用户通道 +// ?access_token= → 浏览器 EventSource 专用回退 // // 注意不能只凭 X-Agent-Name 就分流:那等于任何人报个名字就能读走别人的新邮件通知。 // query 令牌仅本端点接受(EventSource 无法带自定义头),其余接口一律要求请求头, diff --git a/server/internal/handler/forward.go b/server/internal/handler/forward.go index e845f7d..7af4b36 100644 --- a/server/internal/handler/forward.go +++ b/server/internal/handler/forward.go @@ -60,17 +60,17 @@ func quoteBody(m *models.Mail, sessionWorkspace, sessionAlias string) string { return b.String() } - // senderAddress 拼出转发引用里那个「转发自」地址。 // // ★ 2026-09-15 用户报:「这个 zcode@zcode 转发生成的地址绝对有问题,原本是 zcode@/home…」 // // 两个原因叠在一起: -// ① **发信路径给 Agent 存 from_workspace 存的是 agent 名**,不是路径 -// (实测:zcode 近两天 10 封全是 'zcode',homeagent 'homeagent',opencode 'opencode'; -// pi/dsh 是混的:既有真实路径也有自己的名字)—— 这是历史遗留的写入侧问题; -// ② 引用块又无脑拼 `name@from_workspace` ⇒ 两者一乘就是 `zcode@zcode`, -// 而**那永远不是一个地址**(地址是 name@path.session)。 +// +// ① **发信路径给 Agent 存 from_workspace 存的是 agent 名**,不是路径 +// (实测:zcode 近两天 10 封全是 'zcode',homeagent 'homeagent',opencode 'opencode'; +// pi/dsh 是混的:既有真实路径也有自己的名字)—— 这是历史遗留的写入侧问题; +// ② 引用块又无脑拼 `name@from_workspace` ⇒ 两者一乘就是 `zcode@zcode`, +// 而**那永远不是一个地址**(地址是 name@path.session)。 // // 这里改成用**会话**的真实 workspace/alias 拼(`SessionWorkspaceOf`/`SessionAliasOf`, // 读不到就退化成信上记的值),并且**一旦发现 path 等于 name 就当作"不知道"丢掉** —— diff --git a/server/internal/handler/forward_test.go b/server/internal/handler/forward_test.go index 7a9f3ca..109a7ee 100644 --- a/server/internal/handler/forward_test.go +++ b/server/internal/handler/forward_test.go @@ -84,8 +84,9 @@ func TestQuoteBodyOmitsEmptyCC(t *testing.T) { // 库里存 UTC、界面各处都转本地,只有 quoteBody 漏了 .Local()。 // // ★ 这条判据在 **TZ=UTC 的机器上是空转的**(本地 == UTC,两种写法同结果)—— -// 本机是 UTC+8,所以这里真会红。CI 若在 UTC 下跑,它是"只能验形态"的那一类, -// 形态判据另有一条:不得与 UTC 原样相等(当两者不同时)。 +// +// 本机是 UTC+8,所以这里真会红。CI 若在 UTC 下跑,它是"只能验形态"的那一类, +// 形态判据另有一条:不得与 UTC 原样相等(当两者不同时)。 func TestQuoteBodyTimeIsLocal(t *testing.T) { // 取一个"本地与 UTC 不同"的瞬间来测(否则本判据无从区分) m := &models.Mail{ @@ -109,7 +110,8 @@ func TestQuoteBodyTimeIsLocal(t *testing.T) { // 成因:发信路径给 Agent 存的 from_workspace 是 **agent 名**(zcode/homeagent/opencode 全如此), // 而引用块按 `name@from_workspace` 拼 ⇒ zcode@zcode。 // 判据钉两条:① 用**会话**的 workspace/alias 拼出 `name@path.session`; -// ② path 与 name 相同时也**不许**渲染成 name@name(宁可只写名字)。 +// +// ② path 与 name 相同时也**不许**渲染成 name@name(宁可只写名字)。 func TestQuoteBodySenderAddressNeverNameAtName(t *testing.T) { legacy := &models.Mail{FromName: "zcode", FromWorkspace: "zcode", Subject: "打个招呼", CreatedAt: time.Now()} got := quoteBody(legacy, "/home", "zcode-打个招呼") diff --git a/server/internal/handler/keys.go b/server/internal/handler/keys.go index 5ffdcf9..f08fc7d 100644 --- a/server/internal/handler/keys.go +++ b/server/internal/handler/keys.go @@ -173,4 +173,3 @@ func DeleteMyKey(w http.ResponseWriter, r *http.Request) { } JSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } - diff --git a/server/internal/handler/lunar.go b/server/internal/handler/lunar.go index ecb0b1d..060b7eb 100644 --- a/server/internal/handler/lunar.go +++ b/server/internal/handler/lunar.go @@ -84,9 +84,10 @@ var lunarMonthNames = []string{ // lunarDayNames 农历日名查表。 // // ★ **不用正则从 `Date.String()` 里截**:实测日名前缀有五种形态 -// (初一/十一/二十/廿一/三十),写一个覆盖全部的正则既难读又容易漏 —— -// WebUI 那版就漏了「二十」,20 号会退化成显示整串「七月二十」。 -// 这个表与 `client/electron/src/lib/lunar.ts` 的 `LUNAR_DAY_NAMES` 逐字一致。 +// +// (初一/十一/二十/廿一/三十),写一个覆盖全部的正则既难读又容易漏 —— +// WebUI 那版就漏了「二十」,20 号会退化成显示整串「七月二十」。 +// 这个表与 `client/electron/src/lib/lunar.ts` 的 `LUNAR_DAY_NAMES` 逐字一致。 var lunarDayNames = []string{ "", "初一", "初二", "初三", "初四", "初五", "初六", "初七", "初八", "初九", "初十", "十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十", @@ -96,8 +97,9 @@ var lunarDayNames = []string{ // labelsOf 把一个公历日期转成农历标签。 // // ★ 越界/异常一律回 *零值+false*,**不猜**:农历换算是天文算法, -// 猜一个日子比不显示更糟("看着有农历、其实是错的"最难发现)。 -// `lunar.FromSolar` 内部对极端年份会 panic 保护,这里再兜一层。 +// +// 猜一个日子比不显示更糟("看着有农历、其实是错的"最难发现)。 +// `lunar.FromSolar` 内部对极端年份会 panic 保护,这里再兜一层。 func labelsOf(t time.Time) (LunarLabels, bool) { var d lunar.Date ok := func() (good bool) { diff --git a/server/internal/handler/lunar_test.go b/server/internal/handler/lunar_test.go index cc4b426..d3bfcb0 100644 --- a/server/internal/handler/lunar_test.go +++ b/server/internal/handler/lunar_test.go @@ -56,7 +56,10 @@ func TestLunarDayNamesCoverAllPrefixForms(t *testing.T) { t.Fatalf("日名表要有 31 项(下标 0 占位 + 1..30),实际 %d", len(lunarDayNames)) } // 五种前缀形态各至少要有一个代表 —— 少任何一种就是漏了一类日子 - for _, pair := range []struct{ day int; want string }{ + for _, pair := range []struct { + day int + want string + }{ {1, "初一"}, // 初 {11, "十一"}, // 十 {20, "二十"}, // 二(这一条是 WebUI 漏过的那个) @@ -137,15 +140,16 @@ func TestLunarLeapMonthIsMarked(t *testing.T) { // 越界/极端值**不许 panic**(这是 recover 那层的实质),且真换不出来时必须 ok=false 而不是给个假日子。 // // ★ 这条我第一版写错了:我断言「time.Time{} 应当换不出来」,实测库**换得出来** -// (0001-01-01 → 「〇年冬月十八」)—— 断言的是我的**想象**而不是实际行为。 -// 现在改成断言真正的契约:① 不 panic;② 换不出来就不给(不猜); -// ③ **给出来的时候文字里不许出现“无效/NaN”这类失败标记**。 +// +// (0001-01-01 → 「〇年冬月十八」)—— 断言的是我的**想象**而不是实际行为。 +// 现在改成断言真正的契约:① 不 panic;② 换不出来就不给(不猜); +// ③ **给出来的时候文字里不许出现“无效/NaN”这类失败标记**。 func TestLunarLabelsRefuseRatherThanGuess(t *testing.T) { cases := []time.Time{ - time.Time{}, // 零值(公元 1 年) - time.Date(1900, 6, 15, 0, 0, 0, 0, time.UTC), // 库的传统下限附近 - time.Date(2100, 6, 15, 0, 0, 0, 0, time.UTC), // 上限 - time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC), // 极端上限 + time.Time{}, // 零值(公元 1 年) + time.Date(1900, 6, 15, 0, 0, 0, 0, time.UTC), // 库的传统下限附近 + time.Date(2100, 6, 15, 0, 0, 0, 0, time.UTC), // 上限 + time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC), // 极端上限 } for _, d := range cases { got, ok := func() (l LunarLabels, ok bool) { diff --git a/server/internal/handler/push.go b/server/internal/handler/push.go index 6117c7c..59fedca 100644 --- a/server/internal/handler/push.go +++ b/server/internal/handler/push.go @@ -162,8 +162,8 @@ func ListPushTokens(w http.ResponseWriter, r *http.Request) { items := make([]map[string]interface{}, 0, len(tokens)) for _, t := range tokens { items = append(items, map[string]interface{}{ - "id": t.TokenID, - "provider": t.Provider, + "id": t.TokenID, + "provider": t.Provider, // token 只回尾 6 位:客户端不需要全文(它自己刚发过来的), // 而一个能读到全文的接口等于多一处泄漏面。 "token_tail": tailOf(t.Token, 6), diff --git a/server/internal/handler/push_test.go b/server/internal/handler/push_test.go index 664015c..41ea6f0 100644 --- a/server/internal/handler/push_test.go +++ b/server/internal/handler/push_test.go @@ -198,9 +198,10 @@ func TestPushTokenEndpointInputValidation(t *testing.T) { 但这个字段存在的唯一目的就是「点通知回到那条会话」—— 存错值 = 将来路由到错会话。 所以这里钉三条: - ① 非空且不是 UUID ⇒ **400**(把"静默存错值"换成"这次静默不上报"); - ② 空串仍然合法(契约:客户端还没进任何会话); - ③ 合法 UUID ⇒ 照存(登记成功)。 + + ① 非空且不是 UUID ⇒ **400**(把"静默存错值"换成"这次静默不上报"); + ② 空串仍然合法(契约:客户端还没进任何会话); + ③ 合法 UUID ⇒ 照存(登记成功)。 */ func TestPushTokenSessionIDMustBeUUID(t *testing.T) { setupPushHandlerDB(t) diff --git a/server/internal/handler/ratelimit_test.go b/server/internal/handler/ratelimit_test.go new file mode 100644 index 0000000..73e3b2d --- /dev/null +++ b/server/internal/handler/ratelimit_test.go @@ -0,0 +1,162 @@ +package handler + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/agentmail/gateway/internal/db" +) + +/* +登录失败限速 —— 2026-09-28。 + +⚠ 写这类测试的两个坑(都不是这文件要测的东西,但会把你引到错误的结论上): + + 1. **别在同一条 SQL 里混用 `?` 和 `$N`**。SQLite(现代驱动)把两者 + 统一按 1-based 索引解释,`?` 和 `$1` 绑的是**同一个**参数。于是 + `UPDATE ... WHERE bucket = $1` 配一个 `?` 形状的值时不会报错, + 静默更新 0 行 —— 看起来像「限速器删不掉旧记录」,其实是参数绑错了。 + 统一用 `$N`(跟生产代码一致),别用 `?`。 + 2. **想推进时间就整桶一起改**。`Locked()` 读的是 COUNT 和 MIN(ts) + 两个量:只回拨其中一行,剩下的行会把 count 顶回阈值以上 + (`UPDATE ... LIMIT 1` 就会这样)。要么整桶设成同一时间戳 + (本文件就是这么做的),要么连 WHERE 一起改。 + +这个文件存在的原因不是「补覆盖率」,是**它本来就不存在**: +handler 包里一条登录限速测试都没有,于是下面这个缺陷一直红着没人发现 —— +`Locked()` 从 2025 年写出来就没在 SQLite 下真正返回过 true。 + +缺陷本体:算解锁时刻用的 `SELECT MIN(ts)`,SQLite 驱动把聚合结果当 +string 返回,扫进 *time.Time 直接失败;代码里 `err != nil` 走 +`return false, 0`。后果不是「锁了但没到期」,是**压根没进判定**: +`auth.go:122` 在校验密码之前调 Locked,于是失败多少次都返回 false, +无限次撞库不会 429。代码注释里那套「用 DB 而非内存计数器、多实例共享 +同一份计数」的设计意图,在默认的 SQLite 部署下没兑现。 + +所以这里的判据刻意写成**端到端且用真实 SQLite**: +不是断言「Locked() 返回 true」(那可以靠 mock 骗过), +而是「连打 N 次失败登录 ⇒ HTTP 401,**再打一次必须是 429**」。 +这条判据红 ⇒ 登录限速没生效;绿 ⇒ 它生效。中间任何一层坏了都红。 +*/ + +func setupLoginLimiterDB(t *testing.T) { + t.Helper() + dir := t.TempDir() + if err := db.Connect(context.Background(), "sqlite://"+filepath.Join(dir, "t.db")); err != nil { + t.Fatalf("connect: %v", err) + } + if err := db.Migrate(context.Background()); err != nil { + t.Fatalf("migrate: %v", err) + } + t.Cleanup(db.Close) +} + +// TestLoginLimiterLocksAfterMaxFailures 是本文件的主判据: +// 真实 SQLite 上记满 maxLoginFailures 次失败后,Locked 必须为 true。 +// +// 修复前:count=5 但 MIN(ts) 扫描失败 → (false, 0)。 +// 修复后:走 ORDER BY ts ASC LIMIT 1 → (true, ≈lockoutDuration)。 +func TestLoginLimiterLocksAfterMaxFailures(t *testing.T) { + setupLoginLimiterDB(t) + ctx := context.Background() + const name = "victim" + + for i := 0; i < maxLoginFailures-1; i++ { + if locked, _ := limiter.Locked(ctx, name); locked { + t.Fatalf("第 %d 次之前就锁了,未到阈值", i) + } + limiter.Fail(ctx, name) + } + + // 最后一次:记完正好等于阈值 + limiter.Fail(ctx, name) + + locked, remain := limiter.Locked(ctx, name) + if !locked { + t.Fatalf("记满 %d 次失败后仍未锁定 —— 登录失败限速未生效(无限撞库不会 429)", + maxLoginFailures) + } + // 剩余秒数应接近整个 lockoutDuration(最早的失败就在几毫秒前) + if remain < int(lockoutDuration.Seconds())-2 || remain > int(lockoutDuration.Seconds())+1 { + t.Errorf("剩余锁定秒数 = %d,期望约 %d", remain, int(lockoutDuration.Seconds())) + } +} + +// TestLoginLimiterUnlocksAfterDuration 钉住另一个方向:锁期真的会过。 +// 只修「不锁」而不修「永锁」同样是坏的 —— 那会把账号永久焊死。 +func TestLoginLimiterUnlocksAfterDuration(t *testing.T) { + setupLoginLimiterDB(t) + ctx := context.Background() + const name = "victim" + + for i := 0; i < maxLoginFailures; i++ { + limiter.Fail(ctx, name) + } + if locked, _ := limiter.Locked(ctx, name); !locked { + t.Fatalf("记满 %d 次失败后未锁定", maxLoginFailures) + } + + // 把整桶推到 lockoutDuration 之前,等价于「时间过去了」。 + // 整桶设成同一时间戳:count 和最早时间才指向同一刻(见文件头第 2 条)。 + past := time.Now().Add(-lockoutDuration - time.Second) + if _, err := db.DB.ExecContext(ctx, + `UPDATE rate_limits SET ts = $1 WHERE bucket = $2`, + past, "login:"+name); err != nil { + t.Fatalf("回拨时间戳: %v", err) + } + // 确认回拨真的命中了(第一条坑:静默 0 行会伪装成「限速器有 bug」) + var n int + if err := db.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM rate_limits WHERE bucket = $1 AND ts >= $2`, + "login:"+name, past).Scan(&n); err != nil { + t.Fatalf("核对回拨: %v", err) + } + if n != maxLoginFailures { + t.Fatalf("回拨后只匹配到 %d/%d 行 —— UPDATE 没落到预期的行上", n, maxLoginFailures) + } + if locked, remain := limiter.Locked(ctx, name); locked { + t.Errorf("锁期已过仍判定为锁定(remain=%d),会把账号永久焊死", remain) + } +} + +// TestLoginLimiterWindowExcludesOldFailures 钉住窗口语义: +// 15 分钟窗口**之前**的失败不该计入。少了这条,一次陈旧的失败堆积 +// 就能把一个从没被攻击过的账号锁住。 +func TestLoginLimiterWindowExcludesOldFailures(t *testing.T) { + setupLoginLimiterDB(t) + ctx := context.Background() + const name = "victim" + + // 全部失败都在窗口之前 + for i := 0; i < maxLoginFailures+3; i++ { + if _, err := db.DB.ExecContext(ctx, + `INSERT INTO rate_limits (bucket, ts) VALUES ($1, $2)`, + "login:"+name, time.Now().Add(-failureWindow-time.Duration(i+1)*time.Minute)); err != nil { + t.Fatalf("seed: %v", err) + } + } + if locked, remain := limiter.Locked(ctx, name); locked { + t.Errorf("窗口外的历史失败不该触发锁定,但 locked=true remain=%d", remain) + } +} + +// TestLoginLimiterResetClearsBucket 是登录成功后的清桶路径。 +// 没有它,登录成功一次之后仍会被先前的失败锁住。 +func TestLoginLimiterResetClearsBucket(t *testing.T) { + setupLoginLimiterDB(t) + ctx := context.Background() + const name = "victim" + + for i := 0; i < maxLoginFailures; i++ { + limiter.Fail(ctx, name) + } + if locked, _ := limiter.Locked(ctx, name); !locked { + t.Fatalf("记满 %d 次失败后未锁定", maxLoginFailures) + } + limiter.Reset(ctx, name) + if locked, _ := limiter.Locked(ctx, name); locked { + t.Error("Reset 之后仍处于锁定") + } +} diff --git a/server/internal/handler/rename_proposal.go b/server/internal/handler/rename_proposal.go index cf9d695..fb5d84e 100644 --- a/server/internal/handler/rename_proposal.go +++ b/server/internal/handler/rename_proposal.go @@ -104,11 +104,11 @@ func preview(s string, max int) string { // 白名单而不是任意字符串:免配额通道必须有明确边界, // 否则 `relay: "whatever"` 就成了绕过配额的后门。 // -// permission —— 平台原生的权限询问(opencode 的 permission.updated)。 -// 不转给人,人就看不到,Agent 卡在那里等一个永远不会来的回答。 -// summary —— 本轮的最终总结(session.idle 时最后一条 assistant 消息)。 -// 模型已经把话说完了,插件只是搬运;对它收费会导致配额用尽时 -// Agent 连交代都做不了。 +// permission —— 平台原生的权限询问(opencode 的 permission.updated)。 +// 不转给人,人就看不到,Agent 卡在那里等一个永远不会来的回答。 +// summary —— 本轮的最终总结(session.idle 时最后一条 assistant 消息)。 +// 模型已经把话说完了,插件只是搬运;对它收费会导致配额用尽时 +// Agent 连交代都做不了。 var relayKinds = map[string]bool{ "permission": true, "summary": true, diff --git a/server/internal/handler/session_permission_broadcast_test.go b/server/internal/handler/session_permission_broadcast_test.go index 223959c..0a49dfe 100644 --- a/server/internal/handler/session_permission_broadcast_test.go +++ b/server/internal/handler/session_permission_broadcast_test.go @@ -90,12 +90,12 @@ func TestPermissionUpdateBroadcastsEvenWhenValueUnchanged(t *testing.T) { count := func() int { return strings.Count(rec.Body.String(), "session_update") } - call("workspace") // 第一次:真变化 + call("workspace") // 第一次:真变化 afterFirst := count() if afterFirst == 0 { t.Fatalf("第一次改档位没收到 session_update(判据自己没接上:先看 SSE 客户端有没有挂上)") } - call("workspace") // 第二次:**值没变** —— 恢复路径就靠这一下 + call("workspace") // 第二次:**值没变** —— 恢复路径就靠这一下 afterSecond := count() if afterSecond <= afterFirst { t.Fatalf("同一个档位再选一次**没有**再广播(%d → %d)。\n"+ diff --git a/server/internal/handler/thread_test.go b/server/internal/handler/thread_test.go index 6c2cdd9..f7f3059 100644 --- a/server/internal/handler/thread_test.go +++ b/server/internal/handler/thread_test.go @@ -10,11 +10,11 @@ func TestIntQueryClampsAndFallsBack(t *testing.T) { q string want int }{ - {"", 40}, // 缺省 - {"limit=10", 10}, // 正常 - {"limit=0", 1}, // 低于下限 → 夹到下限 + {"", 40}, // 缺省 + {"limit=10", 10}, // 正常 + {"limit=0", 1}, // 低于下限 → 夹到下限 {"limit=999", 200}, // 高于上限 → 夹到上限 - {"limit=abc", 40}, // 非法 → 回落默认值,而不是让整个请求 400 + {"limit=abc", 40}, // 非法 → 回落默认值,而不是让整个请求 400 {"limit=-5", 1}, } for _, c := range cases { diff --git a/server/internal/repo/agentlock.go b/server/internal/repo/agentlock.go index 4ddcf59..d763998 100644 --- a/server/internal/repo/agentlock.go +++ b/server/internal/repo/agentlock.go @@ -136,4 +136,4 @@ func SessionLockTouch(ctx context.Context, sessionID uuid.UUID) (bool, error) { } n, _ := tag.RowsAffected() return n > 0, nil -} \ No newline at end of file +} diff --git a/server/internal/repo/agentlock_test.go b/server/internal/repo/agentlock_test.go index ba88b32..4fb0d5b 100644 --- a/server/internal/repo/agentlock_test.go +++ b/server/internal/repo/agentlock_test.go @@ -219,4 +219,4 @@ func TestSessionLockReadFailureIsNotTreatedAsUnlocked(t *testing.T) { } } -var _ = uuid.New \ No newline at end of file +var _ = uuid.New diff --git a/server/internal/repo/agentloop.go b/server/internal/repo/agentloop.go index 5b3c84a..9bba689 100644 --- a/server/internal/repo/agentloop.go +++ b/server/internal/repo/agentloop.go @@ -15,10 +15,10 @@ import ( 生产上 `pi ↔ dsh` 一天跑了 **175 封**、正文 612KB,其中 **relay = 0** —— 全部是模型**主动**调 send_mail。而两道防线都只覆盖 `relay != ""` 那条路: - ① 会话预算:`relay != ""` 才扣 ⇒ 主动发信不扣(且该会话 max_rounds=0=不限) - ② maxRelayHops=5:`if relay != "" { ... }` ⇒ 根本不进那个分支 - ③ 插件自动转发守卫:日志明说"本轮不自动转发:来信方 dsh 是 Agent" - —— 守卫**工作正常**,但模型自己发的不受它管 + ① 会话预算:`relay != ""` 才扣 ⇒ 主动发信不扣(且该会话 max_rounds=0=不限) + ② maxRelayHops=5:`if relay != "" { ... }` ⇒ 根本不进那个分支 + ③ 插件自动转发守卫:日志明说"本轮不自动转发:来信方 dsh 是 Agent" + —— 守卫**工作正常**,但模型自己发的不受它管 ⇒ 三条全绕开。没有任何一层在数"两个 Agent 在没有人类参与的情况下连续往返了几次"。 @@ -27,8 +27,8 @@ import ( 从最新一封往前扫,**连续**的"发件方是 Agent 且收件方也是 Agent"的邮件计数, 遇到以下任一即停(= 归零): - · 人类发的信(`from_name` 是用户) - · 人类收的信(`to_name` 是用户) + · 人类发的信(`from_name` 是用户) + · 人类收的信(`to_name` 是用户) "连续"与"人类参与即归零"这两点与 maxRelayHops 一致 —— 正常的 「Agent 干完活回一封、人类看一眼再派下一轮」不受影响;只掐住 diff --git a/server/internal/repo/agentloop_test.go b/server/internal/repo/agentloop_test.go index 168ad44..a0af71a 100644 --- a/server/internal/repo/agentloop_test.go +++ b/server/internal/repo/agentloop_test.go @@ -75,8 +75,10 @@ func TestAgentPingPongCountsConsecutive(t *testing.T) { // ★ 核心语义:**人类插一句话,计数归零**。 // // 这一条决定了这个判据会不会误伤正常用法: -// · 人类说一句 → Agent 回十封 → 人类再一句 ⇒ 每次都在阈值以下 -// · 没有人说话 → 一直涨 ⇒ 到阈值拒绝 +// +// · 人类说一句 → Agent 回十封 → 人类再一句 ⇒ 每次都在阈值以下 +// · 没有人说话 → 一直涨 ⇒ 到阈值拒绝 +// // 与 maxRelayHops 的"连续"同构 —— 两条防线在这个语义上必须一致, // 否则同一个现象在两处得到不同的结论。 func TestAgentPingPongResetsOnHuman(t *testing.T) { @@ -145,13 +147,13 @@ func TestAgentPingPongNotResetByAgents(t *testing.T) { 被 CountTrailingAgentPingPong 一并计入「Agent 互发」计数。它同样满足 「发件方 Agent + 收件方 Agent + 无人类」,于是: - ① 退信把计数顶到 8 ⇒ 闸被**插件自己**触发; - ② 闸在 send 入口、先于 relay 分支 ⇒ 退信本身也被 403; - ③ 退信发不出去 ⇒ 发件人只看到「模型未产生回复」(桥的 sendFailureReply - 同样发不出,真因被吞); - ④ 被拒的尝试不产生新邮件 ⇒ 计数永不回落 ⇒ **会话永久死锁**: - 模型回信 → 403 → 退信也 403 → 发件人重发 → 模型再回 → 再 403。 - 生产上该会话 09-29 22:01 至 09-30 10:11 被 403 拒了 7 次。 + ① 退信把计数顶到 8 ⇒ 闸被**插件自己**触发; + ② 闸在 send 入口、先于 relay 分支 ⇒ 退信本身也被 403; + ③ 退信发不出去 ⇒ 发件人只看到「模型未产生回复」(桥的 sendFailureReply + 同样发不出,真因被吞); + ④ 被拒的尝试不产生新邮件 ⇒ 计数永不回落 ⇒ **会话永久死锁**: + 模型回信 → 403 → 退信也 403 → 发件人重发 → 模型再回 → 再 403。 + 生产上该会话 09-29 22:01 至 09-30 10:11 被 403 拒了 7 次。 正确形状:relay 邮件归 CountTrailingRelayHops(阈值 5,更严)管, 不挤占「模型主动互发」的额度(阈值 8)。两条闸独立计数、互不侵占。 diff --git a/server/internal/repo/debt_registry_test.go b/server/internal/repo/debt_registry_test.go index af19859..6268b7a 100644 --- a/server/internal/repo/debt_registry_test.go +++ b/server/internal/repo/debt_registry_test.go @@ -42,7 +42,6 @@ type debt struct { * ⇒ 直接删掉副本、让 `debtSummary()` 读权威 JSON(同一个事实不留两份)。 */ - /* * `debtMark` 由各条欠账的判据在"确认未清"时调用。 * diff --git a/server/internal/repo/platform_sessions_test.go b/server/internal/repo/platform_sessions_test.go index b53d558..616f7a5 100644 --- a/server/internal/repo/platform_sessions_test.go +++ b/server/internal/repo/platform_sessions_test.go @@ -271,11 +271,13 @@ func TestPlatformSessionsWithoutSlugAreSkipped(t *testing.T) { // 活库实测:200 样本 × 0.3s 里 25% 的时刻镜像为空、9 个目录轮流出场。 // // ★ 这条**不依赖**请求级 scope 字段:每项 `PlatformSession` 自带 `Workspace`, -// 所以**今天就能写、现在就是红、修好即绿**。请求级字段只与 (d2)(上报 `[]`)有关。 +// +// 所以**今天就能写、现在就是红、修好即绿**。请求级字段只与 (d2)(上报 `[]`)有关。 // // ⚠️ 本条**当前为红**——它断言的正是那个尚未修复的缺陷。 -// 按本仓惯例,红的判据要么标 `[FAIL]` 形态、要么随修复一起转绿; -// 这里用 `t.Errorf` 而不是 `t.Fatalf`,好让"为什么红"直接显示在测试输出里。 +// +// 按本仓惯例,红的判据要么标 `[FAIL]` 形态、要么随修复一起转绿; +// 这里用 `t.Errorf` 而不是 `t.Fatalf`,好让"为什么红"直接显示在测试输出里。 func TestReplacePlatformSessionsKeepsOtherWorkspaces(t *testing.T) { setupTestDB(t) seedPlatformAgent(t, "opencode") diff --git a/server/internal/sse/e2e_test.go b/server/internal/sse/e2e_test.go index c21805d..559c9eb 100644 --- a/server/internal/sse/e2e_test.go +++ b/server/internal/sse/e2e_test.go @@ -87,11 +87,11 @@ func TestEventIDMonotonic(t *testing.T) { func TestSendWritesIDField(t *testing.T) { fw := &flushWriter{httptest.NewRecorder(), make(chan bool, 5)} c := &Client{ - ID: "c1", + ID: "c1", UserName: "alice", - Res: fw, - Flusher: fw, - done: make(chan struct{}), + Res: fw, + Flusher: fw, + done: make(chan struct{}), } c.SendWithID("42", "new_mail", map[string]string{"x": "y"})