From e12586b0b81b0a297320aa53e1e3ccb6410e1779 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 4 Aug 2026 04:18:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E5=9B=BE=E9=9B=86=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E4=B8=8E=E6=96=B9=E5=9D=97=E6=A8=A1=E5=9D=97=E8=AF=AD?= =?UTF-8?q?=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 锚定事务目录并以CAS保护恢复安装和逆序回滚 创建与读取图集快照统一使用跨平台可信有界双读 按显式模块绑定和可达控制流验收俄罗斯方块语义 补齐目录替换、并发冲突及脚本解析回归与文档 --- .../src/agent/generation/canvas_generation.rs | 652 +++++++++++++---- .../runtime_protocol/autonomous_completion.rs | 692 +++++++++++++++--- .../autonomous_completion_contract_tests.rs | 155 +++- .../shared-memory/decision-log.md | 4 +- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- 5 files changed, 1294 insertions(+), 213 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 406c37a29..92c146816 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1822,6 +1822,22 @@ fn platform_art_transaction_metadata_is_trusted(metadata: &fs::Metadata, max_byt true } +fn platform_art_transaction_directory_metadata_is_trusted(metadata: &fs::Metadata) -> bool { + if !metadata.is_dir() { + return false; + } + #[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 false; + } + } + true +} + fn platform_art_transaction_metadata_unchanged( before: &fs::Metadata, after: &fs::Metadata, @@ -1871,6 +1887,29 @@ fn open_platform_art_transaction_file_for_read(path: &Path) -> std::io::Result std::io::Result { + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + options.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_SHARE_READ_WRITE_DELETE: u32 = 0x0000_0007; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + options + .share_mode(FILE_SHARE_READ_WRITE_DELETE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT); + } + options.open(path) +} + #[cfg(windows)] fn platform_art_transaction_windows_file_identity(file: &fs::File) -> Result<(u32, u64), String> { use std::ffi::c_void; @@ -1943,6 +1982,72 @@ fn platform_art_transaction_open_files_match( } } +struct TrustedPlatformArtTransactionDirectory { + path: PathBuf, + handle: fs::File, + metadata: fs::Metadata, +} + +impl TrustedPlatformArtTransactionDirectory { + fn open(path: &Path) -> Result { + let path_metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取平台图集事务目录失败:{error}"))?; + if path_metadata.file_type().is_symlink() + || !platform_art_transaction_directory_metadata_is_trusted(&path_metadata) + { + return Err("平台图集事务路径不是可信目录,已拒绝恢复".to_string()); + } + let handle = open_platform_art_transaction_directory_for_read(path) + .map_err(|error| format!("安全打开平台图集事务目录失败:{error}"))?; + let metadata = handle + .metadata() + .map_err(|error| format!("读取已打开平台图集事务目录元数据失败:{error}"))?; + if !platform_art_transaction_directory_metadata_is_trusted(&metadata) + || !platform_art_transaction_open_files_match( + &handle, + &metadata, + &handle, + &path_metadata, + )? + { + return Err("平台图集事务目录在打开期间发生变化,已拒绝恢复".to_string()); + } + let trusted = Self { + path: path.to_path_buf(), + handle, + metadata, + }; + trusted.verify()?; + Ok(trusted) + } + + fn verify(&self) -> Result<(), String> { + let path_metadata = fs::symlink_metadata(&self.path) + .map_err(|error| format!("复核平台图集事务目录失败:{error}"))?; + if path_metadata.file_type().is_symlink() + || !platform_art_transaction_directory_metadata_is_trusted(&path_metadata) + { + return Err("平台图集事务目录身份发生变化,已拒绝继续恢复".to_string()); + } + let current = open_platform_art_transaction_directory_for_read(&self.path) + .map_err(|error| format!("复核打开平台图集事务目录失败:{error}"))?; + let current_metadata = current + .metadata() + .map_err(|error| format!("读取复核平台图集事务目录元数据失败:{error}"))?; + if !platform_art_transaction_directory_metadata_is_trusted(¤t_metadata) + || !platform_art_transaction_open_files_match( + &self.handle, + &self.metadata, + ¤t, + ¤t_metadata, + )? + { + return Err("平台图集事务目录身份发生变化,已拒绝继续恢复".to_string()); + } + Ok(()) + } +} + fn read_platform_art_transaction_file_once( file: &mut fs::File, max_bytes: u64, @@ -2070,6 +2175,24 @@ fn read_bounded_platform_art_transaction_file( read_bounded_platform_art_transaction_file_with_hook(path, max_bytes, label, || Ok(())) } +fn read_bounded_platform_art_transaction_file_in_directory( + transaction_directory: &TrustedPlatformArtTransactionDirectory, + path: &Path, + max_bytes: u64, + label: &str, +) -> Result, String> { + if path.parent() != Some(transaction_directory.path.as_path()) { + return Err(format!("{label}不属于已锚定的平台图集事务目录")); + } + transaction_directory.verify()?; + let read_result = read_bounded_platform_art_transaction_file(path, max_bytes, label); + let identity_result = transaction_directory.verify(); + match (read_result, identity_result) { + (_, Err(error)) => Err(error), + (result, Ok(())) => result, + } +} + fn strict_platform_art_transaction_directory(root: &Path) -> Result { resolve_local_project_path(root, STRICT_PLATFORM_ART_TRANSACTION_PATH) } @@ -2094,11 +2217,13 @@ fn remove_strict_platform_art_transaction_directory( } fn strict_platform_art_transaction_marker_exists( + transaction_directory: &TrustedPlatformArtTransactionDirectory, path: &Path, expected: &[u8], label: &str, ) -> Result { - match fs::symlink_metadata(path) { + transaction_directory.verify()?; + let result = match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { Err(format!("平台图集事务 {label} marker 不是可信普通文件")) } @@ -2106,7 +2231,8 @@ fn strict_platform_art_transaction_marker_exists( Err(format!("平台图集事务 {label} marker 超出大小上限")) } Ok(_) => { - let actual = read_bounded_platform_art_transaction_file( + let actual = read_bounded_platform_art_transaction_file_in_directory( + transaction_directory, path, 64, &format!("平台图集事务 {label} marker"), @@ -2118,6 +2244,11 @@ fn strict_platform_art_transaction_marker_exists( } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(error) => Err(format!("读取平台图集事务 {label} marker 失败:{error}")), + }; + let identity_result = transaction_directory.verify(); + match (result, identity_result) { + (_, Err(error)) => Err(error), + (result, Ok(())) => result, } } @@ -2221,34 +2352,189 @@ fn restore_strict_platform_art_transaction_at( restore_strict_platform_art_transaction_at_with_hook(root, transaction_directory, |_, _| Ok(())) } +#[derive(Clone, Debug, Eq, PartialEq)] +enum PlatformArtRecoveryFileState { + Missing, + Present(Vec), +} + +struct AppliedPlatformArtRecovery { + canonical: PathBuf, + previous: PlatformArtRecoveryFileState, + installed: PlatformArtRecoveryFileState, +} + +fn read_platform_art_recovery_file_state( + path: &Path, + max_bytes: u64, + label: &str, +) -> Result { + match fs::symlink_metadata(path) { + Ok(_) => read_bounded_platform_art_transaction_file(path, max_bytes, label) + .map(PlatformArtRecoveryFileState::Present), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + match fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(PlatformArtRecoveryFileState::Missing) + } + Ok(_) => Err(format!("{label}在缺失状态检查期间出现,已拒绝继续")), + Err(error) => Err(format!( + "复核{label}缺失状态失败:{}: {error}", + path.display() + )), + } + } + Err(error) => Err(format!( + "读取{label}元数据失败:{}: {error}", + path.display() + )), + } +} + +fn install_platform_art_recovery_state_cas( + canonical: &Path, + expected: &PlatformArtRecoveryFileState, + desired: &PlatformArtRecoveryFileState, + suffix: &str, +) -> Result<(), String> { + preflight_platform_art_recovery_target(canonical)?; + let observed = read_platform_art_recovery_file_state( + canonical, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "平台图集事务 CAS 目标", + )?; + if &observed != expected { + return Err(format!( + "平台图集事务 CAS 目标已被并发修改,已拒绝覆盖:{}", + canonical.display() + )); + } + if expected == desired { + return Ok(()); + } + + if matches!(expected, PlatformArtRecoveryFileState::Missing) { + return match desired { + PlatformArtRecoveryFileState::Missing => Ok(()), + PlatformArtRecoveryFileState::Present(bytes) => { + if let Some(parent) = canonical.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建平台图集事务恢复目录失败:{}: {error}", + parent.display() + ) + })?; + } + preflight_platform_art_recovery_target(canonical)?; + match read_platform_art_recovery_file_state( + canonical, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "平台图集事务 CAS 缺失目标", + )? { + PlatformArtRecoveryFileState::Missing => {} + PlatformArtRecoveryFileState::Present(_) => { + return Err(format!( + "平台图集事务 CAS 缺失目标已被并发创建,已拒绝覆盖:{}", + canonical.display() + )); + } + } + write_durable_platform_art_transaction_file( + canonical, + bytes, + "平台图集事务 CAS 安装结果", + ) + } + }; + } + + let file_name = canonical + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("manifest.json"); + let backup = canonical.with_file_name(format!(".{file_name}.previous.{suffix}")); + if fs::symlink_metadata(&backup).is_ok() { + return Err(format!( + "平台图集事务 CAS 备份路径已存在,已拒绝覆盖:{}", + backup.display() + )); + } + fs::rename(canonical, &backup) + .map_err(|error| format!("平台图集事务 CAS 锁定既有目标失败:{error}"))?; + let moved = read_platform_art_recovery_file_state( + &backup, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "平台图集事务 CAS 已移动目标", + ); + if moved.as_ref() != Ok(expected) { + let restore_result = match fs::symlink_metadata(canonical) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::rename(&backup, canonical) + } + _ => { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集事务 CAS 目标移动后发生冲突,已保留备份等待对账:{}", + backup.display() + )); + } + }; + if let Err(error) = restore_result { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集事务 CAS 校验失败且恢复原目标失败:{error}" + )); + } + return Err(moved + .err() + .unwrap_or_else(|| "平台图集事务 CAS 目标在锁定前发生变化,已拒绝覆盖".to_string())); + } + + let install_result = match desired { + PlatformArtRecoveryFileState::Missing => Ok(()), + PlatformArtRecoveryFileState::Present(bytes) => { + write_durable_platform_art_transaction_file( + canonical, + bytes, + "平台图集事务 CAS 安装结果", + ) + } + }; + if let Err(error) = install_result { + let restore_result = match fs::symlink_metadata(canonical) { + Err(current_error) if current_error.kind() == std::io::ErrorKind::NotFound => { + fs::rename(&backup, canonical) + } + _ => { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {error};平台图集事务 CAS 原目标备份保留于 {}", + backup.display() + )); + } + }; + return match restore_result { + Ok(()) => Err(error), + Err(restore_error) => Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {error};恢复平台图集事务 CAS 原目标失败:{restore_error}" + )), + }; + } + fs::remove_file(&backup) + .map_err(|error| format!("回收平台图集事务 CAS 原目标备份失败:{error}"))?; + Ok(()) +} + fn rollback_applied_platform_art_recovery( - applied: &[(PathBuf, Option>)], + applied: &[AppliedPlatformArtRecovery], recovery_suffix: &str, ) -> Result<(), String> { let rollback_suffix = format!("{recovery_suffix}.rollback"); let mut errors = Vec::new(); - for (canonical, previous) in applied.iter().rev() { - let result = if let Some(previous) = previous { - if let Some(parent) = canonical.parent() { - if let Err(error) = fs::create_dir_all(parent) { - errors.push(format!( - "创建平台图集事务回滚目录失败:{}: {error}", - parent.display() - )); - continue; - } - } - replace_platform_art_slice_file(canonical, previous, &rollback_suffix) - } else { - match fs::remove_file(canonical) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(format!( - "回滚平台图集事务恢复新增文件失败:{}: {error}", - canonical.display() - )), - } - }; + for applied_entry in applied.iter().rev() { + let result = install_platform_art_recovery_state_cas( + &applied_entry.canonical, + &applied_entry.installed, + &applied_entry.previous, + &rollback_suffix, + ); if let Err(error) = result { errors.push(error); } @@ -2262,7 +2548,7 @@ fn rollback_applied_platform_art_recovery( fn platform_art_recovery_error_after_rollback( root: &Path, - applied: &[(PathBuf, Option>)], + applied: &[AppliedPlatformArtRecovery], recovery_suffix: &str, error: String, ) -> String { @@ -2286,26 +2572,35 @@ fn restore_strict_platform_art_transaction_at_with_hook( where F: FnMut(usize, &Path) -> Result<(), String>, { - let transaction_metadata = fs::symlink_metadata(transaction_directory) - .map_err(|error| format!("读取平台图集事务目录失败:{error}"))?; - if transaction_metadata.file_type().is_symlink() || !transaction_metadata.is_dir() { - return Err("平台图集事务路径不是可信目录,已拒绝恢复".to_string()); - } + let trusted_transaction_directory = + TrustedPlatformArtTransactionDirectory::open(transaction_directory)?; let prepared_path = transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_PREPARED); let committed_path = transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_COMMITTED); - if strict_platform_art_transaction_marker_exists(&committed_path, b"committed\n", "committed")? - { + if strict_platform_art_transaction_marker_exists( + &trusted_transaction_directory, + &committed_path, + b"committed\n", + "committed", + )? { cleanup_interrupted_platform_art_contract_files_at(root)?; sync_strict_platform_art_contract_state_at(root, true)?; + trusted_transaction_directory.verify()?; remove_strict_platform_art_transaction_directory(transaction_directory)?; return Ok(false); } - if !strict_platform_art_transaction_marker_exists(&prepared_path, b"prepared\n", "prepared")? { + if !strict_platform_art_transaction_marker_exists( + &trusted_transaction_directory, + &prepared_path, + b"prepared\n", + "prepared", + )? { + trusted_transaction_directory.verify()?; remove_strict_platform_art_transaction_directory(transaction_directory)?; return Ok(false); } let journal_path = transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_JOURNAL); - let journal_bytes = read_bounded_platform_art_transaction_file( + let journal_bytes = read_bounded_platform_art_transaction_file_in_directory( + &trusted_transaction_directory, &journal_path, 128 * 1024, "平台图集事务 journal", @@ -2365,7 +2660,8 @@ where let snapshot_path = transaction_directory.join(expected_snapshot); let remaining = STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES .saturating_sub(total_snapshot_bytes); - let snapshot = read_bounded_platform_art_transaction_file( + let snapshot = read_bounded_platform_art_transaction_file_in_directory( + &trusted_transaction_directory, &snapshot_path, remaining, "平台图集事务快照", @@ -2384,9 +2680,9 @@ where if format!("{:x}", Sha256::digest(&snapshot)) != expected_sha256 { return Err("平台图集事务快照摘要不一致,已拒绝恢复".to_string()); } - recovery_plan.push((canonical, Some(snapshot))); + recovery_plan.push((canonical, PlatformArtRecoveryFileState::Present(snapshot))); } else { - recovery_plan.push((canonical, None)); + recovery_plan.push((canonical, PlatformArtRecoveryFileState::Missing)); } } for (canonical, _) in &recovery_plan { @@ -2394,7 +2690,7 @@ where } let mut applied = Vec::with_capacity(recovery_plan.len()); let mut total_rollback_bytes = 0_u64; - for (index, (canonical, snapshot)) in recovery_plan.into_iter().enumerate() { + for (index, (canonical, desired)) in recovery_plan.into_iter().enumerate() { if let Err(error) = before_apply(index, &canonical) { return Err(platform_art_recovery_error_after_rollback( root, @@ -2413,38 +2709,24 @@ where } let remaining = STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES.saturating_sub(total_rollback_bytes); - let previous = match fs::symlink_metadata(&canonical) { - Ok(_) => match read_bounded_platform_art_transaction_file( - &canonical, - remaining, - "平台图集事务恢复前合同", - ) { - Ok(previous) => Some(previous), - Err(error) => { - return Err(platform_art_recovery_error_after_rollback( - root, - &applied, - &recovery_suffix, - error, - )); - } - }, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + let previous = match read_platform_art_recovery_file_state( + &canonical, + remaining, + "平台图集事务恢复前合同", + ) { + Ok(previous) => previous, Err(error) => { return Err(platform_art_recovery_error_after_rollback( root, &applied, &recovery_suffix, - format!( - "读取平台图集事务恢复前合同失败:{}: {error}", - canonical.display() - ), + error, )); } }; - if let Some(previous) = &previous { + if let PlatformArtRecoveryFileState::Present(previous_bytes) = &previous { total_rollback_bytes = match total_rollback_bytes - .checked_add(u64::try_from(previous.len()).unwrap_or(u64::MAX)) + .checked_add(u64::try_from(previous_bytes.len()).unwrap_or(u64::MAX)) { Some(total) if total <= STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES => total, _ => { @@ -2457,31 +2739,12 @@ where } }; } - applied.push((canonical.clone(), previous)); - let apply_result = if let Some(snapshot) = snapshot { - if let Some(parent) = canonical.parent() { - if let Err(error) = fs::create_dir_all(parent) { - Err(format!( - "创建平台图集事务恢复目录失败:{}: {error}", - parent.display() - )) - } else { - replace_platform_art_slice_file(&canonical, &snapshot, &recovery_suffix) - } - } else { - replace_platform_art_slice_file(&canonical, &snapshot, &recovery_suffix) - } - } else { - match fs::remove_file(&canonical) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(format!( - "恢复平台图集事务的原始缺失状态失败:{}: {error}", - canonical.display() - )), - } - }; - if let Err(error) = apply_result { + if let Err(error) = install_platform_art_recovery_state_cas( + &canonical, + &previous, + &desired, + &recovery_suffix, + ) { return Err(platform_art_recovery_error_after_rollback( root, &applied, @@ -2489,9 +2752,41 @@ where error, )); } + applied.push(AppliedPlatformArtRecovery { + canonical: canonical.clone(), + previous, + installed: desired.clone(), + }); + match read_platform_art_recovery_file_state( + &canonical, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "平台图集事务本轮安装结果", + ) { + Ok(observed) if observed == desired => {} + Ok(_) => { + return Err(platform_art_recovery_error_after_rollback( + root, + &applied, + &recovery_suffix, + format!( + "平台图集事务本轮安装结果被并发修改,已拒绝继续:{}", + canonical.display() + ), + )); + } + Err(error) => { + return Err(platform_art_recovery_error_after_rollback( + root, + &applied, + &recovery_suffix, + error, + )); + } + } } cleanup_interrupted_platform_art_contract_files_at(root)?; sync_strict_platform_art_contract_state_at(root, false)?; + trusted_transaction_directory.verify()?; remove_strict_platform_art_transaction_directory(transaction_directory)?; Ok(true) } @@ -2558,46 +2853,23 @@ impl PlatformArtSliceContractRollback { for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() { let canonical = resolve_local_project_path(root, local_path)?; let bytes = match fs::symlink_metadata(&canonical) { - Ok(metadata) => { - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(format!( - "平台图集事务快照来源不是可信普通文件:{}", - canonical.display() - )); - } + Ok(_) => { let remaining = STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES .saturating_sub(total_snapshot_bytes); - if metadata.len() > remaining { - return Err("平台图集事务快照累计超过 64 MiB,已拒绝提交".to_string()); - } - let mut options = fs::OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW); - } - let mut file = options.open(&canonical).map_err(|error| { - format!( - "打开既有平台图集切片合同失败:{}: {error}", - canonical.display() + Some( + read_bounded_platform_art_transaction_file( + &canonical, + remaining, + "既有平台图集切片合同快照来源", ) - })?; - let mut bytes = - Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or_default()); - (&mut file) - .take(remaining.saturating_add(1)) - .read_to_end(&mut bytes) .map_err(|error| { - format!( - "读取既有平台图集切片合同失败:{}: {error}", - canonical.display() - ) - })?; - if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > remaining { - return Err("平台图集事务快照累计超过 64 MiB,已拒绝提交".to_string()); - } - Some(bytes) + if error.contains("大小上限") { + "平台图集事务快照累计超过 64 MiB,已拒绝提交".to_string() + } else { + error + } + })?, + ) } Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(error) => { @@ -5469,6 +5741,40 @@ mod canvas_generation_tests { ); } + #[test] + fn durable_strict_contract_transaction_rejects_replaced_transaction_directory() { + let temporary = tempfile::tempdir().expect("create replaced transaction directory fixture"); + let transaction_directory = temporary.path().join("transaction"); + let displaced_directory = temporary.path().join("transaction-displaced"); + fs::create_dir(&transaction_directory).expect("create original transaction directory"); + let journal_path = transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_JOURNAL); + fs::write(&journal_path, b"original-journal").expect("write original journal"); + let trusted = TrustedPlatformArtTransactionDirectory::open(&transaction_directory) + .expect("anchor original transaction directory"); + + fs::rename(&transaction_directory, &displaced_directory) + .expect("displace original transaction directory"); + fs::create_dir(&transaction_directory).expect("create replacement transaction directory"); + fs::write( + transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_JOURNAL), + b"replacement-journal", + ) + .expect("write replacement journal"); + + let error = read_bounded_platform_art_transaction_file_in_directory( + &trusted, + &journal_path, + 64, + "测试平台图集事务 journal", + ) + .expect_err("replaced transaction directory must fail against anchored identity"); + assert!( + error.contains("目录身份发生变化"), + "unexpected error: {error}" + ); + drop(trusted); + } + #[test] fn durable_strict_contract_transaction_rolls_back_when_late_target_changes_after_preflight() { let temporary = tempfile::tempdir().expect("create late target race project"); @@ -5530,6 +5836,80 @@ mod canvas_generation_tests { assert!(transaction_directory.exists()); } + #[test] + fn durable_strict_contract_transaction_rollback_preserves_concurrently_changed_installed_target( + ) { + let temporary = tempfile::tempdir().expect("create rollback CAS race project"); + let root = temporary.path(); + init_local_game_project_at(root, "rollback-cas-race", "图集恢复回滚 CAS 测试") + .expect("init project"); + for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() { + let path = root.join(local_path); + fs::create_dir_all(path.parent().expect("contract parent")) + .expect("create contract parent"); + fs::write(path, format!("old-contract-{index}")).expect("write original contract"); + } + + let transaction = PlatformArtSliceContractRollback::capture(root, "rollback-cas-race") + .expect("persist complete contract snapshot"); + let transaction_directory = transaction.transaction_directory.clone(); + let partial_contract = STRICT_PLATFORM_ART_CONTRACT_PATHS + .iter() + .enumerate() + .map(|(index, local_path)| { + let path = root.join(local_path); + let bytes = format!("partial-new-contract-{index}").into_bytes(); + fs::write(&path, &bytes).expect("write partial canonical contract"); + (path, bytes) + }) + .collect::>(); + std::mem::forget(transaction); + + let late_index = STRICT_PLATFORM_ART_CONTRACT_PATHS.len() - 1; + let first_path = partial_contract[0].0.clone(); + let late_path = partial_contract[late_index].0.clone(); + let external_change = b"external-concurrent-change"; + let error = restore_strict_platform_art_transaction_at_with_hook( + root, + &transaction_directory, + |index, canonical| { + if index == late_index { + fs::write(&first_path, external_change) + .map_err(|error| format!("write external concurrent change: {error}"))?; + fs::remove_file(canonical) + .map_err(|error| format!("remove late target fixture: {error}"))?; + fs::create_dir(canonical) + .map_err(|error| format!("replace late target with directory: {error}"))?; + } + Ok(()) + }, + ) + .expect_err("rollback CAS conflict must require reconciliation"); + + assert!( + error.starts_with(PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX), + "unexpected error: {error}" + ); + assert_eq!( + fs::read(&first_path).expect("read preserved external change"), + external_change, + "rollback must not overwrite a target changed after this recovery installed it" + ); + for (path, expected) in partial_contract.iter().skip(1).take(late_index - 1) { + assert_eq!( + fs::read(path).expect("read CAS-rolled-back partial contract"), + *expected, + "uncontested earlier recovery must roll back at {}", + path.display() + ); + } + assert!( + late_path.is_dir(), + "externally changed late target remains intact" + ); + assert!(transaction_directory.exists()); + } + #[test] fn durable_strict_contract_transaction_rejects_oversized_sparse_snapshot_before_reading() { let temporary = tempfile::tempdir().expect("create oversized snapshot project"); @@ -5554,6 +5934,32 @@ mod canvas_generation_tests { assert!(!root.join(STRICT_PLATFORM_ART_TRANSACTION_PATH).exists()); } + #[cfg(unix)] + #[test] + fn durable_strict_contract_transaction_capture_rejects_symlink_snapshot_source() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::tempdir().expect("create symlink snapshot source project"); + let root = temporary.path(); + init_local_game_project_at(root, "symlink-snapshot", "符号链接事务快照测试") + .expect("init project"); + let main_path = root.join("assets/art-spritesheet.png"); + let outside = temporary.path().join("outside-main.png"); + fs::write(&outside, b"outside-main").expect("write outside snapshot source"); + fs::remove_file(&main_path).ok(); + symlink(&outside, &main_path).expect("create snapshot source symlink"); + + let error = match PlatformArtSliceContractRollback::capture(root, "symlink-snapshot") { + Ok(_) => panic!("snapshot capture must reject a symlink source"), + Err(error) => error, + }; + assert!( + error.contains("不是可信普通文件") || error.contains("符号链接"), + "unexpected error: {error}" + ); + assert!(!root.join(STRICT_PLATFORM_ART_TRANSACTION_PATH).exists()); + } + #[test] fn durable_strict_contract_transaction_preserves_committed_crash_residue() { let temporary = tempfile::tempdir().expect("create committed crash recovery project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 45a28170e..5deb7e2af 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -903,6 +903,37 @@ fn javascript_condition_is_obviously_false(condition: &str) -> bool { ) } +fn javascript_condition_is_obviously_true(condition: &str) -> bool { + let compact = condition + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::() + .to_ascii_lowercase(); + matches!( + compact.as_str(), + "true" | "1" | "!false" | "!!1" | "1===1" | "1==1" | "true===true" | "true==true" + ) +} + +fn javascript_statement_range(content: &str, start: usize) -> Option> { + let mut statement_start = start; + while content + .as_bytes() + .get(statement_start) + .is_some_and(u8::is_ascii_whitespace) + { + statement_start += 1; + } + let statement_end = if content.as_bytes().get(statement_start) == Some(&b'{') { + matching_javascript_brace(content, statement_start).map(|end| end + 1) + } else { + content[statement_start..] + .find(';') + .map(|offset| statement_start + offset + 1) + }?; + Some(statement_start..statement_end) +} + fn javascript_obvious_false_branch_ranges(content: &str) -> Vec> { let bytes = content.as_bytes(); let mut ranges = Vec::new(); @@ -939,30 +970,41 @@ fn javascript_obvious_false_branch_ranges(content: &str) -> Vec bool { - if javascript_position_is_in_literal_false_block(content, position) { + if javascript_position_is_in_literal_false_block(content, position) + || javascript_position_is_in_uncalled_anonymous_function(content, position) + { return false; } let enclosing = ranges @@ -1225,7 +1269,7 @@ fn javascript_position_is_in_literal_false_block(content: &str, position: usize) .filter(|character| !character.is_ascii_whitespace()) .collect::(); let short_circuit_prefix = statement_prefix.trim_end_matches('('); - if ["false&&", "0&&", "false?"] + if ["false&&", "0&&", "false?", "true||", "1||"] .iter() .any(|marker| short_circuit_prefix.ends_with(marker)) { @@ -1236,6 +1280,92 @@ fn javascript_position_is_in_literal_false_block(content: &str, position: usize) .any(|range| range.contains(&position)) } +fn javascript_position_is_in_uncalled_anonymous_function(content: &str, position: usize) -> bool { + let mut cursor = 0usize; + while let Some(offset) = content[cursor..position.min(content.len())].find("function") { + let start = cursor + offset; + cursor = start + "function".len(); + if start > 0 + && (is_ascii_word_byte(content.as_bytes()[start - 1]) + || content.as_bytes()[start - 1] == b'$') + || position_is_inside_javascript_string(content, start) + { + continue; + } + let mut parameters = cursor; + while content + .as_bytes() + .get(parameters) + .is_some_and(u8::is_ascii_whitespace) + { + parameters += 1; + } + if content.as_bytes().get(parameters) != Some(&b'(') { + continue; + } + let Some(parameters_end) = matching_javascript_parenthesis(content, parameters) else { + continue; + }; + let mut body_start = parameters_end + 1; + while content + .as_bytes() + .get(body_start) + .is_some_and(u8::is_ascii_whitespace) + { + body_start += 1; + } + if content.as_bytes().get(body_start) != Some(&b'{') { + continue; + } + let Some(body_end) = matching_javascript_brace(content, body_start) else { + continue; + }; + if !(body_start..body_end).contains(&position) { + continue; + } + let mut invocation = body_end + 1; + while content + .as_bytes() + .get(invocation) + .is_some_and(u8::is_ascii_whitespace) + { + invocation += 1; + } + if content.as_bytes().get(invocation) == Some(&b')') { + invocation += 1; + while content + .as_bytes() + .get(invocation) + .is_some_and(u8::is_ascii_whitespace) + { + invocation += 1; + } + } + if content.as_bytes().get(invocation) == Some(&b'(') { + return false; + } + let assignment_prefix = content[..start].trim_end(); + if assignment_prefix.ends_with('=') { + if let Some(name) = identifier_before(content, assignment_prefix.len() - 1) { + let marker = format!("{name}("); + if content[body_end + 1..] + .match_indices(&marker) + .any(|(offset, _)| { + let call = body_end + 1 + offset; + call == 0 + || !is_ascii_word_byte(content.as_bytes()[call - 1]) + && content.as_bytes()[call - 1] != b'$' + }) + { + return false; + } + } + } + return true; + } + false +} + fn identifier_before(content: &str, position: usize) -> Option { let bytes = content.as_bytes(); let mut end = position; @@ -2597,6 +2727,17 @@ fn javascript_without_string_literals_or_comments(content: &str) -> String { } fn html_script_type_is_executable(script_tag: &str) -> bool { + if html_attribute_value(script_tag, "language").is_some_and(|language| { + let language = language.trim(); + !language.is_empty() + && !matches!( + language, + "javascript" | "jscript" | "livescript" | "ecmascript" + ) + && !language.starts_with("javascript1.") + }) { + return false; + } let Some(script_type) = html_attribute_value(script_tag, "type") else { return true; }; @@ -3135,6 +3276,42 @@ fn javascript_lexical_tokens(content: &str) -> Vec { while cursor < bytes.len() { match bytes[cursor] { b'\\' => cursor = (cursor + 2).min(bytes.len()), + b'$' if bytes.get(cursor + 1) == Some(&b'{') => { + let expression_start = cursor + 2; + let mut expression_cursor = expression_start; + let mut depth = 1usize; + let mut quote = None; + let mut escaped = false; + while expression_cursor < bytes.len() { + let current = bytes[expression_cursor]; + if escaped { + escaped = false; + } else if current == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if current == active { + quote = None; + } + } else if matches!(current, b'\'' | b'"' | b'`') { + quote = Some(current); + } else if current == b'{' { + depth += 1; + } else if current == b'}' { + depth = depth.saturating_sub(1); + if depth == 0 { + tokens.extend(javascript_lexical_tokens( + &content[expression_start..expression_cursor], + )); + cursor = expression_cursor + 1; + break; + } + } + expression_cursor += 1; + } + if depth != 0 { + cursor = bytes.len(); + } + } b'`' => { cursor += 1; break; @@ -3193,16 +3370,9 @@ fn local_javascript_module_sources(content: &str, allow_static_imports: bool) -> { continue; } - let starts_module_declaration = index == 0 - || index - .checked_sub(1) - .and_then(|previous| tokens.get(previous)) - .is_some_and(|token| matches!(token, JavascriptLexicalToken::Punct(';' | '}'))); if keyword == "import" { match tokens.get(index + 1) { - Some(JavascriptLexicalToken::StringLiteral(source)) - if allow_static_imports && starts_module_declaration => - { + Some(JavascriptLexicalToken::StringLiteral(source)) if allow_static_imports => { sources.push(source.clone()); continue; } @@ -3221,8 +3391,25 @@ fn local_javascript_module_sources(content: &str, allow_static_imports: bool) -> } _ => {} } + if !matches!( + tokens.get(index + 1), + Some( + JavascriptLexicalToken::Identifier(_) + | JavascriptLexicalToken::Punct('{' | '*') + ) + ) { + continue; + } } - if !allow_static_imports || !starts_module_declaration { + if !allow_static_imports { + continue; + } + if keyword == "export" + && !matches!( + tokens.get(index + 1), + Some(JavascriptLexicalToken::Punct('{' | '*')) + ) + { continue; } let mut saw_from = false; @@ -3288,6 +3475,204 @@ fn reachable_local_javascript_module_sources( local_javascript_module_sources(&content, allow_static_imports) } +fn explicit_local_javascript_import_bindings( + content: &str, +) -> Vec<(String, std::collections::BTreeMap)> { + let tokens = javascript_lexical_tokens(content); + let mut imports = Vec::new(); + let mut index = 0usize; + while index < tokens.len() { + if !matches!(&tokens[index], JavascriptLexicalToken::Identifier(value) if value == "import") + || index > 0 && tokens[index - 1] == JavascriptLexicalToken::Punct('.') + { + index += 1; + continue; + } + match tokens.get(index + 1) { + Some(JavascriptLexicalToken::StringLiteral(source)) => { + imports.push((source.clone(), std::collections::BTreeMap::new())); + index += 2; + } + Some(JavascriptLexicalToken::Punct('(')) => index += 2, + _ => { + let mut names = std::collections::BTreeMap::new(); + let mut cursor = index + 1; + if let Some(JavascriptLexicalToken::Identifier(name)) = tokens.get(cursor) { + names.insert("default".to_string(), name.to_ascii_lowercase()); + } + if tokens.get(cursor) == Some(&JavascriptLexicalToken::Punct('{')) { + cursor += 1; + while cursor < tokens.len() + && tokens.get(cursor) != Some(&JavascriptLexicalToken::Punct('}')) + { + if let Some(JavascriptLexicalToken::Identifier(name)) = tokens.get(cursor) { + if name != "as" { + let exported = name.to_ascii_lowercase(); + let mut local = exported.clone(); + if matches!(tokens.get(cursor + 1), Some(JavascriptLexicalToken::Identifier(value)) if value == "as") + { + if let Some(JavascriptLexicalToken::Identifier(alias)) = + tokens.get(cursor + 2) + { + local = alias.to_ascii_lowercase(); + cursor += 2; + } + } + names.insert(exported, local); + } + } + cursor += 1; + } + } + let mut saw_from = false; + while cursor < tokens.len() { + match &tokens[cursor] { + JavascriptLexicalToken::Identifier(value) if value == "from" => { + saw_from = true; + } + JavascriptLexicalToken::StringLiteral(source) if saw_from => { + imports.push((source.clone(), names)); + break; + } + JavascriptLexicalToken::Punct(';') => break, + _ => {} + } + cursor += 1; + } + index = cursor.saturating_add(1); + } + } + } + imports +} + +fn javascript_top_level_declarations(content: &str) -> std::collections::BTreeMap { + let ranges = named_javascript_function_ranges(content); + let mut declarations = std::collections::BTreeMap::new(); + for (name, start, end) in &ranges { + if !ranges + .iter() + .any(|(_, outer_start, outer_end)| outer_start < start && end <= outer_end) + { + declarations.insert(name.clone(), content[*start..*end].to_string()); + } + } + for keyword in ["const", "let", "var"] { + let mut cursor = 0usize; + while let Some(offset) = content[cursor..].find(keyword) { + let start = cursor + offset; + cursor = start + keyword.len(); + if start > 0 + && (is_ascii_word_byte(content.as_bytes()[start - 1]) + || content.as_bytes()[start - 1] == b'$') + || content + .as_bytes() + .get(cursor) + .is_some_and(|byte| is_ascii_word_byte(*byte) || *byte == b'$') + || position_is_inside_javascript_string(content, start) + { + continue; + } + while content + .as_bytes() + .get(cursor) + .is_some_and(u8::is_ascii_whitespace) + { + cursor += 1; + } + let name_start = cursor; + while content + .as_bytes() + .get(cursor) + .is_some_and(|byte| is_ascii_word_byte(*byte) || *byte == b'$') + { + cursor += 1; + } + if cursor == name_start { + continue; + } + let name = content[name_start..cursor].to_string(); + let end = content[cursor..] + .find(';') + .map(|offset| cursor + offset + 1) + .or_else(|| content[cursor..].find('\n').map(|offset| cursor + offset)) + .unwrap_or(content.len()); + declarations + .entry(name) + .or_insert_with(|| content[start..end].to_string()); + } + } + declarations +} + +fn javascript_module_exports_name(content: &str, expected: &str) -> bool { + let tokens = javascript_lexical_tokens(content); + tokens.windows(3).any(|window| { + matches!(&window[0], JavascriptLexicalToken::Identifier(value) if value == "export") + && (matches!(&window[1], JavascriptLexicalToken::Identifier(kind) if matches!(kind.as_str(), "function" | "const" | "let" | "var" | "class")) + && matches!(&window[2], JavascriptLexicalToken::Identifier(name) if name == expected)) + }) || tokens.iter().enumerate().any(|(index, token)| { + matches!(token, JavascriptLexicalToken::Identifier(value) if value == "export") + && tokens.get(index + 1) == Some(&JavascriptLexicalToken::Punct('{')) + && tokens[index + 2..] + .iter() + .take_while(|token| **token != JavascriptLexicalToken::Punct('}')) + .any(|token| matches!(token, JavascriptLexicalToken::Identifier(name) if name == expected)) + }) +} + +fn javascript_module_binding_projection(content: &str, imported: &BTreeSet) -> String { + let declarations = javascript_top_level_declarations(content); + let mut pending = imported.iter().cloned().collect::>(); + let mut included = BTreeSet::new(); + let mut projection = String::new(); + while let Some(name) = pending.pop() { + if !included.insert(name.clone()) + || !javascript_module_exports_name(content, &name) && imported.contains(&name) + { + continue; + } + let Some(declaration) = declarations.get(&name) else { + continue; + }; + projection.push_str(declaration); + projection.push('\n'); + for token in javascript_lexical_tokens(declaration) { + if let JavascriptLexicalToken::Identifier(dependency) = token { + if declarations.contains_key(&dependency) && !included.contains(&dependency) { + pending.push(dependency); + } + } + } + } + projection +} + +fn replace_javascript_identifier(content: &str, from: &str, to: &str) -> String { + let mut output = String::with_capacity(content.len()); + let mut cursor = 0usize; + while let Some(offset) = content[cursor..].find(from) { + let start = cursor + offset; + let end = start + from.len(); + let has_boundary = (start == 0 + || !is_ascii_word_byte(content.as_bytes()[start - 1]) + && content.as_bytes()[start - 1] != b'$') + && content + .as_bytes() + .get(end) + .is_none_or(|byte| !is_ascii_word_byte(*byte) && *byte != b'$'); + output.push_str(&content[cursor..start]); + if has_boundary && !position_is_inside_javascript_string(content, start) { + output.push_str(to); + } else { + output.push_str(from); + } + cursor = end; + } + output.push_str(&content[cursor..]); + output +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] pub(in crate::agent) struct ExternalGameplayJavascript { classic_global: String, @@ -3314,7 +3699,10 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( let mut total_bytes = 0_u64; let mut pending = std::collections::VecDeque::new(); let mut module_contents = std::collections::BTreeMap::::new(); - let mut module_edges = std::collections::BTreeMap::>::new(); + let mut module_bindings = std::collections::BTreeMap::< + String, + Vec<(String, std::collections::BTreeMap)>, + >::new(); for source in executable_external_script_sources_from_html(html) { let local_path = local_gameplay_script_path_from(None, &source.source) .ok_or_else(|| format!("自主构建外部脚本路径不受支持:{}", source.source))?; @@ -3337,7 +3725,20 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( { let inline_id = format!("inline-module:{index}"); module_contents.insert(inline_id.clone(), inline_module.to_ascii_lowercase()); - module_edges.entry(inline_id.clone()).or_default(); + module_bindings.entry(inline_id.clone()).or_default(); + for (imported_source, names) in explicit_local_javascript_import_bindings(&inline_module) { + let imported_path = local_gameplay_script_path_from( + Some("game/index.html"), + &imported_source, + ) + .ok_or_else(|| { + format!("自主构建内联模块依赖路径不受支持:game/index.html -> {imported_source}") + })?; + module_bindings + .entry(inline_id.clone()) + .or_default() + .push((imported_path, names)); + } for imported_source in reachable_local_javascript_module_sources(&inline_module, true) { let imported_path = local_gameplay_script_path_from( Some("game/index.html"), @@ -3346,10 +3747,6 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( .ok_or_else(|| { format!("自主构建内联模块依赖路径不受支持:game/index.html -> {imported_source}") })?; - module_edges - .entry(inline_id.clone()) - .or_default() - .insert(imported_path.clone()); pending.push_back((imported_path, true)); } } @@ -3391,72 +3788,85 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( return Err("自主构建外部脚本累计超过 2 MiB".to_string()); } total_bytes += script_bytes; + if is_module { + for (imported_source, names) in explicit_local_javascript_import_bindings(&script) { + let imported_path = + local_gameplay_script_path_from(Some(&local_path), &imported_source) + .ok_or_else(|| { + format!( + "自主构建模块依赖路径不受支持:{local_path} -> {imported_source}" + ) + })?; + module_bindings + .entry(local_path.clone()) + .or_default() + .push((imported_path, names)); + } + } for imported_source in reachable_local_javascript_module_sources(&script, is_module) { let imported_path = local_gameplay_script_path_from(Some(&local_path), &imported_source).ok_or_else( || format!("自主构建模块依赖路径不受支持:{local_path} -> {imported_source}"), )?; - if is_module { - module_edges - .entry(local_path.clone()) - .or_default() - .insert(imported_path.clone()); - } pending.push_back((imported_path, true)); } let script = script.to_ascii_lowercase(); if is_module { module_contents.insert(local_path.clone(), script); - module_edges.entry(local_path).or_default(); + module_bindings.entry(local_path).or_default(); } else { output.classic_global.push_str(&script); output.classic_global.push('\n'); } } - let mut adjacency = std::collections::BTreeMap::>::new(); - for module in module_contents.keys() { - adjacency.entry(module.clone()).or_default(); - } - for (importer, dependencies) in module_edges { - for dependency in dependencies { - if !module_contents.contains_key(&dependency) { + output + .module_units + .extend(module_contents.values().cloned()); + for (importer, dependencies) in module_bindings { + let Some(importer_content) = module_contents.get(&importer) else { + continue; + }; + let importer_tokens = javascript_lexical_tokens(importer_content); + let mut unit = importer_content.clone(); + let mut added_projection = false; + for (dependency, bindings) in dependencies { + if bindings.is_empty() { continue; } - adjacency - .entry(importer.clone()) - .or_default() - .insert(dependency.clone()); - adjacency - .entry(dependency) - .or_default() - .insert(importer.clone()); - } - } - let mut grouped = BTreeSet::new(); - for module in module_contents.keys() { - if !grouped.insert(module.clone()) { - continue; - } - let mut component = vec![module.clone()]; - let mut cursor = 0usize; - while cursor < component.len() { - let current = component[cursor].clone(); - cursor += 1; - for neighbor in adjacency.get(¤t).into_iter().flatten() { - if grouped.insert(neighbor.clone()) { - component.push(neighbor.clone()); + let used_bindings = bindings + .into_iter() + .filter_map(|(exported, local)| { + (importer_tokens + .iter() + .filter(|token| matches!(token, JavascriptLexicalToken::Identifier(value) if value == &local)) + .count() + > 1) + .then_some((exported, local)) + }) + .collect::>(); + let used_names = used_bindings + .iter() + .map(|(exported, _)| exported.clone()) + .collect::>(); + let Some(dependency_content) = module_contents.get(&dependency) else { + continue; + }; + let mut projection = + javascript_module_binding_projection(dependency_content, &used_names); + for (exported, local) in used_bindings { + if exported != local { + projection = replace_javascript_identifier(&projection, &exported, &local); } } - } - component.sort(); - let mut unit = String::new(); - for member in component { - if let Some(content) = module_contents.get(&member) { - unit.push_str(content); + if !projection.is_empty() { unit.push('\n'); + unit.push_str(&projection); + added_projection = true; } } - output.module_units.push(unit); + if added_projection { + output.module_units.push(unit); + } } Ok(output) } @@ -3509,6 +3919,70 @@ fn compact_javascript(content: &str) -> String { .collect() } +fn javascript_before_top_level_unconditional_termination(content: &str) -> &str { + let bytes = content.as_bytes(); + let Some(body_start) = content.find('{') else { + return content; + }; + let mut depth = 1usize; + let mut cursor = body_start + 1; + let mut quote = None; + let mut escaped = false; + let mut line_comment = false; + let mut block_comment = false; + while cursor < bytes.len() { + let byte = bytes[cursor]; + let next = bytes.get(cursor + 1).copied(); + if escaped { + escaped = false; + } else if line_comment { + if byte == b'\n' { + line_comment = false; + } + } else if block_comment { + if byte == b'*' && next == Some(b'/') { + cursor += 1; + block_comment = false; + } + } else if let Some(active) = quote { + if byte == b'\\' { + escaped = true; + } else if byte == active { + quote = None; + } + } else if byte == b'/' && next == Some(b'/') { + cursor += 1; + line_comment = true; + } else if byte == b'/' && next == Some(b'*') { + cursor += 1; + block_comment = true; + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } else if byte == b'{' { + depth += 1; + } else if byte == b'}' { + depth = depth.saturating_sub(1); + if depth == 0 { + break; + } + } else if depth == 1 { + for keyword in ["return", "throw"] { + if bytes.get(cursor..cursor + keyword.len()) == Some(keyword.as_bytes()) + && (cursor == 0 + || !is_ascii_word_byte(bytes[cursor - 1]) && bytes[cursor - 1] != b'$') + && bytes + .get(cursor + keyword.len()) + .is_none_or(|byte| !is_ascii_word_byte(*byte) && *byte != b'$') + { + return &content[..cursor]; + } + } + } + cursor += 1; + } + content +} + fn compact_javascript_contains_simple_property_assignment( compact: &str, owners: &[&str], @@ -3595,6 +4069,24 @@ fn javascript_contains_reachable_identifier_call(content: &str, name: &str) -> b }) } +fn javascript_contains_reachable_call_with_first_argument( + content: &str, + name: &str, + argument: &str, +) -> bool { + let marker = format!("{name}({argument}"); + content.match_indices(&marker).any(|(position, _)| { + (position == 0 + || (!is_ascii_word_byte(content.as_bytes()[position - 1]) + && content.as_bytes()[position - 1] != b'$')) + && content + .as_bytes() + .get(position + marker.len()) + .is_none_or(|byte| !is_ascii_word_byte(*byte) && *byte != b'$') + && !javascript_position_is_in_literal_false_block(content, position) + }) +} + fn javascript_contains_collision_call(content: &str) -> bool { content.match_indices('(').any(|(open, _)| { identifier_before(content, open).is_some_and(|name| { @@ -3795,6 +4287,7 @@ fn tetris_piece_traversal_writes_board(compact: &str) -> bool { } fn tetris_rotation_body_is_meaningful(body: &str) -> bool { + let body = javascript_before_top_level_unconditional_termination(body); let body = javascript_without_obvious_false_branches(body); let body = body.as_str(); let compact = compact_javascript(body); @@ -3877,6 +4370,7 @@ fn tetris_rotation_body_is_meaningful(body: &str) -> bool { } fn tetris_fall_body_is_meaningful(body: &str, lock_function_name: &str) -> bool { + let body = javascript_before_top_level_unconditional_termination(body); let body = javascript_without_obvious_false_branches(body); let body = body.as_str(); let compact = compact_javascript(body); @@ -3888,7 +4382,12 @@ fn tetris_fall_body_is_meaningful(body: &str, lock_function_name: &str) -> bool mutates_active_piece && tetris_fall_control_flow_is_meaningful(&compact, lock_function_name) } -fn tetris_lock_body_is_meaningful(body: &str, clear_function_name: &str) -> bool { +fn tetris_lock_body_is_meaningful( + body: &str, + clear_function_name: &str, + clear_requires_board_argument: bool, +) -> bool { + let body = javascript_before_top_level_unconditional_termination(body); let body = javascript_without_obvious_false_branches(body); let body = body.as_str(); let compact = compact_javascript(body); @@ -3896,8 +4395,15 @@ fn tetris_lock_body_is_meaningful(body: &str, clear_function_name: &str) -> bool return false; } let writes_board_cell = tetris_piece_traversal_writes_board(&compact); - let invokes_line_clear = - javascript_contains_reachable_identifier_call(&compact, clear_function_name); + let invokes_line_clear = if clear_requires_board_argument { + javascript_contains_reachable_call_with_first_argument( + &compact, + clear_function_name, + "board", + ) + } else { + javascript_contains_reachable_identifier_call(&compact, clear_function_name) + }; writes_board_cell && invokes_line_clear } @@ -3918,6 +4424,19 @@ fn javascript_callback_first_parameter(callback: &str) -> Option { (!parameter.is_empty()).then(|| parameter.to_string()) } +fn javascript_function_has_parameter(function: &str, expected: &str) -> bool { + let Some(open) = function.find('(') else { + return false; + }; + let Some(close) = matching_javascript_parenthesis(function, open) else { + return false; + }; + function[open + 1..close] + .split(',') + .map(str::trim) + .any(|parameter| parameter == expected) +} + fn tetris_every_callback_requires_occupied(callback: &str) -> bool { let compact = compact_javascript(callback); if compact.eq_ignore_ascii_case("boolean") { @@ -4227,6 +4746,7 @@ fn tetris_board_owner_has_guarded_row_replacement(compact: &str, owner: &str) -> } fn tetris_clear_body_is_meaningful(body: &str) -> bool { + let body = javascript_before_top_level_unconditional_termination(body); let body = javascript_without_obvious_false_branches(body); let body = body.as_str(); let compact = compact_javascript(body); @@ -4292,9 +4812,15 @@ fn tetris_executable_unit_semantics_gap(executable: &str) -> Option<&'static str named_javascript_functions(&executable, &ranges, &["merge", "lock", "place"]) .into_iter() .filter(|(_, lock_body)| { - clear_functions.iter().any(|(clear_function_name, _)| { - tetris_lock_body_is_meaningful(lock_body, clear_function_name) - }) + clear_functions + .iter() + .any(|(clear_function_name, clear_body)| { + tetris_lock_body_is_meaningful( + lock_body, + clear_function_name, + javascript_function_has_parameter(clear_body, "board"), + ) + }) }) .collect::>(); if lock_functions.is_empty() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index d9f830609..c3d60e674 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -1546,6 +1546,27 @@ fn inherited_tetris_contract_scans_only_executable_html_scripts() { "HTML form-feed must delimit a non-executable type attribute", ); + let non_javascript_language = valid.replacen( + "", + "", + ); + let bound_modules = read_external_gameplay_javascript_at(root, &bound_module_html) + .expect("read modules joined by explicit export/import bindings"); + assert_eq!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + bound_module_html.as_bytes(), + &bound_modules, ), None, - "modules connected by a real import edge must form one semantic unit", + "explicitly imported exported gameplay bindings may form one semantic chain", ); } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c27b58a63..fa0fc473d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5926,8 +5926,8 @@ - 覆盖决策:game-chat fallback 只允许初始化缺失/占位入口的首次落盘。非占位 `game/index.html` 必须保留,并由当前 `code-prototype` 先读取和实际 patch,取得本人 `mutationRevision` 后才能运行 `game.static_smoke` 与交付;只读 smoke 不得冒充续作。确定性 fallback 只允许已实现真实语义的显式玩法模板:俄罗斯方块模板必须具备 10×20 棋盘、下落、移动、旋转、锁定、消行和触顶失败,收集模板只用于明确收集类目标,未知玩法失败关闭。纯继续目标未恢复时同样失败关闭。完成门新增 baseline 玩法连续性和 action-driven state 检查,generic Canvas 非空、三个按钮存在或静态 smoke 通过都不能单独证明任务没有换题。 - 美术决策:`art-spec.png` 回归为规范图和下游派生 reference,不能铺作完整场景,也不能裁剪成玩家/目标。首版必须继续由 `art-asset-plan` 通过 icon-spritesheet 生成透明 `art-spritesheet.png`;桌面 Runtime 同时下载服务端 `iconImageSrcs`,按当前图集 resourceId 写入本地切片清单。game-chat 在任何本地落盘前要求稳定、非空的图集 resourceId,并在正式图集登记前要求切片严格等于四、每片 `sourceResourceId` 精确绑定整图,全部切片累计下载最多 `32 MiB`;四类差异按尺寸加规范 RGBA 像素摘要判定,PNG 编码字节不同不代表视觉内容不同。主图、四张 canonical 切片与切片清单作为一个提交合同,并把主图/切片摘要及 Canvas 身份冻结到 `.agent/runtime/art-spritesheet-contract.json` 私有回执;主图安装、回执或资产登记失败时必须恢复整组旧合同。generation 账本恢复允许对摘要一致的已落盘主图幂等补齐合同,摘要冲突不得覆盖。完成门重新读取切片时继续有界解码,并要求实际素材、公开清单、当前 Canvas 登记与私有回执在内容摘要、规范像素摘要、可见 alpha、四类唯一性和资源身份上全部一致。`code-prototype` 在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景和反馈四类不同切片。纯代码核心画面、猜测 atlas 等分坐标、单个裁切冒充全部类别、整图展示与路径诱饵失败关闭;编辑器仍允许仅有 `sliceWarning` 的完整透明图集完成,但 game-chat 必须等到真实切片可用。 - 图集身份与恢复补充:canonical game-chat 图集是 External 通用资源模型的严格完成子集。主图与四个切片都必须含非空 `assetObjectId`,五个 ID 互不复用;同一对象在顶层、resource 与 asset 中重复出现的 `assetObjectId` 和 `taskId` 都必须逐项完全一致,冲突时不得择一冻结。公开切片清单和私有回执同时冻结 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,四项切片按 usage 唯一且逐项比较 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256`。旧项目仅缺私有回执时不得从可编辑公开清单伪造回执;只有同一 `project-supervisor-game-chat` 父 run 下处于 running 的 `art-asset-plan` scheduled child、固定输出路径且当前合同确实失效时,才允许 `replaceExisting=true` 原位 repair。任何 canonical 文件变化前,Runtime 必须在 `.agent/runtime` 私有事务目录原子持久化九个固定合同路径的旧状态并回读,随后写 `prepared` marker;主图字节先写 staging,再安装 canonical 主图,随后才提交四切片、公开清单、私有回执和 Canvas 登记,登记成功后写 `committed` marker 才可回收快照和 backup。恢复只在取得同一项目写锁后按持久 transaction id 扫描;`prepared` 未 `committed` 必须整组恢复旧合同,`committed` 只做幂等清理,不能凭随机 previous/replacement 文件名干扰正在提交的事务。这样即使进程被强杀且远端暂不可用,也能恢复完整旧合同,不留下“旧主图 + 新切片”或半写 canonical PNG。 -- 图集事务退役补充:九路径快照在读取前先用跨平台不跟随符号链接 / reparse point 的文件句柄核算 64 MiB 总预算,marker、journal 与快照均通过有界双次读取和句柄元数据复核拒绝同长度并发改写;实际读取仍受剩余预算限制,稀疏或并发增长文件不能触发无界分配。恢复必须先把全部 journal 条目和九路径快照完成结构、大小与摘要校验并形成内存计划,随后缓存全部 canonical 路径的恢复前状态;每项落盘前再次校验目标与父目录,后续项失败时按逆序回滚本轮已应用项,末尾路径竞态或快照损坏不得留下新旧混合合同。`committed` 持久化后先删除并同步 `prepared`,再清理 `.previous / .replacement`、同步 canonical 合同并最后删除事务目录;递归删除中断后最多留下只有 `committed` 的可清理事务,不能重新落入 rollback 分支。 -- Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。HTML tokenizer 只把 TAB / LF / FF / CR / SPACE 视为标签空白;静态门移除字符串、注释、`template / noscript / textarea / title / style / xmp / iframe / noembed / plaintext`、带 `src` 脚本的非执行正文、非 JavaScript script、不可达函数、短路表达式和 `if(false)` / 明显恒假分支诱饵,并要求有标识符边界的可达 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断实际控制且绑定棋盘或不可重赋值的棋盘别名。同项目 `game/*.js / game/*.mjs` 外部脚本及本地 module 依赖图按真实连通单元聚合语义、隔离互不导入的 module,并按去重文件数和累计 2 MiB 上限有界读取;对象属性 `import / from` 与控制块后的正则正文不得伪造依赖。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩在 Chromium 隔离执行上下文的 Promise 闭包中,以同一浏览器任务内 trusted 输入分发尾部冻结的状态作为 click 前基线,页面全局对象不能改写因果证据,真实 window bubble 处理器仍可被正确验收,0ms timer 不得抢入该因果窗口。探针 fingerprint 必须覆盖 install、ready 与 finish 三段真实脚本。锁定、消行与 restart 的既有严格约束保持不变。旧合同或纯继续 successor 以及 game-chat 快车道在读取回执前按有效原任务重新分类、重算 fingerprint 并回读迁移结果,旧 generic 回执只能视为 stale,不能交付完成。 +- 图集事务退役补充:九路径快照在创建和恢复读取时都使用跨平台不跟随符号链接 / reparse point 的文件句柄核算 64 MiB 总预算,marker、journal 与快照均通过有界双次读取和句柄元数据复核拒绝同长度并发改写;实际读取仍受剩余预算限制,稀疏或并发增长文件不能触发无界分配。恢复开始时锚定可信事务目录句柄,每次读取控制文件前后都复核目录身份,拒绝 rename、junction 或替换目录。恢复必须先把全部 journal 条目和九路径快照完成结构、大小与摘要校验并形成内存计划,随后缓存全部 canonical 路径的恢复前状态;每项落盘前再次校验目标与父目录,后续项失败时按逆序回滚本轮已应用项,但回滚前必须 CAS 证明目标仍等于本轮安装结果,外部修改不得被覆盖并进入 reconciliation。末尾路径竞态或快照损坏不得留下静默的新旧混合合同。`committed` 持久化后先删除并同步 `prepared`,再清理 `.previous / .replacement`、同步 canonical 合同并最后删除事务目录;递归删除中断后最多留下只有 `committed` 的可清理事务,不能重新落入 rollback 分支。 +- Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。HTML tokenizer 只把 TAB / LF / FF / CR / SPACE 视为标签空白,并按 `type / language / nomodule` 判断 Chromium 中的可执行脚本;静态门移除字符串、注释、`template / noscript / textarea / title / style / xmp / iframe / noembed / plaintext`、带 `src` 脚本的非执行正文、非 JavaScript script、不可达匿名或命名函数、短路表达式、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵,并要求有标识符边界的可达 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断实际控制且绑定未被局部变量或函数参数遮蔽的正式棋盘。同项目 `game/*.js / game/*.mjs` 外部脚本及本地 module 依赖图只按显式 export/import binding 传递语义,side-effect import 不暴露被导入模块的局部绑定,ASI 换行与 template `${...}` 内真实 import 仍参与依赖解析;文件按去重数量和累计 2 MiB 上限有界读取,对象属性 `import / from` 与控制块后的正则正文不得伪造依赖。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩在 Chromium 隔离执行上下文的 Promise 闭包中,以同一浏览器任务内 trusted 输入分发尾部冻结的状态作为 click 前基线,页面全局对象不能改写因果证据,真实 window bubble 处理器仍可被正确验收,0ms timer 不得抢入该因果窗口。探针 fingerprint 必须覆盖 install、ready 与 finish 三段真实脚本。锁定、消行与 restart 的既有严格约束保持不变。旧合同或纯继续 successor 以及 game-chat 快车道在读取回执前按有效原任务重新分类、重算 fingerprint 并回读迁移结果,旧 generic 回执只能视为 stale,不能交付完成。 - 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`start-dev-stack.mjs`、`src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`response_stream.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 ## 2026-08-03 托管 MCP 未鉴权响应提供安全接入引导 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index ad2ba15fc..1248a4ef3 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -827,9 +827,9 @@ game-project/ - 2026-08-03 失败续跑收口:同一 `project-supervisor` Session、同一持久 source 的最近可信根 run 已失败、取消或预算耗尽,且新输入只是严格受限的继续意图(例如“继续”“接着做”“继续完成”“continue”“go on”)时,宿主仍创建新的 root run 身份,但必须把上一根 run 的原始任务作为继承目标和完成合同基线;首次和连续 successor 的 effective task、合同 SHA、Runtime hydration 与 scheduler 必须一致。不得把继续短语本身当游戏主题,也不得按真正新需求重置 seed manifest。跨 Session、跨 GUI / CLI / game-chat source、上一根 run 已正常完成、输入包含新的具体玩法要求或无法唯一识别前序根 run 时都不继承,继续按新任务执行。继承只复用目标与已有产物基线,不复用旧 Provider request、pending action 或副作用身份。 - game-chat 快车道只能在 `game/index.html` 缺失或仍是初始化占位,且当前 child run 尚未写入正式入口时使用首次 fallback `file.write`。项目已存在非占位入口时,后续 `code-prototype` 必须先保留并读取既有玩法,做真实局部修改并取得本人 `mutationRevision`,之后才能运行 `game.static_smoke` 与交付;禁止为了满足首版时限重新生成整份默认小游戏,也禁止连续只读 smoke。占位 fallback 仅允许俄罗斯方块和明确收集类等已有真实语义模板,未知玩法失败关闭。纯继续意图未能恢复唯一原始目标时同样失败关闭,不输出以“继续”为标题的兜底产物。 - `assets/art-spec.png` 的唯一语义是视觉规范与派生参考,不是运行时背景、角色、目标或图集。game-chat 的核心玩家、方块/目标、障碍/场景和反馈必须来自独立派生的透明 `assets/art-spritesheet.png` 及其服务端 `iconImageSrcs` 本地切片;Runtime 以 `sourceResourceId` 把切片清单绑定到当前图集,并要求活动 Canvas 分别绘制四类不同切片。纯代码核心实体、猜测图集等分坐标、单个裁切冒充全部类别、整图展示、隐藏引用、微小水印和诱饵路径均不构成真实美术使用。`playable-web-game-state.v1.sequence` 只在真实输入、状态迁移或模拟状态变化时递增,不得由纯渲染帧推进。 -- game-chat canonical 图集进一步要求主图与四个切片都有非空且互不复用的 Canvas `assetObjectId`;同一对象在顶层、resource 与 asset 中重复返回的 `assetObjectId` 和 `taskId` 必须分别一致,冲突时失败关闭。公开 `assets/art-spritesheet-slices/manifest.json` 与私有 `.agent/runtime/art-spritesheet-contract.json` 必须同时绑定 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,并对四种 usage 的 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256` 做完整一致性比较。旧项目缺私有回执时不得从公开文件反向生成回执;只允许同一 game-chat root 下处于 running 的 scheduled `art-asset-plan` 对固定主图执行受限 `replaceExisting=true` repair,普通 pending、其它 Agent、其它路径或有效合同均拒绝。九个固定合同文件在任何 canonical 改动前必须快照到 `.agent/runtime` 私有事务目录并写 `prepared` marker,Canvas 资产登记成功并写 `committed` marker 后才可清理;marker、journal 和快照必须由跨平台不跟随 symlink / reparse point 的句柄进行有界双次读取,拒绝同长度并发改写。恢复只在同一项目写锁内先完整验证全部 journal 条目和快照、形成内存恢复计划并缓存全部 canonical 的恢复前状态,再按 transaction id 整组回滚或幂等清理;每项写入前重新校验目标与父目录,晚序目标竞态或任一末尾快照损坏时必须逆序撤销本轮已应用项,不得留下部分恢复。远端下载阶段不得长期持锁,也不得在无锁 request 阶段修改 canonical 文件。 +- game-chat canonical 图集进一步要求主图与四个切片都有非空且互不复用的 Canvas `assetObjectId`;同一对象在顶层、resource 与 asset 中重复返回的 `assetObjectId` 和 `taskId` 必须分别一致,冲突时失败关闭。公开 `assets/art-spritesheet-slices/manifest.json` 与私有 `.agent/runtime/art-spritesheet-contract.json` 必须同时绑定 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,并对四种 usage 的 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256` 做完整一致性比较。旧项目缺私有回执时不得从公开文件反向生成回执;只允许同一 game-chat root 下处于 running 的 scheduled `art-asset-plan` 对固定主图执行受限 `replaceExisting=true` repair,普通 pending、其它 Agent、其它路径或有效合同均拒绝。九个固定合同文件在任何 canonical 改动前必须通过可信有界双读快照到 `.agent/runtime` 私有事务目录并写 `prepared` marker,Canvas 资产登记成功并写 `committed` marker 后才可清理;marker、journal 和快照必须由跨平台不跟随 symlink / reparse point 的句柄读取,拒绝同长度并发改写。恢复开始时锚定事务目录句柄,每次控制文件读取前后都复核目录身份;随后在同一项目写锁内完整验证全部 journal 条目和快照、形成内存恢复计划并缓存全部 canonical 的恢复前状态,再按 transaction id 整组回滚或幂等清理。每项写入前重新校验目标与父目录,晚序目标竞态或任一末尾快照损坏时逆序撤销本轮已应用项;回滚前必须 CAS 证明目标仍是本轮安装结果,外部修改不得被覆盖,冲突进入 reconciliation。远端下载阶段不得长期持锁,也不得在无锁 request 阶段修改 canonical 文件。 - 图集本地提交以主图 staging 为线性化前置:任何新主图先写随机私有 staging 文件,替换时保留 previous,canonical 主图完整安装后才写四切片、公开清单、私有回执和项目资产登记。进程若在 backup/install 窗口退出,同一 accepted External generation 恢复先识别唯一同 suffix 的 previous/replacement 对并恢复旧主图,再按远端结果完成替换;若 canonical 已等于远端摘要,则不再要求替换授权,直接补齐其余合同。成功后清理主图、四切片、公开清单、私有回执和项目 manifest 的全部遗留 staging/backup。首次生成也禁止直接流式写 canonical 路径,避免部分 PNG 被误认为已安装结果。 -- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查按 HTML 规定的五种空白解析标签,忽略字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script、不可达函数、短路动态 import 和 `if(false)` / 明显恒假分支诱饵,以标识符边界绑定真实 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断真实控制,并作用于棋盘或可证明的常量别名。本地 `.js / .mjs` 入口、inline module import 与 module 传递依赖统一限制在 `game/`,按真实 import graph 的连通单元聚合、按文件去重并受 256 文件、累计 2 MiB 上限约束;互不导入的 module 保持作用域隔离,对象属性和正则正文不能伪造依赖。浏览器因果探针运行于 Chromium 隔离执行上下文,在同一浏览器任务内冻结 trusted 输入分发尾部状态作为 click 前基线,前后原始状态只保存在 Promise 闭包中,因此受测页面不能通过全局变量改写证据,页面既有 capture-phase 或 window bubble click 处理器也不会被误判,0ms timer 不得抢入因果窗口;探针 fingerprint 覆盖 install、ready 与 finish 的真实脚本。其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。 +- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查按 HTML 规定的五种空白解析标签,并按 `type / language / nomodule` 判断可执行脚本;它忽略字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script、不可达匿名或命名函数、短路动态 import、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵,以标识符边界绑定真实 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断真实控制,并作用于未被局部变量或函数参数遮蔽的正式棋盘。本地 `.js / .mjs` 入口、inline module import 与 module 传递依赖统一限制在 `game/`,只按显式 export/import binding 传递语义,side-effect import 不共享局部词法绑定;ASI 换行和 template `${...}` 内真实表达式仍参与依赖解析,文件按去重数量并受 256 文件、累计 2 MiB 上限约束,对象属性和正则正文不能伪造依赖。浏览器因果探针运行于 Chromium 隔离执行上下文,在同一浏览器任务内冻结 trusted 输入分发尾部状态作为 click 前基线,前后原始状态只保存在 Promise 闭包中,因此受测页面不能通过全局变量改写证据,页面既有 capture-phase 或 window bubble click 处理器也不会被误判,0ms timer 不得抢入因果窗口;探针 fingerprint 覆盖 install、ready 与 finish 的真实脚本。其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。 - 泥点不足是确定性业务中断,不是瞬态 Provider 故障或未知副作用。钱包的 `泥点余额不足` 与 `可消费泥点不足:...` 两种领域文案统一映射为稳定原因 `mud-points-insufficient`,不得自动重试;即使 External Generation durable ledger 已存在,也必须落为 `failed`,不能误入 `needs-reconciliation`。game-chat 顶部状态、持久失败对话与 `【Supervisor 阶段记录】` 统一显示“泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。”,并禁止透传 operationId、URL、路径、密钥或任意上游正文。 - tool-plan 成功响应落账前,对内置 Runtime 原生函数与 legacy wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做项目路径 canonicalization:`file.*.path`、`project.patchset.changes[*].path`、`project.git_commit.paths[*]`、`command.*.cwd`、`image.inspect.paths[*]` 与 `canvas.asset_generate.outputPath` 若是当前项目根目录内的完整绝对路径,转换为 `/` 分隔的项目相对路径后再校验、持久化并执行;源码/叙述字段、任务产物描述、动态 MCP arguments 和项目外绝对路径不得改写,后两者继续由绝对路径门禁失败关闭。项目根只允许搜索/列举范围与命令 cwd 规范化为 `.`,不能成为文件目标。当前进程与重启恢复都必须从同一份规范化 handoff 重放,禁止分别执行原响应和持久响应。