Compare commits

...

4 Commits

Author SHA1 Message Date
suzmii f056c8a325 Merge remote-tracking branch 'origin/master' into fix/acl-elevation-single-flight
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m9s
Project CI / Backend tests (pull_request) Successful in 4m43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m14s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m4s
Project CI / Frontend tests (pull_request) Successful in 2m27s
Project CI / Native shell tests (pull_request) Successful in 6m16s
Project CI / Repository checks (pull_request) Successful in 2m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m45s
2026-09-23 20:13:56 +08:00
suzmii d94ee96836 补一条用例:Rust 侧取消 UAC 的稳定标记同样不触发重试
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
- tests/recentProjectsHook:AGC_ACL_ELEVATION_DENIED 这类失败只检查一次,钉住 Rust 错误标记与前端「不可重试」判定之间的契约
2026-09-23 20:11:34 +08:00
suzmii c079003719 Merge remote-tracking branch 'origin/master' into fix/acl-elevation-single-flight
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	docs/project-memory/shared-memory/pitfalls.md
2026-09-23 19:59:41 +08:00
suzmii af099abbfa AGC ACL 提权修复按目标做 single-flight,避免并发重复弹 UAC
- 新增 acl_repair_gate:以 (规范化 repair target, scope) 为 key 的进程级 single-flight 与结果冷却(成功 30s / 失败 15s / 用户取消 120s),等待窗口 60s 超时按失败关闭
- acl_repair_gate:leader 异常退出由 RAII 兜底记为失败并唤醒全部等待者,避免等待者被永久挂住
- config:提权修复改经闸门执行;用户取消 UAC 统一返回稳定标记 AGC_ACL_ELEVATION_DENIED,不再依赖中文文案判定
- commands/main:新增 clear_game_creator_acl_elevation_denials,供用户主动操作时解除拒绝记忆
- useRecentProjects:识别新的稳定标记;打开/新建项目与重命名刷新时清除提权拒绝记忆
- tests/acl_repair_gate:并发只执行一次、冷却复用、清除后可重试、follower 超时、leader panic 唤醒等待者
- docs:decision-log 与 pitfalls 记录 single-flight 取舍与未做项
2026-09-23 19:44:06 +08:00
12 changed files with 671 additions and 24 deletions
@@ -0,0 +1,244 @@
//! ACL 提权修复目标的并发去重与结果记忆。
//!
//! 同一目标被并发请求时只允许一次真实提权,其余调用等待并复用同一结果;
//! 结果在冷却窗口内直接复用,其中用户拒绝(UAC 取消)的窗口最长,
//! 避免自动重试把用户反复拽回安全桌面。
use std::collections::HashMap;
use std::hash::Hash;
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,
}
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<AclRepairOutcome>,
recorded_at: Option<Instant>,
}
pub(crate) struct AclRepairGate<K> {
entries: Mutex<HashMap<K, Entry>>,
settled: Condvar,
}
impl<K: Clone + Eq + Hash> AclRepairGate<K> {
pub(crate) fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
settled: Condvar::new(),
}
}
/// 以 `key` 为粒度执行一次提权修复:并发调用只会有一次真正执行,
/// 其余调用等待并复用结果;冷却窗口内直接复用上一次结果。
pub(crate) fn run<F>(
&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 => {
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);
entries.insert(
key.clone(),
Entry {
running: true,
outcome: None,
recorded_at: Some(now),
},
);
drop(entries);
let guard = LeaderGuard {
gate: self,
key: key.clone(),
recorded_at: now,
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<K> Default for AclRepairGate<K>
where
K: Clone + Eq + Hash,
{
fn default() -> Self {
Self::new()
}
}
struct LeaderGuard<'a, K: Clone + Eq + Hash> {
gate: &'a AclRepairGate<K>,
key: K,
recorded_at: Instant,
armed: bool,
}
impl<K: Clone + Eq + Hash> LeaderGuard<'_, K> {
fn complete(mut self, outcome: AclRepairOutcome) -> AclRepairGateResult {
self.armed = false;
let mut entries = lock(&self.gate.entries);
entries.insert(
self.key.clone(),
Entry {
running: false,
outcome: Some(outcome.clone()),
recorded_at: Some(self.recorded_at),
},
);
drop(entries);
self.gate.settled.notify_all();
AclRepairGateResult::Executed(outcome)
}
}
impl<K: Clone + Eq + Hash> Drop for LeaderGuard<'_, K> {
/// leader 异常退出时不能让等待者永久挂住:记成失败并唤醒全部等待者。
fn drop(&mut self) {
if !self.armed {
return;
}
let mut entries = lock(&self.gate.entries);
entries.insert(
self.key.clone(),
Entry {
running: false,
outcome: Some(AclRepairOutcome::Failed(
"AGC ACL 提权修复执行线程异常退出".to_string(),
)),
recorded_at: Some(self.recorded_at),
},
);
drop(entries);
self.gate.settled.notify_all();
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn prune<K: Eq + Hash>(entries: &mut HashMap<K, Entry>, 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<AclRepairGate<AclRepairKey>> =
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),
};
/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除「被拒绝」记忆。
pub(crate) fn clear_acl_repair_denials() {
ACL_REPAIR_GATE.clear_denials();
}
@@ -1874,6 +1874,12 @@ pub(crate) fn read_game_creator_app_config() -> Result<GameCreatorAppConfigView,
game_creator_app_config_view(load_game_creator_app_config()?)
}
/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除 ACL 提权拒绝记忆。
#[tauri::command]
pub(crate) fn clear_game_creator_acl_elevation_denials() {
crate::config::clear_windows_acl_repair_denials();
}
static GAME_CREATOR_CONFIG_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[tauri::command]
@@ -2789,15 +2789,33 @@ 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();
}
/// 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 提权目标不在当前用户允许的 {} 范围内:{}",
@@ -2805,13 +2823,56 @@ 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 = (
repair_path.to_string_lossy().to_lowercase(),
scope.wire_name(),
);
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(),
@@ -2834,19 +2895,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()
))
)),
}
}
@@ -107,6 +107,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,
@@ -0,0 +1,260 @@
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),
}
}
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::<Vec<_>>();
let results = handles
.into_iter()
.map(|handle| handle.join().expect("提权闸门线程不得 panic"))
.collect::<Vec<_>>();
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 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(_))
));
}
@@ -6127,6 +6127,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;
+21 -6
View File
@@ -459,7 +459,11 @@ export interface AgentRuntimeResult {
}
export type AgentRuntimeResponseStreamStatus =
'streaming' | 'ready' | 'committed' | 'discarded' | 'failed';
| 'streaming'
| 'ready'
| 'committed'
| 'discarded'
| 'failed';
export interface AgentRuntimeResponseStream {
schemaVersion: string;
@@ -565,13 +569,22 @@ export interface GameCreatorAgentLlmConfigStatus {
}
export type GameCreatorLlmApiKind =
'openai_responses' | 'openai_chat' | 'anthropic';
| 'openai_responses'
| 'openai_chat'
| 'anthropic';
export type GameCreatorAgentMode =
'codex_app_server' | 'codex_cli' | 'provider';
| 'codex_app_server'
| 'codex_cli'
| 'provider';
export type RuntimeLlmProviderPresetId =
'custom' | 'openai' | 'deepseek' | 'anthropic' | 'ark';
| 'custom'
| 'openai'
| 'deepseek'
| 'anthropic'
| 'ark';
export type RuntimeAgentLlmProviderPresetId =
'inherit' | RuntimeLlmProviderPresetId;
| 'inherit'
| RuntimeLlmProviderPresetId;
export interface GameCreatorLlmConfig {
customEnabled?: boolean;
@@ -931,7 +944,9 @@ export type GameCreatorDirectToolCallKind =
| 'other';
export type GameCreatorDirectToolCallStatus =
'running' | 'completed' | 'failed';
| 'running'
| 'completed'
| 'failed';
export interface GameCreatorDirectToolCallChange {
path: string;
@@ -27,10 +27,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',
@@ -222,8 +224,19 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
};
}, [recentWorkspaces, recentWorkspaceRefreshKey]);
/** 用户主动操作后解除 Rust 侧的提权拒绝记忆(否则冷却期内不会再请求提权)。 */
function resetAclElevationDenials() {
const invoke = resolveTauriInvoke();
if (!invoke) {
return;
}
void invoke('clear_game_creator_acl_elevation_denials').catch(() => {
// 重置失败不影响本次列表刷新:下一次用户操作会再试。
});
}
function rememberRecentWorkspace(projectPath: string) {
// 用户主动打开或新建项目:解除提权类失败的跳过标记。
resetAclElevationDenials();
nonRetryablePathsRef.current.clear();
setRecentWorkspaces(writeRecentWorkspace(projectPath));
setRecentWorkspaceRefreshKey((current) => current + 1);
@@ -234,6 +247,7 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
if (!invoke) {
return;
}
resetAclElevationDenials();
nonRetryablePathsRef.current.delete(projectPath);
const inspection = await inspectRecentWorkspaceWithRetry(
invoke,
@@ -198,7 +198,7 @@ export function PlanningChatView({
: undefined;
const streaming = Boolean(
animation &&
(!animation.persisted || animation.visible !== animation.target),
(!animation.persisted || animation.visible !== animation.target),
);
return (
<div
@@ -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<string, unknown>) => {
if (command !== 'inspect_local_project_directory') {
throw new Error(`unexpected invoke ${command}`);
}
attempts += 1;
throw new Error(
'AGC_ACL_ELEVATION_DENIEDAGC 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);
});
@@ -1,5 +1,13 @@
# 决策记录
## 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 兜底记为失败并唤醒全部等待者,避免等待者被永久挂住。
- 错误类型化:用户取消 UAC 的错误统一带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此判定「不可自动重试」,不再依赖中文文案匹配。
- 用户主动操作(打开/新建项目、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。
- 未做:给提权子进程加有界等待(`Start-Process -Wait` 目前无超时)。理由:中断挂起的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 的等待由 60s 窗口兜底。
## 2026-09-23 自绘标题栏是窗口边框:弹层从它下方开始,焦点陷阱放行它
- 背景:AGC 打开任意一个 `ThemedModal` 弹窗(发布面板、发布进度、资源预览、账本、错误报告等)后,右上角「最小化 / 最大化 / 关闭」点击没有任何反应,标题栏拖拽也不能移动窗口;关掉弹窗立刻恢复。原因是标题栏在模态之外,而 `focus-trap-react` 在 document 捕获阶段监听 `mousedown`/`touchstart`/`click`,模态外的点击被 `preventDefault()``click` 直接 `stopImmediatePropagation()` —— React 的监听在更内层,事件到不了它,所以表现是「点了没反应」而不是报错。另有 `.app-update-overlay``inset: 0` 真的把标题栏盖住了。
@@ -1,5 +1,13 @@
# 踩坑与排障记录
## 同一祖先下的多个项目会各自弹一次 UAC
- **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。
- **原因**`windows_acl_repair_target``src-tauri/src/config.rs`)对 Managed 作用域返回「第一个读取被拒的祖先」——同一祖先下的多个项目解析到**同一个** repair target;而唯一的去重是单次调用内的局部 `attempted_targets`,跨调用、跨线程都没有记忆。启动页一次并发检查 ≤8 个最近项目,就会并发启动同样多次 `powershell -Verb RunAs`
- **处理**:进程级 single-flightkey = `(规范化 repair target, scope)`)+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ 等待窗口 60s 超时按失败关闭;leader 异常退出由 RAII 兜底唤醒等待者。用户取消带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此不自动重试;用户主动操作会清除拒绝记忆。
- **验证**`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者)。
- **关联**`src-tauri/src/acl_repair_gate.rs``src-tauri/src/config.rs`、issue #498
## 策划回复的重复终态不能重新启动伪流式
策划 Runtime 会通过状态事件与命令返回交付同一份最终视图。若前端清空临时正文后再拿“最后一条非用户历史消息”回填动画,就会出现正式回复旁又播放一遍、播放后消失的假重试。正文应按 `messageId` 保存显示进度,与正式消息共用一个气泡;请求完成不清动画,不延迟正式业务状态。Provider 自动重试复用消息 ID 并发送空文本,只允许重置未持久化的该条回复。正文、工具状态和 reasoning 分开;事件与异步命令收尾均检查项目及活动回合,旧请求不能覆盖新回合。详见 [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。