评审收口:闸门 key 归一化路径写法 + 冷却改从结果落库时刻算起(#498)
- acl_repair_gate:冷却基准从 leader 起跑时刻改为结果落库时刻;UAC 被挂着几十秒到两分钟时,120s 拒绝冷却不再提前过期,避免紧跟的自动整表重查立刻再弹一次 - config:新增 windows_acl_repair_gate_key,闸门 key 的路径半边先去掉 \\?\ / \\?\UNC\ 前缀再统一小写;最近项目列表里同一项目实测同时存在 \\?\C:\... 与 C:\... 两种写法,按原始字符串做 key 会让同一个目录弹两次 UAC - tests/acl_repair_gate:补「冷却从结果落库时刻算起」与「路径写法归一成一个 key」两条用例;两条都做过逆向确认(改回修复前语义即失败) - docs:decision-log 与 pitfalls 补记 key 归一化、冷却基准,以及真机复现的三个坑(DENY 要加在祖先的父目录、夹具路径必须落在 Managed 放行范围内、提权子进程会按 repair target 再校验 scope)
This commit is contained in:
@@ -125,7 +125,6 @@ impl<K: Clone + Eq + Hash> AclRepairGate<K> {
|
||||
let guard = LeaderGuard {
|
||||
gate: self,
|
||||
key: key.clone(),
|
||||
recorded_at: now,
|
||||
armed: true,
|
||||
};
|
||||
let outcome = execute();
|
||||
@@ -162,7 +161,6 @@ where
|
||||
struct LeaderGuard<'a, K: Clone + Eq + Hash> {
|
||||
gate: &'a AclRepairGate<K>,
|
||||
key: K,
|
||||
recorded_at: Instant,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
@@ -175,7 +173,10 @@ impl<K: Clone + Eq + Hash> LeaderGuard<'_, K> {
|
||||
Entry {
|
||||
running: false,
|
||||
outcome: Some(outcome.clone()),
|
||||
recorded_at: Some(self.recorded_at),
|
||||
// 冷却从「结果落库」时刻算起,而不是 leader 起跑时刻:UAC 弹窗可能被挂着
|
||||
// 几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,
|
||||
// 紧接着的自动重查会立刻再弹一次。
|
||||
recorded_at: Some(Instant::now()),
|
||||
},
|
||||
);
|
||||
drop(entries);
|
||||
@@ -198,7 +199,7 @@ impl<K: Clone + Eq + Hash> Drop for LeaderGuard<'_, K> {
|
||||
outcome: Some(AclRepairOutcome::Failed(
|
||||
"AGC ACL 提权修复执行线程异常退出".to_string(),
|
||||
)),
|
||||
recorded_at: Some(self.recorded_at),
|
||||
recorded_at: Some(Instant::now()),
|
||||
},
|
||||
);
|
||||
drop(entries);
|
||||
|
||||
@@ -2800,6 +2800,23 @@ pub(crate) fn clear_windows_acl_repair_denials() {
|
||||
crate::acl_repair_gate::clear_acl_repair_denials();
|
||||
}
|
||||
|
||||
/// 闸门 key 的路径半边:`\\?\` 扩展长度前缀与 `\\?\UNC\` 必须先归一化,
|
||||
/// 否则同一个物理目录的不同写法会算出不同 key,single-flight 就退化成「每种写法弹一次」。
|
||||
/// 最近项目列表里同一项目会同时存在 `\\?\C:\...` 与 `C:\...` 两种形态,归一化后它们共用一次提权。
|
||||
/// 这里只做前缀与大小写归一(不 `canonicalize`):待修复目标恰恰是「读不动的目录」,解析不可靠。
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn windows_acl_repair_gate_key(
|
||||
repair_path: &Path,
|
||||
scope: WindowsAclRepairScope,
|
||||
) -> crate::acl_repair_gate::AclRepairKey {
|
||||
(
|
||||
normalize_windows_policy_path(repair_path)
|
||||
.to_string_lossy()
|
||||
.to_lowercase(),
|
||||
scope.wire_name(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Starts a one-shot elevated copy of the current executable. The elevated
|
||||
/// process performs only the allow-listed ACL repair command and exits with a
|
||||
/// truthful status; UAC cancellation is never treated as success.
|
||||
@@ -2824,10 +2841,7 @@ fn attempt_elevated_windows_acl_repair(
|
||||
));
|
||||
}
|
||||
let repair_path = windows_acl_repair_target(path, scope);
|
||||
let key = (
|
||||
repair_path.to_string_lossy().to_lowercase(),
|
||||
scope.wire_name(),
|
||||
);
|
||||
let key = windows_acl_repair_gate_key(&repair_path, scope);
|
||||
let gate_result =
|
||||
ACL_REPAIR_GATE.run(key, std::time::Instant::now(), &ACL_REPAIR_POLICY, || {
|
||||
run_elevated_windows_acl_repair_once(path, target_user_sid, scope, &repair_path)
|
||||
|
||||
@@ -174,6 +174,77 @@ fn successful_repair_and_failure_are_reused_for_their_own_cooldowns() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_is_measured_from_the_recorded_result_not_the_leader_start() {
|
||||
// 真机场景:UAC 弹窗被挂着几十秒到两分钟。若冷却从 leader 起跑时刻算,
|
||||
// 120s 拒绝冷却会在用户应答前就过期,紧接着的自动重查立刻再弹一次。
|
||||
let gate = AclRepairGate::new();
|
||||
let key = test_key("c:\\slow-success");
|
||||
let policy = AclRepairPolicy {
|
||||
success_cooldown: Duration::from_millis(200),
|
||||
..test_policy()
|
||||
};
|
||||
let executions = AtomicUsize::new(0);
|
||||
|
||||
let executed = gate.run(key.clone(), Instant::now(), &policy, || {
|
||||
executions.fetch_add(1, Ordering::SeqCst);
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
AclRepairOutcome::Repaired
|
||||
});
|
||||
assert_eq!(
|
||||
executed,
|
||||
AclRepairGateResult::Executed(AclRepairOutcome::Repaired)
|
||||
);
|
||||
|
||||
let reused = gate.run(key, Instant::now(), &policy, || {
|
||||
executions.fetch_add(1, Ordering::SeqCst);
|
||||
panic!("冷却必须从结果落库时刻算起,不能用 leader 起跑时刻")
|
||||
});
|
||||
assert_eq!(
|
||||
reused,
|
||||
AclRepairGateResult::Reused(AclRepairOutcome::Repaired)
|
||||
);
|
||||
assert_eq!(executions.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn repair_gate_key_merges_path_spelling_variants_of_one_target() {
|
||||
use crate::config::{windows_acl_repair_gate_key, WindowsAclRepairScope};
|
||||
use std::path::Path;
|
||||
|
||||
// 最近项目里同一项目会同时出现 `\\?\C:\...` 与 `C:\...` 两种写法(客户端列表实测),
|
||||
// 不归一化就是两个 key -> 同一个目录弹两次 UAC。
|
||||
let plain =
|
||||
Path::new(r"C:\Users\dongy\AppData\Roaming\world.genarrative.ai-game-creator\projects");
|
||||
let extended =
|
||||
Path::new(r"\\?\C:\Users\dongy\AppData\Roaming\world.genarrative.ai-game-creator\projects");
|
||||
let share = Path::new(r"\\server\share\projects");
|
||||
let share_extended = Path::new(r"\\?\UNC\server\share\projects");
|
||||
|
||||
for scope in [
|
||||
WindowsAclRepairScope::Managed,
|
||||
WindowsAclRepairScope::UserSelected,
|
||||
] {
|
||||
assert_eq!(
|
||||
windows_acl_repair_gate_key(plain, scope),
|
||||
windows_acl_repair_gate_key(extended, scope)
|
||||
);
|
||||
assert_eq!(
|
||||
windows_acl_repair_gate_key(share, scope),
|
||||
windows_acl_repair_gate_key(share_extended, scope)
|
||||
);
|
||||
assert_ne!(
|
||||
windows_acl_repair_gate_key(plain, scope),
|
||||
windows_acl_repair_gate_key(share, scope)
|
||||
);
|
||||
}
|
||||
assert_ne!(
|
||||
windows_acl_repair_gate_key(plain, WindowsAclRepairScope::Managed),
|
||||
windows_acl_repair_gate_key(plain, WindowsAclRepairScope::UserSelected)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_denials_allows_an_explicit_user_retry() {
|
||||
let gate = AclRepairGate::new();
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
- 背景:`windows_acl_repair_target` 对 Managed 作用域返回的是「第一个读取被拒的祖先」,同一祖先下的多个项目会解析到**同一个** repair target;而唯一的去重只是单次调用内的局部 `attempted_targets`。于是启动页一次挂载(≤8 个最近项目并发检查)会启动同样多次 `powershell -Verb RunAs`,用户看到叠在一起的 UAC 弹窗(issue #498)。
|
||||
- 决策:新增进程级闸门 `acl_repair_gate`,key = `(规范化 repair target, scope)`。并发调用只允许一次真实提权,其余等待并复用**同一结果**;结果在冷却窗口内直接复用(成功 30s / 失败 15s / 用户取消 120s),等待窗口 60s 超时按失败关闭。leader 异常退出由 RAII 兜底记为失败并唤醒全部等待者,避免等待者被永久挂住。
|
||||
- 决策补充(key 归一化):key 的路径半边经 `windows_acl_repair_gate_key` 归一化——去掉 `\\?\` / `\\?\UNC\` 前缀并统一小写。最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法(客户端 localStorage 实测),不归一化就是两个 key,同一个目录仍会弹两次 UAC。这里刻意只做前缀与大小写归一而不 `canonicalize`:待修复目标恰恰是「读不动的目录」,解析不可靠。
|
||||
- 决策补充(冷却基准):冷却从**结果落库**时刻算起,不是 leader 起跑时刻。UAC 弹窗会被挂着几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,前端 15s/45s/120s 的整表重查紧跟着再弹一次。
|
||||
- 错误类型化:用户取消 UAC 的错误统一带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此判定「不可自动重试」,不再依赖中文文案匹配。
|
||||
- 用户主动操作(打开/新建项目、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。
|
||||
- 未做:给提权子进程加有界等待(`Start-Process -Wait` 目前无超时)。理由:中断挂起的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 的等待由 60s 窗口兜底。
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
- **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。
|
||||
- **原因**:`windows_acl_repair_target`(`src-tauri/src/config.rs`)对 Managed 作用域返回「第一个读取被拒的祖先」——同一祖先下的多个项目解析到**同一个** repair target;而唯一的去重是单次调用内的局部 `attempted_targets`,跨调用、跨线程都没有记忆。启动页一次并发检查 ≤8 个最近项目,就会并发启动同样多次 `powershell -Verb RunAs`。
|
||||
- **处理**:进程级 single-flight(key = `(规范化 repair target, scope)`)+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ 等待窗口 60s 超时按失败关闭;leader 异常退出由 RAII 兜底唤醒等待者。用户取消带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此不自动重试;用户主动操作会清除拒绝记忆。
|
||||
- **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者)。
|
||||
- **不要踩的坑**:① 闸门 key 必须归一化 `\\?\` / `\\?\UNC\` 前缀——最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法,按原始字符串做 key 会让同一个目录弹两次 UAC(`windows_acl_repair_gate_key`);② 冷却必须从**结果落库**时刻算起,用 leader 起跑时刻会让 120s 拒绝冷却在 UAC 被挂着两分钟时提前过期,紧接着的自动重查立刻再弹一次;③ 复现「多个项目共用同一 target」时,DENY 要写在祖先的**父目录**上靠继承落入祖先——`icacls` 直接加在容器自身实测只影响子项(容器自身 `GetFileAttributes` 仍成功),target 会退化成每个项目自己,repro 不出并发弹窗;④ 夹具路径必须落在 `game_creator_private_path_allows_auto_elevation` 放行范围内(runtime config dir / `.config/genarrative` / 打包 AppData / 带 `.agent/manifest.json` 的项目根),因为提权子进程会按 **repair target** 再校验一次 `scope.allows_path`,否则失败关闭。
|
||||
- **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一)。真机复现(无需提权交互即可计数):在 Managed 放行范围内建 8 个带 `.agent/manifest.json` 的假项目 → 对共同祖先的**父目录** `icacls <父目录> /deny *<sid>:(OI)(CI)(RX)` → 挂载启动页,同时数 `powershell.exe` 里命令行带 `RunAs` 的进程数(`Start-Process -Wait` 会让它一直存活到用户应答)与 `consent.exe` 峰值:修复前 8 个并发请求,修复后 1 个;把同一目录的 `\\?\C:\...` 与 `C:\...` 两种写法一起塞进最近项目,还能验证 key 归一化是否生效(修复前 2 个、修复后 1 个)。
|
||||
- **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。
|
||||
|
||||
## 策划回复的重复终态不能重新启动伪流式
|
||||
|
||||
Reference in New Issue
Block a user