From 216407d93eab3f430416b285f4b70dca508c8778 Mon Sep 17 00:00:00 2001 From: menghao Date: Fri, 31 Jul 2026 12:00:48 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E5=B8=83=E5=B1=80=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=20(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 冻结资源画布布局数据与 CAS 合同 实现双模式本地 sidecar 安全读写 接入二维拖动、默认排版和跨重启恢复 补齐并发冲突、安全边界和界面测试 同步技术文档与共享决策 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/116 Reviewed-by: 段舒康 Co-authored-by: menghao Co-committed-by: menghao --- .../src-tauri/src/commands.rs | 25 + .../src-tauri/src/main.rs | 6 +- .../src-tauri/src/project.rs | 2 + .../src-tauri/src/project/filesystem.rs | 10 + .../src-tauri/src/project/resource_layout.rs | 990 ++++++++++++++++++ apps/ai-game-creator-shell/src/App.tsx | 59 +- .../LocalGamePreviewFrame.tsx | 2 + .../SupervisorChatOnlyView.tsx | 13 +- apps/ai-game-creator-shell/src/styles.css | 41 +- .../src/view/project-development/index.tsx | 451 ++++---- .../resourceCanvasLayoutModel.ts | 325 ++++++ .../useProjectResourceCanvasLayout.ts | 597 +++++++++++ .../tests/appSurface/harness.ts | 25 + .../appSurface/project-development.suite.ts | 563 +++++++++- .../resourceCanvasLayoutContract.test.ts | 27 + .../tests/resourceCanvasLayoutModel.test.ts | 208 ++++ .../useProjectResourceCanvasLayout.test.ts | 746 +++++++++++++ ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 100 +- .../shared-memory/decision-log.md | 10 + docs/project-memory/shared-memory/pitfalls.md | 32 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 21 +- .../shared/src/contracts/gameCreationApp.ts | 50 + scripts/check-native-shells.mjs | 34 +- .../platform-image/tests/vector_engine.rs | 16 +- .../shared-contracts/src/game_creation_app.rs | 169 ++- 25 files changed, 4170 insertions(+), 352 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasLayoutContract.test.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts create mode 100644 apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts 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 614254bed..67a4f83f4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -196,6 +196,31 @@ pub(crate) fn get_local_game_manifest( read_manifest_for_project(root) } +#[tauri::command] +pub(crate) fn read_local_project_resource_canvas_layout( + project_path: String, + mode: ProjectResourceCanvasLayoutMode, +) -> Result { + read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode) +} + +#[tauri::command] +pub(crate) fn update_local_project_resource_canvas_layout( + project_path: String, + expected_project_id: String, + mode: ProjectResourceCanvasLayoutMode, + expected_revision: u64, + positions: Vec, +) -> Result { + update_project_resource_canvas_layout_at( + Path::new(project_path.trim()), + mode, + &expected_project_id, + expected_revision, + positions, + ) +} + #[tauri::command] pub(crate) async fn control_agent_run( app: tauri::AppHandle, 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 66a9706ae..d72097414 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -30,9 +30,11 @@ use shared_contracts::game_creation_app::{ GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor, GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState, GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus, + ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition, + UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus, GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS, - GAME_CREATION_APP_LIMITED_RUN_COMMANDS, + GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION, }; use tauri::{Emitter, Manager}; use tauri_plugin_dialog::DialogExt; @@ -1917,6 +1919,8 @@ fn main() { activate_local_game_preview, stop_local_game_preview, get_local_game_preview_status, + read_local_project_resource_canvas_layout, + update_local_project_resource_canvas_layout, get_local_game_manifest ]) .build(tauri_context) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index d87420cae..aee2ea42a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -10,6 +10,7 @@ mod export; mod filesystem; mod manifest; mod memory; +mod resource_layout; mod verification; pub(crate) use agent_db::*; @@ -19,4 +20,5 @@ pub(crate) use export::*; pub(crate) use filesystem::*; pub(crate) use manifest::*; pub(crate) use memory::*; +pub(crate) use resource_layout::*; pub(crate) use verification::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index ef7930abd..f1fd8f501 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -159,6 +159,7 @@ pub(crate) fn list_local_project_files_at( let relative_path = relative_project_path(root, &path)?; if is_agent_runtime_private_control_path(&relative_path) || is_agent_checkpoint_control_path(&relative_path) + || is_agent_workbench_control_path(&relative_path) { continue; } @@ -236,6 +237,12 @@ fn is_agent_checkpoint_control_path(normalized_path: &str) -> bool { && matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case("checkpoints")) } +fn is_agent_workbench_control_path(normalized_path: &str) -> bool { + let mut parts = normalized_path.split('/'); + matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case(".agent")) + && matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case("workbench")) +} + pub(crate) fn reject_agent_runtime_private_control_path( normalized_path: &str, ) -> Result<(), String> { @@ -245,6 +252,9 @@ pub(crate) fn reject_agent_runtime_private_control_path( if is_agent_checkpoint_control_path(normalized_path) { return Err("Agent checkpoint 控制面不可通过通用文件工具访问".to_string()); } + if is_agent_workbench_control_path(normalized_path) { + return Err("Agent workbench 控制面不可通过通用文件工具访问".to_string()); + } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs new file mode 100644 index 000000000..f6045c9fc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs @@ -0,0 +1,990 @@ +use super::*; +use shared_contracts::game_creation_app::{ + validate_project_resource_canvas_layout_revision, + GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION, +}; +use std::collections::HashSet; +use std::fs::File; +use std::sync::{Mutex, OnceLock}; + +const RESOURCE_LAYOUT_DIRECTORY: &str = ".agent/workbench/resource-layouts"; +const RESOURCE_LAYOUT_LOCK_PATH: &str = ".agent/workbench/resource-layouts/.layout.lock"; +const RESOURCE_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024; +const RESOURCE_LAYOUT_MAX_POSITIONS: usize = 4096; +const RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS: usize = 512; +const RESOURCE_LAYOUT_MAX_COORDINATE: u32 = 1_000_000; +const RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS: usize = 100; +const RESOURCE_LAYOUT_LOCK_WAIT_MILLIS: u64 = 10; + +static RESOURCE_LAYOUT_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); + +#[derive(Debug)] +struct ResourceLayoutWriteLock { + _file: File, +} + +fn resource_layout_mode_name(mode: ProjectResourceCanvasLayoutMode) -> &'static str { + match mode { + ProjectResourceCanvasLayoutMode::Dependency => "dependency", + ProjectResourceCanvasLayoutMode::Type => "type", + } +} + +fn resource_layout_relative_path(mode: ProjectResourceCanvasLayoutMode) -> String { + format!( + "{RESOURCE_LAYOUT_DIRECTORY}/{}.json", + resource_layout_mode_name(mode) + ) +} + +fn acquire_resource_layout_write_lock(root: &Path) -> Result { + for attempt in 0..RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS { + if let Some(file) = try_open_resource_layout_write_lock_file(root)? { + return Ok(ResourceLayoutWriteLock { _file: file }); + } + if attempt + 1 < RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS { + std::thread::sleep(Duration::from_millis(RESOURCE_LAYOUT_LOCK_WAIT_MILLIS)); + } + } + Err("资源布局正在被其他窗口保存,请稍后重试".to_string()) +} + +#[cfg(unix)] +fn try_open_resource_layout_write_lock_file(root: &Path) -> Result, String> { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; + + let _open_guard = RESOURCE_LAYOUT_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "资源布局锁安全打开门禁已损坏".to_string())?; + validate_project_root(root)?; + let relative_path = normalize_relative_path(RESOURCE_LAYOUT_LOCK_PATH)?; + let path = root.join(&relative_path); + let mut components = relative_path.split('/').collect::>(); + let file_name = components + .pop() + .ok_or_else(|| "资源布局锁路径缺少文件名".to_string())?; + let mut directory = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(root) + .map_err(|error| format!("安全打开项目目录失败:{}: {error}", root.display()))?; + for component in components { + let component = + CString::new(component).map_err(|_| "资源布局锁目录包含 NUL".to_string())?; + // SAFETY: `directory` is a live directory fd and `component` is NUL terminated. + let created = unsafe { libc::mkdirat(directory.as_raw_fd(), component.as_ptr(), 0o700) }; + if created != 0 { + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::AlreadyExists { + return Err(format!( + "创建资源布局锁目录失败:{}: {error}", + path.display() + )); + } + } + // SAFETY: `directory` and `component` remain valid for the duration of openat. + let fd = unsafe { + libc::openat( + directory.as_raw_fd(), + component.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if fd < 0 { + return Err(format!( + "安全打开资源布局锁目录失败:{}: {}", + path.display(), + std::io::Error::last_os_error() + )); + } + // SAFETY: openat returned a new owned fd. + directory = unsafe { File::from_raw_fd(fd) }; + } + let file_name = CString::new(file_name).map_err(|_| "资源布局锁文件名包含 NUL".to_string())?; + // SAFETY: `directory` is a live directory fd and `file_name` is NUL terminated. + let fd = unsafe { + libc::openat( + directory.as_raw_fd(), + file_name.as_ptr(), + libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW, + 0o600, + ) + }; + if fd < 0 { + return Err(format!( + "安全打开资源布局锁失败:{}: {}", + path.display(), + std::io::Error::last_os_error() + )); + } + // SAFETY: openat returned a new owned fd. + let file = unsafe { File::from_raw_fd(fd) }; + let metadata = file + .metadata() + .map_err(|error| format!("读取资源布局锁句柄元数据失败:{}: {error}", path.display()))?; + // SAFETY: geteuid takes no arguments and has no memory safety preconditions. + let effective_user_id = unsafe { libc::geteuid() }; + if !metadata.is_file() || metadata.uid() != effective_user_id || metadata.nlink() != 1 { + return Err(format!( + "资源布局锁必须是当前用户持有的无硬链接普通文件:{}", + path.display() + )); + } + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("收紧资源布局锁权限失败:{}: {error}", path.display()))?; + let path_metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("复核资源布局锁路径失败:{}: {error}", path.display()))?; + if path_metadata.file_type().is_symlink() + || path_metadata.dev() != metadata.dev() + || path_metadata.ino() != metadata.ino() + { + return Err(format!( + "资源布局锁路径在安全打开期间发生替换:{}", + path.display() + )); + } + let verified = file + .metadata() + .map_err(|error| format!("复核资源布局锁句柄失败:{}: {error}", path.display()))?; + if verified.uid() != effective_user_id + || verified.nlink() != 1 + || verified.permissions().mode() & 0o777 != 0o600 + { + return Err(format!( + "资源布局锁必须由当前用户持有且权限为 0600:{}", + path.display() + )); + } + // SAFETY: flock observes only the live fd owned by `file`; dropping it releases the lock. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(Some(file)); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + Ok(None) + } else { + Err(format!( + "获取资源布局系统文件锁失败:{}: {error}", + path.display() + )) + } +} + +#[cfg(windows)] +fn try_open_resource_layout_write_lock_file(root: &Path) -> Result, String> { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_SHARE_READ_WRITE: u32 = 0x0000_0003; + + let _open_guard = RESOURCE_LAYOUT_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "资源布局锁安全打开门禁已损坏".to_string())?; + validate_project_root(root)?; + let relative_path = normalize_relative_path(RESOURCE_LAYOUT_LOCK_PATH)?; + let path = root.join(&relative_path); + let mut components = relative_path.split('/').collect::>(); + components + .pop() + .ok_or_else(|| "资源布局锁路径缺少文件名".to_string())?; + let mut guarded_directories = Vec::with_capacity(components.len() + 1); + let mut current = root.to_path_buf(); + for component in std::iter::once(None).chain(components.into_iter().map(Some)) { + if let Some(component) = component { + current.push(component); + if !current.exists() { + fs::create_dir(¤t).map_err(|error| { + format!("创建资源布局锁目录失败:{}: {error}", current.display()) + })?; + } + } + let metadata = fs::symlink_metadata(¤t).map_err(|error| { + format!( + "读取资源布局锁目录元数据失败:{}: {error}", + current.display() + ) + })?; + if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "资源布局锁目录必须是普通目录且不能是 reparse point:{}", + current.display() + )); + } + let handle = fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(¤t) + .map_err(|error| { + format!("安全打开资源布局锁目录失败:{}: {error}", current.display()) + })?; + let opened = handle + .metadata() + .map_err(|error| format!("复核资源布局锁目录失败:{}: {error}", current.display()))?; + if !opened.is_dir() || opened.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "资源布局锁目录打开后身份无效:{}", + current.display() + )); + } + guarded_directories.push(handle); + } + if let Ok(metadata) = fs::symlink_metadata(&path) { + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Err(format!( + "资源布局锁必须是普通文件且不能是 reparse point:{}", + path.display() + )); + } + } + match fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(&path) + { + Ok(file) => { + validate_windows_regular_file_handle(&file, "资源布局锁")?; + crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?; + Ok(Some(file)) + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock + ) => + { + Ok(None) + } + Err(error) => Err(format!( + "获取资源布局系统文件锁失败:{}: {error}", + path.display() + )), + } +} + +#[cfg(not(any(unix, windows)))] +fn try_open_resource_layout_write_lock_file(root: &Path) -> Result, String> { + Err(format!( + "当前平台不支持资源布局系统文件锁:{}", + root.join(RESOURCE_LAYOUT_LOCK_PATH).display() + )) +} + +fn current_resource_layout_project_id(root: &Path) -> Result { + validate_project_root(root)?; + let manifest_path = resolve_local_project_path(root, ".agent/manifest.json")?; + let manifest = read_manifest(&manifest_path)?; + let project_id = manifest.project_id.trim(); + if project_id.is_empty() { + return Err("项目 manifest 缺少 projectId".to_string()); + } + Ok(project_id.to_string()) +} + +fn empty_resource_layout( + project_id: String, + mode: ProjectResourceCanvasLayoutMode, +) -> ProjectResourceCanvasLayout { + ProjectResourceCanvasLayout { + schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(), + project_id, + mode, + revision: 0, + positions: Vec::new(), + updated_at: 0, + } +} + +fn validate_resource_layout_positions( + positions: &[ProjectResourceCanvasPosition], +) -> Result<(), String> { + if positions.len() > RESOURCE_LAYOUT_MAX_POSITIONS { + return Err(format!( + "资源布局最多支持 {RESOURCE_LAYOUT_MAX_POSITIONS} 个位置" + )); + } + let mut resource_ids = HashSet::with_capacity(positions.len()); + for position in positions { + let resource_id = position.resource_id.trim(); + if resource_id.is_empty() { + return Err("资源布局 resourceId 不能为空".to_string()); + } + if resource_id.chars().count() > RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS { + return Err(format!( + "资源布局 resourceId 最多支持 {RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS} 个字符" + )); + } + if resource_id.chars().any(char::is_control) { + return Err("资源布局 resourceId 不能包含控制字符".to_string()); + } + if !resource_ids.insert(resource_id) { + return Err("资源布局 resourceId 不能重复".to_string()); + } + if position.x > RESOURCE_LAYOUT_MAX_COORDINATE + || position.y > RESOURCE_LAYOUT_MAX_COORDINATE + { + return Err(format!( + "资源布局坐标不能超过 {RESOURCE_LAYOUT_MAX_COORDINATE}" + )); + } + } + Ok(()) +} + +fn validate_resource_layout( + layout: &ProjectResourceCanvasLayout, + project_id: &str, + mode: ProjectResourceCanvasLayoutMode, +) -> Result<(), String> { + if layout.schema_version != GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION { + return Err(format!( + "不支持的资源布局 schema:{}", + layout.schema_version + )); + } + if layout.project_id != project_id { + return Err("资源布局 projectId 与当前项目不匹配".to_string()); + } + if layout.mode != mode { + return Err("资源布局 mode 与文件不匹配".to_string()); + } + validate_project_resource_canvas_layout_revision(layout.revision).map_err(str::to_string)?; + validate_resource_layout_positions(&layout.positions) +} + +fn validate_existing_resource_layout_storage( + root: &Path, + relative_path: &str, +) -> Result<(), String> { + let path = resolve_local_project_path(root, relative_path)?; + match fs::symlink_metadata(&path) { + Ok(_) => { + let (_, metadata) = open_project_snapshot_regular_file(&path, "资源布局 sidecar")?; + if metadata.len() > RESOURCE_LAYOUT_MAX_BYTES as u64 { + return Err(format!( + "资源布局 sidecar 超过 {RESOURCE_LAYOUT_MAX_BYTES} 字节上限" + )); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取资源布局 sidecar 元数据失败:{}: {error}", + path.display() + )); + } + } + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + match fs::symlink_metadata(&backup_path) { + Ok(_) => { + let (_, metadata) = + open_project_snapshot_regular_file(&backup_path, "资源布局恢复副本")?; + if metadata.len() > RESOURCE_LAYOUT_MAX_BYTES as u64 { + return Err(format!( + "资源布局恢复副本超过 {RESOURCE_LAYOUT_MAX_BYTES} 字节上限" + )); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取资源布局恢复副本元数据失败:{}: {error}", + backup_path.display() + )); + } + } + Ok(()) +} + +fn read_resource_layout_sidecar( + root: &Path, + relative_path: &str, +) -> Result, String> { + validate_existing_resource_layout_storage(root, relative_path)?; + read_agent_runtime_json_sidecar_with_max_bytes( + root, + relative_path, + "资源布局 sidecar", + RESOURCE_LAYOUT_MAX_BYTES, + ) +} + +pub(crate) fn read_project_resource_canvas_layout_at( + root: &Path, + mode: ProjectResourceCanvasLayoutMode, +) -> Result { + let project_id = current_resource_layout_project_id(root)?; + let relative_path = resource_layout_relative_path(mode); + let Some(layout) = read_resource_layout_sidecar(root, &relative_path)? else { + return Ok(empty_resource_layout(project_id, mode)); + }; + validate_resource_layout(&layout, &project_id, mode)?; + Ok(layout) +} + +pub(crate) fn update_project_resource_canvas_layout_at( + root: &Path, + mode: ProjectResourceCanvasLayoutMode, + expected_project_id: &str, + expected_revision: u64, + positions: Vec, +) -> Result { + validate_project_resource_canvas_layout_revision(expected_revision).map_err(str::to_string)?; + validate_resource_layout_positions(&positions)?; + let expected_project_id = expected_project_id.trim(); + if expected_project_id.is_empty() { + return Err("资源布局 expectedProjectId 不能为空".to_string()); + } + let preflight_project_id = current_resource_layout_project_id(root)?; + if preflight_project_id != expected_project_id { + return Err("资源布局 expectedProjectId 与当前项目不匹配".to_string()); + } + let _lock = acquire_resource_layout_write_lock(root)?; + let project_id = current_resource_layout_project_id(root)?; + if project_id != expected_project_id { + return Err("项目 manifest 在资源布局锁获取期间发生变化,请重试".to_string()); + } + let relative_path = resource_layout_relative_path(mode); + let current = match read_resource_layout_sidecar(root, &relative_path)? { + Some(layout) => { + validate_resource_layout(&layout, &project_id, mode)?; + layout + } + None => empty_resource_layout(project_id.clone(), mode), + }; + if current.revision != expected_revision { + return Ok(UpdateProjectResourceCanvasLayoutResult { + status: UpdateProjectResourceCanvasLayoutStatus::Conflict, + layout: current, + }); + } + if current.revision == GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION { + return Err("资源布局 revision 已达到 JavaScript 安全整数上限,无法继续保存".to_string()); + } + let next_revision = current.revision + 1; + if current_resource_layout_project_id(root)? != project_id { + return Err("项目 manifest 在资源布局写入前发生变化,请重试".to_string()); + } + let next = ProjectResourceCanvasLayout { + schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(), + project_id, + mode, + revision: next_revision, + positions, + updated_at: unix_millis().min(u128::from(u64::MAX)) as u64, + }; + validate_existing_resource_layout_storage(root, &relative_path)?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "资源布局 sidecar", + &next, + RESOURCE_LAYOUT_MAX_BYTES, + )?; + Ok(UpdateProjectResourceCanvasLayoutResult { + status: UpdateProjectResourceCanvasLayoutStatus::Updated, + layout: next, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Barrier, + }; + + static NEXT_RESOURCE_LAYOUT_TEST_ID: AtomicU64 = AtomicU64::new(0); + + fn unique_resource_layout_project_path() -> PathBuf { + std::env::temp_dir().join(format!( + "genarrative-resource-layout-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + NEXT_RESOURCE_LAYOUT_TEST_ID.fetch_add(1, Ordering::Relaxed) + )) + } + + fn layout_position(id: &str, x: u32) -> ProjectResourceCanvasPosition { + ProjectResourceCanvasPosition { + resource_id: id.to_string(), + section: shared_contracts::game_creation_app::ProjectResourceCanvasSection::Art, + x, + y: 24, + manually_placed: true, + } + } + + #[test] + fn resource_layout_missing_file_starts_at_revision_zero_and_modes_are_independent() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-project", "布局项目").expect("init project"); + + let dependency = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect("read dependency layout"); + assert_eq!(dependency.revision, 0); + assert!(dependency.positions.is_empty()); + assert!(!root + .join(resource_layout_relative_path( + ProjectResourceCanvasLayoutMode::Dependency + )) + .exists()); + + let updated = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-project", + 0, + vec![layout_position("asset-a", 18)], + ) + .expect("update dependency layout"); + assert_eq!( + updated.status, + UpdateProjectResourceCanvasLayoutStatus::Updated + ); + assert_eq!(updated.layout.revision, 1); + + let type_layout = + read_project_resource_canvas_layout_at(&root, ProjectResourceCanvasLayoutMode::Type) + .expect("read type layout"); + assert_eq!(type_layout.revision, 0); + assert!(type_layout.positions.is_empty()); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_layout_invalid_roots_have_zero_workbench_side_effects() { + let missing_root = unique_resource_layout_project_path(); + let missing_error = update_project_resource_canvas_layout_at( + &missing_root, + ProjectResourceCanvasLayoutMode::Dependency, + "missing-project", + 0, + vec![layout_position("asset-a", 10)], + ) + .expect_err("missing project root must fail"); + assert!(missing_error.contains("manifest") || missing_error.contains("项目")); + assert!(!missing_root.exists()); + + let non_project_root = unique_resource_layout_project_path(); + fs::create_dir_all(&non_project_root).expect("create non-project root"); + update_project_resource_canvas_layout_at( + &non_project_root, + ProjectResourceCanvasLayoutMode::Dependency, + "non-project", + 0, + vec![layout_position("asset-a", 10)], + ) + .expect_err("non-project root must fail"); + assert!(!non_project_root.join(".agent").exists()); + fs::remove_dir_all(&non_project_root).ok(); + + let invalid_manifest_root = unique_resource_layout_project_path(); + let agent_directory = invalid_manifest_root.join(".agent"); + fs::create_dir_all(&agent_directory).expect("create invalid project agent directory"); + fs::write(agent_directory.join("manifest.json"), b"{invalid-json") + .expect("write invalid manifest"); + update_project_resource_canvas_layout_at( + &invalid_manifest_root, + ProjectResourceCanvasLayoutMode::Dependency, + "invalid-project", + 0, + vec![layout_position("asset-a", 10)], + ) + .expect_err("invalid project manifest must fail"); + assert!(!agent_directory.join("workbench").exists()); + fs::remove_dir_all(invalid_manifest_root).ok(); + } + + #[test] + fn resource_layout_expected_project_id_fences_stale_windows_before_lock_side_effects() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-current-project", "布局当前项目") + .expect("init project"); + + let error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-stale-project", + 0, + vec![layout_position("asset-a", 10)], + ) + .expect_err("stale project window must fail"); + assert!(error.contains("expectedProjectId")); + assert!(!root.join(".agent/workbench").exists()); + fs::remove_dir_all(root).ok(); + } + + #[cfg(unix)] + #[test] + fn resource_layout_live_system_lock_is_not_reclaimed_from_old_mtime() { + use std::fs::FileTimes; + use std::os::unix::fs::MetadataExt; + + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-system-lock", "布局系统锁") + .expect("init project"); + let first = acquire_resource_layout_write_lock(&root).expect("acquire first lock"); + let lock_path = root.join(RESOURCE_LAYOUT_LOCK_PATH); + let original = fs::symlink_metadata(&lock_path).expect("stat original lock"); + first + ._file + .set_times(FileTimes::new().set_modified(UNIX_EPOCH)) + .expect("age live lock mtime"); + + assert!(try_open_resource_layout_write_lock_file(&root) + .expect("try competing lock") + .is_none()); + let while_locked = fs::symlink_metadata(&lock_path).expect("stat live lock"); + assert_eq!(while_locked.dev(), original.dev()); + assert_eq!(while_locked.ino(), original.ino()); + + drop(first); + let second = try_open_resource_layout_write_lock_file(&root) + .expect("try released lock") + .expect("released system lock must be acquirable"); + let after_reacquire = fs::symlink_metadata(&lock_path).expect("stat reacquired lock"); + assert_eq!(after_reacquire.dev(), original.dev()); + assert_eq!(after_reacquire.ino(), original.ino()); + drop(second); + assert!(lock_path.exists()); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_layout_compare_and_swap_returns_latest_without_overwrite() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-cas", "布局 CAS").expect("init project"); + let first = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-cas", + 0, + vec![layout_position("asset-a", 10)], + ) + .expect("first update"); + assert_eq!(first.layout.revision, 1); + + let conflict = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-cas", + 0, + vec![layout_position("asset-a", 999)], + ) + .expect("conflict result"); + assert_eq!( + conflict.status, + UpdateProjectResourceCanvasLayoutStatus::Conflict + ); + assert_eq!(conflict.layout.revision, 1); + assert_eq!(conflict.layout.positions[0].x, 10); + + let persisted = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect("read persisted"); + assert_eq!(persisted.positions[0].x, 10); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_layout_safe_revision_exhaustion_fails_without_overwrite() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-revision-max", "布局 revision 上限") + .expect("init project"); + let path = root.join(resource_layout_relative_path( + ProjectResourceCanvasLayoutMode::Dependency, + )); + fs::create_dir_all(path.parent().expect("layout parent")).expect("create layout parent"); + let exhausted = ProjectResourceCanvasLayout { + schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(), + project_id: "layout-revision-max".to_string(), + mode: ProjectResourceCanvasLayoutMode::Dependency, + revision: GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION, + positions: vec![layout_position("asset-a", 10)], + updated_at: 1, + }; + let original = serde_json::to_vec(&exhausted).expect("serialize exhausted layout"); + fs::write(&path, &original).expect("write exhausted layout"); + + let error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-revision-max", + GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION, + vec![layout_position("asset-a", 999)], + ) + .expect_err("revision exhaustion must fail"); + assert!(error.contains("revision")); + assert_eq!(fs::read(&path).expect("read exhausted layout"), original); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_layout_rejects_unsafe_revision_without_side_effects() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-unsafe-revision", "布局非安全 revision") + .expect("init project"); + + let expected_error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-unsafe-revision", + GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1, + vec![layout_position("asset-a", 10)], + ) + .expect_err("unsafe expected revision must fail"); + assert!(expected_error.contains("revision")); + assert!(!root.join(".agent/workbench").exists()); + + let path = root.join(resource_layout_relative_path( + ProjectResourceCanvasLayoutMode::Dependency, + )); + fs::create_dir_all(path.parent().expect("layout parent")).expect("create layout parent"); + let unsafe_payload = serde_json::json!({ + "schemaVersion": GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION, + "projectId": "layout-unsafe-revision", + "mode": "dependency", + "revision": GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1, + "positions": [], + "updatedAt": 1 + }); + let original = serde_json::to_vec(&unsafe_payload).expect("serialize unsafe fixture"); + fs::write(&path, &original).expect("write unsafe fixture"); + + let read_error = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect_err("unsafe persisted revision must fail"); + assert!(read_error.contains("revision") || read_error.contains("安全整数")); + assert_eq!(fs::read(&path).expect("read unsafe fixture"), original); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_layout_rejects_duplicate_ids_and_project_identity_drift() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-identity", "布局身份").expect("init project"); + let duplicate_error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Type, + "layout-identity", + 0, + vec![ + layout_position("asset-a", 10), + layout_position("asset-a", 20), + ], + ) + .expect_err("duplicate ids must fail"); + assert!(duplicate_error.contains("不能重复")); + + let path = root.join(resource_layout_relative_path( + ProjectResourceCanvasLayoutMode::Dependency, + )); + fs::create_dir_all(path.parent().expect("layout parent")).expect("create parent"); + let foreign = ProjectResourceCanvasLayout { + schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(), + project_id: "foreign-project".to_string(), + mode: ProjectResourceCanvasLayoutMode::Dependency, + revision: 1, + positions: vec![], + updated_at: 1, + }; + fs::write( + &path, + serde_json::to_vec(&foreign).expect("serialize foreign"), + ) + .expect("write foreign layout"); + let identity_error = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect_err("foreign project id must fail"); + assert!(identity_error.contains("projectId")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_layout_rejects_corrupt_unknown_and_out_of_bounds_payloads() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-invalid", "无效布局").expect("init project"); + let path = root.join(resource_layout_relative_path( + ProjectResourceCanvasLayoutMode::Dependency, + )); + fs::create_dir_all(path.parent().expect("layout parent")).expect("create parent"); + fs::write(&path, b"{invalid-json").expect("write corrupt layout"); + let corrupt_error = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect_err("corrupt layout must fail"); + assert!(corrupt_error.contains("解析") || corrupt_error.contains("JSON")); + + let unknown = ProjectResourceCanvasLayout { + schema_version: "game-creator-resource-layout.v999".to_string(), + project_id: "layout-invalid".to_string(), + mode: ProjectResourceCanvasLayoutMode::Dependency, + revision: 1, + positions: vec![], + updated_at: 1, + }; + fs::write( + &path, + serde_json::to_vec(&unknown).expect("serialize unknown"), + ) + .expect("write unknown layout"); + let schema_error = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect_err("unknown schema must fail"); + assert!(schema_error.contains("schema")); + + let coordinate_error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Type, + "layout-invalid", + 0, + vec![layout_position( + "asset-coordinate", + RESOURCE_LAYOUT_MAX_COORDINATE + 1, + )], + ) + .expect_err("oversized coordinate must fail"); + assert!(coordinate_error.contains("坐标")); + let id_error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Type, + "layout-invalid", + 0, + vec![layout_position( + &"x".repeat(RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS + 1), + 10, + )], + ) + .expect_err("oversized resource id must fail"); + assert!(id_error.contains("resourceId")); + fs::remove_dir_all(root).ok(); + } + + #[cfg(unix)] + #[test] + fn resource_layout_rejects_symlinks_and_hardlinks() { + use std::os::unix::fs::symlink; + + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-links", "布局链接").expect("init project"); + let path = root.join(resource_layout_relative_path( + ProjectResourceCanvasLayoutMode::Dependency, + )); + fs::create_dir_all(path.parent().expect("layout parent")).expect("create parent"); + let payload = ProjectResourceCanvasLayout { + schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(), + project_id: "layout-links".to_string(), + mode: ProjectResourceCanvasLayoutMode::Dependency, + revision: 1, + positions: vec![], + updated_at: 1, + }; + let bytes = serde_json::to_vec(&payload).expect("serialize layout"); + let target = root.join("layout-link-target.json"); + fs::write(&target, &bytes).expect("write symlink target"); + symlink(&target, &path).expect("create sidecar symlink"); + let symlink_error = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect_err("symlink must fail"); + assert!(symlink_error.contains("符号链接")); + + fs::remove_file(&path).expect("remove symlink"); + fs::write(&path, &bytes).expect("write sidecar"); + let hardlink = root.join("layout-hardlink.json"); + fs::hard_link(&path, &hardlink).expect("create hardlink"); + let hardlink_error = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect_err("hardlink must fail"); + assert!(hardlink_error.contains("硬链接")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_layout_serializes_concurrent_updates_and_hides_workbench_files() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-concurrent", "布局并发").expect("init project"); + let barrier = Arc::new(Barrier::new(2)); + let handles = [10, 20].map(|x| { + let root = root.clone(); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-concurrent", + 0, + vec![layout_position("asset-a", x)], + ) + }) + }); + let results = handles + .into_iter() + .map(|handle| { + handle + .join() + .expect("layout update thread") + .expect("layout update") + }) + .collect::>(); + assert_eq!( + results + .iter() + .filter(|result| result.status == UpdateProjectResourceCanvasLayoutStatus::Updated) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| result.status == UpdateProjectResourceCanvasLayoutStatus::Conflict) + .count(), + 1 + ); + + let relative_path = + resource_layout_relative_path(ProjectResourceCanvasLayoutMode::Dependency); + let listed = list_local_project_files_at(&root).expect("list project files"); + assert!(!listed + .files + .iter() + .any(|entry| entry.path.starts_with(".agent/workbench"))); + assert!(read_local_project_file_at(&root, &relative_path).is_err()); + assert!(write_local_project_file_at(&root, &relative_path, "tampered").is_err()); + assert!(delete_local_project_file_at(&root, &relative_path).is_err()); + fs::remove_dir_all(root).ok(); + } +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index f9f223696..cec14b71b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -4,6 +4,7 @@ import { type ChangeEvent, type FormEvent, type UIEvent, + useCallback, useEffect, useLayoutEffect, useRef, @@ -64,10 +65,10 @@ import type { LocalGameMemoryResult, LocalPreviewResult, LocalPreviewStatus, - LocalProjectDirectoryStatus, LocalProjectCheckpointResult, LocalProjectCheckpointSummary, LocalProjectDiffResult, + LocalProjectDirectoryStatus, LocalProjectExportPackageResult, LocalProjectExportPackagesResult, LocalProjectFileEntry, @@ -200,6 +201,7 @@ import { } from './features/project-workspace/agentRunTrace'; import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; +import { resolveEmbeddedPreviewUrl } from './features/project-workspace/LocalGamePreviewFrame'; import { appendMemoryContent, memoryScopeLabel, @@ -221,7 +223,6 @@ import { import { handleProjectSummaryChatCommand } from './features/project-workspace/projectSummaryCommands'; import { ProjectSupervisorView } from './features/project-workspace/ProjectSupervisorView'; import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane'; -import { resolveEmbeddedPreviewUrl } from './features/project-workspace/LocalGamePreviewFrame'; import { buildGameChatProgressEvidence, collectGameChatResultImages, @@ -596,6 +597,9 @@ export function App({ ) => Promise) | null >(null); + const executeChatAgentReplyRef = useRef< + (prompt: string) => Promise + >(async () => undefined); const agentConversationSavingRef = useRef(false); const agentConversationBackgroundBusyRef = useRef(false); const agentConversationLoadVersionRef = useRef(0); @@ -620,27 +624,30 @@ export function App({ storeGameChatAutoPreviewAuthorization(authorization); } - function updateProjectSupervisorRuntime( - runtime: AgentRuntimeState | null, - previous = projectSupervisorRuntimeRef.current, - ) { - const nextRuntime = runtime - ? normalizeAgentRuntimeState(runtime, previous) - : null; - const nextProjectPath = localProjectPathRef.current; - if ( - gameChatOnly && - nextProjectPath && - nextRuntime?.runId && - !isAgentRuntimeTerminalState(nextRuntime) - ) { - gameChatObservedRunKeysRef.current.add( - `${nextProjectPath}\n${nextRuntime.runId}`, - ); - } - projectSupervisorRuntimeRef.current = nextRuntime; - setProjectSupervisorRuntime(nextRuntime); - } + const updateProjectSupervisorRuntime = useCallback( + ( + runtime: AgentRuntimeState | null, + previous = projectSupervisorRuntimeRef.current, + ) => { + const nextRuntime = runtime + ? normalizeAgentRuntimeState(runtime, previous) + : null; + const nextProjectPath = localProjectPathRef.current; + if ( + gameChatOnly && + nextProjectPath && + nextRuntime?.runId && + !isAgentRuntimeTerminalState(nextRuntime) + ) { + gameChatObservedRunKeysRef.current.add( + `${nextProjectPath}\n${nextRuntime.runId}`, + ); + } + projectSupervisorRuntimeRef.current = nextRuntime; + setProjectSupervisorRuntime(nextRuntime); + }, + [gameChatOnly], + ); function updateProjectSupervisorResponseStream( incoming: AgentRuntimeResponseStream | null | undefined, @@ -946,7 +953,7 @@ export function App({ disposed = true; cleanup?.(); }; - }, []); + }, [updateProjectSupervisorRuntime]); useEffect(() => { const invoke = resolveTauriInvoke(); @@ -5149,6 +5156,8 @@ export function App({ } } + executeChatAgentReplyRef.current = executeChatAgentReply; + useEffect(() => { const latch = initialSupervisorMessageLatchRef.current; if (!gameChatOnly || !latch.prompt || !localProject) { @@ -5169,7 +5178,7 @@ export function App({ ...current, { role: 'user', text: latch.prompt, runtimeOwned: true }, ]); - void executeChatAgentReply(latch.prompt); + void executeChatAgentReplyRef.current(latch.prompt); }, [chatAgentBusy, gameChatOnly, initialSupervisorMessage, localProject]); async function handleProjectSupervisorToolAction( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx index 40a425b5a..401757601 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx @@ -1,3 +1,5 @@ +/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its small rendering adapter for focused tests. */ + export type LocalGamePreviewLike = { status?: string | null; url?: string | null; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index f3ce2ea79..0b886a469 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -1,3 +1,5 @@ +/* eslint-disable react-refresh/only-export-components -- Testable game-chat presentation helpers share this focused view module. */ + import { FolderOpen, Send, Settings } from 'lucide-react'; import type { ComponentProps, @@ -21,12 +23,12 @@ import type { import { formatAgentRuntimeEvent, isAgentRuntimeTerminalState, - projectProfessionalAgentLabel, projectNameFromPath, + projectProfessionalAgentLabel, projectRuntimePlanProgress, projectRuntimeVisibleCurrentWork, - projectSupervisorCollaboratingAgentRuntimes, projectSupervisorChatRuntimeStatus, + projectSupervisorCollaboratingAgentRuntimes, ProjectSupervisorRuntimeControls, } from '../agent-runtime'; import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog'; @@ -111,7 +113,12 @@ function gameChatResultImageLabel(kind: string, path: string) { } function isGameChatResultImagePath(path: string) { - if (/[\\\u0000-\u001f\u007f]/u.test(path)) { + if ( + Array.from(path).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return character === '\\' || codePoint <= 0x1f || codePoint === 0x7f; + }) + ) { return false; } const segments = path.split('/'); diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 5b627d28a..cafeb314c 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3928,19 +3928,16 @@ iframe.preview-frame { font-size: 11px; } -.game-resource-row { - display: flex; - align-items: flex-start; - gap: 12px; +.game-resource-plane { + position: relative; + min-width: 620px; min-height: 108px; } -.game-resource-canvas--dependency .game-resource-card { - position: relative; - margin-left: calc(var(--resource-depth, 0) * 12px); -} - .game-resource-card { + position: absolute; + top: 0; + left: 0; display: grid; grid-template-columns: 36px minmax(100px, 1fr); grid-template-rows: auto auto auto; @@ -3957,7 +3954,11 @@ iframe.preview-frame { color: #4e382f; text-align: left; box-shadow: 0 6px 18px rgb(96 62 47 / 6%); - cursor: pointer; + cursor: grab; + touch-action: none; + user-select: none; + transform: translate3d(var(--resource-x, 0), var(--resource-y, 0), 0); + will-change: transform; } .game-resource-card:hover, @@ -3969,20 +3970,12 @@ iframe.preview-frame { } .game-resource-card.is-dragging { - opacity: 0.42; + z-index: 2; + opacity: 0.72; cursor: grabbing; -} - -.game-resource-card.is-drop-before { box-shadow: - -5px 0 0 -2px #cf7047, - 0 8px 22px rgb(195 105 62 / 15%); -} - -.game-resource-card.is-drop-after { - box-shadow: - 5px 0 0 -2px #cf7047, - 0 8px 22px rgb(195 105 62 / 15%); + 0 12px 28px rgb(195 105 62 / 24%), + 0 0 0 2px rgb(213 123 81 / 18%); } .game-resource-card-icon { @@ -4938,10 +4931,6 @@ iframe.preview-frame { min-width: 460px; } - .game-resource-row { - flex-wrap: wrap; - } - .game-run-slice-controls { grid-template-columns: repeat(3, minmax(0, 1fr)); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 9062762a7..dd5e99492 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -18,7 +18,6 @@ import { } from 'lucide-react'; import { type CSSProperties, - type DragEvent, type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, @@ -36,11 +35,18 @@ import type { GameCreationAppManifest, GameCreationAppPreviewState, GameCreationAppTaskState, + ProjectResourceCanvasLayoutMode, + ProjectResourceCanvasSection, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { LocalGamePreviewFrame, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; +import { + RESOURCE_CANVAS_DRAG_THRESHOLD, + resourceCanvasSectionExtent, +} from './resourceCanvasLayoutModel'; +import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout'; type AttachmentResult = { fileName: string; @@ -50,28 +56,31 @@ type AttachmentResult = { error?: string; }; -type ResourceCategory = 'document' | 'version' | 'art' | 'audio'; -type ResourceSortMode = 'dependency' | 'type'; +type ResourceCategory = ProjectResourceCanvasSection; +type ResourceSortMode = ProjectResourceCanvasLayoutMode; type WorkbenchMode = 'resources' | 'run'; type ApprovalMode = 'strict' | 'risk' | 'none'; -type ResourceOrderState = Record< - ResourceSortMode, - Record ->; - -type ResourceDropTarget = { - resourceId: string; - placement: 'before' | 'after'; -}; type Point = { x: number; y: number; }; +type ResourceCardDrag = { + pointerId: number; + resourceId: string; + section: ResourceCategory; + startClientX: number; + startClientY: number; + startX: number; + startY: number; + moved: boolean; +}; + type ProjectResource = { id: string; category: ResourceCategory; + subtype: string; label: string; path: string; mediaType: string; @@ -295,6 +304,7 @@ function resourcesFromProject( resources.push({ id: `task:${task.id}:${path}`, category, + subtype: 'task-artifact', label: fileName(path), path, mediaType: category === 'document' ? '项目文档' : '项目产物', @@ -316,6 +326,7 @@ function resourcesFromProject( resources.push({ id: `asset:${asset.id}`, category: categoryFromResource(asset.localPath, asset.mediaType), + subtype: asset.kind, label: `${fileName(asset.localPath)}${ isPendingUiPrototype ? '(待视觉验收)' : '' }`, @@ -345,6 +356,7 @@ function resourcesFromProject( attachment.localPath, attachment.mediaType, ), + subtype: 'attachment', label: attachment.fileName, path: attachment.localPath, mediaType: attachment.mediaType || '未知媒体类型', @@ -359,6 +371,7 @@ function resourcesFromProject( resources.push({ id: `agent-result:${result.agentId}:${result.runId}`, category: 'document', + subtype: 'agent-result', label: result.title, path: `专业 Agent 文本回执 · ${result.label}`, mediaType: 'Agent 历史文本回执', @@ -380,83 +393,6 @@ function resourcesFromProject( return Array.from(uniqueByPath.values()); } -function sortCategoryResources( - resources: ProjectResource[], - mode: ResourceSortMode, -) { - return [...resources].sort((left, right) => { - if (mode === 'dependency') { - return ( - left.dependencyDepth - right.dependencyDepth || - left.label.localeCompare(right.label, 'zh-CN') - ); - } - return left.label.localeCompare(right.label, 'zh-CN'); - }); -} - -function buildResourceOrder(resources: ProjectResource[]): ResourceOrderState { - return { - dependency: Object.fromEntries( - categoryOrder.map((category) => [ - category, - sortCategoryResources( - resources.filter((resource) => resource.category === category), - 'dependency', - ).map((resource) => resource.id), - ]), - ) as Record, - type: Object.fromEntries( - categoryOrder.map((category) => [ - category, - sortCategoryResources( - resources.filter((resource) => resource.category === category), - 'type', - ).map((resource) => resource.id), - ]), - ) as Record, - }; -} - -function reconcileResourceOrder( - current: ResourceOrderState, - resources: ProjectResource[], -) { - const next: ResourceOrderState = { - dependency: { - document: [...current.dependency.document], - version: [...current.dependency.version], - art: [...current.dependency.art], - audio: [...current.dependency.audio], - }, - type: { - document: [...current.type.document], - version: [...current.type.version], - art: [...current.type.art], - audio: [...current.type.audio], - }, - }; - for (const mode of ['dependency', 'type'] as const) { - for (const category of categoryOrder) { - const categoryResources = resources.filter( - (resource) => resource.category === category, - ); - const validIds = new Set( - categoryResources.map((resource) => resource.id), - ); - const preservedIds = current[mode][category].filter((id) => - validIds.has(id), - ); - const preservedSet = new Set(preservedIds); - const appendedIds = sortCategoryResources(categoryResources, mode) - .map((resource) => resource.id) - .filter((id) => !preservedSet.has(id)); - next[mode][category] = [...preservedIds, ...appendedIds]; - } - } - return next; -} - function summarizeAgent( manifest: GameCreationAppManifest, group: AgentSummary['group'], @@ -504,22 +440,24 @@ function ResourceCard({ resource, selected, dragging, - dropPlacement, + x, + y, onSelect, - onDragStart, - onDragOver, - onDrop, - onDragEnd, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, }: { resource: ProjectResource; selected: boolean; dragging: boolean; - dropPlacement: ResourceDropTarget['placement'] | null; + x: number; + y: number; onSelect: () => void; - onDragStart: (event: DragEvent) => void; - onDragOver: (event: DragEvent) => void; - onDrop: (event: DragEvent) => void; - onDragEnd: () => void; + onPointerDown: (event: ReactPointerEvent) => void; + onPointerMove: (event: ReactPointerEvent) => void; + onPointerUp: (event: ReactPointerEvent) => void; + onPointerCancel: (event: ReactPointerEvent) => void; }) { const Icon = categoryIcons[resource.category]; return ( @@ -527,21 +465,21 @@ function ResourceCard({ type="button" className={`game-resource-card${selected ? ' is-selected' : ''}${ dragging ? ' is-dragging' : '' - }${dropPlacement ? ` is-drop-${dropPlacement}` : ''}`} + }`} aria-pressed={selected} - draggable data-resource-id={resource.id} - title="拖动可调整当前会话中的排列" + title="拖动调整资源位置" style={ { - '--resource-depth': Math.min(6, resource.dependencyDepth), + '--resource-x': `${x}px`, + '--resource-y': `${y}px`, } as CSSProperties } onClick={onSelect} - onDragStart={onDragStart} - onDragOver={onDragOver} - onDrop={onDrop} - onDragEnd={onDragEnd} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerCancel} > + + + + + +"#; + let (stop_tx, stop_rx) = mpsc::channel(); + let server = thread::spawn(move || { + while stop_rx.try_recv().is_err() { + match listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + html.len() + ); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(html); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("preview accept failed: {error}"), + } + } + }); + + let evidence = tempfile::tempdir().expect("evidence tempdir"); + let validation = validate_local_preview_in_browser(BrowserValidationInput { + url: format!("http://127.0.0.1:{port}/"), + viewports: REQUIRED_VIEWPORTS.to_vec(), + expected_text: vec!["Stable generic fixture".to_string()], + settle_ms: 100, + fail_on_console_error: true, + playtest_scenario: Some(BrowserPlaytestScenario::GenericV1), + evidence_root: evidence.path().join("evidence"), + }) + .await; + let _ = stop_tx.send(()); + server.join().expect("preview server"); + + let result = validation.expect("real stable generic browser validation"); + assert!( + result.passed, + "diagnostics={:#?}\nviewports={:#?}", + result.diagnostics, result.viewport_results + ); + let playtest = result.playtest.expect("generic playtest result"); + assert!(playtest.passed, "{:#?}", playtest.diagnostics); + assert_eq!(playtest.initial_sequence, Some(0)); + assert_eq!(playtest.final_sequence, Some(3)); + assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready)); + assert!(playtest.assertions.iter().all(|assertion| assertion.passed)); +} + +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_generic_playtest_accepts_first_lost_when_retry_proves_non_loss_progression() { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::thread; + + discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed"); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); + let port = listener.local_addr().expect("preview address").port(); + listener.set_nonblocking(true).expect("nonblocking preview"); + let html = br#" + +Recoverable Lost Generic Browser Fixture + +
Recoverable-lost generic fixture
+ + + + + + + +"#; + let (stop_tx, stop_rx) = mpsc::channel(); + let server = thread::spawn(move || { + while stop_rx.try_recv().is_err() { + match listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + html.len() + ); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(html); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("preview accept failed: {error}"), + } + } + }); + + let evidence = tempfile::tempdir().expect("evidence tempdir"); + let validation = validate_local_preview_in_browser(BrowserValidationInput { + url: format!("http://127.0.0.1:{port}/"), + viewports: REQUIRED_VIEWPORTS.to_vec(), + expected_text: vec!["Recoverable-lost generic fixture".to_string()], + settle_ms: 100, + fail_on_console_error: true, + playtest_scenario: Some(BrowserPlaytestScenario::GenericV1), + evidence_root: evidence.path().join("evidence"), + }) + .await; + let _ = stop_tx.send(()); + server.join().expect("preview server"); + + let result = validation.expect("real recoverable-lost generic browser validation"); + assert!( + result.passed, + "diagnostics={:#?}\nviewports={:#?}", + result.diagnostics, result.viewport_results + ); + let playtest = result.playtest.expect("generic playtest result"); + assert!(playtest.passed, "{:#?}", playtest.diagnostics); + assert_eq!(playtest.initial_sequence, Some(0)); + assert_eq!(playtest.final_sequence, Some(5)); + assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Playing)); + assert!(playtest.assertions.iter().all(|assertion| assertion.passed)); +} + +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_generic_playtest_rejects_fixed_lost_on_both_controlled_attempts() { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::thread; + + discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed"); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); + let port = listener.local_addr().expect("preview address").port(); + listener.set_nonblocking(true).expect("nonblocking preview"); + let html = br#" + +Fixed Lost Generic Browser Fixture + +
Fixed-lost generic fixture
+ + + + + + + +"#; + let (stop_tx, stop_rx) = mpsc::channel(); + let server = thread::spawn(move || { + while stop_rx.try_recv().is_err() { + match listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + html.len() + ); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(html); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("preview accept failed: {error}"), + } + } + }); + + let evidence = tempfile::tempdir().expect("evidence tempdir"); + let validation = validate_local_preview_in_browser(BrowserValidationInput { + url: format!("http://127.0.0.1:{port}/"), + viewports: REQUIRED_VIEWPORTS.to_vec(), + expected_text: vec!["Fixed-lost generic fixture".to_string()], + settle_ms: 100, + fail_on_console_error: true, + playtest_scenario: Some(BrowserPlaytestScenario::GenericV1), + evidence_root: evidence.path().join("evidence"), + }) + .await; + let _ = stop_tx.send(()); + server.join().expect("preview server"); + + let result = validation.expect("real fixed-lost generic browser validation"); + assert!(!result.passed, "fixed lost must fail browser validation"); + let playtest = result.playtest.expect("generic playtest result"); + assert!(!playtest.passed, "{:#?}", playtest.assertions); + assert_eq!(playtest.initial_sequence, Some(0)); + assert_eq!(playtest.final_sequence, Some(5)); + assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Lost)); + assert!( + playtest + .diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("固定失败")), + "unexpected diagnostics: {:#?}", + playtest.diagnostics + ); + assert_eq!( + playtest + .assertions + .iter() + .find(|assertion| assertion.name == "non-loss-progression-observed") + .map(|assertion| assertion.passed), + Some(false) + ); +} + #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] async fn real_chrome_lane_defense_playtest() { @@ -873,7 +1510,7 @@ async fn real_chrome_lane_defense_playtest() { listener.set_nonblocking(true).expect("nonblocking preview"); let html = br#" -Lane Defense Browser Fixture +Lane Defense Browser Fixture
Lane defense fixture
diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index 936d1befb..adeec51c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -1316,11 +1316,7 @@ fn configure_project_command_process_group(command: &mut tokio::process::Command } #[cfg(windows)] { - use std::os::windows::process::CommandExt; - const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; - command - .as_std_mut() - .creation_flags(CREATE_NEW_PROCESS_GROUP); + crate::configure_windows_background_tokio_command(command, true); } } @@ -1501,13 +1497,16 @@ async fn request_project_command_process_group_termination( if !taskkill.is_absolute() || !taskkill.is_file() { return Err("请求终止受控进程组失败:taskkill.exe 不是绝对普通文件".to_string()); } - let status = tokio::process::Command::new(taskkill) + let mut command = tokio::process::Command::new(taskkill); + command .args(["/PID", &process_id.to_string(), "/T", "/F"]) .env_clear() .env("SystemRoot", &system_root) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stderr(Stdio::null()); + crate::configure_windows_background_tokio_command(&mut command, false); + let status = command .status() .await .map_err(|error| format!("请求终止受控进程组失败:启动 taskkill.exe 失败:{error}"))?; 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 ed9e40a67..19fded1e3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -368,6 +368,7 @@ pub(crate) fn game_creator_llm_reasoning_effort_name( fn validate_game_creator_runtime_config_dir_metadata( path: &Path, tighten: bool, + initialize_windows_owner: bool, ) -> Result<(), String> { let metadata = fs::symlink_metadata(path).map_err(|error| { format!( @@ -382,6 +383,7 @@ fn validate_game_creator_runtime_config_dir_metadata( #[cfg(unix)] { use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let _ = initialize_windows_owner; // SAFETY: geteuid takes no arguments and has no memory safety preconditions. let effective_user_id = unsafe { libc::geteuid() }; @@ -418,7 +420,12 @@ fn validate_game_creator_runtime_config_dir_metadata( if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { return Err("客户端 AppData 配置目录不能是 Windows reparse point".to_string()); } - secure_windows_game_creator_path_for_current_user(path, true, tighten)?; + secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + true, + tighten, + initialize_windows_owner, + )?; } #[cfg(not(any(unix, windows)))] @@ -437,24 +444,155 @@ fn resolve_game_creator_runtime_config_dir( if !path.is_absolute() { return Err("客户端 AppData 配置目录必须是绝对路径".to_string()); } + let mut created = false; if create_and_tighten { - fs::create_dir_all(path).map_err(|error| { - format!( - "创建客户端 AppData 配置目录失败:{}: {error}", - path.display() - ) - })?; + match fs::symlink_metadata(path) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|create_error| { + format!( + "创建客户端 AppData 配置父目录失败:{}: {create_error}", + parent.display() + ) + })?; + } + match fs::create_dir(path) { + Ok(()) => created = true, + // 与其他启动进程竞争时,不把对方创建的目录误判为本进程的新对象。 + Err(create_error) + if create_error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(create_error) => { + return Err(format!( + "创建客户端 AppData 配置目录失败:{}: {create_error}", + path.display() + )); + } + } + } + Err(error) => { + return Err(format!( + "检查客户端 AppData 配置目录失败:{}: {error}", + path.display() + )); + } + } } + // canonicalize 会跟随目录链接,因此必须先检查用户给出的目录项本身。 + validate_game_creator_runtime_config_dir_entry_type(path)?; let canonical = fs::canonicalize(path).map_err(|error| { format!( "解析客户端 AppData 配置目录失败:{}: {error}", path.display() ) })?; - validate_game_creator_runtime_config_dir_metadata(&canonical, create_and_tighten)?; + match validate_game_creator_runtime_config_dir_metadata(&canonical, create_and_tighten, created) + { + Ok(()) => {} + #[cfg(windows)] + Err(error) + if create_and_tighten + && !created + && error.starts_with("Windows 安全对象不属于当前用户:") => + { + let backup = migrate_windows_foreign_owner_config_dir(path)?; + fs::create_dir(path).map_err(|create_error| { + format!( + "旧 AppData 配置已安全保留在 {},但重新创建当前用户配置目录失败:{}: {create_error}", + backup.display(), + path.display() + ) + })?; + validate_game_creator_runtime_config_dir_metadata(path, true, true).map_err( + |validation_error| { + format!( + "旧 AppData 配置已安全保留在 {},但新配置目录安全初始化失败:{validation_error}", + backup.display() + ) + }, + )?; + return fs::canonicalize(path).map_err(|canonicalize_error| { + format!( + "旧 AppData 配置已安全保留在 {},但解析新配置目录失败:{}: {canonicalize_error}", + backup.display(), + path.display() + ) + }); + } + Err(error) => return Err(error), + } Ok(canonical) } +fn validate_game_creator_runtime_config_dir_entry_type(path: &Path) -> Result<(), String> { + let metadata = fs::symlink_metadata(path).map_err(|error| { + format!( + "读取客户端 AppData 配置目录元数据失败:{}: {error}", + path.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("客户端 AppData 配置目录必须是普通目录,不能是链接或其他文件".to_string()); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err("客户端 AppData 配置目录不能是 Windows reparse point".to_string()); + } + } + Ok(()) +} + +#[cfg(windows)] +fn migrate_windows_foreign_owner_config_dir(path: &Path) -> Result { + validate_game_creator_runtime_config_dir_entry_type(path)?; + let parent = path.parent().ok_or_else(|| { + format!( + "AppData 配置目录没有可用于安全迁移的父目录:{}", + path.display() + ) + })?; + let name = path + .file_name() + .ok_or_else(|| format!("AppData 配置目录名称无效,无法安全迁移:{}", path.display()))?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + for attempt in 0..100_u32 { + let backup = parent.join(format!( + "{}.owner-mismatch-backup-{timestamp}-{}-{attempt}", + name.to_string_lossy(), + std::process::id() + )); + match fs::symlink_metadata(&backup) { + Ok(_) => continue, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "检查旧 AppData 配置备份路径失败:{}: {error}", + backup.display() + )); + } + } + // 同一父目录内 rename 是原子目录项替换;目标已确认不存在,旧配置不会被覆盖。 + fs::rename(path, &backup).map_err(|error| { + format!( + "AppData 配置目录 owner 不匹配,无法安全迁移。请保留并手动恢复 {};计划备份路径为 {}:{error}", + path.display(), + backup.display() + ) + })?; + return Ok(backup); + } + Err(format!( + "AppData 配置目录 owner 不匹配,但无法找到不冲突的备份路径;请手动保留并恢复 {}", + path.display() + )) +} + pub(crate) fn prepare_game_creator_runtime_config_dir(path: &Path) -> Result { resolve_game_creator_runtime_config_dir(path, true) } @@ -480,6 +618,28 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user( path: &Path, is_directory: bool, tighten: bool, +) -> Result<(), String> { + secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + is_directory, + tighten, + false, + ) +} + +#[cfg(windows)] +pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user( + path: &Path, +) -> Result<(), String> { + secure_windows_game_creator_path_for_current_user_with_owner_policy(path, false, true, true) +} + +#[cfg(windows)] +fn secure_windows_game_creator_path_for_current_user_with_owner_policy( + path: &Path, + is_directory: bool, + tighten: bool, + initialize_owner: bool, ) -> Result<(), String> { use std::ffi::c_void; use std::os::windows::ffi::OsStrExt; @@ -661,6 +821,41 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user( .encode_wide() .chain(std::iter::once(0)) .collect::>(); + let mut initial_owner = std::ptr::null_mut(); + let mut initial_descriptor = std::ptr::null_mut(); + // 先验证 owner,再修改 DACL,避免对其他用户持有的旧配置做任何权限变更。 + let owner_status = unsafe { + GetNamedSecurityInfoW( + wide_path.as_mut_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + &mut initial_owner, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut initial_descriptor, + ) + }; + if owner_status != 0 || initial_owner.is_null() || initial_descriptor.is_null() { + if !initial_descriptor.is_null() { + unsafe { LocalFree(initial_descriptor) }; + } + return Err(format!( + "读取 Windows owner 失败:{}: error {owner_status}", + path.display() + )); + } + let owner_matches = unsafe { IsValidSid(initial_owner) } != 0 + && unsafe { EqualSid(initial_owner, current_user_sid) } != 0; + unsafe { LocalFree(initial_descriptor) }; + if !owner_matches { + if !(initialize_owner && tighten) { + return Err(format!( + "Windows 安全对象不属于当前用户:{}", + path.display() + )); + } + } if tighten { let mut entry = ExplicitAccessW { access_permissions: FILE_ALL_ACCESS, @@ -693,8 +888,18 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user( SetNamedSecurityInfoW( wide_path.as_mut_ptr(), SE_FILE_OBJECT, - DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, - std::ptr::null_mut(), + DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION + | if initialize_owner { + OWNER_SECURITY_INFORMATION + } else { + 0 + }, + if initialize_owner { + current_user_sid + } else { + std::ptr::null_mut() + }, std::ptr::null_mut(), private_dacl, std::ptr::null_mut(), @@ -704,7 +909,7 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user( unsafe { LocalFree(private_dacl) }; if set_status != 0 { return Err(format!( - "收紧 Windows 当前用户私有 DACL 失败:{}: error {set_status}", + "初始化 Windows 当前用户 owner/私有 DACL 失败:{}: error {set_status}", path.display() )); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs index 33ef15c67..74a7d4b92 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs @@ -2263,6 +2263,7 @@ fn build_sandboxed_git_command( let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" }; let sandbox = context.sandbox.path(); let mut command = Command::new(&context.executable); + crate::configure_windows_background_std_command(&mut command, false); command.env_clear(); for key in ["SystemRoot", "WINDIR", "PATHEXT"] { if let Some(value) = std::env::var_os(key) { 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 d72097414..8f72f2bcc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2,10 +2,11 @@ use std::collections::BTreeMap; use std::fs; -use std::fs::File; -use std::io::{BufRead, BufReader, Read, Write}; +use std::fs::{File, OpenOptions}; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; use std::sync::{mpsc, Arc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -143,6 +144,12 @@ struct LocalPreviewStatus { root: Option, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalGameProjectRevisionStatus { + revision: u64, +} + #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GenerateLocalGameDraftResult { @@ -1580,6 +1587,292 @@ struct LlmAgentHandoff { next: String, } +const DIAGNOSTIC_LOG_MAX_BYTES: u64 = 256 * 1024; +static DIAGNOSTIC_LOG_LOCK: OnceLock> = OnceLock::new(); +static STARTUP_PANIC_LOG_PATH: OnceLock = OnceLock::new(); +static STARTUP_ERROR_DIALOG_SHOWN: AtomicBool = AtomicBool::new(false); + +fn diagnostic_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn append_bounded_diagnostic_line_with_limit( + path: &Path, + line: &str, + max_bytes: u64, +) -> std::io::Result<()> { + let _guard = DIAGNOSTIC_LOG_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut file = open_secure_diagnostic_log(path)?; + if file.metadata()?.len() >= max_bytes { + file.seek(SeekFrom::Start(0)).map_err(|error| { + std::io::Error::new(error.kind(), format!("seek current log: {error}")) + })?; + let mut previous_content = Vec::new(); + std::io::Read::by_ref(&mut file) + .take(max_bytes.saturating_add(1)) + .read_to_end(&mut previous_content) + .map_err(|error| { + std::io::Error::new(error.kind(), format!("read current log: {error}")) + })?; + let previous_path = path.with_extension("previous.log"); + let mut previous = open_secure_diagnostic_log(&previous_path)?; + previous.set_len(0).map_err(|error| { + std::io::Error::new(error.kind(), format!("truncate previous log: {error}")) + })?; + previous.write_all(&previous_content).map_err(|error| { + std::io::Error::new(error.kind(), format!("write previous log: {error}")) + })?; + previous.flush().map_err(|error| { + std::io::Error::new(error.kind(), format!("flush previous log: {error}")) + })?; + file.set_len(0).map_err(|error| { + std::io::Error::new(error.kind(), format!("truncate current log: {error}")) + })?; + } + file.seek(SeekFrom::End(0)) + .map_err(|error| std::io::Error::new(error.kind(), format!("seek log end: {error}")))?; + writeln!(file, "{} {line}", diagnostic_timestamp())?; + file.flush() +} + +fn open_secure_diagnostic_log(path: &Path) -> std::io::Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "diagnostic log must be a regular file", + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + let file = options.open(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "diagnostic log must be a regular file", + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() != 1 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "diagnostic log must not be a hardlink", + )); + } + } + #[cfg(windows)] + crate::runner::validate_windows_regular_file_handle(&file, "diagnostic log") + .map_err(std::io::Error::other)?; + Ok(file) +} + +pub(crate) fn append_bounded_diagnostic_line(path: &Path, line: &str) -> std::io::Result<()> { + append_bounded_diagnostic_line_with_limit(path, line, DIAGNOSTIC_LOG_MAX_BYTES) +} + +fn redact_windows_absolute_paths(value: &str) -> String { + let bytes = value.as_bytes(); + let mut output = String::with_capacity(value.len()); + let mut cursor = 0; + while cursor < bytes.len() { + let previous_allows_drive_path = cursor == 0 || !bytes[cursor - 1].is_ascii_alphanumeric(); + let is_drive_path = previous_allows_drive_path + && cursor + 2 < bytes.len() + && bytes[cursor].is_ascii_alphabetic() + && bytes[cursor + 1] == b':' + && matches!(bytes[cursor + 2], b'\\' | b'/'); + if !is_drive_path { + let ch = value[cursor..] + .chars() + .next() + .expect("valid character boundary"); + output.push(ch); + cursor += ch.len_utf8(); + continue; + } + output.push_str(""); + cursor += 3; + while cursor < bytes.len() + && !bytes[cursor].is_ascii_whitespace() + && !matches!(bytes[cursor], b'\"' | b'\'' | b',' | b';') + { + cursor += 1; + } + } + output +} + +fn redact_unix_absolute_paths(value: &str) -> String { + let chars = value.chars().collect::>(); + let mut output = String::with_capacity(value.len()); + let mut cursor = 0; + while cursor < chars.len() { + let previous_allows_path = cursor == 0 + || chars[cursor - 1].is_whitespace() + || matches!(chars[cursor - 1], '=' | '(' | ':' | ':'); + let is_url_separator = chars.get(cursor + 1) == Some(&'/'); + if chars[cursor] != '/' || !previous_allows_path || is_url_separator { + output.push(chars[cursor]); + cursor += 1; + continue; + } + output.push_str(""); + cursor += 1; + while cursor < chars.len() + && !chars[cursor].is_whitespace() + && !matches!(chars[cursor], '"' | '\'' | ',' | ';') + { + cursor += 1; + } + } + output +} + +pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Path>) -> String { + let mut sanitized = value.replace(['\r', '\n'], " "); + if let Some(root) = private_root { + let root = root.to_string_lossy(); + if !root.is_empty() { + sanitized = sanitized.replace(root.as_ref(), ""); + } + } + let lowercase = sanitized.to_ascii_lowercase(); + if [ + "authorization", + "bearer ", + "api_key", + "apikey", + "api key", + "x-api-key", + "token=", + "token:", + "credential", + ] + .iter() + .any(|marker| lowercase.contains(marker)) + { + return "".to_string(); + } + sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized)); + sanitized.chars().take(2_048).collect() +} + +fn initialize_game_chat_startup_log(identifier: &str) -> PathBuf { + let appdata_path = std::env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join(identifier) + .join("startup.log"); + if append_bounded_diagnostic_line(&appdata_path, "startup.begin").is_ok() { + return appdata_path; + } + let fallback_path = std::env::temp_dir() + .join("Genarrative-Game-Chat-Diagnostics") + .join("startup.log"); + let _ = append_bounded_diagnostic_line( + &fallback_path, + "startup.begin appdata-log-unavailable=true", + ); + fallback_path +} + +fn install_startup_panic_log(path: PathBuf) { + if STARTUP_PANIC_LOG_PATH.set(path).is_err() { + return; + } + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if let Some(path) = STARTUP_PANIC_LOG_PATH.get() { + let location = info + .location() + .map(|location| { + let file = Path::new(location.file()) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("unknown"); + format!("{file}:{}:{}", location.line(), location.column()) + }) + .unwrap_or_else(|| "unknown".to_string()); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.panic location={location} details=redacted"), + ); + } + previous(info); + })); +} + +#[cfg(windows)] +fn show_startup_error_dialog(log_path: &Path) { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + MessageBoxW, MB_ICONERROR, MB_OK, MB_SETFOREGROUND, + }; + + if STARTUP_ERROR_DIALOG_SHOWN.swap(true, AtomicOrdering::AcqRel) { + return; + } + let title = std::ffi::OsStr::new("Genarrative Game Chat") + .encode_wide() + .chain(Some(0)) + .collect::>(); + let message_text = format!( + "应用启动失败。请将以下诊断日志发给开发人员:\n{}", + log_path.display() + ); + let message = std::ffi::OsStr::new(&message_text) + .encode_wide() + .chain(Some(0)) + .collect::>(); + // SAFETY: both UTF-16 buffers are NUL-terminated and live for the duration of the call. + unsafe { + MessageBoxW( + std::ptr::null_mut(), + message.as_ptr(), + title.as_ptr(), + MB_OK | MB_ICONERROR | MB_SETFOREGROUND, + ); + } +} + +#[cfg(not(windows))] +fn show_startup_error_dialog(log_path: &Path) { + if STARTUP_ERROR_DIALOG_SHOWN.swap(true, AtomicOrdering::AcqRel) { + return; + } + eprintln!( + "Genarrative Game Chat startup failed; see {}", + log_path.display() + ); +} + #[derive(Clone, Debug)] struct GameCreatorAgentLoopResult { run_id: String, @@ -1699,20 +1992,24 @@ fn main() { Err(_) => std::process::exit(125), } } - let game_chat_launch = match parse_game_chat_launch_args(&args) { + let explicit_game_chat_launch = match parse_game_chat_launch_args(&args) { + Ok(options) => options, + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + }; + let game_chat_launch = match select_game_chat_launch_options( + explicit_game_chat_launch, + cfg!(debug_assertions), + cfg!(feature = "game-chat-release"), + ) { Ok(options) => options, Err(error) => { eprintln!("{error}"); std::process::exit(1); } }; - #[cfg(not(debug_assertions))] - if game_chat_launch.is_some() { - eprintln!("--game-chat 仅在开发构建中可用"); - std::process::exit(1); - } - #[cfg(test)] - let _ = &game_chat_launch; let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) { Ok(config_dir) => config_dir, Err(error) => { @@ -1784,35 +2081,106 @@ fn main() { } let mut tauri_context = tauri::generate_context!(); - #[cfg(debug_assertions)] + let startup_log = if cfg!(all(not(debug_assertions), feature = "game-chat-release")) { + let path = initialize_game_chat_startup_log(&tauri_context.config().identifier); + install_startup_panic_log(path.clone()); + Some(path) + } else { + None + }; if let Some(options) = game_chat_launch.as_ref() { if let Err(error) = apply_game_chat_initial_window_url(tauri_context.config_mut(), options) { + if let Some(path) = startup_log.as_deref() { + let details = sanitize_diagnostic_message(error.as_str(), path.parent()); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.window-url.failed details={details}"), + ); + show_startup_error_dialog(path); + } eprintln!("{error}"); std::process::exit(1); } } + if let Some(path) = startup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.context.ready"); + } + let setup_log = startup_log.clone(); let app = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) .manage(game_creator_preview_registry()) .setup(move |app| { - configure_game_creator_runtime_config_dir(app.handle())?; + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.setup.begin"); + let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.begin"); + } + configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| { + if let Some(path) = setup_log.as_deref() { + let details = sanitize_diagnostic_message(&error.to_string(), path.parent()); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.appdata.configure.failed details={details}"), + ); + show_startup_error_dialog(path); + } + })?; + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete"); + } let config_dir = game_creator_runtime_config_dir().ok_or_else(|| { - std::io::Error::new( + let error = std::io::Error::new( std::io::ErrorKind::NotFound, "客户端 AppData 配置目录未初始化", - ) - })?; - configure_external_agent_runner(&config_dir).map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("配置 Agent Runner 失败:{error}"), - ) + ); + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line( + path, + "startup.appdata.resolve.failed details=config-dir-uninitialized", + ); + show_startup_error_dialog(path); + } + error })?; + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.runner.configure.begin"); + } + configure_external_agent_runner(&config_dir) + .inspect_err(|error| { + if let Some(path) = setup_log.as_deref() { + let details = + sanitize_diagnostic_message(error, Some(config_dir.as_path())); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.runner.configure.failed details={details}"), + ); + show_startup_error_dialog(path); + } + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("配置 Agent Runner 失败:{error}"), + ) + })?; + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.runner.configure.complete"); + } let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir) + .inspect_err(|error| { + if let Some(path) = setup_log.as_deref() { + let details = + sanitize_diagnostic_message(error, Some(config_dir.as_path())); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.runner.owner-lock.failed details={details}"), + ); + show_startup_error_dialog(path); + } + }) .map_err(|error| { std::io::Error::new( std::io::ErrorKind::AlreadyExists, @@ -1820,23 +2188,56 @@ fn main() { ) })?; app.manage(gui_owner_lock); - ensure_external_agent_runner_started_for_gui().map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("启动 Agent Runner 失败:{error}"), - ) - })?; - attach_external_agent_runner_gui_owner().map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("绑定 Agent Runner GUI owner 失败:{error}"), - ) - })?; + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.runner.start.begin"); + } + ensure_external_agent_runner_started_for_gui() + .inspect_err(|error| { + if let Some(path) = setup_log.as_deref() { + let details = + sanitize_diagnostic_message(error, Some(config_dir.as_path())); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.runner.start.failed details={details}"), + ); + show_startup_error_dialog(path); + } + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("启动 Agent Runner 失败:{error}"), + ) + })?; + attach_external_agent_runner_gui_owner() + .inspect_err(|error| { + if let Some(path) = setup_log.as_deref() { + let details = + sanitize_diagnostic_message(error, Some(config_dir.as_path())); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.runner.attach-owner.failed details={details}"), + ); + show_startup_error_dialog(path); + } + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("绑定 Agent Runner GUI owner 失败:{error}"), + ) + })?; + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete"); + } set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); #[cfg(all(debug_assertions, not(test)))] if game_chat_launch.is_none() { open_developer_window(app.handle())?; } + if let Some(path) = setup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.setup.complete"); + } Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -1918,14 +2319,174 @@ fn main() { start_local_game_preview, activate_local_game_preview, stop_local_game_preview, + stop_local_game_preview_if_matches, get_local_game_preview_status, read_local_project_resource_canvas_layout, update_local_project_resource_canvas_layout, + get_local_game_project_revision, get_local_game_manifest ]) - .build(tauri_context) - .expect("failed to build Genarrative AI Game Creator shell"); - app.run(|_, event| handle_game_creator_gui_run_event(&event)); + .build(tauri_context); + let app = match app { + Ok(app) => { + if let Some(path) = startup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.build.complete"); + } + app + } + Err(error) => { + if let Some(path) = startup_log.as_deref() { + let details = sanitize_diagnostic_message(&error.to_string(), path.parent()); + let _ = append_bounded_diagnostic_line( + path, + &format!("startup.build.failed details={details}"), + ); + show_startup_error_dialog(path); + } + eprintln!("failed to build Genarrative AI Game Creator shell: {error}"); + std::process::exit(1); + } + }; + if let Some(path) = startup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.run.begin"); + } + let shutdown_log = startup_log.clone(); + app.run(move |_, event| { + let game_chat_release = cfg!(all(not(debug_assertions), feature = "game-chat-release")); + if game_chat_release && should_shutdown_runner_on_tauri_event(true, &event) { + if let Some(path) = shutdown_log.as_deref() { + let _ = append_bounded_diagnostic_line( + path, + "startup.runner.shutdown-for-client-exit.begin", + ); + } + match shutdown_external_agent_runner_for_client_exit() { + Ok(()) => { + if let Some(path) = shutdown_log.as_deref() { + let _ = append_bounded_diagnostic_line( + path, + "startup.runner.shutdown-for-client-exit.complete", + ); + } + } + Err(error) => { + if let Some(path) = shutdown_log.as_deref() { + let details = sanitize_diagnostic_message(&error, path.parent()); + let _ = append_bounded_diagnostic_line( + path, + &format!( + "startup.runner.shutdown-for-client-exit.failed details={details}" + ), + ); + } + eprintln!("game-chat 客户端退出协议关闭 Agent Runner 失败:{error}") + } + } + } else if !game_chat_release { + handle_game_creator_gui_run_event(&event); + } + }); + if let Some(path) = startup_log.as_deref() { + let _ = append_bounded_diagnostic_line(path, "startup.run.complete"); + } +} + +#[cfg(test)] +mod diagnostic_log_tests { + use super::*; + + #[test] + fn bounded_diagnostic_log_rotates_and_keeps_only_one_previous_file() { + let directory = tempfile::tempdir().expect("create diagnostics directory"); + let path = directory.path().join("startup.log"); + let first_record = "x".repeat(128); + append_bounded_diagnostic_line_with_limit(&path, &first_record, 64) + .expect("write first record"); + append_bounded_diagnostic_line_with_limit(&path, "second-record", 64) + .expect("rotate diagnostic log"); + + let current = fs::read_to_string(&path).expect("read current diagnostic log"); + let previous = fs::read_to_string(path.with_extension("previous.log")) + .expect("read previous diagnostic log"); + assert!(current.contains("second-record")); + assert!(previous.contains(&"x".repeat(32))); + } + + #[test] + fn diagnostic_message_redacts_sensitive_values_and_absolute_paths() { + assert_eq!( + sanitize_diagnostic_message("Authorization: Bearer secret", None), + "" + ); + assert_eq!( + sanitize_diagnostic_message(r"failed at C:\private\project\game.json", None), + "failed at " + ); + assert_eq!( + sanitize_diagnostic_message("failed at /home/example/private/game.json", None), + "failed at " + ); + } + + #[test] + fn diagnostic_log_rejects_hardlink_targets_including_rotation_backup() { + let directory = tempfile::tempdir().expect("create diagnostics directory"); + let outside = directory.path().join("outside.txt"); + fs::write(&outside, "outside-unchanged").expect("write outside target"); + let path = directory.path().join("startup.log"); + fs::hard_link(&outside, &path).expect("create diagnostic hardlink"); + assert!(append_bounded_diagnostic_line(&path, "must-not-write").is_err()); + assert_eq!( + fs::read_to_string(&outside).expect("read outside target"), + "outside-unchanged" + ); + + fs::remove_file(&path).expect("remove diagnostic hardlink"); + fs::write(&path, "rotate-me").expect("write diagnostic file"); + let previous = path.with_extension("previous.log"); + fs::hard_link(&outside, &previous).expect("create previous hardlink"); + assert!(append_bounded_diagnostic_line_with_limit(&path, "blocked", 1).is_err()); + assert_eq!( + fs::read_to_string(&outside).expect("read outside target after rotation"), + "outside-unchanged" + ); + } + + #[cfg(unix)] + #[test] + fn diagnostic_log_rejects_symlink_targets() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().expect("create diagnostics directory"); + let outside = directory.path().join("outside.txt"); + fs::write(&outside, "outside-unchanged").expect("write outside target"); + let path = directory.path().join("startup.log"); + symlink(&outside, &path).expect("create diagnostic symlink"); + assert!(append_bounded_diagnostic_line(&path, "must-not-write").is_err()); + assert_eq!( + fs::read_to_string(&outside).expect("read outside target"), + "outside-unchanged" + ); + } + + #[cfg(windows)] + #[test] + fn diagnostic_log_rejects_windows_symlink_or_reparse_targets_when_supported() { + use std::os::windows::fs::symlink_file; + + let directory = tempfile::tempdir().expect("create diagnostics directory"); + let outside = directory.path().join("outside.txt"); + fs::write(&outside, "outside-unchanged").expect("write outside target"); + let path = directory.path().join("startup.log"); + if symlink_file(&outside, &path).is_err() { + return; + } + assert!(append_bounded_diagnostic_line(&path, "must-not-write").is_err()); + assert_eq!( + fs::read_to_string(&outside).expect("read outside target"), + "outside-unchanged" + ); + } } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs index 8ce881baf..066a6a37a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs @@ -631,6 +631,7 @@ fn apply_game_creator_mcp_platform_environment(command: &mut tokio::process::Com command.env(name, value); } } + crate::configure_windows_background_tokio_command(command, false); } #[cfg(not(windows))] diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 3060b666f..d36f60193 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -65,6 +65,21 @@ impl PreviewRegistry { let _ = server.stop.send(()); (stopped_preview_status(), true) } + + pub(crate) fn stop_if_matches(&self, expected: &LocalPreviewResult) -> bool { + let mut current = self.current.lock().expect("preview registry lock"); + if current + .as_ref() + .is_none_or(|server| server.preview != *expected) + { + return false; + } + let Some(server) = current.take() else { + return false; + }; + let _ = server.stop.send(()); + true + } } static GAME_CREATOR_PREVIEW_REGISTRY: OnceLock = OnceLock::new(); @@ -160,18 +175,35 @@ pub(crate) fn filter_preview_status_for_project( #[tauri::command] pub(crate) fn start_local_game_preview( project_path: String, + expected_revision: Option, registry: tauri::State<'_, PreviewRegistry>, ) -> Result { let root = Path::new(project_path.trim()); - start_local_game_preview_at(root, ®istry) + start_local_game_preview_at_revision(root, expected_revision, ®istry) } pub(crate) fn start_local_game_preview_at( root: &Path, registry: &PreviewRegistry, +) -> Result { + start_local_game_preview_at_revision(root, None, registry) +} + +pub(crate) fn start_local_game_preview_at_revision( + root: &Path, + expected_revision: Option, + registry: &PreviewRegistry, ) -> Result { enforce_project_permission_policy(root, "preview.start")?; let _lock = acquire_project_write_lock(root, "preview.start")?; + if let Some(expected_revision) = expected_revision { + let current_revision = read_game_creator_agent_runtime_project_revision(root)?.revision; + if current_revision != expected_revision { + return Err(format!( + "本地游戏项目已在验证后发生变化(已验证 revision:{expected_revision},当前 revision:{current_revision})" + )); + } + } let (preview, stop) = start_local_game_preview_for_project(root)?; if let Err(error) = record_preview_state( root, @@ -231,6 +263,46 @@ pub(crate) fn stop_local_game_preview_for_root( Ok(status) } +#[tauri::command] +pub(crate) fn stop_local_game_preview_if_matches( + project_path: String, + expected_preview: LocalPreviewResult, + registry: tauri::State<'_, PreviewRegistry>, +) -> Result { + stop_local_game_preview_if_matches_at( + Path::new(project_path.trim()), + &expected_preview, + ®istry, + ) +} + +pub(crate) fn stop_local_game_preview_if_matches_at( + root: &Path, + expected_preview: &LocalPreviewResult, + registry: &PreviewRegistry, +) -> Result { + let expected_status = local_preview_status_from_result(expected_preview); + ensure_preview_belongs_to_project(&expected_status, root)?; + if !registry.stop_if_matches(expected_preview) { + return Ok(false); + } + // This command is a compensating cleanup for a preview that became stale while an + // asynchronous start was in flight. Stop the exact registry identity before waiting + // for project persistence so a denied stop policy or a busy project lock cannot leak + // the loopback server. A newer preview for the same project owns the durable state. + let _lock = acquire_project_write_lock(root, "preview.stop")?; + let current_status = registry.status(); + if current_status.status == "running" + && ensure_preview_belongs_to_project(¤t_status, root).is_ok() + { + return Ok(true); + } + record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?; + append_preview_log(root, "stopped", None)?; + append_preview_stop_trace_step(root)?; + Ok(true) +} + #[tauri::command] pub(crate) fn get_local_game_preview_status( registry: tauri::State<'_, PreviewRegistry>, @@ -253,6 +325,23 @@ pub(crate) fn get_local_game_preview_status_at( )) } +#[tauri::command] +pub(crate) fn get_local_game_project_revision( + project_path: String, +) -> Result { + get_local_game_project_revision_at(Path::new(project_path.trim())) +} + +pub(crate) fn get_local_game_project_revision_at( + root: &Path, +) -> Result { + enforce_project_permission_policy(root, "preview.status")?; + let revision = read_game_creator_agent_runtime_project_revision(root)?; + Ok(LocalGameProjectRevisionStatus { + revision: revision.revision, + }) +} + #[tauri::command] pub(crate) fn activate_local_game_preview( registry: tauri::State<'_, PreviewRegistry>, @@ -478,7 +567,7 @@ pub(crate) fn content_type(path: &Path) -> &'static str { fn http_response(status: &str, content_type: &str, body: &[u8], content_length: usize) -> Vec { let header = format!( - "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\r\nPragma: no-cache\r\nExpires: 0\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", content_length ); let mut response = header.into_bytes(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs index 93d9879b0..30835b4d9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs @@ -404,13 +404,14 @@ async fn terminate_project_verification_process_tree(child: &mut tokio::process: terminate_project_verification_process_group(process_id); #[cfg(windows)] { - let _ = tokio::process::Command::new("taskkill") + let mut command = tokio::process::Command::new("taskkill"); + command .args(["/PID", &process_id.to_string(), "/T", "/F"]) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .await; + .stderr(std::process::Stdio::null()); + crate::configure_windows_background_tokio_command(&mut command, false); + let _ = command.status().await; } } let _ = child.kill().await; diff --git a/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs b/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs index 92461f3f1..ba92e21c2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs @@ -1451,6 +1451,7 @@ fn isolated_git_command(root: &Path) -> Command { let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" }; let mut command = Command::new("git"); + crate::configure_windows_background_std_command(&mut command, false); command.env_clear(); for (key, value) in inherited_environment { command.env(key, value); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index f5c50af25..475f62ca9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -16,9 +16,9 @@ pub(crate) use client::{ read_external_agent_runner_mcp_catalog, read_external_agent_runner_status, require_external_agent_runner_configured_for_cli_runtime_write, require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner, - shutdown_external_agent_runner, shutdown_external_agent_runner_if_idle, - steer_external_agent_runner, wake_external_agent_runner_pending, - wake_external_agent_runner_pending_for_run, + shutdown_external_agent_runner, shutdown_external_agent_runner_for_client_exit, + shutdown_external_agent_runner_if_idle, steer_external_agent_runner, + wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run, }; #[cfg(windows)] pub(crate) use endpoint::validate_windows_regular_file_handle; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 846c567ea..975968661 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -4,16 +4,193 @@ use serde_json::Value; use sha2::{Digest as _, Sha256}; use std::ffi::OsString; use std::fs; -use std::io::{self, Write}; +use std::io::{self, BufRead, BufReader, Read, Write}; use std::net::{Ipv4Addr, SocketAddrV4, TcpStream}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; -pub(super) fn launch_external_agent_runner(config_dir: &Path) -> Result { +const AGENT_RUNNER_LOG_FILE_NAME: &str = "agent-runner.log"; +const AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES: usize = 8 * 1024; +const AGENT_RUNNER_LOG_OUTPUT_MAX_CHARS: usize = 1_024; +const AGENT_RUNNER_CLIENT_EXIT_TIMEOUT: Duration = Duration::from_secs(15); + +fn redact_url_queries(line: &str) -> String { + line.split_whitespace() + .map(|token| { + if (token.starts_with("http://") || token.starts_with("https://")) + && token.contains('?') + { + let base = token.split_once('?').map(|(base, _)| base).unwrap_or(token); + format!("{base}?") + } else { + token.to_string() + } + }) + .collect::>() + .join(" ") +} + +fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String { + let lowercase = line.to_ascii_lowercase(); + if [ + "authorization", + "bearer ", + "api_key", + "apikey", + "api key", + "x-api-key", + "token=", + "token:", + "credential", + "password", + "cookie", + "set-cookie", + "secret", + "access_token", + "refresh_token", + "\"token\"", + "'token'", + ] + .iter() + .any(|marker| lowercase.contains(marker)) + { + return "".to_string(); + } + if lowercase.contains("panic") { + return "".to_string(); + } + let safe_internal_detail = + lowercase.starts_with("agent.runner.failed:") || lowercase.starts_with("runner."); + if !safe_internal_detail { + let summary = if ["error", "failed", "failure", "失败", "错误", "异常"] + .iter() + .any(|marker| lowercase.contains(marker)) + { + "" + } else if ["warning", "warn:"] + .iter() + .any(|marker| lowercase.contains(marker)) + { + "" + } else { + "" + }; + return summary.to_string(); + } + crate::sanitize_diagnostic_message(&redact_url_queries(line), Some(config_dir)) + .chars() + .take(AGENT_RUNNER_LOG_OUTPUT_MAX_CHARS) + .collect() +} + +fn read_bounded_agent_runner_line( + reader: &mut R, +) -> io::Result> { + let mut content = Vec::new(); + let mut truncated = false; + let mut saw_bytes = false; + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + return if saw_bytes { + Ok(Some(( + String::from_utf8_lossy(&content).into_owned(), + truncated, + ))) + } else { + Ok(None) + }; + } + saw_bytes = true; + let newline = available.iter().position(|byte| *byte == b'\n'); + let consumed = newline.map(|index| index + 1).unwrap_or(available.len()); + let payload_len = newline.unwrap_or(available.len()); + let remaining = AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES.saturating_sub(content.len()); + let copied = payload_len.min(remaining); + content.extend_from_slice(&available[..copied]); + if copied < payload_len { + truncated = true; + } + reader.consume(consumed); + if newline.is_some() { + return Ok(Some(( + String::from_utf8_lossy(&content).into_owned(), + truncated, + ))); + } + } +} + +fn spawn_agent_runner_log_pump( + stream: R, + stream_name: &'static str, + log_path: PathBuf, + config_dir: PathBuf, +) where + R: Read + Send + 'static, +{ + let _ = thread::Builder::new() + .name(format!("agent-runner-{stream_name}-log")) + .spawn(move || { + let mut reader = BufReader::new(stream); + loop { + match read_bounded_agent_runner_line(&mut reader) { + Ok(None) => break, + Ok(Some((line, truncated))) => { + let line = sanitize_agent_runner_output(line.trim(), &config_dir); + let _ = crate::append_bounded_diagnostic_line( + &log_path, + &format!( + "runner.{stream_name} truncated={} {line}", + if truncated { "true" } else { "false" } + ), + ); + } + Err(_) => { + let _ = crate::append_bounded_diagnostic_line( + &log_path, + &format!("runner.{stream_name}.read-failed details=redacted"), + ); + break; + } + } + } + }); +} + +pub(super) struct LaunchedExternalAgentRunner { + child: Child, + #[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))] + runner_job: crate::WindowsKillOnCloseJob, +} + +#[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))] +fn terminate_failed_external_agent_runner_launch(child: &mut Child, error: String) -> String { + let kill_error = child.kill().err(); + let wait_error = child.wait().err(); + match (kill_error, wait_error) { + (None, None) => error, + (kill_error, wait_error) => format!( + "{error};清理启动失败的 Agent Runner 时出错:kill={},wait={}", + kill_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "ok".to_string()), + wait_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "ok".to_string()) + ), + } +} + +pub(super) fn launch_external_agent_runner( + config_dir: &Path, +) -> Result { let executable = std::env::current_exe() .map_err(|error| format!("读取 Agent Runner 当前二进制失败:{error}"))?; + let runner_log_path = config_dir.join(AGENT_RUNNER_LOG_FILE_NAME); + let _ = crate::append_bounded_diagnostic_line(&runner_log_path, "runner.launch.begin"); let mut command = Command::new(executable); let gui_owner_required = EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT.load(std::sync::atomic::Ordering::Acquire); @@ -23,8 +200,8 @@ pub(super) fn launch_external_agent_runner(config_dir: &Path) -> Result Result job, + Err(error) => { + return Err(terminate_failed_external_agent_runner_launch( + &mut child, error, + )); + } + }; + #[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))] + if let Err(error) = runner_job.resume_suspended_runner(&child) { + drop(runner_job); + return Err(terminate_failed_external_agent_runner_launch( + &mut child, error, + )); + } + if let Some(stdout) = child.stdout.take() { + spawn_agent_runner_log_pump( + stdout, + "stdout", + runner_log_path.clone(), + config_dir.to_path_buf(), + ); + } + if let Some(stderr) = child.stderr.take() { + spawn_agent_runner_log_pump( + stderr, + "stderr", + runner_log_path.clone(), + config_dir.to_path_buf(), + ); + } + #[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))] + let _ = crate::append_bounded_diagnostic_line( + &runner_log_path, + "runner.launch.job.assigned-and-resumed", + ); + let _ = crate::append_bounded_diagnostic_line(&runner_log_path, "runner.launch.spawned"); + Ok(LaunchedExternalAgentRunner { + child, + #[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))] + runner_job, + }) } pub(super) fn external_agent_runner_launch_arguments( @@ -245,7 +464,22 @@ fn request_external_agent_runner_shutdown_if_idle_at( return Ok(false); } - let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT; + wait_for_external_agent_runner_boot_exit( + endpoint_path, + endpoint, + EXTERNAL_AGENT_RUNNER_START_TIMEOUT, + "旧 Agent Runner 未在版本切换期限内退出", + )?; + Ok(true) +} + +fn wait_for_external_agent_runner_boot_exit( + endpoint_path: &Path, + endpoint: &ExternalAgentRunnerEndpoint, + timeout: Duration, + timeout_error: &str, +) -> Result<(), String> { + let deadline = Instant::now() + timeout; let lock_path = endpoint_path .parent() .map(external_agent_runner_lock_path) @@ -253,23 +487,62 @@ fn request_external_agent_runner_shutdown_if_idle_at( loop { match read_external_agent_runner_endpoint(endpoint_path) { Ok(current) if current.boot_id == endpoint.boot_id => {} - Ok(_) => return Ok(true), + Ok(_) => return Ok(()), Err(_) => { if let Some(lock) = try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")? { drop(lock); - return Ok(true); + return Ok(()); } } } if Instant::now() >= deadline { - return Err("旧 Agent Runner 未在版本切换期限内退出".to_string()); + return Err(timeout_error.to_string()); } thread::sleep(Duration::from_millis(50)); } } +fn read_external_agent_runner_endpoint_for_shutdown( + config_dir: &Path, +) -> Result, String> { + let endpoint_path = external_agent_runner_endpoint_path(config_dir); + let lock_path = external_agent_runner_lock_path(config_dir); + let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT; + loop { + match fs::symlink_metadata(&endpoint_path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err("Agent Runner endpoint 不允许符号链接".to_string()); + } + Ok(_) => { + let endpoint = read_external_agent_runner_endpoint(&endpoint_path)?; + return Ok(Some((endpoint_path, endpoint))); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + if let Some(lock) = + try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")? + { + drop(lock); + return Ok(None); + } + if Instant::now() >= deadline { + return Err( + "Agent Runner 启动锁仍被占用,但 endpoint 未在期限内就绪".to_string() + ); + } + thread::sleep(Duration::from_millis(50)); + } + Err(error) => { + return Err(format!( + "读取 Agent Runner endpoint 元数据失败:{}: {error}", + endpoint_path.display() + )); + } + } + } +} + pub(super) fn shutdown_external_agent_runner_if_idle_at(config_dir: &Path) -> Result { let endpoint_path = external_agent_runner_endpoint_path(config_dir); let lock_path = external_agent_runner_lock_path(config_dir); @@ -656,6 +929,57 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { } } +pub(super) fn shutdown_external_agent_runner_for_client_exit_at( + config_dir: &Path, +) -> Result<(), String> { + let Some((endpoint_path, endpoint)) = + read_external_agent_runner_endpoint_for_shutdown(config_dir)? + else { + return Ok(()); + }; + let request_id = random_identifier(b"genarrative-agent-runner-client-exit-request-id")?; + let result = match send_external_agent_runner_request_with_protocol_and_id( + &endpoint, + endpoint.protocol_version, + request_id, + "runner.shutdown_for_client_exit", + ExternalAgentRunnerRequestParams::default(), + ) { + Ok(result) => result, + Err(error) => { + return match read_external_agent_runner_endpoint(&endpoint_path) { + Ok(current) if current.boot_id == endpoint.boot_id => Err(error), + _ => Ok(()), + }; + } + }; + let accepted = result + .get("accepted") + .and_then(Value::as_bool) + .ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 accepted".to_string())?; + let will_shutdown = result + .get("willShutdown") + .and_then(Value::as_bool) + .ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 willShutdown".to_string())?; + if !accepted || !will_shutdown { + return Err("Agent Runner 拒绝按客户端退出协议关闭".to_string()); + } + wait_for_external_agent_runner_boot_exit( + &endpoint_path, + &endpoint, + AGENT_RUNNER_CLIENT_EXIT_TIMEOUT, + "Agent Runner 未在客户端退出期限内停止", + ) +} + +pub(crate) fn shutdown_external_agent_runner_for_client_exit() -> Result<(), String> { + let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); + let Some(config_dir) = external_agent_runner_config_dir() else { + return Ok(()); + }; + shutdown_external_agent_runner_for_client_exit_at(&config_dir) +} + pub(super) fn wait_for_external_agent_runner( config_dir: &Path, child: &mut Child, @@ -663,7 +987,6 @@ pub(super) fn wait_for_external_agent_runner( ) -> Result { let endpoint_path = external_agent_runner_endpoint_path(config_dir); let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT; - let mut child_exit_status = None; loop { if let Some(endpoint) = read_current_external_agent_runner_endpoint(&endpoint_path, executable_fingerprint) @@ -672,17 +995,14 @@ pub(super) fn wait_for_external_agent_runner( return Ok(endpoint); } } - if child_exit_status.is_none() { - child_exit_status = child - .try_wait() - .map_err(|error| format!("检查外部 Agent Runner 子进程失败:{error}"))? - .map(|status| status.to_string()); + if let Some(status) = child + .try_wait() + .map_err(|error| format!("检查外部 Agent Runner 子进程失败:{error}"))? + { + return Err(format!("外部 Agent Runner 在就绪前退出:{status}")); } if Instant::now() >= deadline { - return Err(match child_exit_status { - Some(status) => format!("外部 Agent Runner 在就绪前退出:{status}"), - None => "外部 Agent Runner 未在启动期限内就绪".to_string(), - }); + return Err("外部 Agent Runner 未在启动期限内就绪".to_string()); } thread::sleep(Duration::from_millis(50)); } @@ -719,20 +1039,22 @@ pub(super) fn ensure_external_agent_runner( } } } - let mut child = launch_external_agent_runner(config_dir)?; - match wait_for_external_agent_runner(config_dir, &mut child, &executable_fingerprint) { + let mut launched = launch_external_agent_runner(config_dir)?; + match wait_for_external_agent_runner(config_dir, &mut launched.child, &executable_fingerprint) { Ok(endpoint) => { thread::Builder::new() .name("agent-runner-reaper".to_string()) .spawn(move || { - let _ = child.wait(); + #[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))] + let _runner_job = launched.runner_job; + let _ = launched.child.wait(); }) .map_err(|error| format!("启动 Agent Runner 子进程回收线程失败:{error}"))?; Ok(endpoint) } Err(error) => { - let _ = child.kill(); - let _ = child.wait(); + let _ = launched.child.kill(); + let _ = launched.child.wait(); Err(error) } } @@ -1117,3 +1439,63 @@ pub(crate) fn read_external_agent_runner_status() -> ExternalAgentRunnerStatus { let config_dir = external_agent_runner_config_dir(); read_external_agent_runner_status_at(config_dir.as_deref()) } + +#[cfg(test)] +mod diagnostic_log_tests { + use super::*; + + #[test] + fn runner_log_output_redacts_config_paths_and_credentials() { + let config_dir = Path::new(r"C:\Users\example\AppData\Roaming\game-chat"); + assert_eq!( + sanitize_agent_runner_output( + r"agent.runner.failed: failed to open C:\Users\example\AppData\Roaming\game-chat\state.json", + config_dir, + ), + "agent.runner.failed: failed to open \\state.json" + ); + assert_eq!( + sanitize_agent_runner_output("Authorization: Bearer secret", config_dir), + "" + ); + assert_eq!( + sanitize_agent_runner_output( + r"agent.runner.failed: project C:\private\game\index.html failed", + config_dir, + ), + "agent.runner.failed: project failed" + ); + assert_eq!( + sanitize_agent_runner_output("normal model response body", config_dir), + "" + ); + assert_eq!( + sanitize_agent_runner_output( + "agent.runner.failed: request failed https://example.invalid/api?value=1", + config_dir, + ), + "agent.runner.failed: request failed https://example.invalid/api?" + ); + assert_eq!( + sanitize_agent_runner_output("error password=hunter2", config_dir), + "" + ); + } + + #[test] + fn runner_log_line_reader_caps_long_lines_and_drains_to_next_line() { + let mut input = vec![b'x'; AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES + 500]; + input.extend_from_slice(b"\nerror: second line\n"); + let mut reader = BufReader::new(std::io::Cursor::new(input)); + let (first, first_truncated) = read_bounded_agent_runner_line(&mut reader) + .expect("read first line") + .expect("first line exists"); + assert_eq!(first.len(), AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES); + assert!(first_truncated); + let (second, second_truncated) = read_bounded_agent_runner_line(&mut reader) + .expect("read second line") + .expect("second line exists"); + assert_eq!(second, "error: second line"); + assert!(!second_truncated); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index 25547702e..f0be74db5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -617,6 +617,14 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( }), ) } + "runner.shutdown_for_client_exit" if cfg!(any(test, feature = "game-chat-release")) => { + state.draining.store(true, Ordering::Release); + state.shutdown_requested.store(true, Ordering::Release); + ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": true, "willShutdown": true }), + ) + } "runner.shutdown_if_idle" | "shutdown_if_idle" => { if request.params.root.is_some() { match external_agent_runner_request_root(request) { @@ -784,6 +792,7 @@ pub(super) fn handle_external_agent_runner_request( | "runner.attach_gui_owner" | "runner.shutdown" | "shutdown" + | "runner.shutdown_for_client_exit" | "runner.shutdown_if_idle" | "shutdown_if_idle" => dispatch_external_agent_runner_runtime_request(&request, state), _ => ExternalAgentRunnerResponse::failure( diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index 67b42b3c9..b4c91e3fa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -331,7 +331,37 @@ pub(super) fn private_create_new_file(path: &Path) -> io::Result { .open(path) } - #[cfg(not(unix))] + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + let file = OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)?; + let secured = (|| { + validate_windows_regular_file_handle(&file, "新建私有临时文件") + .map_err(io::Error::other)?; + crate::initialize_windows_game_creator_file_owner_for_current_user(path) + .map_err(io::Error::other)?; + validate_windows_regular_file_handle(&file, "新建私有临时文件") + .map_err(io::Error::other)?; + crate::secure_windows_game_creator_path_for_current_user(path, false, false) + .map_err(io::Error::other) + })(); + if let Err(error) = secured { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + Ok(file) + } + + #[cfg(not(any(unix, windows)))] { OpenOptions::new().create_new(true).write(true).open(path) } @@ -444,6 +474,18 @@ pub(super) fn write_external_agent_runner_endpoint_atomic( let parent = path .parent() .ok_or_else(|| "Agent Runner endpoint 缺少父目录".to_string())?; + #[cfg(windows)] + { + let private_parent = crate::inspect_game_creator_runtime_config_dir(parent)?; + let expected_path = private_parent.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME); + if path != expected_path { + return Err(format!( + "Agent Runner endpoint 必须位于已验证的私有 AppData 固定路径:{}", + expected_path.display() + )); + } + } + #[cfg(not(windows))] fs::create_dir_all(parent).map_err(|error| { format!( "创建 Agent Runner endpoint 目录失败:{}: {error}", @@ -750,6 +792,11 @@ pub(super) fn try_open_external_agent_runner_lock( } } +#[cfg(windows)] +pub(super) fn windows_external_agent_runner_lock_is_busy_error(error: &io::Error) -> bool { + matches!(error.raw_os_error(), Some(32 | 33)) +} + #[cfg(windows)] pub(super) fn try_open_external_agent_runner_lock( path: &Path, @@ -759,6 +806,18 @@ pub(super) fn try_open_external_agent_runner_lock( const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + let parent = path + .parent() + .ok_or_else(|| format!("{label} 缺少 AppData 父目录:{}", path.display()))?; + let private_parent = crate::inspect_game_creator_runtime_config_dir(parent)?; + let expected_path = private_parent.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME); + if path != expected_path { + return Err(format!( + "{label} 必须位于已验证的私有 AppData 固定路径:{}", + expected_path.display() + )); + } + match OpenOptions::new() .create(true) .read(true) @@ -781,17 +840,16 @@ pub(super) fn try_open_external_agent_runner_lock( )); } validate_windows_regular_file_handle(&file, label)?; - crate::secure_windows_game_creator_path_for_current_user(path, false, true)?; + // share_mode(0) gives this process an exclusive handle. At this point the fixed + // lock path is known to be a stale, single-link, non-reparse regular file inside + // the current TokenUser's private AppData. Repairing its owner is therefore safe + // and is required when Windows creates it with TokenOwner=Administrators. + crate::initialize_windows_game_creator_file_owner_for_current_user(path)?; + validate_windows_regular_file_handle(&file, label)?; + crate::secure_windows_game_creator_path_for_current_user(path, false, false)?; Ok(Some(file)) } - Err(error) - if matches!( - error.kind(), - io::ErrorKind::PermissionDenied | io::ErrorKind::WouldBlock - ) => - { - Ok(None) - } + Err(error) if windows_external_agent_runner_lock_is_busy_error(&error) => Ok(None), Err(error) => Err(format!( "安全打开 {label} 失败:{}: {error}", path.display() diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index c45b9288c..e5b29e3f3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -1236,6 +1236,156 @@ fn forced_shutdown_is_accepted_even_when_runtime_is_busy() { assert!(state.shutdown_requested.load(Ordering::Acquire)); } +#[test] +fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + let pending = root.join(".agent/runtime/pending-actions/code-prototype/run-client-exit.json"); + fs::create_dir_all(pending.parent().expect("pending parent")) + .expect("create pending directory"); + let durable_bytes = br#"{"durable":true}"#; + fs::write(&pending, durable_bytes).expect("write pending action"); + let token = "client-exit-private-token-client-exit-private-token"; + let state = ExternalAgentRunnerServerState::new( + directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(token, "client-exit-boot-id", 32326), + ); + state.remember_root(&root); + + let unauthorized_response = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "shutdown-client-exit-unauthorized".to_string(), + token: "wrong-client-exit-private-token".to_string(), + method: "runner.shutdown_for_client_exit".to_string(), + params: ExternalAgentRunnerRequestParams::default(), + }, + &state, + ); + assert!(!unauthorized_response.ok); + assert_eq!( + unauthorized_response + .error + .as_ref() + .map(|error| error.code.as_str()), + Some("unauthorized") + ); + assert!(!state.shutdown_requested.load(Ordering::Acquire)); + assert!(!state.draining.load(Ordering::Acquire)); + assert_eq!( + fs::read(&pending).expect("read pending action after rejected shutdown"), + durable_bytes + ); + + let idle_response = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "shutdown-client-exit-idle-check".to_string(), + token: token.to_string(), + method: "runner.shutdown_if_idle".to_string(), + params: ExternalAgentRunnerRequestParams::default(), + }, + &state, + ); + assert!(idle_response.ok); + assert_eq!( + idle_response + .result + .as_ref() + .and_then(|value| value["idle"].as_bool()), + Some(false) + ); + assert!(!state.shutdown_requested.load(Ordering::Acquire)); + assert!(!state.draining.load(Ordering::Acquire)); + + let shutdown_response = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "shutdown-client-exit-force-1".to_string(), + token: token.to_string(), + method: "runner.shutdown_for_client_exit".to_string(), + params: ExternalAgentRunnerRequestParams::default(), + }, + &state, + ); + assert!(shutdown_response.ok); + assert_eq!( + shutdown_response + .result + .as_ref() + .and_then(|value| value["accepted"].as_bool()), + Some(true) + ); + assert_eq!( + shutdown_response + .result + .as_ref() + .and_then(|value| value["willShutdown"].as_bool()), + Some(true) + ); + assert!(state.shutdown_requested.load(Ordering::Acquire)); + assert!(state.draining.load(Ordering::Acquire)); + assert_eq!( + fs::read(&pending).expect("read pending action"), + durable_bytes + ); + + let write_response = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "shutdown-client-exit-write-after-drain".to_string(), + token: token.to_string(), + method: "runtime.continue_action".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some(root.to_string_lossy().into_owned()), + agent: Some("code-prototype".to_string()), + run_id: Some("run-client-exit".to_string()), + action_id: Some("action-client-exit".to_string()), + ..ExternalAgentRunnerRequestParams::default() + }, + }, + &state, + ); + assert!(!write_response.ok); + assert_eq!( + write_response + .error + .as_ref() + .map(|error| error.code.as_str()), + Some("runner-draining") + ); + + let repeated_response = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "shutdown-client-exit-force-2".to_string(), + token: token.to_string(), + method: "runner.shutdown_for_client_exit".to_string(), + params: ExternalAgentRunnerRequestParams::default(), + }, + &state, + ); + assert!(repeated_response.ok); + assert_eq!( + repeated_response + .result + .as_ref() + .and_then(|value| value["accepted"].as_bool()), + Some(true) + ); + assert_eq!( + repeated_response + .result + .as_ref() + .and_then(|value| value["willShutdown"].as_bool()), + Some(true) + ); + assert_eq!( + fs::read(&pending).expect("reread pending action"), + durable_bytes + ); +} + #[test] fn durable_tool_plan_handoff_prevents_shutdown_even_when_corrupt() { let directory = unique_test_directory(); @@ -1482,7 +1632,9 @@ fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() { #[test] fn stale_protocol_endpoint_does_not_override_instance_lock_arbitration() { let directory = unique_test_directory(); - let endpoint_path = directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME); + let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare private runner AppData"); + let endpoint_path = config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME); let mut stale = test_endpoint( "stale-private-token-stale-private-token", "stale-boot-id", @@ -1502,13 +1654,133 @@ fn stale_protocol_endpoint_does_not_override_instance_lock_arbitration() { .is_none()); let boot_id = "current-lock-owner"; let lock = acquire_external_agent_runner_instance_lock( - &external_agent_runner_lock_path(&directory.0), + &external_agent_runner_lock_path(&config_dir), boot_id, ) .expect("stale endpoint must not block the authoritative instance lock"); drop(lock); } +#[test] +fn active_runner_lock_is_not_repaired_or_truncated() { + let directory = unique_test_directory(); + let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare private runner AppData"); + let lock_path = external_agent_runner_lock_path(&config_dir); + let first = acquire_external_agent_runner_instance_lock(&lock_path, "first-active-boot") + .expect("acquire first runner lock"); + + let error = match acquire_external_agent_runner_instance_lock(&lock_path, "second-boot") { + Ok(_) => panic!("active runner lock must reject a second owner"), + Err(error) => error, + }; + + assert!(error.contains("其他进程运行")); + drop(first); + let diagnostic: Value = serde_json::from_slice( + &fs::read(&lock_path).expect("read runner lock after rejected acquisition"), + ) + .expect("parse runner lock after rejected acquisition"); + assert_eq!(diagnostic["bootId"], "first-active-boot"); +} + +#[cfg(windows)] +#[test] +fn windows_stale_runner_lock_is_reowned_for_token_user() { + let directory = unique_test_directory(); + let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare private runner AppData"); + let lock_path = external_agent_runner_lock_path(&config_dir); + fs::write(&lock_path, b"stale-lock-from-token-default-owner") + .expect("create stale runner lock"); + if !crate::tests::configuration::set_windows_test_path_owner_to_distinct_token_owner(&lock_path) + { + eprintln!("skip: 当前 Windows token 没有区别于 TokenUser 且可设置的默认 owner SID"); + return; + } + assert!( + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false).is_err(), + "fixture lock must start with a foreign owner" + ); + + let lock = acquire_external_agent_runner_instance_lock(&lock_path, "reowned-boot") + .expect("repair and acquire stale runner lock"); + + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false) + .expect("runner lock owner must match TokenUser SID"); + drop(lock); + let diagnostic: Value = serde_json::from_slice( + &fs::read(&lock_path).expect("read repaired runner lock diagnostic"), + ) + .expect("parse repaired runner lock diagnostic"); + assert_eq!(diagnostic["bootId"], "reowned-boot"); +} + +#[cfg(windows)] +#[test] +fn windows_runner_lock_rejects_hard_link_without_touching_target() { + let directory = unique_test_directory(); + let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare private runner AppData"); + let target = config_dir.join("lock-target.txt"); + let lock_path = external_agent_runner_lock_path(&config_dir); + fs::write(&target, b"do-not-truncate").expect("write lock target"); + fs::hard_link(&target, &lock_path).expect("create runner lock hard link"); + + let error = match acquire_external_agent_runner_instance_lock(&lock_path, "hard-link-boot") { + Ok(_) => panic!("runner lock hard link must be rejected"), + Err(error) => error, + }; + + assert!(error.contains("硬链接")); + assert_eq!( + fs::read(&target).expect("read untouched lock target"), + b"do-not-truncate" + ); +} + +#[cfg(windows)] +#[test] +fn windows_runner_lock_rejects_symlink_without_touching_target_when_supported() { + use std::os::windows::fs::symlink_file; + + let directory = unique_test_directory(); + let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare private runner AppData"); + let target = config_dir.join("lock-symlink-target.txt"); + let lock_path = external_agent_runner_lock_path(&config_dir); + fs::write(&target, b"do-not-truncate").expect("write lock symlink target"); + if symlink_file(&target, &lock_path).is_err() { + eprintln!("skip: 当前 Windows 环境不允许创建文件符号链接"); + return; + } + + let error = match acquire_external_agent_runner_instance_lock(&lock_path, "symlink-boot") { + Ok(_) => panic!("runner lock symlink must be rejected"), + Err(error) => error, + }; + + assert!(error.contains("reparse point") || error.contains("普通文件")); + assert_eq!( + fs::read(&target).expect("read untouched lock symlink target"), + b"do-not-truncate" + ); +} + +#[cfg(windows)] +#[test] +fn windows_runner_lock_busy_error_classification_is_exact() { + assert!(windows_external_agent_runner_lock_is_busy_error( + &io::Error::from_raw_os_error(32) + )); + assert!(windows_external_agent_runner_lock_is_busy_error( + &io::Error::from_raw_os_error(33) + )); + assert!(!windows_external_agent_runner_lock_is_busy_error( + &io::Error::from_raw_os_error(5) + )); +} + #[cfg(unix)] #[test] fn runner_lock_rejects_symlink_without_touching_target() { @@ -1617,6 +1889,13 @@ fn project_execution_owner_is_unique_across_appdata_and_records_recovery() { record.recovered_from_boot_id.as_deref(), Some("owner-boot-a") ); + #[cfg(windows)] + crate::secure_windows_game_creator_path_for_current_user( + &root.join(EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_PATH), + false, + false, + ) + .expect("project owner diagnostic must match TokenUser SID"); } #[test] @@ -1962,7 +2241,9 @@ fn read_only_runner_configuration_does_not_chmod_appdata() { #[test] fn endpoint_write_is_atomic_and_private() { let directory = unique_test_directory(); - let path = directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME); + let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare private runner AppData"); + let path = config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME); let first = test_endpoint( "first-private-token-first-private-token", "boot-first", @@ -1981,7 +2262,7 @@ fn endpoint_write_is_atomic_and_private() { assert_eq!(persisted.boot_id, "boot-second"); assert_eq!(persisted.port, 20202); assert_eq!(persisted.token, "second-private-token-second-private-token"); - let names = fs::read_dir(&directory.0) + let names = fs::read_dir(&config_dir) .expect("list endpoint directory") .map(|entry| { entry @@ -2004,6 +2285,39 @@ fn endpoint_write_is_atomic_and_private() { & 0o777; assert_eq!(mode, 0o600); } + + #[cfg(windows)] + crate::secure_windows_game_creator_path_for_current_user(&path, false, false) + .expect("endpoint owner must match TokenUser SID"); +} + +#[test] +fn runner_child_exit_is_reported_without_waiting_for_start_timeout() { + let directory = unique_test_directory(); + let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare private runner AppData"); + #[cfg(windows)] + let mut child = std::process::Command::new("cmd.exe") + .args(["/D", "/C", "exit", "/B", "7"]) + .spawn() + .expect("spawn immediately failing child"); + #[cfg(unix)] + let mut child = std::process::Command::new("/bin/sh") + .args(["-c", "exit 7"]) + .spawn() + .expect("spawn immediately failing child"); + + let started = Instant::now(); + let error = match wait_for_external_agent_runner(&config_dir, &mut child, &"a".repeat(64)) { + Ok(_) => panic!("exited child must fail runner startup"), + Err(error) => error, + }; + + assert!(error.contains("在就绪前退出")); + assert!( + started.elapsed() < Duration::from_secs(2), + "exited child must not wait for the full startup deadline" + ); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index f91ddab26..a8c3ba393 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -978,3 +978,205 @@ fn appdata_config_dir_is_owned_privately() { fs::remove_dir_all(config_dir).ok(); } + +#[cfg(windows)] +#[test] +fn newly_created_windows_appdata_is_owned_by_token_user() { + let root = unique_project_path(); + let config_dir = root.join("appdata"); + + let prepared = prepare_game_creator_runtime_config_dir(&config_dir) + .expect("create and secure Windows AppData directory"); + + // TokenOwner 可能是 Administrators;安全边界必须以 TokenUser SID 为准。 + secure_windows_game_creator_path_for_current_user(&prepared, true, false) + .expect("prepared directory owner must match TokenUser SID"); + fs::remove_dir_all(root).ok(); +} + +#[cfg(windows)] +#[test] +fn windows_foreign_owner_prepare_preserves_backup_and_recreates_private_appdata() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("create backup test root"); + let config_dir = root.join("appdata"); + fs::create_dir(&config_dir).expect("create old config directory"); + fs::write(config_dir.join("important.json"), b"preserve-me").expect("write old configuration"); + if !set_windows_test_path_owner_to_distinct_token_owner(&config_dir) { + eprintln!("skip: 当前 Windows token 没有区别于 TokenUser 且可设置的默认 owner SID"); + fs::remove_dir_all(root).ok(); + return; + } + assert!(secure_windows_game_creator_path_for_current_user(&config_dir, true, false).is_err()); + + let prepared = prepare_game_creator_runtime_config_dir(&config_dir) + .expect("prepare must isolate foreign-owner AppData and recreate it"); + + assert_eq!( + prepared, + fs::canonicalize(&config_dir).expect("canonical AppData") + ); + secure_windows_game_creator_path_for_current_user(&prepared, true, false) + .expect("new AppData must be owned privately by TokenUser SID"); + let backups = fs::read_dir(&root) + .expect("read backup parent") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name().is_some_and(|name| { + name.to_string_lossy() + .starts_with("appdata.owner-mismatch-backup-") + }) + }) + .collect::>(); + assert_eq!( + backups.len(), + 1, + "must create exactly one owner-mismatch backup" + ); + assert_eq!( + fs::read(backups[0].join("important.json")).expect("read preserved configuration"), + b"preserve-me" + ); + assert!(!config_dir.join("important.json").exists()); + fs::remove_dir_all(root).ok(); +} + +#[cfg(windows)] +pub(crate) fn set_windows_test_path_owner_to_distinct_token_owner(path: &Path) -> bool { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + + type Handle = *mut c_void; + type Sid = *mut c_void; + + #[repr(C)] + struct SidAndAttributes { + sid: Sid, + attributes: u32, + } + + #[repr(C)] + struct TokenUser { + user: SidAndAttributes, + } + + #[repr(C)] + struct TokenOwner { + owner: Sid, + } + + #[link(name = "advapi32")] + unsafe extern "system" { + fn OpenProcessToken(process: Handle, access: u32, token: *mut Handle) -> i32; + fn GetTokenInformation( + token: Handle, + information_class: u32, + information: *mut c_void, + information_length: u32, + return_length: *mut u32, + ) -> i32; + fn EqualSid(first: Sid, second: Sid) -> i32; + fn SetNamedSecurityInfoW( + object_name: *mut u16, + object_type: u32, + security_info: u32, + owner: Sid, + group: Sid, + dacl: *mut c_void, + sacl: *mut c_void, + ) -> u32; + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> Handle; + fn CloseHandle(handle: Handle) -> i32; + } + + const TOKEN_QUERY: u32 = 0x0000_0008; + const TOKEN_USER_CLASS: u32 = 1; + const TOKEN_OWNER_CLASS: u32 = 4; + const SE_FILE_OBJECT: u32 = 1; + const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001; + + unsafe fn token_information(token: Handle, class: u32) -> Option> { + let mut required = 0_u32; + unsafe { GetTokenInformation(token, class, std::ptr::null_mut(), 0, &mut required) }; + if required == 0 { + return None; + } + let word_size = std::mem::size_of::(); + let mut buffer = vec![0_usize; (required as usize).div_ceil(word_size)]; + if unsafe { + GetTokenInformation( + token, + class, + buffer.as_mut_ptr().cast(), + required, + &mut required, + ) + } == 0 + { + return None; + } + Some(buffer) + } + + let mut token = std::ptr::null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 + || token.is_null() + { + return false; + } + let changed = (|| { + let user_buffer = unsafe { token_information(token, TOKEN_USER_CLASS) }?; + let owner_buffer = unsafe { token_information(token, TOKEN_OWNER_CLASS) }?; + let token_user = unsafe { (*(user_buffer.as_ptr().cast::())).user.sid }; + let token_owner = unsafe { (*(owner_buffer.as_ptr().cast::())).owner }; + if token_user.is_null() + || token_owner.is_null() + || unsafe { EqualSid(token_user, token_owner) } != 0 + { + return None; + } + let mut wide_path = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let status = unsafe { + SetNamedSecurityInfoW( + wide_path.as_mut_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + token_owner, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + (status == 0).then_some(()) + })() + .is_some(); + unsafe { CloseHandle(token) }; + changed +} + +#[cfg(windows)] +#[test] +fn windows_appdata_validation_does_not_follow_directory_links() { + let root = unique_project_path(); + let real = root.join("real-appdata"); + let link = root.join("linked-appdata"); + fs::create_dir_all(&real).expect("create real directory"); + if std::os::windows::fs::symlink_dir(&real, &link).is_err() { + fs::remove_dir_all(root).ok(); + return; + } + + let error = inspect_game_creator_runtime_config_dir(&link) + .expect_err("AppData directory link must be rejected before canonicalize"); + assert!(error.contains("链接") || error.contains("reparse point")); + fs::remove_dir_all(root).ok(); +} 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 dc98e4f36..32e1692c9 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 @@ -5277,7 +5277,7 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() { mod collaboration; mod command_runtime; -mod configuration; +pub(crate) mod configuration; mod goal; mod project; mod project_tools; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 6b9d5a842..550350adc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -2829,6 +2829,52 @@ fn game_chat_launch_args_are_strict_and_keep_normal_start_compatible() { } } +#[test] +fn game_chat_release_flavor_selects_only_its_fixed_page_without_changing_debug() { + let explicit = GameChatLaunchOptions { + project_path: Some("/tmp/game".to_string()), + initial_message: Some("继续".to_string()), + }; + + assert_eq!( + select_game_chat_launch_options(None, false, true) + .expect("game-chat release default launch") + .expect("game-chat release options"), + GameChatLaunchOptions::default() + ); + assert_eq!( + select_game_chat_launch_options(Some(explicit.clone()), true, false) + .expect("debug explicit launch"), + Some(explicit) + ); + assert_eq!( + select_game_chat_launch_options(None, true, true).expect("debug normal launch"), + None, + "enabling the packaging feature must not change debug startup" + ); + assert!( + select_game_chat_launch_options(Some(GameChatLaunchOptions::default()), false, false) + .expect_err("ordinary release must reject --game-chat") + .contains("--game-chat") + ); +} + +#[test] +fn game_chat_release_requests_dedicated_runner_shutdown_only_on_final_exit() { + assert!(should_shutdown_runner_on_tauri_event( + true, + &tauri::RunEvent::Exit + )); + assert!(!should_shutdown_runner_on_tauri_event( + true, + &tauri::RunEvent::Ready + )); + assert!(!should_shutdown_runner_on_tauri_event( + false, + &tauri::RunEvent::Exit + )); +} + #[test] fn game_chat_window_url_encodes_optional_project_path() { assert_eq!( @@ -3164,6 +3210,12 @@ fn local_preview_head_preserves_asset_content_length() { assert!(response.contains("200 OK"), "{response}"); assert!(response.contains("Content-Type: image/png"), "{response}"); + assert!( + response.contains("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"), + "{response}" + ); + assert!(response.contains("Pragma: no-cache"), "{response}"); + assert!(response.contains("Expires: 0"), "{response}"); assert!(response.contains("Content-Length: 7"), "{response}"); assert!(!response.contains("PNGDATA"), "{response}"); assert!(response.ends_with("\r\n\r\n"), "{response}"); @@ -3171,6 +3223,109 @@ fn local_preview_head_preserves_asset_content_length() { fs::remove_dir_all(root).ok(); } +#[test] +fn local_preview_project_revision_reports_the_current_atomic_sidecar() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "预览 revision 状态测试").expect("project init"); + let revision = advance_project_revision_for_test( + &root, + "code-prototype", + "preview-revision-status-run", + "file.write", + ); + + let status = + get_local_game_project_revision_at(&root).expect("read local preview project revision"); + assert_eq!(status.revision, revision); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn local_preview_start_rejects_a_stale_validated_revision_atomically() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "预览 revision 启动门禁测试") + .expect("project init"); + let revision = advance_project_revision_for_test( + &root, + "code-prototype", + "preview-revision-start-run", + "file.write", + ); + let registry = PreviewRegistry::default(); + + let error = start_local_game_preview_at_revision(&root, Some(revision + 1), ®istry) + .expect_err("stale validated revision must not start a preview"); + assert!(error.contains("已验证 revision"), "{error}"); + assert!(error.contains(&revision.to_string()), "{error}"); + assert_eq!(registry.status(), stopped_preview_status()); + + let preview = start_local_game_preview_at_revision(&root, Some(revision), ®istry) + .expect("matching revision starts preview"); + assert_eq!(registry.status().url.as_deref(), Some(preview.url.as_str())); + let _ = registry.stop(); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn stale_preview_cleanup_does_not_stop_a_newer_matching_project_server() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "预览原子停止测试").expect("project init"); + let registry = PreviewRegistry::default(); + let (first, first_stop) = + start_local_game_preview_for_project(&root).expect("first preview start"); + registry.set_running(first.clone(), first_stop); + let (second, second_stop) = + start_local_game_preview_for_project(&root).expect("second preview start"); + registry.set_running(second.clone(), second_stop); + assert_ne!(first.url, second.url); + + assert!( + !stop_local_game_preview_if_matches_at(&root, &first, ®istry) + .expect("stale cleanup is a no-op") + ); + let running = registry.status(); + assert_eq!(running.status, "running"); + assert_eq!(running.url.as_deref(), Some(second.url.as_str())); + assert_eq!(running.port, Some(second.port)); + + assert!( + stop_local_game_preview_if_matches_at(&root, &second, ®istry) + .expect("matching cleanup stops current preview") + ); + assert_eq!(registry.status(), stopped_preview_status()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn stale_preview_cleanup_cannot_be_blocked_by_project_stop_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "预览补偿清理策略测试").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["preview.open".to_string(), "preview.stop".to_string()], + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("deny user preview open and stop commands"); + let registry = PreviewRegistry::default(); + let (preview, stop) = + start_local_game_preview_for_project(&root).expect("preview server start"); + registry.set_running(preview.clone(), stop); + + assert!( + stop_local_game_preview_if_matches_at(&root, &preview, ®istry) + .expect("stale compensating cleanup") + ); + assert_eq!(registry.status(), stopped_preview_status()); + + fs::remove_dir_all(root).ok(); +} + #[test] fn local_preview_serves_generated_playable_game() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 33faee356..4f098396f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -1,5 +1,35 @@ use super::*; +fn spawn_mock_llm_http_failures( + failure_count: usize, + failed_status_line: &'static str, + failed_body: String, + request_notice_sender: Option>, +) -> String { + let listener = bind_test_tcp_listener("mock repeated HTTP Provider failure bind"); + let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + std::thread::spawn(move || { + for _ in 0..failure_count { + let (mut stream, _) = listener + .accept() + .expect("mock repeated HTTP Provider failure accept"); + drop(read_mock_http_request(&mut stream)); + if let Some(sender) = request_notice_sender.as_ref() { + let _ = sender.send(()); + } + let response = format!( + "HTTP/1.1 {failed_status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + failed_body.len(), + failed_body + ); + stream + .write_all(response.as_bytes()) + .expect("mock repeated HTTP Provider failure response"); + } + }); + base_url +} + #[test] fn llm_context_budget_validation_rejects_invalid_combinations() { let mut llm = GameCreatorLlmConfig::default(); @@ -3543,7 +3573,7 @@ async fn provider_retry_http_and_deserialize_failures_recover_through_durable_si "server-error", "503 Service Unavailable", serde_json::json!({"error": {"message": "temporarily unavailable"}}).to_string(), - "upstream-5xx", + "upstream-503", ), ("deserialize", "200 OK", "{".to_string(), "deserialize"), ]; @@ -3892,8 +3922,9 @@ async fn provider_retry_waiting_tool_plan_resumes_only_after_due_and_cleans_side let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "Provider 持久等待测试").expect("project init"); let (request_notice_sender, request_notice_receiver) = mpsc::channel(); - let base_url = spawn_mock_llm_transport_failures_then_response( - 1, + let base_url = spawn_mock_llm_http_failure_then_response( + "503 Service Unavailable", + serde_json::json!({"error": {"message": "temporary upstream outage"}}).to_string(), final_tool_plan_response("持久等待到期后已完成"), Some(request_notice_sender), ); @@ -3930,6 +3961,12 @@ async fn provider_retry_waiting_tool_plan_resumes_only_after_due_and_cleans_side assert_eq!(waiting.status, "running"); assert_eq!(waiting.run_id, run_id); assert_eq!(waiting.session_id, started.state.session_id); + assert_eq!( + waiting.current_action, + "Provider 上游返回 HTTP 503,准备自动重试 1/1" + ); + assert!(waiting.waiting_on.starts_with("预计 ")); + assert!(waiting.waiting_on.ends_with(" 秒后重试")); let mut lane_released = false; for _ in 0..250 { lane_released = game_creator_agent_runtime_task_lock_is_available(&root, "design-director") @@ -3945,6 +3982,7 @@ async fn provider_retry_waiting_tool_plan_resumes_only_after_due_and_cleans_side .expect("persisted Provider retry exists"); assert_eq!(retry.next_attempt, 1); assert_eq!(retry.max_retries, 1); + assert_eq!(retry.error_kind, "upstream-503"); assert_eq!(retry.identity.request_kind, "tool-plan"); assert_eq!(retry.identity.base_request_slot, "loop-1-repair-0"); assert_eq!(retry.identity.request_fingerprint.len(), 64); @@ -4513,9 +4551,19 @@ async fn provider_retry_waiting_exhaustion_fails_and_removes_sidecar() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "Provider 等待耗尽测试").expect("project init"); let (request_notice_sender, request_notice_receiver) = mpsc::channel(); - let base_url = spawn_mock_llm_transport_failures_then_response( + let provider_secret = ["sk", "retry-exhaustion-secret"].join("-"); + let upstream_body = serde_json::json!({ + "error": { + "message": format!( + "private upstream body url=https://provider.example/private?api_key={provider_secret} path=C:\\private\\provider.txt" + ) + } + }) + .to_string(); + let base_url = spawn_mock_llm_http_failures( 2, - final_tool_plan_response("重试耗尽后不应收到此响应"), + "503 Service Unavailable", + upstream_body, Some(request_notice_sender), ); let _config_guard = write_test_local_config(format!( @@ -4556,10 +4604,73 @@ async fn provider_retry_waiting_exhaustion_fails_and_removes_sidecar() { .expect("last allowed physical Provider request"); let failed = wait_for_agent_runtime_idle(&root, "design-director"); assert_eq!(failed.phase, "failed"); - assert!(failed - .error - .as_deref() - .is_some_and(|error| error.contains("kind=transport"))); + let error = failed.error.as_deref().expect("exhausted Provider error"); + assert!(error.contains("kind=upstream-503 httpStatus=503 fingerprint=")); + assert!(error.contains(" retryAttempt=1 maxRetries=1 retryState=exhausted")); + for forbidden in [ + "private upstream body", + "provider.example", + "api_key=", + provider_secret.as_str(), + "C:\\private\\provider.txt", + ] { + assert!( + !error.contains(forbidden), + "exhausted Provider error leaked {forbidden}" + ); + } + let conversation = read_local_conversation_at(&root, Some("design-director")) + .expect("read exhausted Provider conversation"); + assert!(conversation.messages.iter().any(|message| { + message.role == "assistant" + && message.content == "专业 Agent 上游服务返回 HTTP 503;自动重试已耗尽(1/1)" + })); + let conversation_text = + serde_json::to_string(&conversation).expect("serialize exhausted Provider conversation"); + for forbidden in [ + "private upstream body", + "provider.example", + "api_key=", + provider_secret.as_str(), + "C:\\private\\provider.txt", + "fingerprint=", + "retryState=", + ] { + assert!( + !conversation_text.contains(forbidden), + "Provider conversation leaked {forbidden}" + ); + } + let projected = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read exhausted Provider failure projection"); + let failure_events = projected + .recent_events + .iter() + .filter(|event| { + event.run_id == run_id && matches!(event.event_type.as_str(), "error" | "turn.failed") + }) + .collect::>(); + assert_eq!(failure_events.len(), 2); + assert!(failure_events.iter().all(|event| { + event.detail.as_deref() == Some("专业 Agent 上游服务返回 HTTP 503;自动重试已耗尽(1/1)") + })); + let public_events = serde_json::to_string(&failure_events) + .expect("serialize exhausted Provider failure events"); + for forbidden in [ + "fingerprint=", + "chars=", + "retryAttempt=", + "retryState=", + "absolute-path", + "redacted-secret", + "provider.example", + provider_secret.as_str(), + ] { + assert!( + !public_events.contains(forbidden), + "Provider failure event leaked {forbidden}" + ); + } assert!( crate::provider_retry::read_for_run_at(&root, "design-director", run_id) .expect("read Provider retry after exhaustion") @@ -5424,7 +5535,7 @@ fn agent_llm_public_error_summary_never_copies_provider_error_text() { let raw = format!( "provider failed at https://provider-error.example/v1 for /tmp/provider-private/project and task PROVIDER_TASK_SENTINEL with secret {provider_secret}" ); - let error = platform_llm::LlmError::Transport(raw); + let error = platform_llm::LlmError::Transport(raw.clone()); let summary = game_creator_agent_llm_error_public_summary(&error); assert!(summary.starts_with("kind=transport fingerprint=")); assert!(summary.contains(" chars=")); @@ -5439,6 +5550,25 @@ fn agent_llm_public_error_summary_never_copies_provider_error_text() { "public summary leaked {forbidden}" ); } + + let upstream = platform_llm::LlmError::Upstream { + status_code: 503, + message: raw, + }; + let upstream_summary = game_creator_agent_llm_error_public_summary(&upstream); + assert!(upstream_summary.starts_with("kind=upstream-503 httpStatus=503 fingerprint=")); + assert!(upstream_summary.contains(" chars=")); + for forbidden in [ + "provider-error.example", + "/tmp/provider-private/project", + "PROVIDER_TASK_SENTINEL", + provider_secret.as_str(), + ] { + assert!( + !upstream_summary.contains(forbidden), + "upstream public summary leaked {forbidden}" + ); + } } #[tokio::test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index e4cf0c63e..51c511cde 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -1982,6 +1982,65 @@ fn image_inspect_safe_receipt_keeps_legacy_v1_audit_readable() { fs::remove_dir_all(root).ok(); } +#[test] +fn preview_validate_public_event_detail_stays_structured_below_event_limit() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "预览事件短投影测试").expect("project init"); + let agent_id = "preview-playtest"; + let run_id = "autonomous-ready-preview-playtest-0123456789abcdefabcd"; + let evidence_root = format!(".agent/runtime/browser-validations/{agent_id}/{run_id}/27"); + let observation = AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "ok".to_string(), + summary: "preview.validate:ok".to_string(), + detail: Some( + serde_json::json!({ + "passed": true, + "revision": 27, + "reportPath": format!("{evidence_root}/validation.json"), + "screenshots": [ + format!("{evidence_root}/desktop.png"), + format!("{evidence_root}/mobile.png"), + ], + "diagnostics": [], + "playtest": { + "passed": true, + "scenario": "lane-defense-v1", + }, + }) + .to_string(), + ), + }; + + let public = agent_runtime_action_receipt_public_safe_detail_for_test(&root, &observation) + .expect("validated preview public event detail"); + assert!(public.chars().count() < 500, "{public}"); + let public = serde_json::from_str::(&public).expect("parse public preview detail"); + assert_eq!(public["passed"], true); + assert_eq!(public["revision"], 27); + assert_eq!(public["diagnosticsCount"], 0); + assert_eq!(public["playtestPassed"], true); + assert_eq!(public["playtestScenario"], "lane-defense-v1"); + assert!(public.get("reportPath").is_none()); + assert!(public.get("screenshots").is_none()); + + let receipt = agent_runtime_action_receipt_safe_detail_for_owner_for_test( + &root, + agent_id, + run_id, + &observation, + ) + .expect("validated preview durable receipt detail"); + let receipt = serde_json::from_str::(&receipt).expect("parse receipt detail"); + assert_eq!( + receipt["reportPath"], + format!("{evidence_root}/validation.json") + ); + assert_eq!(receipt["screenshots"].as_array().map(Vec::len), Some(2)); + + fs::remove_dir_all(root).ok(); +} + #[test] fn seed_refresh_downgrades_completed_visual_tasks_when_registered_file_is_missing() { let _config_guard = crate::tests::write_test_local_config( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 7eb7bf8ae..4d31702f1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -32,6 +32,7 @@ pub(super) use super::super::{ pub(super) use crate::{ advance_game_creator_agent_runtime_turn_at, + agent_runtime_action_receipt_public_safe_detail_for_test, agent_runtime_action_receipt_safe_detail_for_owner_for_test, agent_runtime_contains_secret_key_prefix, agent_runtime_executable_tools, agent_runtime_read_only_delivery_completion_plan_update, agent_runtime_run_profile_identity_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 97aca310a..8fb6c488e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -774,8 +774,7 @@ fn runtime_failure_public_audits_hash_private_delivery_diagnostics() { let error_sha256 = format!("{:x}", Sha256::digest(private_error.as_bytes())); let error_chars = private_error.chars().count(); - let expected_public_detail = - format!("errorSha256={error_sha256} · errorChars={error_chars}"); + let expected_public_detail = "专业 Agent 执行失败,请稍后重试"; let result = read_game_creator_agent_runtime_at(&root, agent_id) .expect("read failed runtime projection"); let public_failure_events = result @@ -797,7 +796,7 @@ fn runtime_failure_public_audits_hash_private_delivery_diagnostics() { .iter() .any(|event| event.event_type == terminal_event_type)); assert!(public_failure_events.iter().all(|event| { - event.detail.as_deref() == Some(expected_public_detail.as_str()) + event.detail.as_deref() == Some(expected_public_detail) && !event.summary.contains(private_error) })); let event_log = fs::read_to_string(game_creator_agent_runtime_event_path(&root, agent_id)) @@ -892,7 +891,7 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu let error_sha256 = format!("{:x}", Sha256::digest(private_error.as_bytes())); let error_chars = private_error.chars().count(); - let expected_public_detail = format!("errorSha256={error_sha256} · errorChars={error_chars}"); + let expected_public_detail = "专业 Agent 服务请求失败,请稍后重试"; let result = read_game_creator_agent_runtime_at(&root, "design-director") .expect("read public failure projections"); for event_type in ["error", "turn.failed"] { @@ -901,10 +900,7 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu .iter() .find(|event| event.run_id == run_id && event.event_type == event_type) .expect("public failure event"); - assert_eq!( - event.detail.as_deref(), - Some(expected_public_detail.as_str()) - ); + assert_eq!(event.detail.as_deref(), Some(expected_public_detail)); } let event_log = fs::read_to_string(game_creator_agent_runtime_event_path( &root, @@ -940,7 +936,7 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu ) .expect("read private failure conversation"); assert!(conversation.messages.iter().any(|message| { - message.role == "assistant" && message.content == format!("后台任务失败:{private_error}") + message.role == "assistant" && message.content == "专业 Agent 服务请求失败,请稍后重试" })); fs::remove_dir_all(root).ok(); @@ -1116,8 +1112,12 @@ fn structured_plan_state_write_failure_stops_before_context_and_audit() { message.role == "assistant" && message.content.contains("该回复不得落盘") })); assert!(conversation.messages.iter().any(|message| { - message.role == "assistant" && message.content.contains("后台任务失败") + message.role == "assistant" && message.content == "专业 Agent 执行失败,请稍后重试" })); + let conversation_text = + serde_json::to_string(&conversation).expect("serialize state write failure conversation"); + assert!(!conversation_text.contains("absolute-path")); + assert!(!conversation_text.contains("redacted sensitive context")); fs::remove_dir(&state_path).expect("remove sabotaged runtime state directory"); fs::remove_dir_all(root).ok(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index b0135a60f..b2bc08304 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -1,14 +1,349 @@ use super::*; +#[cfg(windows)] +fn configure_windows_background_std_command_with_suspension( + command: &mut std::process::Command, + create_process_group: bool, + create_suspended: bool, +) { + use std::os::windows::process::CommandExt; + + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + const CREATE_SUSPENDED: u32 = 0x0000_0004; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags( + CREATE_NO_WINDOW + | if create_process_group { + CREATE_NEW_PROCESS_GROUP + } else { + 0 + } + | if create_suspended { + CREATE_SUSPENDED + } else { + 0 + }, + ); +} + +#[cfg(windows)] +pub(crate) fn configure_windows_background_std_command( + command: &mut std::process::Command, + create_process_group: bool, +) { + configure_windows_background_std_command_with_suspension(command, create_process_group, false); +} + +#[cfg(not(windows))] +pub(crate) fn configure_windows_background_std_command( + _command: &mut std::process::Command, + _create_process_group: bool, +) { +} + +pub(crate) fn configure_windows_background_tokio_command( + command: &mut tokio::process::Command, + create_process_group: bool, +) { + configure_windows_background_std_command(command.as_std_mut(), create_process_group); +} + +#[cfg(all(windows, feature = "game-chat-release"))] +pub(crate) fn configure_windows_suspended_background_std_command( + command: &mut std::process::Command, + create_process_group: bool, +) { + configure_windows_background_std_command_with_suspension(command, create_process_group, true); +} + +#[cfg(all(windows, feature = "game-chat-release"))] +pub(crate) struct WindowsKillOnCloseJob { + handle: windows_sys::Win32::Foundation::HANDLE, +} + +#[cfg(all(windows, feature = "game-chat-release"))] +unsafe impl Send for WindowsKillOnCloseJob {} + +#[cfg(all(windows, feature = "game-chat-release"))] +impl WindowsKillOnCloseJob { + pub(crate) fn assign_runner(child: &std::process::Child) -> Result { + use std::mem::size_of; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }; + + let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return Err(format!( + "创建 game-chat Agent Runner Windows Job Object 失败:{}", + std::io::Error::last_os_error() + )); + } + + let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let configured = unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &information as *const _ as *const _, + size_of::() as u32, + ) + }; + if configured == 0 { + let error = std::io::Error::last_os_error(); + unsafe { + CloseHandle(handle); + } + return Err(format!( + "配置 game-chat Agent Runner Windows Job Object 失败:{error}" + )); + } + + let process = child.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE; + if process.is_null() || unsafe { AssignProcessToJobObject(handle, process) } == 0 { + let error = std::io::Error::last_os_error(); + unsafe { + CloseHandle(handle); + } + return Err(format!( + "将 game-chat Agent Runner 加入 Windows Job Object 失败:{error}" + )); + } + + Ok(Self { handle }) + } + + pub(crate) fn resume_suspended_runner( + &self, + child: &std::process::Child, + ) -> Result<(), String> { + use std::mem::size_of; + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, + }; + use windows_sys::Win32::System::Threading::{ + GetProcessIdOfThread, OpenThread, ResumeThread, THREAD_QUERY_LIMITED_INFORMATION, + THREAD_SUSPEND_RESUME, + }; + + const ERROR_NO_MORE_FILES: i32 = 18; + const RESUME_THREAD_FAILED: u32 = u32::MAX; + + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; + if snapshot.is_null() || snapshot == INVALID_HANDLE_VALUE { + return Err(format!( + "枚举 game-chat Agent Runner 挂起线程失败:{}", + std::io::Error::last_os_error() + )); + } + + let mut entry = THREADENTRY32 { + dwSize: size_of::() as u32, + ..Default::default() + }; + let mut runner_thread_id = None; + if unsafe { Thread32First(snapshot, &mut entry) } == 0 { + let error = std::io::Error::last_os_error(); + unsafe { + CloseHandle(snapshot); + } + return Err(format!("读取 game-chat Agent Runner 挂起线程失败:{error}")); + } + loop { + if entry.th32OwnerProcessID == child.id() { + if runner_thread_id.replace(entry.th32ThreadID).is_some() { + unsafe { + CloseHandle(snapshot); + } + return Err( + "恢复 game-chat Agent Runner 失败:挂起进程存在多个线程".to_string() + ); + } + } + if unsafe { Thread32Next(snapshot, &mut entry) } != 0 { + continue; + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_NO_MORE_FILES) { + unsafe { + CloseHandle(snapshot); + } + return Err(format!( + "继续读取 game-chat Agent Runner 挂起线程失败:{error}" + )); + } + break; + } + unsafe { + CloseHandle(snapshot); + } + + let thread_id = runner_thread_id + .ok_or_else(|| "恢复 game-chat Agent Runner 失败:找不到挂起线程".to_string())?; + let thread = unsafe { + OpenThread( + THREAD_SUSPEND_RESUME | THREAD_QUERY_LIMITED_INFORMATION, + 0, + thread_id, + ) + }; + if thread.is_null() || thread == INVALID_HANDLE_VALUE { + return Err(format!( + "打开 game-chat Agent Runner 挂起线程失败:{}", + std::io::Error::last_os_error() + )); + } + if unsafe { GetProcessIdOfThread(thread) } != child.id() { + unsafe { + CloseHandle(thread); + } + return Err("恢复 game-chat Agent Runner 失败:挂起线程所属进程已发生变化".to_string()); + } + let previous_suspend_count = unsafe { ResumeThread(thread) }; + let resume_error = if previous_suspend_count == RESUME_THREAD_FAILED { + Some(format!( + "恢复 game-chat Agent Runner 挂起线程失败:{}", + std::io::Error::last_os_error() + )) + } else if previous_suspend_count != 1 { + Some(format!( + "恢复 game-chat Agent Runner 挂起线程失败:异常挂起计数 {previous_suspend_count}" + )) + } else { + None + }; + unsafe { + CloseHandle(thread); + } + if let Some(error) = resume_error { + return Err(error); + } + Ok(()) + } +} + +#[cfg(all(windows, feature = "game-chat-release"))] +impl Drop for WindowsKillOnCloseJob { + fn drop(&mut self) { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(self.handle); + } + } +} + +#[cfg(all(test, windows, feature = "game-chat-release"))] +mod windows_kill_on_close_job_tests { + use super::*; + use std::process::{Command, Stdio}; + use std::thread; + use std::time::{Duration, Instant}; + + #[test] + fn game_chat_runner_starts_suspended_then_job_kills_it_when_handle_closes() { + let directory = tempfile::tempdir().expect("create Windows Job test directory"); + let marker = directory.path().join("runner-started.txt"); + let mut command = Command::new("cmd.exe"); + command + .args([ + "/D", + "/S", + "/C", + "echo started>runner-started.txt & ping.exe -n 30 127.0.0.1 >NUL", + ]) + .current_dir(directory.path()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_windows_suspended_background_std_command(&mut command, true); + let mut child = command.spawn().expect("spawn Windows Job test child"); + let job = match WindowsKillOnCloseJob::assign_runner(&child) { + Ok(job) => job, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + panic!("assign Windows Job test child: {error}"); + } + }; + thread::sleep(Duration::from_millis(150)); + assert!( + !marker.exists(), + "CREATE_SUSPENDED child must not execute before ResumeThread" + ); + if let Err(error) = job.resume_suspended_runner(&child) { + drop(job); + let _ = child.kill(); + let _ = child.wait(); + panic!("resume Windows Job test child: {error}"); + } + + let started_deadline = Instant::now() + Duration::from_secs(3); + while !marker.exists() { + assert!( + Instant::now() < started_deadline, + "resumed Windows Job test child must execute" + ); + thread::sleep(Duration::from_millis(25)); + } + + drop(job); + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if child + .try_wait() + .expect("poll Windows Job test child") + .is_some() + { + break; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("closing the kill-on-close Job must terminate its assigned process"); + } + thread::sleep(Duration::from_millis(25)); + } + } +} + const GAME_CHAT_LAUNCH_USAGE: &str = "用法:--game-chat [--project-path <本地项目绝对路径>] [--initial-message <首条消息>]"; -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Eq, PartialEq)] pub(crate) struct GameChatLaunchOptions { pub(crate) project_path: Option, pub(crate) initial_message: Option, } +pub(crate) fn select_game_chat_launch_options( + explicit: Option, + debug_build: bool, + game_chat_release: bool, +) -> Result, String> { + if debug_build { + return Ok(explicit); + } + if game_chat_release { + return Ok(Some(explicit.unwrap_or_default())); + } + if explicit.is_some() { + return Err("--game-chat 仅在开发构建或 game-chat release 中可用".to_string()); + } + Ok(None) +} + +pub(crate) fn should_shutdown_runner_on_tauri_event( + game_chat_release: bool, + event: &tauri::RunEvent, +) -> bool { + game_chat_release && matches!(event, tauri::RunEvent::Exit) +} + pub(crate) fn parse_game_chat_launch_args( args: &[String], ) -> Result, String> { @@ -78,7 +413,6 @@ pub(crate) fn supervisor_chat_window_url(project_path: &str) -> tauri::WebviewUr ))) } -#[cfg(any(debug_assertions, test))] pub(crate) fn game_chat_window_url( project_path: Option<&str>, initial_message: Option<&str>, @@ -87,7 +421,6 @@ pub(crate) fn game_chat_window_url( tauri::WebviewUrl::App(PathBuf::from(format!("index.html?{query}"))) } -#[cfg(any(debug_assertions, test))] pub(crate) fn apply_game_chat_initial_window_url( config: &mut tauri::Config, options: &GameChatLaunchOptions, @@ -105,7 +438,6 @@ pub(crate) fn apply_game_chat_initial_window_url( Ok(()) } -#[cfg(any(debug_assertions, test))] fn game_chat_window_query(project_path: Option<&str>, initial_message: Option<&str>) -> String { let mut query = "game-chat".to_string(); if let Some(project_path) = project_path { @@ -152,6 +484,9 @@ pub(crate) fn open_game_creator_workspace_window( window: tauri::Window, project_path: String, ) -> Result<(), String> { + if cfg!(all(not(debug_assertions), feature = "game-chat-release")) { + return Err("game-chat 独立版只能打开游戏创作对话页面".to_string()); + } let project_path = validate_workspace_window_project_path(&project_path)?; if let Some(existing) = app.get_webview_window("main") { existing.close().map_err(|error| error.to_string())?; @@ -171,6 +506,9 @@ pub(crate) fn open_game_creator_launcher_window( app: tauri::AppHandle, window: tauri::Window, ) -> Result<(), String> { + if cfg!(all(not(debug_assertions), feature = "game-chat-release")) { + return Err("game-chat 独立版只能打开游戏创作对话页面".to_string()); + } if let Some(existing) = app.get_webview_window("launcher") { existing.set_focus().map_err(|error| error.to_string())?; } else { diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json new file mode 100644 index 000000000..5d1096ce0 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Genarrative Game Chat", + "version": "0.1.1", + "identifier": "world.genarrative.ai-game-creator.game-chat", + "build": { + "beforeBuildCommand": "node scripts/build-game-chat-release.mjs" + }, + "app": { + "windows": [ + { + "label": "client", + "title": "Genarrative Game Chat", + "url": "index.html", + "width": 1280, + "height": 800, + "minWidth": 1280, + "minHeight": 800 + } + ] + }, + "bundle": { + "targets": ["nsis"] + } +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index cec14b71b..0295facff 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -63,6 +63,7 @@ import type { LocalConversationMessageRecord, LocalConversationResult, LocalGameMemoryResult, + LocalGameProjectRevisionStatus, LocalPreviewResult, LocalPreviewStatus, LocalProjectCheckpointResult, @@ -236,16 +237,139 @@ import { } from './view/project-development'; const initialSupervisorMessageClaimsByPage = new WeakMap>(); -const GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = +const LEGACY_GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = 'genarrative.game-chat.auto-preview-authorization.v1'; +const GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = + 'genarrative.game-chat.auto-preview-authorization.v2'; type GameChatAutoPreviewAuthorization = { + afterRevision: number; + afterValidatedAt: number; + authorizationId: string; projectPath: string; runId: string; }; +type GameChatPlayableRevision = { + runId: string; + revision: number; + validatedAt: number; +}; + +type GameChatPreviewValidationCandidate = GameChatPlayableRevision & { + eventOrder: number; + playable: boolean; +}; + +function gameChatPlayableRevisionIsAfterAuthorization( + revision: GameChatPlayableRevision, + authorization: GameChatAutoPreviewAuthorization, +) { + return ( + revision.revision > authorization.afterRevision || + (revision.revision === authorization.afterRevision && + revision.validatedAt > authorization.afterValidatedAt) + ); +} + +export function latestGameChatPlayableRevision( + runtime: AgentRuntimeState | null, + runtimeByAgentId: Record, +): GameChatPlayableRevision | null { + if (!runtime?.runId) { + return null; + } + const playtestChildren = new Map(); + for (const child of Object.values(runtimeByAgentId)) { + if ( + child?.agentId !== 'preview-playtest' || + child.taskId !== 'preview-playtest' || + child.source !== 'agent-ready-task-scheduler' || + child.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || + child.parentRunId !== runtime.runId + ) { + continue; + } + playtestChildren.set( + `${child.agentId}\n${child.sessionId}\n${child.runId}`, + child, + ); + } + let latest: GameChatPreviewValidationCandidate | null = null; + let eventOrder = 0; + for (const child of playtestChildren.values()) { + for (const event of child.recentEvents ?? []) { + eventOrder += 1; + if ( + event.agentId !== child.agentId || + event.taskId !== child.taskId || + event.sessionId !== child.sessionId || + event.runId !== child.runId || + event.eventType !== 'observation' || + !Number.isSafeInteger(event.updatedAt) || + event.updatedAt < 0 || + !event.summary.startsWith('preview.validate:') || + !event.detail?.trim().startsWith('{') + ) { + continue; + } + try { + const detail = JSON.parse(event.detail) as { + passed?: unknown; + playtestPassed?: unknown; + revision?: unknown; + }; + const playable = + event.summary.startsWith('preview.validate:ok') && + detail.passed === true && + detail.playtestPassed === true; + if ( + typeof detail.passed !== 'boolean' || + typeof detail.revision !== 'number' || + !Number.isSafeInteger(detail.revision) || + detail.revision <= 0 + ) { + continue; + } + const shouldReplace = + !latest || + detail.revision > latest.revision || + (detail.revision === latest.revision && + event.updatedAt > latest.validatedAt) || + (detail.revision === latest.revision && + event.updatedAt === latest.validatedAt && + ((latest.playable && !playable) || + (latest.playable === playable && + eventOrder > latest.eventOrder))); + if (!shouldReplace) { + continue; + } + latest = { + eventOrder, + playable, + runId: runtime.runId, + revision: detail.revision, + validatedAt: event.updatedAt, + }; + } catch { + // Ignore malformed or truncated public evidence and wait for a valid revision. + } + } + } + return latest?.playable + ? { + runId: latest.runId, + revision: latest.revision, + validatedAt: latest.validatedAt, + } + : null; +} + function readStoredGameChatAutoPreviewAuthorization(): GameChatAutoPreviewAuthorization | null { try { + window.localStorage.removeItem( + LEGACY_GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY, + ); const raw = window.localStorage.getItem( GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY, ); @@ -255,11 +379,22 @@ function readStoredGameChatAutoPreviewAuthorization(): GameChatAutoPreviewAuthor const parsed = JSON.parse(raw) as Partial; const projectPath = parsed.projectPath?.trim() ?? ''; const runId = parsed.runId?.trim() ?? ''; + const authorizationId = parsed.authorizationId?.trim() ?? ''; + const afterRevision = parsed.afterRevision; + const afterValidatedAt = parsed.afterValidatedAt; if ( !projectPath || !runId || + !authorizationId || + typeof afterRevision !== 'number' || + !Number.isSafeInteger(afterRevision) || + afterRevision < 0 || + typeof afterValidatedAt !== 'number' || + !Number.isSafeInteger(afterValidatedAt) || + afterValidatedAt < 0 || !isAbsoluteProjectPath(projectPath) || projectPathHasControlCharacter(projectPath) || + projectPathHasControlCharacter(authorizationId) || projectPathHasControlCharacter(runId) ) { window.localStorage.removeItem( @@ -267,7 +402,13 @@ function readStoredGameChatAutoPreviewAuthorization(): GameChatAutoPreviewAuthor ); return null; } - return { projectPath, runId }; + return { + afterRevision, + afterValidatedAt, + authorizationId, + projectPath, + runId, + }; } catch { return null; } @@ -342,6 +483,23 @@ export function WorkspaceLauncher(props: WorkspaceLauncherProps) { return ; } +export function GameChatReleaseApp({ + initialProjectPath = '', + initialSupervisorMessage = '', +}: { + initialProjectPath?: string; + initialSupervisorMessage?: string; +}) { + return ( + + ); +} + type AppProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; @@ -399,9 +557,12 @@ export function App({ const [preview, setPreview] = useState(null); const gameChatPreviewRef = useRef(null); gameChatPreviewRef.current = preview; + const [gameChatPreviewRevision, setGameChatPreviewRevision] = useState< + number | null + >(null); + const gameChatPreviewRevisionRef = useRef(null); + gameChatPreviewRevisionRef.current = gameChatPreviewRevision; const [previewStatus, setPreviewStatus] = useState('未启动'); - const previewStatusRef = useRef(previewStatus); - previewStatusRef.current = previewStatus; const [gameChatProjectSelectionBusy, setGameChatProjectSelectionBusy] = useState(false); const gameChatProjectSelectionVersionRef = useRef(0); @@ -503,6 +664,8 @@ export function App({ const [agentRuntimeById, setAgentRuntimeById] = useState< Record >({}); + const agentRuntimeByIdRef = useRef(agentRuntimeById); + agentRuntimeByIdRef.current = agentRuntimeById; const [professionalAgentResultsById, setProfessionalAgentResultsById] = useState>({}); const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); @@ -1163,6 +1326,55 @@ export function App({ } let disposed = false; let inFlight = false; + let attemptedRunId: string | null = null; + let attemptedAuthorizationId: string | null = null; + const authorizationMatches = ( + expected: GameChatAutoPreviewAuthorization, + ) => { + const current = gameChatAutoPreviewAuthorizationRef.current; + return ( + current?.authorizationId === expected.authorizationId && + current.projectPath === expected.projectPath && + current.runId === expected.runId + ); + }; + const attemptIsCurrent = ( + runId: string, + authorization: GameChatAutoPreviewAuthorization, + ) => + !disposed && + localProjectPathRef.current === nextProjectPath && + projectSupervisorRuntimeRef.current?.runId === runId && + authorizationMatches(authorization); + const clearMatchingAuthorization = ( + authorization: GameChatAutoPreviewAuthorization, + ) => { + if (authorizationMatches(authorization)) { + setGameChatAutoPreviewAuthorization(null); + } + }; + const readCurrentProjectRevision = async () => { + const result = await invoke( + 'get_local_game_project_revision', + { projectPath: nextProjectPath }, + ); + if (!Number.isSafeInteger(result.revision) || result.revision < 0) { + throw new Error('本地游戏项目 revision 无效'); + } + return result.revision; + }; + const stopStaleStartedPreview = async ( + startedPreview: LocalPreviewResult, + ) => { + try { + await invoke('stop_local_game_preview_if_matches', { + projectPath: nextProjectPath, + expectedPreview: startedPreview, + }); + } catch { + // A newer preview identity or a closed project already owns the visible state. + } + }; const syncPreview = async () => { if (disposed || inFlight) { return; @@ -1176,6 +1388,25 @@ export function App({ if (disposed || localProjectPathRef.current !== nextProjectPath) { return; } + const currentSupervisor = projectSupervisorRuntimeRef.current; + let playableRevision = latestGameChatPlayableRevision( + currentSupervisor, + agentRuntimeByIdRef.current, + ); + if (playableRevision) { + const currentRevision = await readCurrentProjectRevision(); + if ( + disposed || + localProjectPathRef.current !== nextProjectPath || + projectSupervisorRuntimeRef.current?.runId !== + playableRevision.runId + ) { + return; + } + if (currentRevision !== playableRevision.revision) { + playableRevision = null; + } + } const runningPreview = status.status === 'running' && status.url && @@ -1194,7 +1425,27 @@ export function App({ if (runningPreview) { updateClientPreview(runningPreview); setPreviewStatus(`运行中:127.0.0.1:${runningPreview.port}`); - setGameChatAutoPreviewAuthorization(null); + if ( + playableRevision && + playableRevision.revision > + (gameChatPreviewRevisionRef.current ?? 0) + ) { + gameChatPreviewRevisionRef.current = playableRevision.revision; + setGameChatPreviewRevision(playableRevision.revision); + } + const runningAuthorization = + gameChatAutoPreviewAuthorizationRef.current; + if ( + playableRevision && + runningAuthorization?.projectPath === nextProjectPath && + runningAuthorization.runId === playableRevision.runId && + gameChatPlayableRevisionIsAfterAuthorization( + playableRevision, + runningAuthorization, + ) + ) { + clearMatchingAuthorization(runningAuthorization); + } return; } const hadRunningPreview = Boolean(gameChatPreviewRef.current); @@ -1202,63 +1453,30 @@ export function App({ if (hadRunningPreview) { setPreviewStatus('未启动'); } - - const currentSupervisor = projectSupervisorRuntimeRef.current; - let authorization = gameChatAutoPreviewAuthorizationRef.current; + const authorization = gameChatAutoPreviewAuthorizationRef.current; if ( - !authorization && - currentSupervisor?.runId && - isAgentRuntimeTerminalState(currentSupervisor) && - previewStatusRef.current.startsWith( - '项目正在被其他写操作占用:', + !playableRevision || + authorization?.projectPath !== nextProjectPath || + authorization.runId !== playableRevision.runId || + !gameChatPlayableRevisionIsAfterAuthorization( + playableRevision, + authorization, ) - ) { - const interruptedAttemptKey = `${nextProjectPath}\n${currentSupervisor.runId}`; - if ( - gameChatObservedRunKeysRef.current.has(interruptedAttemptKey) && - gameChatAutoPreviewAttemptedRef.current.delete( - interruptedAttemptKey, - ) - ) { - authorization = { - projectPath: nextProjectPath, - runId: currentSupervisor.runId, - }; - setGameChatAutoPreviewAuthorization(authorization); - } - } - if ( - !authorization || - authorization.projectPath !== nextProjectPath || - currentSupervisor?.runId !== authorization.runId ) { return; } + const autoPreviewRunId = playableRevision.runId; + attemptedRunId = autoPreviewRunId; + attemptedAuthorizationId = authorization.authorizationId; const nextManifest = await invoke( 'get_local_game_manifest', { projectPath: nextProjectPath }, ); - if ( - disposed || - localProjectPathRef.current !== nextProjectPath || - projectSupervisorRuntimeRef.current?.runId !== authorization.runId - ) { + if (!attemptIsCurrent(autoPreviewRunId, authorization)) { return; } setManifest(nextManifest); - const firstPrototypeReady = nextManifest.tasks.some( - (task) => task.id === 'code-prototype' && task.status === 'completed', - ); - if (!firstPrototypeReady) { - if ( - currentSupervisor && - isAgentRuntimeTerminalState(currentSupervisor) - ) { - setGameChatAutoPreviewAuthorization(null); - } - return; - } - const attemptKey = `${nextProjectPath}\n${authorization.runId}`; + const attemptKey = `${nextProjectPath}\n${autoPreviewRunId}\nauthorization:${authorization.authorizationId}\nrevision:${playableRevision.revision}\nvalidatedAt:${playableRevision.validatedAt}`; if (gameChatAutoPreviewAttemptedRef.current.has(attemptKey)) { return; } @@ -1266,6 +1484,9 @@ export function App({ 'read_project_permission_policy', { projectPath: nextProjectPath }, ); + if (!attemptIsCurrent(autoPreviewRunId, authorization)) { + return; + } const previewDenied = policyView.policy.deniedCommands.includes('preview.start') || Object.values(policyView.policy.agentPolicies ?? {}).some((policy) => @@ -1273,7 +1494,7 @@ export function App({ ); if (previewDenied) { gameChatAutoPreviewAttemptedRef.current.add(attemptKey); - setGameChatAutoPreviewAuthorization(null); + clearMatchingAuthorization(authorization); const message = '项目权限策略拒绝执行:preview.start'; setCommandLog((current) => [ ...current, @@ -1282,6 +1503,13 @@ export function App({ setPreviewStatus(message); return; } + const revisionBeforeStart = await readCurrentProjectRevision(); + if ( + !attemptIsCurrent(autoPreviewRunId, authorization) || + revisionBeforeStart !== playableRevision.revision + ) { + return; + } appendLocalPermissionLog( nextProjectPath, 'permission.confirm', @@ -1291,26 +1519,60 @@ export function App({ try { startedPreview = await invoke( 'start_local_game_preview', - { projectPath: nextProjectPath }, + { + projectPath: nextProjectPath, + expectedRevision: playableRevision.revision, + }, ); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.startsWith('项目正在被其他写操作占用:')) { + const message = + error instanceof Error ? error.message : String(error); + const retryWithNewEvidence = + message.startsWith('项目正在被其他写操作占用:') || + message.startsWith('本地游戏项目已在验证后发生变化'); + if (!retryWithNewEvidence) { gameChatAutoPreviewAttemptedRef.current.add(attemptKey); - setGameChatAutoPreviewAuthorization(null); + clearMatchingAuthorization(authorization); } throw error; } - gameChatAutoPreviewAttemptedRef.current.add(attemptKey); - setGameChatAutoPreviewAuthorization(null); - if (disposed || localProjectPathRef.current !== nextProjectPath) { + let revisionAfterStart: number; + try { + revisionAfterStart = await readCurrentProjectRevision(); + } catch (error) { + await stopStaleStartedPreview(startedPreview); + throw error; + } + if ( + !attemptIsCurrent(autoPreviewRunId, authorization) || + revisionAfterStart !== playableRevision.revision + ) { + await stopStaleStartedPreview(startedPreview); return; } + gameChatAutoPreviewAttemptedRef.current.add(attemptKey); + clearMatchingAuthorization(authorization); updateClientPreview(startedPreview); + if ( + playableRevision && + playableRevision.revision > (gameChatPreviewRevisionRef.current ?? 0) + ) { + gameChatPreviewRevisionRef.current = playableRevision.revision; + setGameChatPreviewRevision(playableRevision.revision); + } setPreviewStatus(`运行中:127.0.0.1:${startedPreview.port}`); setCommandLog((current) => [...current, 'preview.start']); } catch (error) { - if (!disposed && localProjectPathRef.current === nextProjectPath) { + if ( + !disposed && + localProjectPathRef.current === nextProjectPath && + (!attemptedRunId || + projectSupervisorRuntimeRef.current?.runId === attemptedRunId) && + (!attemptedAuthorizationId || + !gameChatAutoPreviewAuthorizationRef.current || + gameChatAutoPreviewAuthorizationRef.current.authorizationId === + attemptedAuthorizationId) + ) { setPreviewStatus( error instanceof Error ? error.message : String(error), ); @@ -2402,6 +2664,8 @@ export function App({ setGameChatAutoPreviewAuthorization(null); } updateClientPreview(null); + gameChatPreviewRevisionRef.current = null; + setGameChatPreviewRevision(null); setPreviewStatus('未启动'); setWorkspaceStatus('正在打开'); setProjectStatus('正在初始化'); @@ -5046,16 +5310,62 @@ export function App({ if (!sessionId || localProjectPathRef.current !== nextProjectPath) { return; } + const submissionRunProfile = + supervisorChatOnly && !gameChatOnly + ? 'standard' + : 'autonomous-game-build'; + const runtimeAtSubmission = projectSupervisorRuntimeRef.current; + const steerRuntime = gameChatOnly + ? matchingAgentRuntimeForSteer( + [runtimeAtSubmission], + PROJECT_SUPERVISOR_AGENT_ID, + sessionId, + submissionRunProfile, + ) + : null; + let autoPreviewAfterRevision = 0; + let autoPreviewAfterValidatedAt = 0; + if (steerRuntime) { + const playableAtSubmission = latestGameChatPlayableRevision( + runtimeAtSubmission, + agentRuntimeByIdRef.current, + ); + autoPreviewAfterRevision = Math.max( + gameChatPreviewRevisionRef.current ?? 0, + playableAtSubmission?.revision ?? 0, + ); + autoPreviewAfterValidatedAt = playableAtSubmission?.validatedAt ?? 0; + try { + const revisionStatus = await invoke( + 'get_local_game_project_revision', + { projectPath: nextProjectPath }, + ); + if ( + Number.isSafeInteger(revisionStatus.revision) && + revisionStatus.revision >= 0 + ) { + autoPreviewAfterRevision = Math.max( + autoPreviewAfterRevision, + revisionStatus.revision, + ); + } + } catch { + // The durable evidence cursor still prevents consuming an older validation. + } + if ( + localProjectPathRef.current !== nextProjectPath || + projectSupervisorSessionIdRef.current !== sessionId + ) { + return; + } + } const submission = await submitProjectSupervisorRuntimeTask({ invoke, projectPath: nextProjectPath, sessionId, prompt, - runtime: projectSupervisorRuntimeRef.current, - runProfile: - supervisorChatOnly && !gameChatOnly - ? 'standard' - : 'autonomous-game-build', + runtime: runtimeAtSubmission, + runProfile: submissionRunProfile, }); const runtimeResult = submission.runtimeResult; const acceptedRunId = submission.acceptedRunId.trim(); @@ -5092,6 +5402,11 @@ export function App({ `${nextProjectPath}\n${acceptedRunId}`, ); setGameChatAutoPreviewAuthorization({ + afterRevision: + submission.mode === 'steer' ? autoPreviewAfterRevision : 0, + afterValidatedAt: + submission.mode === 'steer' ? autoPreviewAfterValidatedAt : 0, + authorizationId: createAgentChatRunId('game-chat-preview-auth'), projectPath: nextProjectPath, runId: acceptedRunId, }); @@ -10162,6 +10477,7 @@ export function App({ onCancelNonEmptyProjectCreate={cancelProjectCreateInNonEmptyFolder} onConfirmNonEmptyProjectCreate={confirmProjectCreateInNonEmptyFolder} preview={preview} + previewRevision={gameChatPreviewRevision} previewStatus={previewStatus} projectPath={gameChatProjectPath} projectReady={Boolean(localProject)} diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 7acc280e5..4f6cfa6cc 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -110,6 +110,10 @@ export interface LocalPreviewStatus { root: string | null; } +export interface LocalGameProjectRevisionStatus { + revision: number; +} + export interface GenerateLocalGameDraftResult { projectPath: string; gameIndexPath: string; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index a2691184f..09b2219f3 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -836,6 +836,39 @@ export function agentRuntimeCancelStatus( : `已取消后台任务:${runId}`; } +function agentRuntimeProviderRetryStatus(runtime: AgentRuntimeState) { + if (runtime.phase !== 'waiting-for-provider-retry') { + return null; + } + const currentAction = runtime.currentAction?.trim(); + const waitingOn = runtime.waitingOn?.trim(); + const safeCurrentAction = + currentAction && + /^(?:Goal 已恢复,)?Provider (?:上游返回 HTTP \d{3}|瞬态故障),准备自动重试 \d+\/\d+$/.test( + currentAction, + ) + ? currentAction + : null; + const safeWaitingOn = + waitingOn && /^预计 \d+ 秒后重试$/.test(waitingOn) ? waitingOn : null; + if (safeCurrentAction && safeWaitingOn) { + return `${safeCurrentAction};${safeWaitingOn}`; + } + if (safeCurrentAction) { + return safeCurrentAction; + } + + const legacyAttempt = currentAction?.match( + /^(?:等待 Provider 瞬态重试|Goal 已恢复,继续等待 Provider 瞬态重试) (\d+)\/(\d+)$/, + ); + const retryProgress = + legacyAttempt?.[1] && legacyAttempt[2] + ? `,准备自动重试 ${legacyAttempt[1]}/${legacyAttempt[2]}` + : ',正在准备自动重试'; + const safeFallback = `Provider 上游服务暂时不可用${retryProgress}`; + return safeWaitingOn ? `${safeFallback};${safeWaitingOn}` : safeFallback; +} + export function agentRuntimeConversationStatus(runtime: AgentRuntimeState) { if (isAgentRuntimeTerminalState(runtime)) { if (runtime.status === 'failed' || runtime.phase === 'failed') { @@ -869,6 +902,10 @@ export function agentRuntimeConversationStatus(runtime: AgentRuntimeState) { ) { return '持久目标已暂停'; } + const providerRetryStatus = agentRuntimeProviderRetryStatus(runtime); + if (providerRetryStatus) { + return providerRetryStatus; + } const waitingOn = runtime.waitingOn ?? agentRuntimeWaitingOnFromPhase(runtime.phase); return waitingOn ? `Agent 正在运行,等待${waitingOn}` : 'Agent 正在运行'; @@ -880,7 +917,9 @@ export function projectSupervisorChatRuntimeStatus(runtime: AgentRuntimeState) { } if (isAgentRuntimeTerminalState(runtime)) { if (runtime.status === 'failed' || runtime.phase === 'failed') { - return runtime.error || 'Agent 运行失败'; + return runtime.error + ? projectRuntimeVisibleError(runtime.error, '项目总控 Agent', true) + : 'Agent 运行失败'; } if (runtime.status === 'cancelled' || runtime.phase === 'cancelled') { return '本轮已取消'; @@ -891,9 +930,25 @@ export function projectSupervisorChatRuntimeStatus(runtime: AgentRuntimeState) { } export function formatAgentRuntimeEvent(event: AgentRuntimeEventRecord) { - const summary = event.summary || event.detail || event.runId; + const isFailureEvent = [ + 'error', + 'turn.failed', + 'turn.budget_exhausted', + ].includes(event.eventType); + const containsInternalFailureDiagnostics = Boolean( + event.detail && + /(?:errorSha256|errorChars|fingerprint|chars|retryAttempt|retryState)=|<(?:absolute-path|redacted-url)>|\[redacted(?:[- ]secret| sensitive context)\]/i.test( + event.detail, + ), + ); + const visibleDetail = + isFailureEvent && containsInternalFailureDiagnostics ? null : event.detail; + const summary = + event.summary || + visibleDetail || + (isFailureEvent ? 'Agent Runtime 本轮处理失败。' : event.runId); const detail = - event.detail && event.detail !== summary ? ` · ${event.detail}` : ''; + visibleDetail && visibleDetail !== summary ? ` · ${visibleDetail}` : ''; return `${event.eventType} · ${event.status} / ${event.phase} · ${summary}${detail}`; } @@ -1333,6 +1388,10 @@ export function projectRuntimePlanProgress(runtime: AgentRuntimeState) { } export function projectRuntimeVisibleCurrentWork(runtime: AgentRuntimeState) { + const providerRetryStatus = agentRuntimeProviderRetryStatus(runtime); + if (providerRetryStatus) { + return providerRetryStatus; + } const activePlanStep = agentRuntimeActivePlanStep(runtime); if (activePlanStep) { const stepText = agentRuntimePlanStepText(activePlanStep); @@ -1389,6 +1448,34 @@ export function projectRuntimeVisibleError( if (isRuntimeConfigMissingError(message)) { return '运行时配置未完成,请先打开配置'; } + const exhaustedUpstreamRetry = visibleMessage.match( + /(?:^|[\s::])kind=upstream-(\d{3}) httpStatus=(\d{3}) fingerprint=[0-9a-f]{64} chars=\d+ retryAttempt=(\d+) maxRetries=(\d+) retryState=exhausted\s*$/, + ); + if (exhaustedUpstreamRetry) { + const [, kindStatusText, httpStatusText, retryAttemptText, maxRetriesText] = + exhaustedUpstreamRetry; + if ( + !kindStatusText || + !httpStatusText || + !retryAttemptText || + !maxRetriesText + ) { + return `${subject} 执行失败,请稍后重试`; + } + const httpStatus = Number.parseInt(httpStatusText, 10); + const retryAttempt = Number.parseInt(retryAttemptText, 10); + const maxRetries = Number.parseInt(maxRetriesText, 10); + if ( + kindStatusText === httpStatusText && + httpStatus >= 500 && + httpStatus <= 599 && + retryAttempt === maxRetries && + maxRetries >= 0 && + maxRetries <= 4_294_967_295 + ) { + return `${subject} 上游服务返回 HTTP ${httpStatus};自动重试已耗尽(${retryAttempt}/${maxRetries})`; + } + } if ( normalized.includes('kind=transport') || normalized.includes('transport') || @@ -1443,6 +1530,21 @@ export function projectRuntimeVisibleError( return `${subject} 执行失败,请稍后重试`; } +export function projectSupervisorVisibleConversationText( + message: string, + role: ChatMessage['role'] = 'assistant', +) { + const failurePrefix = '后台任务失败:'; + if (role !== 'assistant' || !message.startsWith(failurePrefix)) { + return message; + } + return projectRuntimeVisibleError( + message.slice(failurePrefix.length), + '项目总控 Agent', + true, + ); +} + export function projectRuntimeVisibleToolSummary(summary: string) { return summary .split('·') diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx index 492556ce3..e7db17e8b 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx @@ -634,8 +634,24 @@ export function AgentRuntimeStatusPanel({ ))} ) : null} - {runtime.error ?

{runtime.error}

: null} - {error ?

{error}

: null} + {runtime.error ? ( +

+ {projectRuntimeVisibleError( + runtime.error, + projectProfessionalAgentLabel(runtime.agentId), + true, + )} +

+ ) : null} + {error ? ( +

+ {projectRuntimeVisibleError( + error, + projectProfessionalAgentLabel(runtime.agentId), + true, + )} +

+ ) : null} )} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx index 876c2b011..bb2dc5233 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx @@ -31,6 +31,7 @@ import { formatAgentRecentRuntimeTask, formatAgentRuntimeTaskQueue, ProjectSupervisorRuntimePanel, + projectSupervisorVisibleConversationText, } from '../agent-runtime'; import { formatAgentCardLlmStatus, @@ -806,7 +807,10 @@ export function ProjectWorkspaceChatPane({ {visibleMessages.map((message, index) => (

- {message.text} + {projectSupervisorVisibleConversationText( + message.text, + message.role, + )}

{message.draftCommand ? (