diff --git a/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs b/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs index 1662b71fa..2902ec5f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs @@ -125,7 +125,6 @@ impl AclRepairGate { 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, key: K, - recorded_at: Instant, armed: bool, } @@ -175,7 +173,10 @@ impl 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 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); diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 991b32acd..32a83d2bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -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) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs index 758c99cd8..8249b55d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs @@ -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(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 68d795a29..7ece2e6e9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 窗口兜底。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index dce1ff335..7738d5a9f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 *:(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。 ## 策划回复的重复终态不能重新启动伪流式