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 new file mode 100644 index 000000000..f473568f8 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs @@ -0,0 +1,269 @@ +//! ACL 提权修复目标的并发去重与结果记忆。 +//! +//! 同一目标被并发请求时只允许一次真实提权,其余调用等待并复用同一结果; +//! 结果在冷却窗口内直接复用,其中用户拒绝(UAC 取消)的窗口最长, +//! 避免自动重试把用户反复拽回安全桌面。 + +use std::collections::HashMap; +use std::hash::Hash; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Condvar, LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +/// 一次提权修复的结果。用户拒绝与修复失败必须可区分:前者不该被重试。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AclRepairOutcome { + Repaired, + Denied(String), + Failed(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AclRepairGateResult { + Executed(AclRepairOutcome), + Reused(AclRepairOutcome), + /// leader 在等待窗口内仍未结束(例如 UAC 无人应答);调用方按失败关闭处理。 + WaitTimedOut, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct AclRepairPolicy { + pub(crate) success_cooldown: Duration, + pub(crate) denial_cooldown: Duration, + pub(crate) failure_cooldown: Duration, + pub(crate) wait_timeout: Duration, + /// leader 超过这个时长仍未落库即视为卡死,允许新调用接管该 key。 + /// UAC 弹窗最多被系统挂约两分钟,所以这个上限取得比它宽得多;没有它, + /// 一次挂死的 `Start-Process -Wait` 会让这个目标在进程重启前一直失败关闭。 + pub(crate) leader_deadline: Duration, +} + +impl AclRepairPolicy { + fn cooldown_for(&self, outcome: &AclRepairOutcome) -> Duration { + match outcome { + AclRepairOutcome::Repaired => self.success_cooldown, + AclRepairOutcome::Denied(_) => self.denial_cooldown, + AclRepairOutcome::Failed(_) => self.failure_cooldown, + } + } + + fn retention(&self) -> Duration { + self.success_cooldown + .max(self.denial_cooldown) + .max(self.failure_cooldown) + } +} + +struct Entry { + running: bool, + outcome: Option, + recorded_at: Option, + /// leader 起跑时刻,用于判定该 leader 是否已经卡死。 + started_at: Instant, + /// 当前 leader 的令牌:被接管后旧 leader 迟到的结果不得覆盖新 leader 的结果。 + leader_id: u64, +} + +pub(crate) struct AclRepairGate { + entries: Mutex>, + settled: Condvar, + next_leader_id: AtomicU64, +} + +impl AclRepairGate { + pub(crate) fn new() -> Self { + Self { + entries: Mutex::new(HashMap::new()), + settled: Condvar::new(), + next_leader_id: AtomicU64::new(1), + } + } + + /// 以 `key` 为粒度执行一次提权修复:并发调用只会有一次真正执行, + /// 其余调用等待并复用结果;冷却窗口内直接复用上一次结果。 + pub(crate) fn run( + &self, + key: K, + now: Instant, + policy: &AclRepairPolicy, + execute: F, + ) -> AclRepairGateResult + where + F: FnOnce() -> AclRepairOutcome, + { + let wait_deadline = Instant::now() + policy.wait_timeout; + let mut entries = lock(&self.entries); + loop { + match entries.get(&key) { + Some(entry) if entry.running => { + // 卡死的 leader(例如 `Start-Process -Wait` 真挂住)不能永久占住这个 key: + // 超过 leader_deadline 就由新调用接管,否则该目标在进程重启前只会一直失败关闭。 + if now.saturating_duration_since(entry.started_at) >= policy.leader_deadline { + break; + } + let remaining = wait_deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return AclRepairGateResult::WaitTimedOut; + } + let (guard, _) = self + .settled + .wait_timeout(entries, remaining) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + entries = guard; + } + Some(entry) => { + let reusable = entry.outcome.clone().zip(entry.recorded_at).filter( + |(outcome, recorded_at)| { + now.saturating_duration_since(*recorded_at) + < policy.cooldown_for(outcome) + }, + ); + match reusable { + Some((outcome, _)) => return AclRepairGateResult::Reused(outcome), + None => break, + } + } + None => break, + } + } + + prune(&mut entries, now, policy); + let leader_id = self.next_leader_id.fetch_add(1, Ordering::Relaxed); + entries.insert( + key.clone(), + Entry { + running: true, + outcome: None, + // 结果尚未落库:冷却基准只在真正记录结果时才写。 + recorded_at: None, + started_at: now, + leader_id, + }, + ); + drop(entries); + + let guard = LeaderGuard { + gate: self, + key: key.clone(), + leader_id, + armed: true, + }; + let outcome = execute(); + guard.complete(outcome) + } + + /// 用户主动操作后允许重新尝试提权:清掉「被拒绝」的记忆。 + pub(crate) fn clear_denials(&self) { + let mut entries = lock(&self.entries); + entries.retain(|_, entry| { + entry.running || !matches!(entry.outcome, Some(AclRepairOutcome::Denied(_))) + }); + drop(entries); + self.settled.notify_all(); + } + + #[cfg(test)] + pub(crate) fn is_running(&self, key: &K) -> bool { + lock(&self.entries) + .get(key) + .is_some_and(|entry| entry.running) + } +} + +impl Default for AclRepairGate +where + K: Clone + Eq + Hash, +{ + fn default() -> Self { + Self::new() + } +} + +struct LeaderGuard<'a, K: Clone + Eq + Hash> { + gate: &'a AclRepairGate, + key: K, + leader_id: u64, + armed: bool, +} + +impl LeaderGuard<'_, K> { + fn complete(mut self, outcome: AclRepairOutcome) -> AclRepairGateResult { + self.armed = false; + let mut entries = lock(&self.gate.entries); + // 只在仍是当前 leader 时落库:leader 卡死被接管后,迟到的结果必须丢弃, + // 否则会把接管者已经写下的结果覆盖回去。 + if let Some(entry) = entries.get_mut(&self.key) { + if entry.leader_id == self.leader_id { + entry.running = false; + entry.outcome = Some(outcome.clone()); + // 冷却从「结果落库」时刻算起,而不是 leader 起跑时刻:UAC 弹窗可能被挂着 + // 几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期, + // 紧接着的自动重查会立刻再弹一次。 + entry.recorded_at = Some(Instant::now()); + } + } + drop(entries); + self.gate.settled.notify_all(); + AclRepairGateResult::Executed(outcome) + } +} + +impl Drop for LeaderGuard<'_, K> { + /// leader 异常退出时不能让等待者永久挂住:记成失败并唤醒全部等待者。 + fn drop(&mut self) { + if !self.armed { + return; + } + let mut entries = lock(&self.gate.entries); + if let Some(entry) = entries.get_mut(&self.key) { + if entry.leader_id == self.leader_id { + entry.running = false; + entry.outcome = Some(AclRepairOutcome::Failed( + "AGC ACL 提权修复执行线程异常退出".to_string(), + )); + entry.recorded_at = Some(Instant::now()); + } + } + drop(entries); + self.gate.settled.notify_all(); + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn prune(entries: &mut HashMap, now: Instant, policy: &AclRepairPolicy) { + // 只是防止 map 随进程生命周期无限增长;窗口远大于冷却期即可。 + let retention = policy.retention().saturating_mul(4); + entries.retain(|_, entry| { + if entry.running { + return true; + } + entry + .recorded_at + .is_none_or(|recorded_at| now.saturating_duration_since(recorded_at) < retention) + }); +} + +/// 提权修复的进程级闸门;key = (规范化目标路径, scope 名)。 +pub(crate) type AclRepairKey = (String, &'static str); + +pub(crate) static ACL_REPAIR_GATE: LazyLock> = + LazyLock::new(AclRepairGate::new); + +pub(crate) const ACL_REPAIR_POLICY: AclRepairPolicy = AclRepairPolicy { + success_cooldown: Duration::from_secs(30), + denial_cooldown: Duration::from_secs(120), + failure_cooldown: Duration::from_secs(15), + wait_timeout: Duration::from_secs(60), + // 系统对无人应答的 UAC 弹窗约 2 分钟超时,取 5 分钟只兜「真挂死」这一种情况。 + leader_deadline: Duration::from_secs(300), +}; + +/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除「被拒绝」记忆。 +pub(crate) fn clear_acl_repair_denials() { + ACL_REPAIR_GATE.clear_denials(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 16a027c5e..ff25df850 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1874,6 +1874,12 @@ pub(crate) fn read_game_creator_app_config() -> Result = std::sync::Mutex::new(()); #[tauri::command] 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 cfbc650b5..babe29eb0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -2779,15 +2779,50 @@ fn windows_acl_repair_argument_list( .join(" ") } +/// 用户取消 UAC 的稳定错误标记:调用方(前端)据此判定「不可自动重试」, +/// 而不是去匹配中文文案。 +#[cfg(windows)] +pub(crate) const WINDOWS_ACL_REPAIR_DENIED_MARKER: &str = "AGC_ACL_ELEVATION_DENIED"; + +/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除提权拒绝记忆, +/// 使同一次会话内的显式重试仍能再次请求提权。 +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. +/// +/// 同一 (规范化目标, scope) 的修复在进程内做 single-flight:并发调用只会有一次 +/// 真实提权,其余等待并复用结果;冷却窗口内直接复用,避免自动重试反复弹 UAC。 #[cfg(windows)] fn attempt_elevated_windows_acl_repair( path: &Path, target_user_sid: &str, scope: WindowsAclRepairScope, ) -> Result<(), String> { + use crate::acl_repair_gate::{ + AclRepairGateResult, AclRepairOutcome, ACL_REPAIR_GATE, ACL_REPAIR_POLICY, + }; + if !scope.allows_path(path) { return Err(format!( "AGC ACL 提权目标不在当前用户允许的 {} 范围内:{}", @@ -2795,13 +2830,53 @@ fn attempt_elevated_windows_acl_repair( path.display() )); } - let executable = - std::env::current_exe().map_err(|error| format!("定位 AGC ACL 修复程序失败:{error}"))?; - if !executable.is_file() { - return Err("AGC ACL 修复程序不存在".to_string()); - } let repair_path = windows_acl_repair_target(path, scope); - let nonce = create_windows_acl_repair_authorization(&repair_path, target_user_sid, scope)?; + 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) + }); + match gate_result { + AclRepairGateResult::Executed(outcome) | AclRepairGateResult::Reused(outcome) => { + match outcome { + AclRepairOutcome::Repaired => Ok(()), + AclRepairOutcome::Denied(detail) => { + Err(format!("{WINDOWS_ACL_REPAIR_DENIED_MARKER}:{detail}")) + } + AclRepairOutcome::Failed(detail) => Err(detail), + } + } + AclRepairGateResult::WaitTimedOut => Err(format!( + "AGC ACL 提权修复等待超时:同一目标的提权仍在进行中:{}", + repair_path.display() + )), + } +} + +#[cfg(windows)] +fn run_elevated_windows_acl_repair_once( + path: &Path, + target_user_sid: &str, + scope: WindowsAclRepairScope, + repair_path: &Path, +) -> crate::acl_repair_gate::AclRepairOutcome { + use crate::acl_repair_gate::AclRepairOutcome; + + let executable = match std::env::current_exe() { + Ok(executable) => executable, + Err(error) => { + return AclRepairOutcome::Failed(format!("定位 AGC ACL 修复程序失败:{error}")); + } + }; + if !executable.is_file() { + return AclRepairOutcome::Failed("AGC ACL 修复程序不存在".to_string()); + } + let nonce = match create_windows_acl_repair_authorization(repair_path, target_user_sid, scope) { + Ok(nonce) => nonce, + Err(error) => { + return AclRepairOutcome::Failed(format!("准备 AGC ACL 提权授权失败:{error}")); + } + }; let escaped_executable = executable.to_string_lossy().replace('\'', "''"); let arguments = windows_acl_repair_argument_list( &repair_path.to_string_lossy(), @@ -2824,19 +2899,20 @@ fn attempt_elevated_windows_acl_repair( script.as_str(), ]) .creation_flags(0x0800_0000) - .status() - .map_err(|error| format!("启动 AGC ACL 提权修复失败:{error}")); + .status(); let _ = windows_acl_repair_authorization_path(&nonce).and_then(|authorization_path| { fs::remove_file(authorization_path).map_err(|error| error.to_string()) }); - let status = status?; - if status.success() { - Ok(()) - } else { - Err(format!( + match status { + Err(error) => AclRepairOutcome::Failed(format!("启动 AGC ACL 提权修复失败:{error}")), + Ok(status) if status.success() => AclRepairOutcome::Repaired, + Ok(status) if status.code() == Some(1_223) => AclRepairOutcome::Denied( + "AGC ACL 提权修复被用户取消(exit code Some(1223))".to_string(), + ), + Ok(status) => AclRepairOutcome::Failed(format!( "AGC ACL 提权修复未成功(exit code {:?})", status.code() - )) + )), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 061c90877..d0204c71e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -106,6 +106,7 @@ fn register_non_canonical_asset_kind_reporter() { // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs")); +mod acl_repair_gate; mod agent; mod agent_native_tools; mod analytics; @@ -2687,6 +2688,7 @@ fn main() { install_platform_account_session, clear_platform_account_session, read_game_creator_app_config, + clear_game_creator_acl_elevation_denials, write_game_creator_app_config, select_game_creator_model, discover_game_creator_llm_models, 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 new file mode 100644 index 000000000..2cdb2bbe4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs @@ -0,0 +1,376 @@ +use super::*; +use crate::acl_repair_gate::{ + AclRepairGate, AclRepairGateResult, AclRepairOutcome, AclRepairPolicy, +}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +fn test_policy() -> AclRepairPolicy { + AclRepairPolicy { + success_cooldown: Duration::from_secs(30), + denial_cooldown: Duration::from_secs(300), + failure_cooldown: Duration::from_secs(15), + wait_timeout: Duration::from_secs(5), + leader_deadline: Duration::from_secs(300), + } +} + +fn test_key(target: &str) -> (String, &'static str) { + (target.to_string(), "managed") +} + +#[test] +fn concurrent_requests_for_one_target_run_the_repair_once() { + let gate = Arc::new(AclRepairGate::new()); + let executions = Arc::new(AtomicUsize::new(0)); + let started_at = Instant::now(); + + let handles = (0..8) + .map(|_| { + let gate = Arc::clone(&gate); + let executions = Arc::clone(&executions); + std::thread::spawn(move || { + gate.run(test_key("c:\\target"), started_at, &test_policy(), || { + executions.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(150)); + AclRepairOutcome::Repaired + }) + }) + }) + .collect::>(); + let results = handles + .into_iter() + .map(|handle| handle.join().expect("提权闸门线程不得 panic")) + .collect::>(); + + assert_eq!(executions.load(Ordering::SeqCst), 1); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, AclRepairGateResult::Executed(_))) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| matches!( + result, + AclRepairGateResult::Reused(AclRepairOutcome::Repaired) + )) + .count(), + 7 + ); +} + +#[test] +fn different_targets_are_not_deduplicated() { + let gate = AclRepairGate::new(); + let executions = AtomicUsize::new(0); + let now = Instant::now(); + + for target in ["c:\\one", "c:\\two"] { + let result = gate.run(test_key(target), now, &test_policy(), || { + executions.fetch_add(1, Ordering::SeqCst); + AclRepairOutcome::Repaired + }); + assert!(matches!(result, AclRepairGateResult::Executed(_))); + } + + assert_eq!(executions.load(Ordering::SeqCst), 2); +} + +#[test] +fn denied_elevation_is_reused_for_the_denial_cooldown() { + let gate = AclRepairGate::new(); + let key = test_key("c:\\denied"); + let started_at = Instant::now(); + let policy = test_policy(); + + let first = gate.run(key.clone(), started_at, &policy, || { + AclRepairOutcome::Denied("UAC 已取消".to_string()) + }); + assert!(matches!( + first, + AclRepairGateResult::Executed(AclRepairOutcome::Denied(_)) + )); + + let inside_cooldown = gate.run( + key.clone(), + started_at + Duration::from_secs(60), + &policy, + || panic!("拒绝冷却期内不得再次触发提权"), + ); + assert!(matches!( + inside_cooldown, + AclRepairGateResult::Reused(AclRepairOutcome::Denied(_)) + )); + + let after_cooldown = gate.run(key, started_at + Duration::from_secs(301), &policy, || { + AclRepairOutcome::Repaired + }); + assert_eq!( + after_cooldown, + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + ); +} + +#[test] +fn successful_repair_and_failure_are_reused_for_their_own_cooldowns() { + let gate = AclRepairGate::new(); + let policy = test_policy(); + let started_at = Instant::now(); + + let repaired_key = test_key("c:\\repaired"); + assert!(matches!( + gate.run(repaired_key.clone(), started_at, &policy, || { + AclRepairOutcome::Repaired + }), + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + )); + assert_eq!( + gate.run( + repaired_key.clone(), + started_at + Duration::from_secs(29), + &policy, + || panic!("成功冷却期内不得重复提权") + ), + AclRepairGateResult::Reused(AclRepairOutcome::Repaired) + ); + assert!(matches!( + gate.run( + repaired_key, + started_at + Duration::from_secs(31), + &policy, + || { AclRepairOutcome::Repaired } + ), + AclRepairGateResult::Executed(_) + )); + + let failed_key = test_key("c:\\failed"); + assert!(matches!( + gate.run(failed_key.clone(), started_at, &policy, || { + AclRepairOutcome::Failed("提权修复退出码 1".to_string()) + }), + AclRepairGateResult::Executed(AclRepairOutcome::Failed(_)) + )); + assert!(matches!( + gate.run( + failed_key.clone(), + started_at + Duration::from_secs(14), + &policy, + || { panic!("失败冷却期内不得重复提权") } + ), + AclRepairGateResult::Reused(AclRepairOutcome::Failed(_)) + )); + assert!(matches!( + gate.run( + failed_key, + started_at + Duration::from_secs(16), + &policy, + || { AclRepairOutcome::Repaired } + ), + AclRepairGateResult::Executed(_) + )); +} + +#[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 stale_leader_is_taken_over_and_its_late_result_is_discarded() { + // 真机场景:`Start-Process -Wait` 挂死时,follower 等到 60s 只会失败关闭, + // 而这个 key 会被永久占住(clear_denials 也不清理 running)——只能重启客户端。 + // 超过 leader_deadline 必须允许接管,且旧 leader 迟到的结果不得覆盖接管者。 + let gate = Arc::new(AclRepairGate::new()); + let key = test_key("c:\\stale-leader"); + let policy = AclRepairPolicy { + leader_deadline: Duration::from_millis(150), + ..test_policy() + }; + let started_at = Instant::now(); + let slow = { + let gate = Arc::clone(&gate); + let key = key.clone(); + std::thread::spawn(move || { + gate.run(key, started_at, &policy, || { + std::thread::sleep(Duration::from_millis(400)); + AclRepairOutcome::Failed("卡死的 leader 迟到落库".to_string()) + }) + }) + }; + + std::thread::sleep(Duration::from_millis(250)); + let taken_over = gate.run(key.clone(), Instant::now(), &policy, || { + AclRepairOutcome::Repaired + }); + assert_eq!( + taken_over, + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + ); + + assert!(matches!( + slow.join().expect("leader 线程不得 panic"), + AclRepairGateResult::Executed(AclRepairOutcome::Failed(_)) + )); + assert_eq!( + gate.run(key, Instant::now(), &policy, || { + panic!("冷却内必须复用接管者的结果,不得再执行") + }), + AclRepairGateResult::Reused(AclRepairOutcome::Repaired) + ); +} + +#[test] +fn clearing_denials_allows_an_explicit_user_retry() { + let gate = AclRepairGate::new(); + let key = test_key("c:\\denied-cleared"); + let started_at = Instant::now(); + let policy = test_policy(); + + gate.run(key.clone(), started_at, &policy, || { + AclRepairOutcome::Denied("UAC 已取消".to_string()) + }); + gate.clear_denials(); + + let retried = gate.run(key, started_at + Duration::from_secs(1), &policy, || { + AclRepairOutcome::Repaired + }); + assert_eq!( + retried, + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + ); +} + +#[test] +fn followers_give_up_when_the_leader_never_finishes() { + let gate = Arc::new(AclRepairGate::new()); + let key = test_key("c:\\slow"); + let (release_sender, release_receiver) = std::sync::mpsc::channel::<()>(); + let leader_gate = Arc::clone(&gate); + let leader_key = key.clone(); + let leader = std::thread::spawn(move || { + leader_gate.run(leader_key, Instant::now(), &test_policy(), || { + let _ = release_receiver.recv_timeout(Duration::from_secs(5)); + AclRepairOutcome::Repaired + }) + }); + + let policy = AclRepairPolicy { + wait_timeout: Duration::from_millis(50), + ..test_policy() + }; + let follower = std::thread::spawn(move || { + gate.run(key, Instant::now(), &policy, || { + panic!("follower 不得自行执行提权") + }) + }); + let follower_result = follower.join().expect("follower 线程不得 panic"); + assert_eq!(follower_result, AclRepairGateResult::WaitTimedOut); + + release_sender.send(()).expect("放行 leader"); + assert!(matches!( + leader.join().expect("leader 线程不得 panic"), + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + )); +} + +#[test] +fn leader_panic_releases_followers_instead_of_letting_them_wait() { + let gate = Arc::new(AclRepairGate::new()); + let key = test_key("c:\\panicking"); + let (entered_sender, entered_receiver) = std::sync::mpsc::channel::<()>(); + let leader_gate = Arc::clone(&gate); + let leader_key = key.clone(); + let leader = std::thread::spawn(move || { + leader_gate.run(leader_key, Instant::now(), &test_policy(), || { + entered_sender.send(()).expect("通知 follower"); + panic!("提权执行线程异常退出"); + }) + }); + entered_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("leader 已进入执行"); + + let follower_gate = Arc::clone(&gate); + let follower = std::thread::spawn(move || { + follower_gate.run(key, Instant::now(), &test_policy(), || { + panic!("follower 不得自行执行提权") + }) + }); + assert!(leader.join().is_err()); + let follower_result = follower.join().expect("follower 线程不得 panic"); + assert!(matches!( + follower_result, + AclRepairGateResult::Reused(AclRepairOutcome::Failed(_)) + )); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 421d32605..cea4b4352 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -6150,6 +6150,7 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() { fs::remove_dir_all(root).ok(); } +mod acl_repair_gate; mod asset_delete; mod asset_rename; mod collaboration; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/aclElevation.ts b/apps/ai-game-creator-shell/src/features/app-shell/aclElevation.ts new file mode 100644 index 000000000..373ffb56a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/aclElevation.ts @@ -0,0 +1,23 @@ +import { resolveTauriInvoke } from '../../app/tauri'; + +/** + * 用户主动操作(打开/新建项目、选择目录、重命名刷新)时调用:解除 Rust 侧的 ACL 提权拒绝记忆。 + * + * Rust 侧闸门对「用户取消 UAC」有 120s 冷却,冷却期内同一目标的提权请求直接复用拒绝结果、 + * 不再弹窗。所以只要入口是明确的用户动作,就必须先清掉这份记忆,否则用户会看到 + * 「点了打开却立刻失败、也不问我要不要授权」。 + * + * 零成本失败关闭:不在 Tauri 环境直接返回;命令失败也只吞掉(下一次用户操作会再试), + * 不能让「重置拒绝记忆」这种旁路动作影响本次操作本身。 + */ +export function clearAclElevationDenials() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + try { + void invoke('clear_game_creator_acl_elevation_denials').catch(() => {}); + } catch { + // 命令缺失等同步异常同样不影响本次操作:这只是一次旁路清零。 + } +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index f47f69b4a..75b0e8cb7 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -54,6 +54,7 @@ import { projectPathHasControlCharacter, } from '../project-summary/projectSummary'; import { importDesignFiles } from '../project-workspace/importDesignFiles'; +import { clearAclElevationDenials } from './aclElevation'; import { ensureHomeWebCreationEnvironment, HOME_WEB_PREFLIGHT_FAILURE, @@ -614,6 +615,9 @@ export function useHomeProjectCreation({ mode: 'open' | 'create', analytics?: ProjectOpenAnalytics, ) { + // 打开/新建是明确的用户动作:先解除 Rust 侧的提权拒绝记忆,否则 120s 冷却内 + // 首条 inspect_local_project_directory 会直接复用「用户取消」的结果,既不弹 UAC 也打不开。 + clearAclElevationDenials(); if (mode === 'create') { await createProjectFromProjectPage(nextProjectPath); return; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index 00080005e..eba55356d 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -13,6 +13,7 @@ import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from '../project-summary/projectSummary'; +import { clearAclElevationDenials } from './aclElevation'; import { buildRecentProjectRows, readRecentWorkspaces, @@ -27,10 +28,12 @@ const RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS = [300]; const RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS = [15_000, 45_000, 120_000]; /** * 提权/权限类失败不重试:Rust 侧会重新走 `Start-Process -Verb RunAs -Wait`, - * 而提权闸门只存在于单次 invoke 内,重试等于在用户刚点「否」后再弹一次 UAC。 - * 判据与 config.rs 的 `windows_acl_error_may_need_elevation` 同口径。 + * 重试等于在用户刚点「否」后再弹一次 UAC。 + * `AGC_ACL_ELEVATION_DENIED` 是 Rust 侧用户取消 UAC 的稳定标记(config.rs), + * 其余为 ACL/DACL 判据与历史文案,与 `windows_acl_error_may_need_elevation` 同口径。 */ const RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS = [ + 'AGC_ACL_ELEVATION_DENIED', 'DACL', '权限', 'error 5', @@ -223,7 +226,7 @@ export function useRecentProjects(setStatus: Dispatch>) { }, [recentWorkspaces, recentWorkspaceRefreshKey]); function rememberRecentWorkspace(projectPath: string) { - // 用户主动打开或新建项目:解除提权类失败的跳过标记。 + clearAclElevationDenials(); nonRetryablePathsRef.current.clear(); setRecentWorkspaces(writeRecentWorkspace(projectPath)); setRecentWorkspaceRefreshKey((current) => current + 1); @@ -234,6 +237,7 @@ export function useRecentProjects(setStatus: Dispatch>) { if (!invoke) { return; } + clearAclElevationDenials(); nonRetryablePathsRef.current.delete(projectPath); const inspection = await inspectRecentWorkspaceWithRetry( invoke, diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index e081afb8b..9fcfbbf19 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2883,6 +2883,9 @@ export function registerRecentProjectsTests() { '厨房突围', ); } + if (command === 'clear_game_creator_acl_elevation_denials') { + return undefined; + } if (command === 'open_game_creator_workspace_window') { return undefined; } @@ -2986,10 +2989,25 @@ export function registerRecentProjectsTests() { { projectPath: '/tmp/broken-status' }, ); + // 打开是明确的用户动作:必须先解除 Rust 侧的提权拒绝记忆,再 inspect。 + // 否则 120s 拒绝冷却内首条 inspect 直接复用「用户取消」的结果:既不弹 UAC,也打不开项目。 + const callsBeforeOpen = invoke.mock.calls.length; fireEvent.click(screen.getByRole('button', { name: '打开项目 厨房突围' })); await waitFor(() => { expect(screen.getByLabelText('陶泥儿项目对话')).not.toBeNull(); }); + const openedCalls = invoke.mock.calls.slice(callsBeforeOpen); + const clearedAt = openedCalls.findIndex( + ([command]) => command === 'clear_game_creator_acl_elevation_denials', + ); + const inspectedAt = openedCalls.findIndex( + ([command, args]) => + command === 'inspect_local_project_directory' && + (args as { projectPath?: string } | undefined)?.projectPath === + '/tmp/ok-game', + ); + expect(clearedAt).toBeGreaterThanOrEqual(0); + expect(inspectedAt).toBeGreaterThan(clearedAt); expect(screen.getByLabelText('项目开发工作台')).not.toBeNull(); expect(invoke).not.toHaveBeenCalledWith( 'open_game_creator_workspace_window', diff --git a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx index 7abb15b64..c94636a71 100644 --- a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx +++ b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx @@ -206,3 +206,30 @@ test('提权类失败不重试:不放大 UAC 弹窗', async () => { }); expect(attempts).toBe(1); }); + +test('Rust 侧取消 UAC 的稳定标记同样不触发重试', async () => { + let attempts = 0; + const invoke = vi.fn( + async (command: string, _args?: Record) => { + if (command !== 'inspect_local_project_directory') { + throw new Error(`unexpected invoke ${command}`); + } + attempts += 1; + throw new Error( + 'AGC_ACL_ELEVATION_DENIED:AGC ACL 提权修复被用户取消(exit code Some(1223))', + ); + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify(['/tmp/denied-elevation-project']), + ); + + const { result } = renderHook(() => useRecentProjects(vi.fn())); + + await waitFor(() => { + expect(result.current.projectRows[0]?.status).toBe('检查失败'); + }); + expect(attempts).toBe(1); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f0d4029e1..cfe9ecc7e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,16 @@ # 决策记录 +## 2026-09-23 ACL 提权修复按目标做 single-flight + +- 背景:`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 的整表重查紧跟着再弹一次。 +- 决策补充(leader 失效接管):`leader_deadline`(默认 5 分钟)之后,新调用可以接管仍是 `running` 的 key;每个 leader 带令牌,被接管后旧 leader 迟到的结果直接丢弃,不会覆盖接管者的结果。真机上无人应答的 UAC 约 2 分钟自然超时,所以这个上限只兜「提权子进程真挂死」——否则该目标会永久按失败关闭(`clear_denials` 不清理 running,只能重启客户端)。 +- 错误类型化:用户取消 UAC 的错误统一带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此判定「不可自动重试」,不再依赖中文文案匹配。 +- 用户主动操作(打开/新建项目、文件选择器选择目录、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。前端唯一入口是 `features/app-shell/aclElevation.ts` 的 `clearAclElevationDenials()`:最近项目 hook(`rememberRecentWorkspace` / `refreshRecentWorkspace`)与打开/新建链路(`useHomeProjectCreation.openProject`,覆盖行内打开与 picker)共用它;漏挂入口会让用户「点了打开立即失败、也不问授权」。 +- 未做:给提权子进程加有界等待(`Start-Process -Wait` 目前无超时)。理由:中断挂起的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 的等待由 60s 窗口兜底。 + ## 2026-09-24 DirectProject 状态条口径翻转、几何约束与对话 Markdown 容错 - 背景:AGC DirectProject 对话区底部的「陶泥儿正在处理 / 已耗时 12.4秒」状态条同时退化三处:① 读秒 1 秒一跳(耗时文案不足一分钟显示一位小数,小数位却一秒才动一格);② 窗口压矮时被挤扁(300px 高压到 33px、240px 时 24px,文字被 `overflow: hidden` 裁掉);③ `turn.started` 之前(模型首 token 前,实测约十秒)整条卡片不出现,界面没有任何「正在处理」的交代。同批还修了对话 Markdown 的两处代码块问题(不换行把消息拉宽、粘在正文行里的围栏导致代码块解析错位)。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index de4f438cb..cc779a9a5 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,15 @@ # 踩坑与排障记录 +## 同一祖先下的多个项目会各自弹一次 UAC + +- **现象**: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`,前端据此不自动重试;用户主动操作会清除拒绝记忆。 +- **不要踩的坑**:① 闸门 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 唤醒等待者、冷却基准、路径写法归一、leader 卡死接管与迟到结果丢弃)。真机复现(无需提权交互即可计数):在 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 个)。 +- **leader 卡死的兜底**:闸门只有 follower 的有界等待(60s),若提权子进程真的挂死(`Start-Process -Wait` 无超时),`leader_deadline`(5 分钟)之前该 key 一直被占住,之后新调用会接管并按新 leader 执行;被接管后旧 leader 迟到的结果按令牌丢弃,不会覆盖接管者。`clear_game_creator_acl_elevation_denials` 只清「被拒绝」记忆,不清理 running。 +- **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。 + > 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 ## 2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析