# 起因:一次端到端验证暴露的静默缺口
建了示例工程让 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
175 lines
7.5 KiB
Go
175 lines
7.5 KiB
Go
package models
|
||
|
||
import "time"
|
||
|
||
// ─── 权限档位 ───
|
||
//
|
||
// 三档描述「这条任务允许 Agent 动手到什么程度」。**AgentMail 声明,平台执行,
|
||
// 插件只做翻译** —— 不能让插件按工具名自己猜着拦,那会同时违反 I-1(平台原生
|
||
// 信号是唯一真相来源)与 I-4(插件只搬运不决策),而且四个插件对「workspace
|
||
// 到底管什么」必然各猜一套。
|
||
//
|
||
// 档位与 DSH 原生的三档沙箱一一对应(read-only / workspace-write /
|
||
// danger-full-access,见 @deepseek-ai/dsh-sandbox-policy)—— 那不是巧合,
|
||
// 是同一个问题的同一个答案。
|
||
const (
|
||
// ModePlan 只读:查资料、读代码、出方案,一个字都不许写。
|
||
//
|
||
// 危险操作**直接拒绝**,不产生权限邮件 —— plan 档的语义就是「这轮不动手」,
|
||
// 没什么可问人的。模型该做的是把方案写在回信里。
|
||
ModePlan = "plan"
|
||
|
||
// ModeWorkspace 本目录内可动手,越界要问人。默认档。
|
||
//
|
||
// 「本目录」= 会话的 workspace(三维地址的 path 位)。越界的定义是
|
||
// 写到那个目录之外,或跑一条无法判定影响范围的命令。
|
||
ModeWorkspace = "workspace"
|
||
|
||
// ModeFull 自动放行,不问人。
|
||
//
|
||
// 不产生权限邮件:既然已经声明了全权,再问一遍只是噪音。
|
||
ModeFull = "full"
|
||
)
|
||
|
||
// DefaultPermissionMode 是没有显式指定时的档位。
|
||
//
|
||
// 选 workspace 而不是 full:默认值应当是「多数任务够用且出错代价可控」的那一档。
|
||
// 一个默认全权的系统里,「我忘了收紧」与「我确实需要全权」在数据上无法区分。
|
||
// PermissionWaitWindow 是权限待办的等待窗口。
|
||
//
|
||
// 超过它之后,提出询问的插件**很可能**已不再阻塞等待 —— pi 桥的回合超时
|
||
// (AGENTMAIL_TURN_TIMEOUT_MS) 默认同为 10 分钟,超时即拆掉 worker 与它的决策
|
||
// 路由表;此后的决策只会作为通知投递,不再恢复当时那次工具调用。
|
||
//
|
||
// 服务端并不知道插件此刻是否还在等(那是它进程内的状态),所以这里只用来标出
|
||
// 「这封待办已经放了很久」,而不是替插件宣布裁决。
|
||
//
|
||
// 对外只发**截止时刻**,不发「是否失效」的布尔值:布尔值是「发出那一刻」的
|
||
// 快照,推给客户端(尤其经 SSE 缓存)后会永久停在旧值;而截止时刻是持久事实,
|
||
// 任何客户端在任何时候都能自己比出结论。
|
||
const PermissionWaitWindow = 10 * time.Minute
|
||
|
||
// PermissionDeadline 返回一条待办的失效时刻(created_at + 等待窗口)。
|
||
func PermissionDeadline(createdAt time.Time) time.Time {
|
||
return createdAt.Add(PermissionWaitWindow)
|
||
}
|
||
|
||
const DefaultPermissionMode = ModeWorkspace
|
||
|
||
// PermissionModes 是全部合法档位,按宽松程度递增排列。
|
||
//
|
||
// 顺序有意义:ModeAtMost 靠它做「向更严取整」。
|
||
var PermissionModes = []string{ModePlan, ModeWorkspace, ModeFull}
|
||
|
||
// ValidPermissionMode 判断是不是合法档位。
|
||
func ValidPermissionMode(m string) bool {
|
||
for _, v := range PermissionModes {
|
||
if v == m {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// NormalizePermissionMode 把外部输入收敛成合法档位。
|
||
//
|
||
// 空串 → 默认档;非法值 → 默认档(**不是** ModeFull)。
|
||
// 拼错一个档位名不该换来比预期更大的权限。
|
||
func NormalizePermissionMode(m string) string {
|
||
if ValidPermissionMode(m) {
|
||
return m
|
||
}
|
||
return DefaultPermissionMode
|
||
}
|
||
|
||
// modeRank 是档位的宽松程度序号,越大越宽松。
|
||
//
|
||
// 只接已经归一化过的档位 —— 调用方负责先跑 NormalizePermissionMode。
|
||
// 让它自己处理非法值会造出两套语义:曾经这里把未知值当 rank 0(plan),
|
||
// 而 NormalizePermissionMode 把它归到 workspace,于是同一个脏值在不同函数里
|
||
// 含义不同,ModeAtMost 也因此不可交换(单元测试当场抓到)。
|
||
func modeRank(m string) int {
|
||
for i, v := range PermissionModes {
|
||
if v == m {
|
||
return i
|
||
}
|
||
}
|
||
// 归一化后不可能走到这里;防御性地返回默认档的序号。
|
||
return modeRank(DefaultPermissionMode)
|
||
}
|
||
|
||
// ModeAtMost 返回 a 与 b 里更严的那一档。
|
||
//
|
||
// 两个用途:
|
||
// - 子会话继承:Agent 派活时子会话不得比父会话宽松(plan 档派不出 full 档子任务)
|
||
// - 平台取整:平台表达不出精确档位时向更严的方向取整
|
||
//
|
||
// 为什么必须是同一个函数:这两处若各写一遍,早晚有一处会写成「取更宽松」。
|
||
//
|
||
// **先归一化再比较**:两个脏值都变成默认档,于是结果与参数顺序无关(可交换),
|
||
// 也与 NormalizePermissionMode / ModeNeedsHuman 对同一个脏值的理解一致。
|
||
func ModeAtMost(a, b string) string {
|
||
na := NormalizePermissionMode(a)
|
||
nb := NormalizePermissionMode(b)
|
||
if modeRank(na) <= modeRank(nb) {
|
||
return na
|
||
}
|
||
return nb
|
||
}
|
||
|
||
// ModeNeedsHuman 这一档会不会产生权限邮件(即需不需要人来点头)。
|
||
//
|
||
// 只有 workspace 档需要人。这一点直接决定了「找不到人类时怎么办」:
|
||
// plan 档当场拒绝、full 档自动放行,两者都不问人,所以**只有 workspace 档
|
||
// 会走到「这条链上有没有人类」这个问题**,找不到就是 409。
|
||
//
|
||
// 这也是为什么 permission.go 里那段「退回第一个管理员」的兜底必须删掉:
|
||
// 它让 409 分支永远不可达(实测:pi 给自己派活跑 bash,权限邮件发给了 jianf),
|
||
// 而那段 409 的注释本身就在论证兜底是错的 —— 管理员对这条 Agent 链一无所知。
|
||
func ModeNeedsHuman(m string) bool {
|
||
return NormalizePermissionMode(m) == ModeWorkspace
|
||
}
|
||
|
||
// ─── 强制力 ───
|
||
//
|
||
// 档位是「要求什么」,强制力是「平台实际做到了什么」。两者必须分开记录并且
|
||
// 都对人可见(I-5:失败必须可见)—— 否则发件人以为 plan 档管住了 homeagent,
|
||
// 而 homeagent 的核心根本没有工具调用拦截点。
|
||
const (
|
||
// EnforcementNative 平台有原生拦截点,档位被完整执行。
|
||
EnforcementNative = "native"
|
||
|
||
// EnforcementPartial 平台有原生拦截点,但覆盖不完整。
|
||
//
|
||
// 为什么需要这个中间值:实测 DSH 的 Landlock 沙箱受内核 ABI 版本限制,
|
||
// read-only / workspace-write 能拦下大部分写与命令执行,但有已知缺口。
|
||
//
|
||
// 只给 native / advisory 两个取值会逼出一个二选一的假陈述:
|
||
// - 标 native → 人以为档位被完整强制,于是把 plan 档当成硬保证;
|
||
// - 标 advisory → 反过来低估(它确实在拦),"无法强制" 的说法是错的。
|
||
// 两边都在骗人。多一个取值比多说一句假话便宜。
|
||
EnforcementPartial = "partial"
|
||
|
||
// EnforcementAdvisory 平台没有拦截点,档位只写进提示词。
|
||
//
|
||
// 模型至少知道「这活只让你看不让你动」,但没有任何机制阻止它动手。
|
||
// 这不是缺陷掩饰 —— 是把「做不到」如实标出来,让发件人自己决定要不要派。
|
||
EnforcementAdvisory = "advisory"
|
||
)
|
||
|
||
// ValidEnforcement 判断强制力取值是否合法。
|
||
func ValidEnforcement(e string) bool {
|
||
return e == EnforcementNative || e == EnforcementPartial || e == EnforcementAdvisory
|
||
}
|
||
|
||
// NormalizeEnforcement 收敛强制力取值。
|
||
//
|
||
// 空串或非法值 → advisory。**保守方向是 advisory 而不是 native**:
|
||
// 没自报过的插件,我们不能替它宣称「档位在这里是被强制的」。
|
||
func NormalizeEnforcement(e string) string {
|
||
if ValidEnforcement(e) {
|
||
return e
|
||
}
|
||
return EnforcementAdvisory
|
||
}
|