Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1931852e9e | |||
| 9034b8a2d3 | |||
| 3480a2f331 | |||
| dcb4f2a1c1 | |||
| 67d5ad6026 | |||
| e80279c174 | |||
| f056c8a325 | |||
| d94ee96836 | |||
| c079003719 | |||
| af099abbfa |
@@ -0,0 +1,245 @@
|
||||
//! 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(),
|
||||
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,
|
||||
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()),
|
||||
// 冷却从「结果落库」时刻算起,而不是 leader 起跑时刻:UAC 弹窗可能被挂着
|
||||
// 几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,
|
||||
// 紧接着的自动重查会立刻再弹一次。
|
||||
recorded_at: Some(Instant::now()),
|
||||
},
|
||||
);
|
||||
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(Instant::now()),
|
||||
},
|
||||
);
|
||||
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,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 提权目标不在当前用户允许的 {} 范围内:{}",
|
||||
@@ -2805,13 +2840,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(),
|
||||
@@ -2834,19 +2909,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,331 @@
|
||||
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 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();
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
// 命令缺失等同步异常同样不影响本次操作:这只是一次旁路清零。
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SetStateAction<string>>) {
|
||||
}, [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<SetStateAction<string>>) {
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
clearAclElevationDenials();
|
||||
nonRetryablePathsRef.current.delete(projectPath);
|
||||
const inspection = await inspectRecentWorkspaceWithRetry(
|
||||
invoke,
|
||||
|
||||
@@ -2705,21 +2705,27 @@ textarea {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 运行中状态条(「陶泥儿正在处理」+ 右侧已耗时):**单行**。
|
||||
它渲染在消息列表**外**、紧贴输入盒上方(见文件末尾 `.project-chat-conversation >
|
||||
.project-chat-process-card` 那条),所以这里只管这一行的几何与配色:左右内缩与上下间距
|
||||
由宿主的对话面板补。卡片经历过「多行摘要 + 单行标题」的旧面板设计,留下了 78px 最小高度
|
||||
与 9px 行距;摘要行在 DirectProject 收敛后已不存在,那两个空位只剩空白。
|
||||
保持 grid(单列)而不是 flex:header 才会被拉满行宽,已耗时的 `margin-left: auto` 才有
|
||||
余量可以吃掉——flex 下 header 收缩到内容宽,auto 边距恒为 0,耗时只能贴着标题。 */
|
||||
.project-chat-process-card {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
min-height: 78px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #b9d8c5;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--platform-subpanel-border, #e1ccbb);
|
||||
border-radius: 8px;
|
||||
background: #f1f8f3;
|
||||
background: var(--platform-neutral-bg, #fffdfa);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 扫光:用主色而不是白色高光。底色是半透明白,白色高光在它上面几乎看不见,
|
||||
「正在处理」于是只剩下一枚脉动点,整条状态看着像静止的。 */
|
||||
.project-chat-process-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -2729,7 +2735,7 @@ textarea {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgb(255 255 255 / 28%),
|
||||
color-mix(in srgb, var(--platform-accent) 18%, transparent),
|
||||
transparent
|
||||
);
|
||||
pointer-events: none;
|
||||
@@ -2752,27 +2758,44 @@ textarea {
|
||||
|
||||
.project-chat-process-card > header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 状态点:主色 + 同色光晕。原先写死绿色(`#2f855a`),工作台里那条想把它换成主色的覆盖规则
|
||||
挂在 `… .project-chat-message-list > .project-chat-process-card` 上,而卡片渲染在列表**外**,
|
||||
那条规则一直没生效,于是暖色面板上挂着一枚绿点。 */
|
||||
.project-chat-process-card > header > span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: #2f855a;
|
||||
box-shadow: 0 0 0 4px rgb(47 133 90 / 14%);
|
||||
background: var(--platform-accent, #2f855a);
|
||||
box-shadow: 0 0 0 4px
|
||||
color-mix(in srgb, var(--platform-accent) 16%, transparent);
|
||||
animation: project-chat-process-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 标题是这张卡的唯一主信息:它挂在过程层级(12px / 次要色)下,这里把它提到正文强色,
|
||||
否则整条状态在浅色底上只剩一片灰。 */
|
||||
.project-chat-process-card > header > strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--platform-text-strong);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-chat-process-elapsed {
|
||||
margin-left: auto !important;
|
||||
color: inherit;
|
||||
margin-left: auto;
|
||||
color: var(--platform-text-base);
|
||||
font-size: inherit;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
/* 100ms 一跳:等宽数字才不会每跳一次就左右抖一下。 */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@keyframes project-chat-process-pulse {
|
||||
@@ -2792,37 +2815,6 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
.project-chat-process-card p {
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
max-height: 1.55em;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-chat-process-card p.is-expanded {
|
||||
display: block;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
scrollbar-color: rgb(47 111 75 / 58%) transparent;
|
||||
}
|
||||
|
||||
.project-chat-process-card p.is-expanded::-webkit-scrollbar-track,
|
||||
.project-chat-process-card p.is-expanded::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.game-workbench-chat .project-chat-process-card {
|
||||
border-color: var(--platform-surface-border);
|
||||
background: var(--platform-warm-bg);
|
||||
}
|
||||
|
||||
.project-chat-surface .agent-runtime-status {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -9303,13 +9295,6 @@ iframe.preview-frame {
|
||||
color: var(--platform-text-base);
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-message-list
|
||||
> .project-chat-process-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.game-workbench-chat .agent-runtime-status {
|
||||
max-height: clamp(120px, 24dvh, 240px);
|
||||
min-height: 0;
|
||||
@@ -12124,24 +12109,6 @@ button.design-workspace-tree__entry:hover,
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* 执行过程卡在暖色皮肤下不再用绿色系:改成中性描边 + 暖底,主色只留给状态点与发送钮。 */
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-message-list
|
||||
> .project-chat-process-card {
|
||||
border-color: var(--platform-line-soft);
|
||||
background: var(--platform-neutral-bg);
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-message-list
|
||||
> .project-chat-process-card
|
||||
> header
|
||||
> span {
|
||||
background: var(--platform-accent);
|
||||
}
|
||||
|
||||
/* 设置浮层(新增):独立 backdrop + dialog,不再往面板下面追加内容。 */
|
||||
.project-chat-settings-backdrop {
|
||||
position: fixed;
|
||||
@@ -12609,14 +12576,15 @@ button.design-workspace-tree__entry:hover,
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 过程卡(「任务执行中 / 正在思考中」)现在渲染在消息列表**外**、紧贴输入盒上方(固定在
|
||||
输入框上面,不随消息滚走)。它不再继承消息列表的左右 16px 内缩,所以要自己补齐,
|
||||
才能与消息内容、输入盒两侧对齐;离开列表后列表的 padding-bottom 也不再作用于它。 */
|
||||
/* 过程卡(「陶泥儿正在处理」)现在渲染在消息列表**外**、紧贴输入盒上方(固定在输入框上面,
|
||||
不随消息滚走)。它不再继承消息列表的左右 16px 内缩,所以要自己补齐,才能与消息内容、
|
||||
输入盒两侧对齐;离开列表后列表的 padding-bottom 也不再作用于它,与最后一条消息的间距
|
||||
同样由这里给。 */
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-conversation
|
||||
> .project-chat-process-card {
|
||||
margin: 0 16px 8px;
|
||||
margin: 12px 16px 8px;
|
||||
}
|
||||
|
||||
/* 工具调用折叠块块头改成两行:第一行图标 + 汇总(超长省略),第二行状态 + 用时,箭头右侧跨两行。
|
||||
|
||||
+6
-15
@@ -1,11 +1,7 @@
|
||||
import {
|
||||
type RefObject,
|
||||
type UIEventHandler,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { RefObject, UIEventHandler } from 'react';
|
||||
|
||||
import { AgentMessageContent } from '../../../../../../../../packages/shared/src/components/AgentMessageContent';
|
||||
import { useLiveNow } from '../../../../../features/project-workspace/useLiveNow';
|
||||
import type { DirectChatTurn } from '../../conversation/directTurnPresentation';
|
||||
import { formatTurnDuration } from '../ToolCallGroup/toolCallGroupPresentation';
|
||||
import { DirectProjectTurn } from './DirectProjectTurn';
|
||||
@@ -82,17 +78,12 @@ export function DirectProjectConversation({
|
||||
/**
|
||||
* 运行中回合的已耗时。
|
||||
*
|
||||
* 计时器只属于这一小块:秒级 tick 不该把整份回合列表(Markdown、工具组、队列芯片)
|
||||
* 一起重渲染。
|
||||
* 时钟订在**这一行**上(叶子节点),走对话侧唯一的 `useLiveNow`(100ms):耗时文案不足
|
||||
* 一分钟保留一位小数,秒级 tick 会让那个小数位一秒才动一格,看着像卡住不动。
|
||||
* 100ms 的 tick 也只重建这块文案,不牵动回合列表(Markdown、工具组、队列芯片)。
|
||||
*/
|
||||
function DirectTurnElapsed({ startedAt }: { startedAt: number }) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (startedAt <= 0) return undefined;
|
||||
setNow(Date.now());
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [startedAt]);
|
||||
const now = useLiveNow(startedAt > 0);
|
||||
if (startedAt <= 0) return null;
|
||||
return (
|
||||
<em className="project-chat-process-elapsed">
|
||||
|
||||
@@ -72,7 +72,6 @@ describe('AgentMessageContent', () => {
|
||||
['.agent-tool-call-group'],
|
||||
[
|
||||
'.project-chat-process-card',
|
||||
'.game-workbench-chat .project-chat-process-card',
|
||||
'.game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-conversation > .project-chat-process-card',
|
||||
],
|
||||
]) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -6336,7 +6336,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(styleNumber(editorRule, 'min-height')).toBe(96);
|
||||
});
|
||||
|
||||
it('keeps workbench chat bubbles aligned without shrinking process cards', () => {
|
||||
it('keeps workbench chat bubbles aligned and the process card inset with them', () => {
|
||||
const styles = readFileSync(
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
@@ -6355,7 +6355,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
)?.[1] ?? '';
|
||||
const processCardRules = Array.from(
|
||||
styles.matchAll(
|
||||
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-message-list\s*>\s*\.project-chat-process-card\s*\{([^}]*)\}/gs,
|
||||
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-conversation\s*>\s*\.project-chat-process-card\s*\{([^}]*)\}/gs,
|
||||
),
|
||||
(match) => match[1],
|
||||
);
|
||||
@@ -6371,10 +6371,10 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(userMessageRule).toContain('border-radius: 14px 14px 4px;');
|
||||
expect(userMessageRule).toContain('background: var(--platform-warm-bg);');
|
||||
expect(userMessageRule).toContain('color: var(--platform-text-base);');
|
||||
// 执行过程卡有两条同选择器规则:第一条是几何(`width: 100%`),后面那条是 Codex 暖色
|
||||
// 皮肤下的配色(把基础规则的绿系换成中性描边 + 暖底)。承重的是几何那条。
|
||||
expect(processCardRules.length).toBeGreaterThanOrEqual(1);
|
||||
expect(processCardRules[0]).toContain('width: 100%;');
|
||||
// 过程卡渲染在消息列表的**兄弟**位置(列表外,紧贴输入盒上方),拿不到列表的
|
||||
// `padding: 14px 16px 0`,左右内缩与上下间距只能自己给:左右必须与列表的 16px 对齐。
|
||||
expect(processCardRules.length).toBe(1);
|
||||
expect(processCardRules[0]).toContain('margin: 12px 16px 8px;');
|
||||
});
|
||||
|
||||
it('enables the run presentation and renders registered images in the resource viewer', async () => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import { act, createRef } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { DirectProjectConversation } from '../src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const STARTED_AT = 1_800_000_000_000;
|
||||
|
||||
/**
|
||||
* 读秒粒度:走对话侧唯一的 `useLiveNow`(`LIVE_TIMER_TICK_MS = 100`)。
|
||||
*
|
||||
* 状态条的耗时文案不足一分钟保留一位小数,刷新就必须是 100ms——按 1 秒一跳时,用户看到的
|
||||
* 是一块「带小数却一格一格跳」的表,像卡住不动。这里用假时钟钉住粒度:把时钟改回 1000ms
|
||||
* 时第二步即红。
|
||||
*/
|
||||
test('运行中状态条的读秒按 100ms 刷新:不足一分钟的耗时以 0.1 秒递增', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(STARTED_AT);
|
||||
const view = render(
|
||||
<DirectProjectConversation
|
||||
turns={[]}
|
||||
messagesRef={createRef<HTMLDivElement>()}
|
||||
historyHasMore={false}
|
||||
nativeRunning
|
||||
activeTurnStartedAt={STARTED_AT}
|
||||
onLoadEarlierHistory={() => undefined}
|
||||
onScroll={() => undefined}
|
||||
/>,
|
||||
);
|
||||
const elapsed = () => view.getByText(/^已耗时 /u).textContent;
|
||||
expect(elapsed()).toBe('已耗时 0.0秒');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(elapsed()).toBe('已耗时 0.1秒');
|
||||
|
||||
// 1 秒一跳的实现会停在上面的 0.1 秒:再走 400ms 必须继续往上加。
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
expect(elapsed()).toBe('已耗时 0.5秒');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
/** 回合不在跑时不订阅时钟:否则空转的 tick 会持续重建状态条所在的那块视图。 */
|
||||
test('没有运行中的回合时不订阅时钟', () => {
|
||||
vi.useFakeTimers();
|
||||
const spy = vi.spyOn(globalThis, 'setInterval');
|
||||
try {
|
||||
render(
|
||||
<DirectProjectConversation
|
||||
turns={[]}
|
||||
messagesRef={createRef<HTMLDivElement>()}
|
||||
historyHasMore={false}
|
||||
nativeRunning={false}
|
||||
activeTurnStartedAt={0}
|
||||
onLoadEarlierHistory={() => undefined}
|
||||
onScroll={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
@@ -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_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);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 决策记录
|
||||
|
||||
## 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 的整表重查紧跟着再弹一次。
|
||||
- 错误类型化:用户取消 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-23 运行视窗:右下角全屏预览 + 没有内容就自动收起的信息栏
|
||||
|
||||
- 背景:运行页右下角缺一个把游戏画面放大到整屏的入口;运行视窗下方常驻「信息展示 / 数值微调」两张卡片,没有选中资源时就是两块空白,验收现场提出「没有功能就暂时隐藏」。
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# 踩坑与排障记录
|
||||
|
||||
## 同一祖先下的多个项目会各自弹一次 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 唤醒等待者、冷却基准、路径写法归一)。真机复现(无需提权交互即可计数):在 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 个)。
|
||||
- **已知残余边界**:闸门只有 follower 的有界等待(60s),没有 leader 失效接管——若提权子进程真的挂死(`Start-Process -Wait` 无超时),该 key 会一直 `running`,之后所有同目标调用都按 60s 超时失败,`clear_game_creator_acl_elevation_denials` 也不清理 running,只能重启客户端恢复。需要更激进策略时再单独讨论(记 `started_at` + 硬上限接管)。
|
||||
- **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。
|
||||
|
||||
## 2026-09-24 对话过程卡的读秒退回 1 秒一跳:刷新粒度必须与显示精度同格
|
||||
|
||||
- **现象**:AGC DirectProject 对话区底部那条「陶泥儿正在处理 / 已耗时 12.4秒」的状态条,小数位一秒才动一格,看着像读数卡住;同一屏里工具卡片的耗时与资源生成侧栏的读秒都在正常走 0.1 秒,只有这一处不动。
|
||||
- **原因**:耗时文案不足一分钟保留一位小数(`formatElapsedDuration`),刷新就必须是 100ms。这次改动方向本身是对的——把 clock 从整个聊天视图下移到耗时那一行,但顺手在组件里另写了一份 `setInterval(..., 1000)`,绕过了对话侧唯一的 `useLiveNow`(`LIVE_TIMER_TICK_MS = 100`);`team-conventions.md` 里「运行时用 100ms 叶子时钟刷新一位小数、终态冻结」这条约定当时已经写好,改动没有对齐它。
|
||||
- **处理(现行口径)**:耗时文案的刷新一律走 `useLiveNow`,不在视图组件里另起 interval;tick 只订在显示耗时的那一行(叶子节点),不能落在整块面板或整份回合列表上。可机检的判据是「不足一分钟的耗时必须每 100ms 递增一次小数位」。
|
||||
- **验证**:`npx vitest run apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx`(把 `LIVE_TIMER_TICK_MS` 临时改回 1000 时第一步即红);真实浏览器里 600ms 内文案从 `18.0秒` 走到 `18.6秒`。
|
||||
- **关联**:`apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx`、`apps/ai-game-creator-shell/src/features/project-workspace/useLiveNow.ts`、`apps/ai-game-creator-shell/src/styles.css`。
|
||||
|
||||
## 策划回复的重复终态不能重新启动伪流式
|
||||
|
||||
策划 Runtime 会通过状态事件与命令返回交付同一份最终视图。若前端清空临时正文后再拿“最后一条非用户历史消息”回填动画,就会出现正式回复旁又播放一遍、播放后消失的假重试。正文应按 `messageId` 保存显示进度,与正式消息共用一个气泡;请求完成不清动画,不延迟正式业务状态。Provider 自动重试复用消息 ID 并发送空文本,只允许重置未持久化的该条回复。正文、工具状态和 reasoning 分开;事件与异步命令收尾均检查项目及活动回合,旧请求不能覆盖新回合。详见 [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。
|
||||
|
||||
Reference in New Issue
Block a user