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 f0775ff3c..11baaec1c 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 @@ -1974,6 +1974,7 @@ fn restore_strict_platform_art_transaction_at( .as_nanos() ); let mut total_snapshot_bytes = 0_u64; + let mut recovery_plan = Vec::with_capacity(STRICT_PLATFORM_ART_CONTRACT_PATHS.len()); for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() { let entry = &entries[index]; if entry.get("localPath").and_then(serde_json::Value::as_str) != Some(*local_path) { @@ -1997,14 +1998,43 @@ fn restore_strict_platform_art_transaction_at( if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("平台图集事务快照不是可信普通文件".to_string()); } - total_snapshot_bytes = total_snapshot_bytes - .checked_add(metadata.len()) - .ok_or_else(|| "平台图集事务快照累计大小溢出".to_string())?; - if total_snapshot_bytes > STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES { + 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 snapshot = fs::read(&snapshot_path) + 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 snapshot_file = options + .open(&snapshot_path) + .map_err(|error| format!("打开平台图集事务快照失败:{error}"))?; + let opened_metadata = snapshot_file + .metadata() + .map_err(|error| format!("读取已打开平台图集事务快照元数据失败:{error}"))?; + if !opened_metadata.is_file() || opened_metadata.len() != metadata.len() { + return Err("平台图集事务快照在恢复校验期间发生变化,已拒绝恢复".to_string()); + } + let mut snapshot = + Vec::with_capacity(usize::try_from(opened_metadata.len()).unwrap_or_default()); + (&mut snapshot_file) + .take(remaining.saturating_add(1)) + .read_to_end(&mut snapshot) .map_err(|error| format!("读取平台图集事务快照失败:{error}"))?; + let snapshot_len = u64::try_from(snapshot.len()).unwrap_or(u64::MAX); + if snapshot_len > remaining { + return Err("平台图集事务快照累计超过 64 MiB,已拒绝恢复".to_string()); + } + if snapshot_len != opened_metadata.len() { + return Err("平台图集事务快照大小在恢复校验期间发生变化,已拒绝恢复".to_string()); + } + total_snapshot_bytes = total_snapshot_bytes + .checked_add(snapshot_len) + .ok_or_else(|| "平台图集事务快照累计大小溢出".to_string())?; let expected_sha256 = entry .get("sha256") .and_then(serde_json::Value::as_str) @@ -2012,6 +2042,13 @@ fn restore_strict_platform_art_transaction_at( if format!("{:x}", Sha256::digest(&snapshot)) != expected_sha256 { return Err("平台图集事务快照摘要不一致,已拒绝恢复".to_string()); } + recovery_plan.push((canonical, Some(snapshot))); + } else { + recovery_plan.push((canonical, None)); + } + } + for (canonical, snapshot) in recovery_plan { + if let Some(snapshot) = snapshot { if let Some(parent) = canonical.parent() { fs::create_dir_all(parent).map_err(|error| { format!( @@ -4885,6 +4922,57 @@ mod canvas_generation_tests { assert!(!root.join(STRICT_PLATFORM_ART_TRANSACTION_PATH).exists()); } + #[test] + fn durable_strict_contract_transaction_validates_every_snapshot_before_restoring_any_path() { + let temporary = tempfile::tempdir().expect("create atomic recovery project"); + let root = temporary.path(); + init_local_game_project_at(root, "atomic-recovery", "原子图集事务恢复测试") + .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 project_lock = acquire_project_write_lock(root, "canvas.asset_generate.recover-test") + .expect("acquire project write lock"); + let transaction = PlatformArtSliceContractRollback::capture(root, "atomic-recovery") + .expect("persist complete contract snapshot"); + let mut partial_contract = Vec::new(); + for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() { + let path = root.join(local_path); + let bytes = format!("partial-new-contract-{index}").into_bytes(); + fs::write(&path, &bytes).expect("write partial canonical contract"); + partial_contract.push((path, bytes)); + } + let last_snapshot = transaction.transaction_directory.join(format!( + "{:02}.snapshot", + STRICT_PLATFORM_ART_CONTRACT_PATHS.len() - 1 + )); + fs::write(last_snapshot, b"corrupted-last-snapshot") + .expect("corrupt the final durable snapshot"); + std::mem::forget(transaction); + + let error = + recover_interrupted_strict_platform_art_transaction_locked_at(root, &project_lock) + .expect_err("a corrupt late snapshot must fail before canonical restoration"); + assert!( + error.contains("快照摘要不一致"), + "unexpected error: {error}" + ); + for (path, expected) in partial_contract { + assert_eq!( + fs::read(&path).expect("read untouched partial contract"), + expected, + "validation failure must not modify {}", + path.display() + ); + } + assert!(root.join(STRICT_PLATFORM_ART_TRANSACTION_PATH).exists()); + drop(project_lock); + } + #[test] fn durable_strict_contract_transaction_rejects_oversized_sparse_snapshot_before_reading() { let temporary = tempfile::tempdir().expect("create oversized snapshot 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 edf63ba67..97f8ddd51 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 @@ -614,6 +614,31 @@ fn html_attribute_value<'a>(tag: &'a str, attribute: &str) -> Option<&'a str> { None } +fn html_has_attribute(tag: &str, attribute: &str) -> bool { + let bytes = tag.as_bytes(); + let attribute_bytes = attribute.as_bytes(); + let mut cursor = 0usize; + while cursor + attribute_bytes.len() <= bytes.len() { + let Some(offset) = tag[cursor..].find(attribute) else { + return false; + }; + let start = cursor + offset; + let end = start + attribute_bytes.len(); + let left_boundary = + start == 0 || matches!(bytes[start - 1], b'<' | b' ' | b'\t' | b'\r' | b'\n'); + let right_boundary = end == bytes.len() + || matches!( + bytes[end], + b'=' | b' ' | b'\t' | b'\r' | b'\n' | b'>' | b'/' + ); + if left_boundary && right_boundary { + return true; + } + cursor = end; + } + false +} + fn css_numeric_property(value: &str, property: &str) -> Option { let compact = value .chars() @@ -2584,56 +2609,55 @@ fn html_script_type_is_executable(script_tag: &str) -> bool { || script_type.starts_with("application/javascript;") } -fn nested_html_element_end(content: &str, body_start: usize, name: &str) -> usize { - let open_marker = format!("<{name}"); - let close_marker = format!(" { - let Some(end) = content[close..].find('>').map(|at| close + at + 1) else { - return content.len(); - }; - depth = depth.saturating_sub(1); - if depth == 0 { - return end; - } - cursor = end; +fn html_tag_end(content: &str, tag_start: usize) -> Option { + let bytes = content.as_bytes(); + let mut quote = None; + for (offset, byte) in bytes.get(tag_start..)?.iter().copied().enumerate() { + if let Some(active_quote) = quote { + if byte == active_quote { + quote = None; } - (Some(open), _) => { - let Some(end) = content[open..].find('>').map(|at| open + at + 1) else { - return content.len(); - }; - if !content[open..end] - .trim_end_matches('>') - .trim_end() - .ends_with('/') - { - depth += 1; - } - cursor = end; - } - (None, Some(close)) => { - let Some(end) = content[close..].find('>').map(|at| close + at + 1) else { - return content.len(); - }; - depth = depth.saturating_sub(1); - if depth == 0 { - return end; - } - cursor = end; - } - (None, None) => return content.len(), + } else if matches!(byte, b'\'' | b'"') { + quote = Some(byte); + } else if byte == b'>' { + return Some(tag_start + offset + 1); } } + None } -fn executable_javascript_from_html(content: &str) -> String { - let mut executable = String::new(); - let mut cursor = 0usize; +fn html_tag_name_has_boundary(content: &str, name_end: usize) -> bool { + content + .as_bytes() + .get(name_end) + .is_none_or(|byte| byte.is_ascii_whitespace() || matches!(byte, b'>' | b'/')) +} + +fn next_named_html_tag( + content: &str, + mut cursor: usize, + name: &str, + closing: bool, +) -> Option<(usize, usize)> { + let marker = if closing { + format!(" usize { + let mut cursor = body_start; + let mut depth = 1usize; while let Some(offset) = content[cursor..].find('<') { let tag_start = cursor + offset; if content[tag_start..].starts_with("") + .map(|end| tag_start + 4 + end + 3) + .unwrap_or(lower.len()); + continue; + } + let Some(tag_after) = html_tag_end(&lower, tag_start) else { break; }; - let tag_end = tag_start + tag_end_offset; - let tag = &content[tag_start..=tag_end]; - for inert_element in ["template", "noscript"] { - let marker = format!("<{inert_element}"); - if tag.starts_with(&marker) - && tag - .as_bytes() - .get(marker.len()) - .is_none_or(|byte| byte.is_ascii_whitespace() || matches!(byte, b'>' | b'/')) - { - cursor = if tag.trim_end_matches('>').trim_end().ends_with('/') { - tag_end + 1 - } else { - nested_html_element_end(content, tag_end + 1, inert_element) - }; - break; - } + let tag_end = tag_after - 1; + let tag = &lower[tag_start..=tag_end]; + if let Some(end) = html_non_executable_container_end(&lower, tag, tag_end) { + cursor = end; + continue; } if cursor >= tag_end + 1 && !tag.starts_with("' | b'/')) - { + if !html_tag_starts_element(tag, "script") { cursor = tag_end + 1; continue; } let body_start = tag_end + 1; - let Some(close_offset) = content[body_start..].find("') - .map(|end| close_start + end + 1) - .unwrap_or(content.len()); + cursor = html_tag_end(&lower, close_start).unwrap_or(lower.len()); } executable } -fn executable_external_script_sources_from_html(content: &str) -> Vec { +#[derive(Debug, Eq, PartialEq)] +pub(in crate::agent) struct ExecutableExternalScriptSource { + source: String, + is_module: bool, +} + +pub(in crate::agent) fn executable_external_script_sources_from_html( + content: &str, +) -> Vec { let lower = content.to_ascii_lowercase(); let mut sources = Vec::new(); let mut cursor = 0usize; @@ -2706,38 +2811,16 @@ fn executable_external_script_sources_from_html(content: &str) -> Vec { .unwrap_or(lower.len()); continue; } - let Some(tag_end_offset) = lower[tag_start..].find('>') else { + let Some(tag_after) = html_tag_end(&lower, tag_start) else { break; }; - let tag_end = tag_start + tag_end_offset; + let tag_end = tag_after - 1; let tag = &lower[tag_start..=tag_end]; - let mut inert = false; - for inert_element in ["template", "noscript"] { - let marker = format!("<{inert_element}"); - if tag.starts_with(&marker) - && tag - .as_bytes() - .get(marker.len()) - .is_none_or(|byte| byte.is_ascii_whitespace() || matches!(byte, b'>' | b'/')) - { - cursor = if tag.trim_end_matches('>').trim_end().ends_with('/') { - tag_end + 1 - } else { - nested_html_element_end(&lower, tag_end + 1, inert_element) - }; - inert = true; - break; - } - } - if inert { + if let Some(end) = html_non_executable_container_end(&lower, tag, tag_end) { + cursor = end; continue; } - if !tag.starts_with("' | b'/')) - { + if !html_tag_starts_element(tag, "script") { cursor = tag_end + 1; continue; } @@ -2745,7 +2828,11 @@ fn executable_external_script_sources_from_html(content: &str) -> Vec { if let Some(source) = html_attribute_value(tag, "src") { let value_offset = source.as_ptr() as usize - tag.as_ptr() as usize; let original_tag = &content[tag_start..=tag_end]; - sources.push(original_tag[value_offset..value_offset + source.len()].to_string()); + sources.push(ExecutableExternalScriptSource { + source: original_tag[value_offset..value_offset + source.len()].to_string(), + is_module: html_attribute_value(tag, "type") + .is_some_and(|value| value.trim() == "module"), + }); } } let body_start = tag_end + 1; @@ -2753,20 +2840,26 @@ fn executable_external_script_sources_from_html(content: &str) -> Vec { break; }; let close_start = body_start + close_offset; - cursor = lower[close_start..] - .find('>') - .map(|end| close_start + end + 1) - .unwrap_or(lower.len()); + cursor = html_tag_end(&lower, close_start).unwrap_or(lower.len()); } sources } -fn local_external_gameplay_script_path(source: &str) -> Option { +fn local_gameplay_script_path_from(importer_path: Option<&str>, source: &str) -> Option { let source = source.split(['?', '#']).next()?.trim(); if source.is_empty() || source.starts_with('/') || source.contains(['\\', '%', ':']) { return None; } - let mut components = vec!["game"]; + if importer_path.is_some() && !source.starts_with("./") && !source.starts_with("../") { + return None; + } + let mut components = importer_path + .map(|path| { + let mut components = path.split('/').collect::>(); + components.pop(); + components + }) + .unwrap_or_else(|| vec!["game"]); for component in source.split('/') { match component { "" | "." => {} @@ -2781,16 +2874,269 @@ fn local_external_gameplay_script_path(source: &str) -> Option { (path.starts_with("game/") && (path.ends_with(".js") || path.ends_with(".mjs"))).then_some(path) } +#[derive(Clone, Debug, Eq, PartialEq)] +enum JavascriptLexicalToken { + Identifier(String), + StringLiteral(String), + Number(String), + Punct(char), +} + +fn javascript_lexical_tokens(content: &str) -> Vec { + let bytes = content.as_bytes(); + let mut tokens = Vec::new(); + let mut cursor = 0usize; + while cursor < bytes.len() { + let byte = bytes[cursor]; + if byte.is_ascii_whitespace() { + cursor += 1; + continue; + } + if byte == b'/' && bytes.get(cursor + 1) == Some(&b'/') { + cursor += 2; + while cursor < bytes.len() && bytes[cursor] != b'\n' { + cursor += 1; + } + continue; + } + if byte == b'/' && bytes.get(cursor + 1) == Some(&b'*') { + cursor += 2; + while cursor + 1 < bytes.len() && !(bytes[cursor] == b'*' && bytes[cursor + 1] == b'/') + { + cursor += 1; + } + cursor = (cursor + 2).min(bytes.len()); + continue; + } + if byte == b'/' + && tokens.last().is_none_or(|token| { + matches!( + token, + JavascriptLexicalToken::Punct( + '(' | '[' + | '{' + | '=' + | ':' + | ',' + | ';' + | '!' + | '?' + | '&' + | '|' + | '+' + | '-' + | '*' + | '%' + | '^' + | '~' + | '<' + | '>' + ) + ) || matches!( + token, + JavascriptLexicalToken::Identifier(keyword) + if matches!( + keyword.as_str(), + "return" + | "case" + | "throw" + | "else" + | "do" + | "typeof" + | "void" + | "delete" + | "in" + | "of" + | "yield" + | "await" + ) + ) + }) + { + cursor += 1; + let mut in_character_class = false; + while cursor < bytes.len() { + match bytes[cursor] { + b'\\' => cursor = (cursor + 2).min(bytes.len()), + b'[' => { + in_character_class = true; + cursor += 1; + } + b']' => { + in_character_class = false; + cursor += 1; + } + b'/' if !in_character_class => { + cursor += 1; + while bytes.get(cursor).is_some_and(u8::is_ascii_alphabetic) { + cursor += 1; + } + break; + } + _ => cursor += 1, + } + } + continue; + } + if matches!(byte, b'\'' | b'"') { + let quote = byte; + cursor += 1; + let mut literal = Vec::new(); + while cursor < bytes.len() { + let current = bytes[cursor]; + if current == quote { + cursor += 1; + break; + } + if current == b'\\' { + // Escaped module paths are intentionally left unsupported so the + // path validator can fail closed rather than normalizing ambiguously. + literal.push(current); + cursor += 1; + if let Some(escaped) = bytes.get(cursor).copied() { + literal.push(escaped); + cursor += 1; + } + continue; + } + literal.push(current); + cursor += 1; + } + tokens.push(JavascriptLexicalToken::StringLiteral( + String::from_utf8_lossy(&literal).into_owned(), + )); + continue; + } + if byte == b'`' { + cursor += 1; + while cursor < bytes.len() { + match bytes[cursor] { + b'\\' => cursor = (cursor + 2).min(bytes.len()), + b'`' => { + cursor += 1; + break; + } + _ => cursor += 1, + } + } + continue; + } + if byte.is_ascii_alphabetic() || matches!(byte, b'_' | b'$') { + let start = cursor; + cursor += 1; + while bytes + .get(cursor) + .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$')) + { + cursor += 1; + } + tokens.push(JavascriptLexicalToken::Identifier( + content[start..cursor].to_string(), + )); + continue; + } + if byte.is_ascii_digit() { + let start = cursor; + cursor += 1; + while bytes + .get(cursor) + .is_some_and(|byte| byte.is_ascii_digit() || *byte == b'.') + { + cursor += 1; + } + tokens.push(JavascriptLexicalToken::Number( + content[start..cursor].to_string(), + )); + continue; + } + tokens.push(JavascriptLexicalToken::Punct(char::from(byte))); + cursor += 1; + } + tokens +} + +fn local_javascript_module_sources(content: &str) -> Vec { + let tokens = javascript_lexical_tokens(content); + let mut sources = Vec::new(); + for (index, token) in tokens.iter().enumerate() { + let JavascriptLexicalToken::Identifier(keyword) = token else { + continue; + }; + if !matches!(keyword.as_str(), "import" | "export") + || index + .checked_sub(1) + .and_then(|previous| tokens.get(previous)) + == Some(&JavascriptLexicalToken::Punct('.')) + { + continue; + } + if keyword == "import" { + match tokens.get(index + 1) { + Some(JavascriptLexicalToken::StringLiteral(source)) => { + sources.push(source.clone()); + continue; + } + Some(JavascriptLexicalToken::Punct('(')) => { + if let Some(JavascriptLexicalToken::StringLiteral(source)) = + tokens.get(index + 2) + { + sources.push(source.clone()); + } + continue; + } + _ => {} + } + } + let mut saw_from = false; + for candidate in tokens.iter().skip(index + 1) { + match candidate { + JavascriptLexicalToken::Punct(';') => break, + JavascriptLexicalToken::Identifier(value) if value == "from" => { + saw_from = true; + } + JavascriptLexicalToken::StringLiteral(source) if saw_from => { + sources.push(source.clone()); + break; + } + _ => {} + } + } + } + sources +} + pub(in crate::agent) fn read_external_gameplay_javascript_at( root: &Path, html: &str, ) -> Result { const MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_BYTES: u64 = 2 * 1024 * 1024; + const MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_FILES: usize = 256; let mut output = String::new(); let mut total_bytes = 0_u64; + let mut pending = std::collections::VecDeque::new(); for source in executable_external_script_sources_from_html(html) { - let local_path = local_external_gameplay_script_path(&source) - .ok_or_else(|| format!("自主构建外部脚本路径不受支持:{source}"))?; + let local_path = local_gameplay_script_path_from(None, &source.source) + .ok_or_else(|| format!("自主构建外部脚本路径不受支持:{}", source.source))?; + pending.push_back((local_path, source.is_module)); + } + for imported_source in local_javascript_module_sources(&executable_javascript_from_html(html)) { + let imported_path = local_gameplay_script_path_from( + Some("game/index.html"), + &imported_source, + ) + .ok_or_else(|| { + format!("自主构建内联模块依赖路径不受支持:game/index.html -> {imported_source}") + })?; + pending.push_back((imported_path, true)); + } + let mut visited = BTreeSet::new(); + while let Some((local_path, is_module)) = pending.pop_front() { + if !visited.insert(local_path.clone()) { + continue; + } + if visited.len() > MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_FILES { + return Err("自主构建外部脚本累计超过 256 个文件".to_string()); + } let path = resolve_local_project_path(root, &local_path)?; let metadata = fs::symlink_metadata(&path) .map_err(|error| format!("读取自主构建外部脚本元数据失败:{local_path}: {error}"))?; @@ -2821,6 +3167,18 @@ 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 in local_javascript_module_sources(&script) { + let imported_path = + local_gameplay_script_path_from(Some(&local_path), &imported_source) + .ok_or_else(|| { + format!( + "自主构建模块依赖路径不受支持:{local_path} -> {imported_source}" + ) + })?; + pending.push_back((imported_path, true)); + } + } output.push_str(&script.to_ascii_lowercase()); output.push('\n'); } @@ -3284,6 +3642,110 @@ fn tetris_filter_predicate_excludes_full_row(predicate: &str) -> bool { false } +fn javascript_identifier_assignment_count( + tokens: &[JavascriptLexicalToken], + identifier: &str, +) -> usize { + tokens + .windows(2) + .filter(|window| { + matches!(&window[0], JavascriptLexicalToken::Identifier(value) if value == identifier) + && window[1] == JavascriptLexicalToken::Punct('=') + }) + .count() +} + +fn tetris_board_identifiers(tokens: &[JavascriptLexicalToken]) -> BTreeSet { + let mut identifiers = BTreeSet::from(["board".to_string()]); + loop { + let mut changed = false; + for window in tokens.windows(5) { + let ( + JavascriptLexicalToken::Identifier(declaration), + JavascriptLexicalToken::Identifier(alias), + JavascriptLexicalToken::Punct('='), + JavascriptLexicalToken::Identifier(target), + delimiter, + ) = (&window[0], &window[1], &window[2], &window[3], &window[4]) + else { + continue; + }; + if declaration != "const" + || !identifiers.contains(target) + || !matches!( + delimiter, + JavascriptLexicalToken::Punct(';') + | JavascriptLexicalToken::Punct(',') + | JavascriptLexicalToken::Punct(')') + ) + || javascript_identifier_assignment_count(tokens, alias) != 1 + { + continue; + } + changed |= identifiers.insert(alias.clone()); + } + if !changed { + break; + } + } + identifiers +} + +fn javascript_board_method_calls( + tokens: &[JavascriptLexicalToken], + owners: &BTreeSet, + method: &str, +) -> Vec>> { + let mut calls = Vec::new(); + let mut index = 0usize; + while index + 3 < tokens.len() { + let is_call = matches!(&tokens[index], JavascriptLexicalToken::Identifier(owner) if owners.contains(owner)) + && tokens[index + 1] == JavascriptLexicalToken::Punct('.') + && matches!(&tokens[index + 2], JavascriptLexicalToken::Identifier(value) if value == method) + && tokens[index + 3] == JavascriptLexicalToken::Punct('('); + if !is_call { + index += 1; + continue; + } + let mut depth = 1usize; + let mut arguments = vec![Vec::new()]; + let mut cursor = index + 4; + while cursor < tokens.len() { + match &tokens[cursor] { + JavascriptLexicalToken::Punct('(') => { + depth += 1; + arguments + .last_mut() + .expect("method call always has an argument slot") + .push(tokens[cursor].clone()); + } + JavascriptLexicalToken::Punct(')') => { + depth = depth.saturating_sub(1); + if depth == 0 { + calls.push(arguments); + index = cursor + 1; + break; + } + arguments + .last_mut() + .expect("method call always has an argument slot") + .push(tokens[cursor].clone()); + } + JavascriptLexicalToken::Punct(',') if depth == 1 => arguments.push(Vec::new()), + token => arguments + .last_mut() + .expect("method call always has an argument slot") + .push(token.clone()), + } + cursor += 1; + } + if cursor >= tokens.len() { + break; + } + } + calls +} + fn tetris_clear_body_is_meaningful(body: &str) -> bool { let body = javascript_without_obvious_false_branches(body); let body = body.as_str(); @@ -3301,10 +3763,23 @@ fn tetris_clear_body_is_meaningful(body: &str) -> bool { let predicate = &compact[open + 1..close]; tetris_filter_predicate_excludes_full_row(predicate) }); - let splice_clear = body.contains(".splice(") - && !compact.contains(".splice(0,0") - && (compact.contains(",1)") || compact.contains(",1,")) - && (body.contains(".unshift(") || body.contains(".push(")); + let tokens = javascript_lexical_tokens(body); + let board_identifiers = tetris_board_identifiers(&tokens); + let removes_one_row = javascript_board_method_calls(&tokens, &board_identifiers, "splice") + .iter() + .any(|arguments| { + arguments.get(1) == Some(&vec![JavascriptLexicalToken::Number("1".to_string())]) + }); + let inserts_row = ["unshift", "push"].iter().any(|method| { + javascript_board_method_calls(&tokens, &board_identifiers, method) + .iter() + .any(|arguments| { + arguments + .first() + .is_some_and(|argument| !argument.is_empty()) + }) + }); + let splice_clear = removes_one_row && inserts_row; filter_clear || splice_clear } 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 b066f180a..7efa79168 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 @@ -1432,6 +1432,90 @@ fn inherited_tetris_contract_scans_only_executable_html_scripts() { Some("board-state"), "noscript content must not count as executable gameplay in a scripting browser", ); + + for container in [ + "textarea", + "title", + "style", + "xmp", + "iframe", + "noembed", + "plaintext", + ] { + let wrapped = valid.replacen( + "") + .expect("executable fixture has a final script close"); + wrapped.insert_str(script_close + "".len(), &format!("")); + } + assert_eq!( + inherited_gameplay_semantics_gap(task, wrapped.as_bytes()).as_deref(), + Some("board-state"), + "{container} content must not count as executable gameplay", + ); + assert!( + executable_external_script_sources_from_html(&format!( + "<{container}>{}", + if container == "plaintext" { + "" + } else { + match container { + "textarea" => "", + "title" => "", + "style" => "", + "xmp" => "", + "iframe" => "", + "noembed" => "", + _ => unreachable!(), + } + } + )) + .is_empty(), + "{container} content must not expose external gameplay scripts", + ); + } + + let self_closing_textarea = + valid.replacen("") + .expect("executable fixture has a final script close"); + let mut self_closing_textarea = self_closing_textarea; + self_closing_textarea.insert_str(textarea_script_close + "".len(), ""); + assert_eq!( + inherited_gameplay_semantics_gap(task, self_closing_textarea.as_bytes()).as_deref(), + Some("board-state"), + "a slash does not make a non-void textarea self-closing in HTML", + ); + + let template_with_fake_close = valid.replacen( + "") + .expect("executable fixture has a final script close"); + let mut template_with_fake_close = template_with_fake_close; + template_with_fake_close.insert_str(template_script_close + "".len(), ""); + assert_eq!( + inherited_gameplay_semantics_gap(task, template_with_fake_close.as_bytes()).as_deref(), + Some("board-state"), + "a template close marker inside script text must not expose later inert scripts", + ); + + let sourced_inline_body = valid.replacen("").expect("fixture closes script"); + let external_html = format!( + "{}{}", + &valid[..script_start], + &valid[script_end + "".len()..] + ); + let temporary = tempfile::tempdir().expect("create module Tetris project"); + let root = temporary.path(); + init_local_game_project_at(root, "module-tetris", task).expect("init project"); + fs::write( + root.join("game/main.js"), + r#"const stringBait = "import './missing-string.js'"; +// import './missing-comment.js'; +const regexBait = /x import '.\/missing-regex.js'/; +import './gameplay/tetris.mjs';"#, + ) + .expect("write module entry"); + fs::create_dir_all(root.join("game/gameplay")).expect("create module directory"); + fs::write( + root.join("game/gameplay/tetris.mjs"), + format!( + "export * from './telemetry.js';\n{}", + &valid[body_start..script_end] + ), + ) + .expect("write module gameplay"); + fs::write( + root.join("game/gameplay/telemetry.js"), + "export const telemetry = true;", + ) + .expect("write nested module dependency"); + + let external = read_external_gameplay_javascript_at(root, &external_html) + .expect("recursively read bounded local module graph"); + assert_eq!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + external_html.as_bytes(), + &external, + ), + None, + "a local module dependency must contribute executable Tetris semantics", + ); + + let classic_html = external_html.replace(" type=\"module\"", ""); + let classic = read_external_gameplay_javascript_at(root, &classic_html) + .expect("read classic entry without traversing invalid static imports"); + assert_eq!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + classic_html.as_bytes(), + &classic, + ) + .as_deref(), + Some("board-state"), + "a classic script must not gain semantics from a static-import decoy", + ); + + let inline_module_html = format!( + "{}{}", + &valid[..script_start], + &valid[script_end + "".len()..] + ); + let inline_external = read_external_gameplay_javascript_at(root, &inline_module_html) + .expect("recursively read an inline module entry"); + assert_eq!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + inline_module_html.as_bytes(), + &inline_external, + ), + None, + "an inline module's local dependency graph must contribute Tetris semantics", + ); +} + #[test] fn game_chat_pure_continue_does_not_inherit_across_sessions() { let (_temporary, root, original_state, original_contract) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs index a814e9cbc..d58e981fe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs @@ -24,12 +24,12 @@ pub(in crate::browser) const GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SA pub(in crate::browser) const GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES: usize = 12; pub(in crate::browser) const GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES: usize = 12; pub(in crate::browser) const GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT: &str = concat!( - "primary-action=trusted-event-isolated-world-promise-closure-capture-to-document-bubble-sequence-advance\n", + "primary-action=trusted-event-isolated-world-promise-closure-mouseup-tail-baseline-to-click-bubble-sequence-advance\n", "tetris-primary-action=same-piece-rotation-change\n", "tetris-start-opportunity=same-piece-gravity-row-or-semantic-lock-progress\n", "tetris-post-action=probe-before-gameplay-to-new-piece-lock-board-and-line-check-progress\n", "tetris-restart=board-counters-reset\n", - "restart=trusted-event-isolated-world-promise-closure-capture-to-document-bubble-sequence-advance" + "restart=trusted-event-isolated-world-promise-closure-mouseup-tail-baseline-to-click-bubble-sequence-advance" ); #[derive(Clone, Debug, Deserialize)] @@ -81,22 +81,30 @@ fn generic_action_sequence_probe_script(selector: &str, ready_key: &str) -> Resu return String(surface[0].textContent || ''); }}; let beforeState = null; - const capture = (event) => {{ + let baselineCaptured = false; + const mouseup = (event) => {{ if (!event.isTrusted) return; + if (event.button !== 0) return; + if (event.target !== control && !(event.target instanceof Node && control.contains(event.target))) return; beforeState = readState(); + baselineCaptured = true; }}; const bubble = (event) => {{ if (!event.isTrusted) return; if (event.target !== control && !(event.target instanceof Node && control.contains(event.target))) return; - control.removeEventListener('click', capture, true); + window.removeEventListener('mouseup', mouseup, false); document.removeEventListener('click', bubble, false); - finish({{ status: 'completed', beforeState, afterState: readState() }}); + finish({{ + status: baselineCaptured ? 'completed' : 'missing-mouseup-baseline', + beforeState, + afterState: baselineCaptured ? readState() : null, + }}); }}; - control.addEventListener('click', capture, {{ capture: true }}); + window.addEventListener('mouseup', mouseup, {{ capture: false }}); document.addEventListener('click', bubble, {{ capture: false }}); globalThis[readyKey] = 'armed'; timeoutId = setTimeout(() => {{ - control.removeEventListener('click', capture, true); + window.removeEventListener('mouseup', mouseup, false); document.removeEventListener('click', bubble, false); finish({{ status: 'timeout', beforeState, afterState: null }}); }}, 5000); @@ -1074,6 +1082,9 @@ mod tests { assert!(script.contains("new Promise")); assert!(script.contains("if (!event.isTrusted) return")); assert!(script.contains("let beforeState = null")); + assert!(script.contains("window.addEventListener('mouseup', mouseup")); + assert!(script.contains("status: baselineCaptured ? 'completed'")); + assert!(!script.contains("control.addEventListener('click'")); assert!(script.contains("resolve(value)")); assert!(!script.contains("__genarrativeGenericActionSequenceProbe")); assert!(!script.contains("beforeGameplay")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index fe9843b7e..6fe435894 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -260,11 +260,11 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() { let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1); assert_eq!( generic, - "dd0cab96750931f10c63dbb06694dc292c83d433a89f1dcf38c0d0e15458ca41" + "6d6ce6843200da907427ef67d0bf0644b6499f76c221ab695b417309edf0fd33" ); assert_eq!( tetris, - "5e1b20b0a83e932c7dbdc6972a6a3b4fc5fd3d937525bbd339eb10631d94d40b" + "742545caae05bb6a673d91a55b23055570358843bff5cbe0acb3e86958786c79" ); assert_eq!( lane, @@ -1265,7 +1265,7 @@ async fn real_chrome_generic_playtest_rejects_one_frame_playing_state() { #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] -async fn real_chrome_generic_playtest_accepts_stable_primary_action_flow() { +async fn real_chrome_generic_playtest_accepts_capture_phase_action_flow() { use std::io::{Read, Write}; use std::net::TcpListener; use std::sync::mpsc; @@ -1307,19 +1307,21 @@ async fn real_chrome_generic_playtest_accepts_stable_primary_action_flow() { advance(() => { state.phase = 'playing'; }); } }); - document.querySelector('[data-playtest-id="primary-action"]').addEventListener('click', (event) => { - if (event.isTrusted && state.phase === 'playing') { + const primary = document.querySelector('[data-playtest-id="primary-action"]'); + const restart = document.querySelector('[data-playtest-id="restart"]'); + document.addEventListener('click', (event) => { + if (event.isTrusted && event.target === primary && state.phase === 'playing') { advance(() => { state.score += 1; }); } - }); - document.querySelector('[data-playtest-id="restart"]').addEventListener('click', (event) => { - if (event.isTrusted) { + }, true); + document.addEventListener('click', (event) => { + if (event.isTrusted && event.target === restart) { advance(() => { state.phase = 'ready'; state.score = 0; }); } - }); + }, true); })(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index decfa2a52..2a3f3f045 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。 -- 图集事务退役补充:九路径快照在读取前先用不跟随符号链接的元数据核算 64 MiB 总预算,实际读取仍受剩余预算限制,稀疏或并发增长文件不能触发无界分配。`committed` 持久化后先删除并同步 `prepared`,再清理 `.previous / .replacement`、同步 canonical 合同并最后删除事务目录;递归删除中断后最多留下只有 `committed` 的可清理事务,不能重新落入 rollback 分支。 -- Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。静态门移除字符串、注释、`template / noscript / 非 JavaScript script` 和 `if(false)` / 明显恒假分支诱饵,并要求有标识符边界的可达 `fall -> lock -> clear` 调用链;同项目 `game/*.js / game/*.mjs` 外部脚本按累计 2 MiB 上限有界读取。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩在 Chromium 隔离执行上下文的 Promise 闭包中保存 trusted click 前后原始状态,页面全局对象不能改写因果证据。锁定、消行与 restart 的既有严格约束保持不变。旧合同或纯继续 successor 以及 game-chat 快车道在读取回执前按有效原任务重新分类、重算指纹并回读迁移结果,旧 generic 回执只能视为 stale,不能交付完成。 +- 图集事务退役补充:九路径快照在读取前先用不跟随符号链接的元数据核算 64 MiB 总预算,实际读取仍受剩余预算限制,稀疏或并发增长文件不能触发无界分配。恢复必须先把全部 journal 条目和九路径快照完成结构、大小与摘要校验并形成内存计划,随后才能修改任一 canonical 路径;末尾快照损坏不得留下新旧混合合同。`committed` 持久化后先删除并同步 `prepared`,再清理 `.previous / .replacement`、同步 canonical 合同并最后删除事务目录;递归删除中断后最多留下只有 `committed` 的可清理事务,不能重新落入 rollback 分支。 +- Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。静态门移除字符串、注释、`template / noscript / textarea / title / style / xmp / iframe / noembed / plaintext`、带 `src` 脚本的非执行正文、非 JavaScript script 和 `if(false)` / 明显恒假分支诱饵,并要求有标识符边界的可达 `fall -> lock -> clear` 调用链;splice 消行必须绑定棋盘或不可重赋值的棋盘别名。同项目 `game/*.js / game/*.mjs` 外部脚本及本地 module 依赖图按去重文件数和累计 2 MiB 上限有界读取。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩在 Chromium 隔离执行上下文的 Promise 闭包中,以同一次 trusted 鼠标输入的 mouseup 尾部状态作为 click 前基线,页面全局对象不能改写因果证据,capture-phase click 仍可被正确验收。锁定、消行与 restart 的既有严格约束保持不变。旧合同或纯继续 successor 以及 game-chat 快车道在读取回执前按有效原任务重新分类、重算指纹并回读迁移结果,旧 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 bbf17d34a..1be85aaca 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 后才可清理;恢复只在同一项目写锁内按 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 后才可清理;恢复只在同一项目写锁内先完整验证全部 journal 条目和快照、形成内存恢复计划,再按 transaction id 整组回滚或幂等清理,任一末尾快照损坏都不得先改写前面的 canonical 路径。远端下载阶段不得长期持锁,也不得在无锁 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。静态连续性检查忽略字符串、注释、`template / noscript / 非 JavaScript script` 和 `if(false)` / 明显恒假分支诱饵,以标识符边界绑定真实 `fall -> lock -> clear` 调用链,并允许在 `game/` 下按累计 2 MiB 上限有界读取本地 `.js / .mjs` 外部脚本。浏览器因果探针运行于 Chromium 隔离执行上下文,trusted click 前后原始状态只保存在 Promise 闭包中,受测页面不能通过全局变量改写证据;其余同方块旋转、重力/锁定、四格落盘或消行、`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 raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script 和 `if(false)` / 明显恒假分支诱饵,以标识符边界绑定真实 `fall -> lock -> clear` 调用链;splice 消行必须作用于棋盘或可证明的常量别名。本地 `.js / .mjs` 入口、inline module import 与 module 传递依赖统一限制在 `game/`,按文件去重并受 256 文件、累计 2 MiB 上限约束。浏览器因果探针运行于 Chromium 隔离执行上下文,使用同一次 trusted 鼠标输入的 mouseup 尾部状态作为 click 前基线,前后原始状态只保存在 Promise 闭包中,因此受测页面不能通过全局变量改写证据,页面既有 capture-phase click 处理器也不会被误判;其余同方块旋转、重力/锁定、四格落盘或消行、`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 重放,禁止分别执行原响应和持久响应。