# 起因:一次端到端验证暴露的静默缺口
建了示例工程让 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
193 lines
7.4 KiB
Go
193 lines
7.4 KiB
Go
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) = %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) {
|
||
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)
|
||
}
|
||
}
|