diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index bf4d3be89..bb39cab08 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -63,6 +63,8 @@ pub(crate) use models::{ AgentRuntimeVerificationGate, ParsedAgentRuntimeToolPlan, AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS, }; +#[cfg(test)] +pub(crate) use provider_control::mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_for_test; pub(crate) use provider_retry::{ game_creator_agent_runtime_provider_request_id, game_creator_agent_runtime_transient_retry_backoff_ms, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs index 338558cfc..69c40f5b2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs @@ -347,6 +347,53 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_request_needs_r root: &Path, snapshot: &AgentRuntimeProviderRequestSnapshot, request_id: &str, +) -> Result<(), String> { + mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_diagnostic_at_locked( + root, snapshot, request_id, None, + ) +} + +pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_at_locked( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, + error: &str, +) -> Result<(), String> { + let failure_kind = if snapshot.request_kind == "tool-plan" { + tool_plan_handoff::failure_kind(error) + } else { + "provider-success-handoff" + }; + let error = redact_agent_runtime_error(root, error, 500); + mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_diagnostic_at_locked( + root, + snapshot, + request_id, + Some((failure_kind, error.as_str())), + ) +} + +#[cfg(test)] +pub(crate) fn mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_for_test( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, + error: &str, +) -> Result<(), String> { + let _control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.provider_request.success_handoff_reconciliation.test", + )?; + mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_at_locked( + root, snapshot, request_id, error, + ) +} + +fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_diagnostic_at_locked( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, + diagnostic: Option<(&'static str, &str)>, ) -> Result<(), String> { let state_path = game_creator_agent_runtime_session_path(root, &snapshot.agent_id); let mut state = serde_json::from_str::( @@ -371,29 +418,58 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_request_needs_r { return Err("孤立 Provider 请求与当前 Runtime 身份冲突".to_string()); } + let diagnostic = diagnostic.map(|(failure_kind, error)| { + ( + failure_kind, + format!("{:x}", Sha256::digest(error.as_bytes())), + error.chars().count(), + ) + }); state.status = "running".to_string(); state.phase = "needs-reconciliation".to_string(); - state.current_action = "Provider 请求终态无法确认".to_string(); - state.waiting_on = "开发者核对 Provider 请求与计费状态".to_string(); - state.next_step = "确认孤立 Provider 请求后再恢复当前 run".to_string(); + if diagnostic.is_some() { + state.current_action = "Provider 成功响应交接失败".to_string(); + state.waiting_on = "开发者核对安全失败分类并修复交接".to_string(); + state.next_step = "修复交接后人工决定是否重试当前 run".to_string(); + } else { + state.current_action = "Provider 请求终态无法确认".to_string(); + state.waiting_on = "开发者核对 Provider 请求与计费状态".to_string(); + state.next_step = "确认孤立 Provider 请求后再恢复当前 run".to_string(); + } + let public_detail = diagnostic + .as_ref() + .map(|(failure_kind, error_sha256, error_chars)| { + format!( + "requestId={request_id} · failureKind={failure_kind} · errorSha256={error_sha256} · errorChars={error_chars}" + ) + }) + .unwrap_or_else(|| format!("requestId={request_id}")); + let event_detail = diagnostic + .is_some() + .then_some(public_detail.as_str()) + .unwrap_or(request_id); state.error = Some(format!( - "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id}" + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: {public_detail}" )); state.updated_at = unix_timestamp(); append_game_creator_agent_runtime_task(root, &state)?; refresh_game_creator_agent_runtime_task_queue(root, &mut state)?; write_game_creator_agent_runtime_state(root, &state)?; + let event_summary = if diagnostic.is_some() { + "Provider 已返回响应,但成功 handoff 未能持久提交;Runtime 已停止自动重放。" + } else { + "检测到只有 started、没有可信终态的 Provider 请求,Runtime 已停止自动重放。" + }; let _ = append_game_creator_agent_runtime_event( root, &state, "provider_request.needs_reconciliation", "running", "needs-reconciliation", - "检测到只有 started、没有可信终态的 Provider 请求,Runtime 已停止自动重放。", - Some(request_id), + event_summary, + Some(event_detail), ); - let _ = append_agent_db_record( - root, + let audit = if let Some((failure_kind, error_sha256, error_chars)) = diagnostic { serde_json::json!({ "recordType": "agent.runtime.provider_request.needs_reconciliation", "agentId": state.agent_id, @@ -404,8 +480,24 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_request_needs_r "requestId": request_id, "requestKind": snapshot.request_kind, "requestSlot": snapshot.request_slot, - }), - ); + "failureKind": failure_kind, + "errorSha256": error_sha256, + "errorChars": error_chars, + }) + } else { + serde_json::json!({ + "recordType": "agent.runtime.provider_request.needs_reconciliation", + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "requestId": request_id, + "requestKind": snapshot.request_kind, + "requestSlot": snapshot.request_slot, + }) + }; + let _ = append_agent_db_record(root, audit); emit_game_creator_agent_runtime_update(root, &snapshot.agent_id); Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs index 5a40a8397..215ca93dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs @@ -1181,12 +1181,12 @@ where "runtime.provider_request.success_handoff_reconciliation", ) { - let _ = - mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( - root, - &snapshot, - &request_id, - ); + let _ = mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_at_locked( + root, + &snapshot, + &request_id, + &error, + ); } let error = redact_agent_runtime_error(root, &error, 500); return Err(format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 0f6304dae..e441dc806 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -7903,6 +7903,63 @@ async fn agent_runtime_orphan_provider_started_requires_reconciliation_without_r fs::remove_dir_all(root).ok(); } +#[test] +fn provider_success_handoff_reconciliation_persists_only_safe_diagnostics() { + let root = unique_project_path(); + let state = start_agent_runtime_steer_fixture(&root, "provider-handoff-diagnostic-run"); + let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "tool-plan", + "loop-11-repair-0", + state.applied_steer_cursor, + ) + .expect("capture Provider handoff diagnostic snapshot"); + let request_id = "provider-request-handoff-diagnostic"; + let private_error = "tool-plan 成功响应交接 arguments 命中敏感规则 #0:PRIVATE_PROVIDER_TEXT"; + let error_sha256 = format!("{:x}", Sha256::digest(private_error.as_bytes())); + + mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_for_test( + &root, + &snapshot, + request_id, + private_error, + ) + .expect("persist safe Provider handoff diagnostic"); + + let runtime = read_game_creator_agent_runtime_at(&root, &state.agent_id) + .expect("read handoff diagnostic runtime") + .state; + let public_error = runtime.error.expect("handoff diagnostic runtime error"); + assert!(public_error.contains(request_id)); + assert!(public_error.contains("failureKind=tool-plan-sensitive-content")); + assert!(public_error.contains(&format!("errorSha256={error_sha256}"))); + assert!(public_error.contains(&format!("errorChars={}", private_error.chars().count()))); + assert!(!public_error.contains("PRIVATE_PROVIDER_TEXT")); + + let records = read_agent_db_records_for_test(&root); + let audit = records + .iter() + .find(|record| { + record["recordType"] == "agent.runtime.provider_request.needs_reconciliation" + && record["requestId"] == request_id + }) + .expect("handoff diagnostic audit"); + assert_eq!(audit["failureKind"], "tool-plan-sensitive-content"); + assert_eq!(audit["errorSha256"], error_sha256); + assert_eq!( + audit["errorChars"], + serde_json::json!(private_error.chars().count()) + ); + assert!(audit.get("error").is_none()); + assert!(audit.get("response").is_none()); + assert!(audit.get("arguments").is_none()); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn context_compaction_started_orphan_requires_reconciliation_without_replay() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs index 45093df77..fabd6acd3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs @@ -22,3 +22,42 @@ pub(crate) use model::{ AgentRuntimeToolPlanHandoffEntry, AgentRuntimeToolPlanHandoffLedger, AgentRuntimeToolPlanHandoffLookup, TOOL_PLAN_HANDOFF_SCHEMA_VERSION, }; + +pub(crate) fn failure_kind(error: &str) -> &'static str { + if error.contains("敏感规则") || error.contains("敏感 JSON") { + "tool-plan-sensitive-content" + } else if error.contains("绝对路径") { + "tool-plan-absolute-path" + } else if error.contains("thinking") { + "tool-plan-thinking-normalization" + } else if error.contains("超过") + || error.contains("不能为空") + || error.contains("无效") + || error.contains("不支持") + { + "tool-plan-response-shape" + } else if error.contains("slot") + || error.contains("loop") + || error.contains("repair") + || error.contains("identity") + || error.contains("顺序") + { + "tool-plan-identity-order" + } else if error.contains("写入") + || error.contains("读取") + || error.contains("创建") + || error.contains("目录") + || error.contains("文件") + || error.contains("权限") + { + "tool-plan-storage" + } else if error.contains("冲突") + || error.contains("不匹配") + || error.contains("不存在") + || error.contains("损坏") + { + "tool-plan-integrity" + } else { + "tool-plan-unknown" + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs index 86ddc767c..9b92a731d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs @@ -124,7 +124,7 @@ pub(super) fn validate_response( && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) => {} _ => return Err("tool-plan 成功响应交接 thinking normalization 元数据无效".to_string()), } - validate_private_content(root, "text", &response.text, true)?; + validate_source_or_narrative_content("text", &response.text)?; if let Some(finish_reason) = response.finish_reason.as_deref() { validate_short_metadata( root, @@ -168,7 +168,7 @@ pub(super) fn validate_response( "tool-plan 成功响应交接 arguments 超过 {TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES} 字节上限" )); } - validate_private_content(root, "arguments", &call.arguments, true)?; + validate_tool_plan_arguments(root, "arguments", &call.arguments)?; } if response.thinking_wrapper_valid && response.thinking_wrapper_balanced @@ -226,7 +226,100 @@ pub(super) fn validate_private_content( Ok(()) } +fn validate_source_or_narrative_content(label: &str, value: &str) -> Result<(), String> { + validate_source_content(label, value)?; + let json_like = value + .trim_start() + .as_bytes() + .first() + .is_some_and(|byte| matches!(byte, b'{' | b'[')); + if json_like { + match serde_json::from_str::(value) { + Ok(json) => { + validate_json_sensitive_keys(label, &json)?; + validate_json_absolute_path_inputs(label, &json, None)?; + } + Err(_) => { + validate_json_like_sensitive_keys(label, value)?; + validate_json_like_absolute_path_inputs(label, value)?; + } + } + } + Ok(()) +} + +fn validate_tool_plan_arguments(root: &Path, label: &str, value: &str) -> Result<(), String> { + validate_secret_tokens_and_controls(label, value)?; + match serde_json::from_str::(value) { + Ok(json) => { + validate_json_sensitive_keys(label, &json)?; + validate_json_absolute_path_inputs(label, &json, None)?; + validate_json_private_string_values(root, label, &json, None)?; + } + Err(_) => validate_private_content(root, label, value, true)?, + } + Ok(()) +} + +fn validate_json_private_string_values( + root: &Path, + label: &str, + value: &serde_json::Value, + parent_key: Option<&str>, +) -> Result<(), String> { + match value { + serde_json::Value::String(value) => { + if parent_key.is_some_and(is_tool_plan_content_field) { + validate_source_content(label, value)?; + } else { + validate_private_content_view(root, label, value)?; + } + } + serde_json::Value::Array(values) => { + for value in values { + validate_json_private_string_values(root, label, value, parent_key)?; + } + } + serde_json::Value::Object(values) => { + for (key, value) in values { + validate_json_private_string_values(root, label, value, Some(key))?; + } + } + _ => {} + } + Ok(()) +} + +fn validate_source_content(label: &str, value: &str) -> Result<(), String> { + validate_secret_tokens_and_controls(label, value)?; + let lower = value.to_ascii_lowercase(); + if let Some(rule) = ["authorization:", "cookie:", "bearer "] + .into_iter() + .position(|marker| lower.contains(marker)) + { + return Err(format!( + "tool-plan 成功响应交接 {label} 命中敏感规则 #{}", + rule + 2 + )); + } + Ok(()) +} + +fn validate_secret_tokens_and_controls(label: &str, value: &str) -> Result<(), String> { + if redact_secret_tokens(value) != value { + return Err(format!("tool-plan 成功响应交接 {label} 命中敏感规则 #5")); + } + if value + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err(format!("tool-plan 成功响应交接 {label} 包含不安全控制字符")); + } + Ok(()) +} + fn validate_private_content_view(_root: &Path, label: &str, value: &str) -> Result<(), String> { + validate_secret_tokens_and_controls(label, value)?; let lower = value.to_ascii_lowercase(); let sensitive_rule = [ ".env", @@ -236,19 +329,12 @@ fn validate_private_content_view(_root: &Path, label: &str, value: &str) -> Resu "bearer ", ] .into_iter() - .position(|marker| lower.contains(marker)) - .or_else(|| (redact_secret_tokens(value) != value).then_some(5)); + .position(|marker| lower.contains(marker)); if let Some(rule) = sensitive_rule { return Err(format!( "tool-plan 成功响应交接 {label} 命中敏感规则 #{rule}" )); } - if value - .chars() - .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) - { - return Err(format!("tool-plan 成功响应交接 {label} 包含不安全控制字符")); - } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index c2673cf20..75194cb00 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -505,7 +505,31 @@ fn tool_plan_handoff_rejects_out_of_order_entries() { #[test] fn tool_plan_handoff_rejects_dangerous_content_and_invalid_calls_without_writing() { let cases = [ - response("load .env.local", Vec::new()), + response( + "safe text", + vec![call( + "call-env-path", + "file.write", + r#"{"path":".env.local","content":"placeholder"}"#, + )], + ), + response("sk-0123456789abcdef", Vec::new()), + response( + "safe text", + vec![call( + "call-secret-content", + "file.write", + r#"{"path":"game/index.html","content":"const sample = 'sk-0123456789abcdef';"}"#, + )], + ), + response( + "safe text", + vec![call( + "call-bearer-content", + "file.write", + r#"{"path":"game/index.html","content":"Authorization: Bearer placeholder"}"#, + )], + ), response( "safe text", vec![call("call-path", "file.write", r#"{"path":"/etc/passwd"}"#)], @@ -699,13 +723,13 @@ fn tool_plan_handoff_allows_ordinary_source_in_content_html_and_patch_fields() { let identity = identity("loop-0-repair-0"); let arguments = serde_json::json!({ "path": "src/security-form.ts", - "content": "const token = props.token; const apiKey = options.apiKey; const samplePath = '/home/example';", + "content": "const token = props.token; const apiKey = options.apiKey; const samplePath = '/home/example'; const envExample = '.env.local';", "html": "", - "patch": "const defaults = { client_secret: label, private_key: fieldName };" + "patch": "const defaults = { client_secret: label, private_key: fieldName, config: 'game-creator.config' };" }) .to_string(); let expected = response( - "safe source plan", + "无需读取 .env 或 game-creator.config;只修改普通源码。", vec![call("call-source-fields", "file.write", &arguments)], ); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a678589e5..ef7d7df3b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3661,3 +3661,11 @@ - 原因:把“入口变了”误当成“本轮全部正式产物都新鲜”,并直接复用资源 manifest 的完整 source 对象写公开审计。manifest 的本地来源元数据与公开 event / agentDb 的最小身份字段不是同一个安全边界。 - 处理:完成合同升级为 `game-creator-autonomous-completion-contract.v2`,`baselineArtifacts` 必填并参与指纹;旧 v1、缺基线或提交前身份漂移失败关闭。资源 manifest 可以保留 prompt,但 `asset.register / asset.update` 写审计前必须清除 `source.prompt`,只保留资源身份和模型。 - 并发补验:revision 漂移、repository context drift 和项目锁竞争只能在同一 logical run 有界重试;只有明确 blocker / repair 或成功 `ok` observation 才可重放终态,失败 observation 不能当通过,只读 Agent 不能借补验获得命令权限,completion 统计仍只能增加一次。 + +## tool-plan handoff 不能把计划叙述和源码字段当成配置载荷扫描 + +- 现象:Provider 已返回 HTTP 200 并计费,tool-plan lifecycle 却只有 `started`,handoff 账本停在上一 loop,Runtime 进入 `needs-reconciliation`;重启 Runner 或 `/resume` 后仍原样被屏障阻断。 +- 原因:在解析 function arguments 之前,对整段 `response.text` 和序列化 arguments 统一执行 `.env`、`game-creator.config` 等字面标记扫描。安全叙述如“无需读取 `.env`”,或 `oldText / newText / content / patch` 中的普通源码字面量,会在真实路径和内容字段尚未区分时被误判。原始响应未成功交接时不会留下正文,因此现场只能结合 loop 边界和最小复现定位,不能把高概率分支冒充已恢复的原响应证据。 +- 处理:计划叙述与规范源码内容字段只检查真实密钥 token 形状、凭据头标记和不安全控制字符;结构化敏感 JSON key、非内容字段的配置痕迹和绝对路径、真实 token、容量、thinking、身份、顺序及账本完整性继续失败关闭。成功 handoff 失败时只在 Runtime event/state 和 Agent DB 保存受控 `failureKind`、脱敏错误 SHA-256、字符数与 requestId,禁止保存正文、arguments、密钥和绝对路径。 +- 验证:必须同时覆盖 narrative 和 `oldText / newText / content / html / patch` 提及 `.env` / `game-creator.config` 可 round-trip,`path=.env.local` 与 `sk-...` 真实 token 仍拒绝,全部 handoff 回归通过;诊断审计必须断言不存在 `error / response / arguments` 原文。修复后的外部 Provider 重试仍需新起独立轮次,不能与故障轮或确定性回归拼接为 PASS。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index b610e4cbe..f7a20de50 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -733,3 +733,4 @@ game-project/ - `design-foundation` 已增加专属职责边界:项目文件只允许写 `memory/project.md` 与 `game/game_design.md`;配置 External Editor API Key 且合同要求界面原型时,只额外允许固定 `assets/ui-prototype.png`。它不得创建、修改、删除或补丁 `game/index.html`,不得改动其它程序实现、发布、音频或美术素材,也不得调用 `preview.start`、`preview.validate`、`game.static_smoke`,或借 `command.exec / command.start / command.run_limited` 启动预览服务、浏览器、Playwright 和桌面 / 移动试玩。程序和质量 Agent 的共享 Runtime 工具合同不因此缩减;有 / 无画布配置和其它 Agent 不受影响的聚焦回归为 `3/3` 通过。 - `canvas.asset_generate.replaceExisting` 默认并必须保持 `false`;只有静态专业 Agent 的 `delegated-*` 唯一 repair run 才能申请 `true`。Runtime 要求当前 delivery 带 `repairOfDelegationId`,原 delivery 已被同一父 Agent / 父 run 认领,原始与返工合同的目标 Agent 和精确 `expectedArtifacts` 路径一致;普通 run、未声明路径、错误 Agent、未认领原交付或缺失原图都失败关闭。图片生成仍服从 `design-foundation` / `art-asset-plan` 的固定输出路径、比例、尺寸、kind 和 label,禁止先删除正式图片;请求前记录旧文件 SHA-256,外部生成返回后在项目写锁内复核,旧图在网络请求期间变化即拒绝覆盖。授权替换先写私有临时文件,再以备份 / rename 切换;落盘或 manifest 登记失败时恢复旧图,不把新旧文件并存状态当作成功。 - 2026-07-27 新起的“16 任务正式产物 + 两张真实画布图片 + current revision 静态 / 双视口浏览器 / PNG 证据 + 受限 repair 替换”独立外部 Provider 验收,使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**。同一轮真实生成并登记 `assets/ui-prototype.png`(`2829418` bytes)与 `assets/art-spritesheet.png`(`1361906` bytes),固定 `16` 个 manifest task 均为当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物、两张 PNG、当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告与截图全部通过。`turn.report=settled` 且唯一 assistant,busy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续独立保留,不与本轮拼接;未来合同变化仍须新起完整轮次复验。 +- 2026-07-27 补充 tool-plan 成功响应交接的内容边界:Provider 的自然语言计划叙述,以及结构化 arguments 中 `body / code / content / css / html / newText / oldText / patch / script / text` 等源码内容字段,只检查真实密钥 token 形状、凭据头标记和不安全控制字符;仅仅提及 `.env` 或 `game-creator.config` 不能阻断已经计费的安全响应。结构化输入中的敏感 JSON key、非内容字段中的配置痕迹或绝对路径、真实 token、容量、thinking、身份、顺序和账本完整性门禁仍失败关闭。成功 handoff 失败进入 reconciliation 时,Runtime 额外只持久化受控 `failureKind`、脱敏错误 SHA-256 和字符数,不保存 Provider 正文、function arguments、密钥或绝对路径。定向回归覆盖叙述/源码字段放行、`.env.local` 路径和真实 token 拒绝、全部 tool-plan handoff 回归及诊断零正文。