Files
MailUI4Agents/server/internal/models/permission_mode_test.go
JianFeeeee bbddee26b9 feat(permission): 待办带上失效时刻;越窗的决策不再假装成功
# 起因:一次端到端验证暴露的静默缺口

建了示例工程让 pi 通过邮件干活(plan 档拦截、workspace 档审批、多 agent 指派)。
plan 档与多 agent 都通过,workspace 档却卡住:**人在界面上批准了一条待办,
接口回 200,但那件事什么都没发生。**

追下去是三件事叠在一起:

1. **桥**等不到决策时(pi 的回合超时 TURN_TIMEOUT_MS,默认 10 分钟)会拆掉 worker
   与它的决策路由表;此后再来的决策只会作为**通知**投给 Agent,不恢复当时那次
   工具调用 —— 该轮已经结束了。
2. **服务端**只有 `permission_requests.result IS NULL`,没有「失效」概念。
   迟到决策照样回 `{"status":"decided"}`。
3. **前端**只看 `permission_result` 判待决/已决,没有任何时间或失效提示。

于是那条待办永远挂在授权页上显示「等待你决策」,人点了也白点。这是 I-5
(失败必须当场可见)要消灭的那类静默成功,而且**跨所有客户端**成立 ——
WebUI 不显示,Electron / Harmony 同样无从显示。

# 设计:邮件上给「时刻」,不给「是否失效」的布尔值

服务端不知道插件此刻是否还在等(那是它进程内的状态),所以只标出「这封待办已经
放了很久」,不替插件宣布裁决。

关键取舍:对外只发**截止时刻**(`permission_expires_at`),不发 `stale` 布尔值。
布尔值是「发出那一刻」的快照 —— 经 SSE 推送并被客户端缓存后会永久停在旧值,
界面就会一直显示「等待你决策」。时刻是持久事实,任何客户端在任何时候都能自己
比出现在过没过期。这也是为什么推导而非落库:它是 created_at 的函数,存下来会失真。

`DecidePermission` 的响应里则用布尔值(`expired`)—— 响应本身就是「此刻」的
一次性快照,不会像邮件那样被缓存反复展示。

# 改动

- `models.PermissionWaitWindow`(10 分钟,与 pi 桥的回合超时同量级)+
  `PermissionDeadline(createdAt)`;两端共用这一处算式,避免「界面说已过期、
  决策说没过期」。
- `Mail.PermissionExpiresAt` / `PermissionRequest.ExpiresAt`:由读路径推导填充。
  5 个读路径各插一行(`AttachPermissionDeadline*`)—— 与审计修复① 加
  permission_kind 时同一套路数,漏掉任一路径只会静默变成 nil。
  只给**仍未决策**的待办填,已决策的不再是待办。
- `decideResponse`(抽出纯函数以便测试):越窗时加 `expired` + `warning`,
  讲清「决策已记录、但不会恢复原调用」。**不改 HTTP 状态码**:决策仍是人的真实
  意愿、仍然有效(桥会当通知投递,Agent 重起一轮),所以不能拒掉,但必须说清。
- 前端:列表里失效项不再与「还能立刻生效」的长得一样(灰底 + 「可能已失效」);
  批准面板在决策**前**(人正要按下去)与决策**后**(人以为事情办了)都显示提示。

# 验证

- Go:models/repo/handler 三处新增测试全绿;全量 `go test ./...` 通过;vet 通过
- 前端:typecheck 通过;200 项测试全绿(含新增 4 条失效态)
- 真机(用现成的过期待办,未造合成数据):
  - `/permission/pending` 返回 `expires_at` = 创建 + 10 分钟,服务端判定已过窗
  - 邮件载荷带上 `permission_expires_at`(前端列表的数据源)
  - 对过期待办提交批准 → `{"expired":true, "expires_at":…, "warning":"该请求已超过
    等待窗口(10 分钟)…不会恢复当时那次工具调用…"}`
- 已用 redeploy-gateway.sh 部署,服务 active、四 agent 心跳正常、日志无 panic
2026-09-11 22:02:25 +08:00

193 lines
7.4 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
// 权限档位的判据测试。
//
// 为什么值得单独一组测试:`ModeAtMost` 被两处调用(子会话继承 / 平台向更严取整),
// 两处若各写一遍必有一处写成「取更宽松」。而 `NormalizePermissionMode` 的保守
// 取向(非法值 → workspace 而非 full是安全属性拼错一个档位名不该换来更大权限。
import (
"testing"
"time"
)
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) = %qwant %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) {
for _, ok := range []string{EnforcementNative, EnforcementPartial, EnforcementAdvisory} {
if !ValidEnforcement(ok) {
t.Fatalf("%q 应当合法", ok)
}
}
for _, e := range []string{"", "NATIVE", "none", "enforced", "partial "} {
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)
}
}
// 显式自报的值必须原样保留partial 是「有拦截点但覆盖不完整」的独立事实,
// 把它降级到任一极端都是在说假话native 高估、advisory 低估)。
for _, in := range []string{EnforcementNative, EnforcementPartial, EnforcementAdvisory} {
if got := NormalizeEnforcement(in); got != in {
t.Fatalf("显式 %q 应原样保留,得到 %q", in, 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)
}
}
// 待办的失效时刻必须是「创建时刻 + 等待窗口」的纯函数。
//
// 这条算式同时被 repo填到邮件上与 handler判断决策是否越窗使用
// 两边必须得出同一个值 —— 各算各的就会出现「界面说已过期、决策说没过期」。
func TestPermissionDeadline(t *testing.T) {
created := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
got := PermissionDeadline(created)
want := created.Add(PermissionWaitWindow)
if !got.Equal(want) {
t.Fatalf("PermissionDeadline = %v期望 %v", got, want)
}
// 纯函数:同样的输入必须给同样的输出,且不受调用时刻影响。
if again := PermissionDeadline(created); !again.Equal(got) {
t.Fatalf("同一输入两次调用结果不同:%v vs %v", got, again)
}
// 窗口本身要是个正数,否则「失效时刻」永远不会到来,界面也就永不过期。
if PermissionWaitWindow <= 0 {
t.Fatalf("等待窗口必须为正,实际 %v", PermissionWaitWindow)
}
}