Windows ACL 提权按 target 做 single-flight + 失败记忆,避免并发重复弹 UAC #498

Closed
opened 2026-09-23 17:37:13 +08:00 by suzmii · 0 comments
Member

背景

PR #492 的评审发现(高):Windows ACL 自动提权路径会并发、重复触发 Start-Process -Verb RunAs -Wait。前端已在该 PR 收口(提权类失败不重试、不驱动整表重查、用户主动操作前跳过),但闸门在调用方一侧,任何并发调用方都会各自启动一次提权。

为什么必须按「目标」做 single-flight(并发去重)

windows_acl_repair_target(apps/ai-game-creator-shell/src-tauri/src/config.rs:2539-2567)对 Managed 作用域不是返回叶路径,而是从根往叶走、返回第一个读取被拒的祖先:

for ancestor in path.ancestors().collect::<Vec<_>>().into_iter().rev() {
    match fs::symlink_metadata(ancestor) {
        Ok(_) => {}
        Err(error) if error.kind() == PermissionDenied || error.raw_os_error() == Some(5) => {
            return ancestor.to_path_buf();   // ← 第一个被拒的祖先
        }
        Err(_) => {}
    }
}

⇒ 同一祖先下的多个项目会解析到同一个 repair target。而调用链上没有任何跨调用记忆(唯一闸门 attempted_targets 是 secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped 内的局部 Vec,config.rs:2330-2352,函数返回即消失),于是:

  • 最近项目列表一次挂载 = N 个项目并发检查(useRecentProjects 上限 8)→ 同一 target 被并发启动 N 次 powershell -Verb RunAs,用户看到叠在一起的 UAC 弹窗;
  • 叠加重试时更大(PR #492 前端已修,但闸门仍在调用方);
  • -Wait 没有超时(config.rs:2824),每个被拒/被忽略的弹窗都会长期占住一条线程。

期望(按优先级)

  1. per-target single-flight(leader/follower):以 (normalize(windows_acl_repair_target(path, scope)), scope) 为 key。leader 执行提权修复,follower 等待并使用同一个结果(成功或错误都要复用,不能只做「跳过」)。调用方可能位于 blocking 线程(inspect_local_project_directory 走 spawn_blocking)或同步派发线程,所以原语要阻塞友好(std::sync::{Mutex, Condvar} 的 leader/follower,而不是要求 async 的 tokio 原语)。
  2. 有界等待:single-flight 必须有超时/可中断,否则 UAC 无人应答时 follower 会被 leader 无限期挂住,把问题从「并发弹窗」变成「并发挂起」。可给提权子进程加有界等待。
  3. 失败记忆(冷却):key 维度记录最近结果与时间。用户拒绝(powershell exit 1223)→ 冷却期内直接返回类型化错误、不再启动提权;成功 → 复用成功结果。冷却窗口与「用户主动操作可再试一次」的语义要和 PR #492 前端的跳过策略一致。
  4. 错误类型化:区分「用户拒绝提权」与「ACL 修复失败」,便于前端决定提示与是否重试(当前两者都只表现为 AGC ACL 提权修复未成功(exit code Some(...)))。
  5. 保持失败关闭:宁可拒绝执行,也不放宽 ACL 或回退宿主权限。

验收

  • 单测:同 target 并发 N 次调用只触发 1 次提权执行(提权执行器可注入或计数),且所有调用方拿到同一结果;不同 target 互不合并。
  • 单测:拒绝一次后的冷却期内,再调用不再 spawn 提权;冷却过期后可再试一次。
  • 真机:一次挂载 8 个同祖先项目最多只出现 1 个 UAC 弹窗;拒绝后不重复弹。

关联

  • 评审报告与证据:PR #492 评审评论(config.rs:2330-2352、config.rs:2539-2567、config.rs:2824、config.rs:910-1041)。
  • 现场:AGC 项目列表/首页在「卡住」后全红(issue #490)。
  • ⚠️ 范围澄清:平台接口 15s 超时不是本 issue。/api/runtime/frontend-config、/api/llm/models、/api/profile/recharge-center 走 WebView → Rust 的 plugin:http(reqwest),与本地 ACL 提权不同层;只有「WebView 主线程 / IPC 回传」整体停摆时两者才会落在同一时间窗。判别方法记录在 issue #490 的采证探针里。
## 背景 PR #492 的评审发现(高):Windows ACL 自动提权路径会**并发、重复**触发 `Start-Process -Verb RunAs -Wait`。前端已在该 PR 收口(提权类失败不重试、不驱动整表重查、用户主动操作前跳过),但闸门在**调用方一侧**,任何并发调用方都会各自启动一次提权。 ## 为什么必须按「目标」做 single-flight(并发去重) `windows_acl_repair_target`(`apps/ai-game-creator-shell/src-tauri/src/config.rs:2539-2567`)对 Managed 作用域不是返回叶路径,而是**从根往叶走、返回第一个读取被拒的祖先**: ```rust for ancestor in path.ancestors().collect::<Vec<_>>().into_iter().rev() { match fs::symlink_metadata(ancestor) { Ok(_) => {} Err(error) if error.kind() == PermissionDenied || error.raw_os_error() == Some(5) => { return ancestor.to_path_buf(); // ← 第一个被拒的祖先 } Err(_) => {} } } ``` ⇒ 同一祖先下的多个项目会解析到**同一个 repair target**。而调用链上没有任何跨调用记忆(唯一闸门 `attempted_targets` 是 `secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped` 内的局部 Vec,`config.rs:2330-2352`,函数返回即消失),于是: - 最近项目列表一次挂载 = N 个项目并发检查(`useRecentProjects` 上限 8)→ 同一 target 被并发启动 N 次 `powershell -Verb RunAs`,用户看到叠在一起的 UAC 弹窗; - 叠加重试时更大(PR #492 前端已修,但闸门仍在调用方); - `-Wait` 没有超时(`config.rs:2824`),每个被拒/被忽略的弹窗都会长期占住一条线程。 ## 期望(按优先级) 1. **per-target single-flight(leader/follower)**:以 `(normalize(windows_acl_repair_target(path, scope)), scope)` 为 key。leader 执行提权修复,follower 等待并使用**同一个结果**(成功或错误都要复用,不能只做「跳过」)。调用方可能位于 blocking 线程(`inspect_local_project_directory` 走 `spawn_blocking`)或同步派发线程,所以原语要阻塞友好(`std::sync::{Mutex, Condvar}` 的 leader/follower,而不是要求 async 的 tokio 原语)。 2. **有界等待**:single-flight 必须有超时/可中断,否则 UAC 无人应答时 follower 会被 leader 无限期挂住,把问题从「并发弹窗」变成「并发挂起」。可给提权子进程加有界等待。 3. **失败记忆(冷却)**:key 维度记录最近结果与时间。用户拒绝(`powershell` exit 1223)→ 冷却期内直接返回类型化错误、不再启动提权;成功 → 复用成功结果。冷却窗口与「用户主动操作可再试一次」的语义要和 PR #492 前端的跳过策略一致。 4. **错误类型化**:区分「用户拒绝提权」与「ACL 修复失败」,便于前端决定提示与是否重试(当前两者都只表现为 `AGC ACL 提权修复未成功(exit code Some(...))`)。 5. **保持失败关闭**:宁可拒绝执行,也不放宽 ACL 或回退宿主权限。 ## 验收 - 单测:同 target 并发 N 次调用只触发 **1 次**提权执行(提权执行器可注入或计数),且所有调用方拿到同一结果;不同 target 互不合并。 - 单测:拒绝一次后的冷却期内,再调用不再 spawn 提权;冷却过期后可再试一次。 - 真机:一次挂载 8 个同祖先项目最多只出现 1 个 UAC 弹窗;拒绝后不重复弹。 ## 关联 - 评审报告与证据:PR #492 评审评论(`config.rs:2330-2352`、`config.rs:2539-2567`、`config.rs:2824`、`config.rs:910-1041`)。 - 现场:AGC 项目列表/首页在「卡住」后全红(issue #490)。 - ⚠️ 范围澄清:**平台接口 15s 超时不是本 issue**。`/api/runtime/frontend-config`、`/api/llm/models`、`/api/profile/recharge-center` 走 WebView → Rust 的 `plugin:http`(reqwest),与本地 ACL 提权不同层;只有「WebView 主线程 / IPC 回传」整体停摆时两者才会落在同一时间窗。判别方法记录在 issue #490 的采证探针里。
suzmii added the Kind/Bug
Priority
High
2
labels 2026-09-23 17:37:13 +08:00
suzmii changed title from Windows ACL 提权加进程级 per-target 冷却:避免任何调用方重复触发 UAC to Windows ACL 提权按 target 做 single-flight + 失败记忆,避免并发重复弹 UAC 2026-09-23 18:00:17 +08:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: GenarrativeAI/Genarrative#498