diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index 6037d5fda..34d2fda40 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -677,6 +677,15 @@ fn agent_runtime_action_receipt_safe_detail_with_owner( "revisionAdvanceCount": value.get("revisionAdvanceCount").and_then(serde_json::Value::as_u64).unwrap_or(0), })).ok(); } + if matches!( + observation.tool.as_str(), + "ui-design-doc.from-images" | "ui-design-doc.run-workflow" + ) { + return agent_runtime_ui_design_doc_safe_detail(root, observation.detail.as_deref()); + } + if observation.tool == "ui-design-doc.into-js" { + return agent_runtime_ui_design_code_safe_detail(root, observation.detail.as_deref()); + } if observation.tool != "project.patchset" { return None; } @@ -1020,6 +1029,184 @@ pub(in crate::agent) fn agent_runtime_action_receipt_safe_text( Some(sanitized) } +/// UI 设计文档回执里身份类字段的长度上限:素材 id / 文档 id / 生成文件名都远小于这个值。 +const AGENT_RUNTIME_UI_DESIGN_SAFE_ID_MAX_CHARS: usize = 96; +/// 文档相对路径的长度上限;同时要求必须落在 `ui/` 下,避免把项目别处的路径带进回执。 +const AGENT_RUNTIME_UI_DESIGN_SAFE_RELATIVE_PATH_MAX_CHARS: usize = 160; +/// 单条回填说明的长度上限与总量上限;超出总量的部分不逐条外传,改用总数表达。 +const AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_MAX_CHARS: usize = 96; +const AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_CHAR_BUDGET: usize = 240; +/// 一份文档最多四张设计图,逐张身份也按同一上限放行。 +const AGENT_RUNTIME_UI_DESIGN_SAFE_IMAGE_ID_LIMIT: usize = 4; + +/// UI 设计文档链路(新建文档 / 跑工作流)的回执明细:只放身份、相对路径、计数与回填说明。 +/// 计数与身份必须完全合格;体积较大的清单(设计图身份、回填说明)在回执长度上限内能放多少放多少, +/// 放不下的部分用总数补齐,绝不把原始明细整体外传。 +fn agent_runtime_ui_design_doc_safe_detail(root: &Path, detail: Option<&str>) -> Option { + let detail = serde_json::from_str::(detail.unwrap_or_default()).ok()?; + let mut safe = serde_json::Map::new(); + safe.insert( + "assetId".to_string(), + agent_runtime_action_receipt_identity_text( + root, + detail.get("assetId")?.as_str()?, + AGENT_RUNTIME_UI_DESIGN_SAFE_ID_MAX_CHARS, + "assetId", + ) + .ok()? + .into(), + ); + safe.insert( + "relativePath".to_string(), + agent_runtime_ui_design_safe_relative_path(root, detail.get("relativePath")?.as_str()?)? + .into(), + ); + for key in [ + "revision", + "revisionAdvanceCount", + "recognizedTreeCount", + "boundNodeCount", + "problematicNodeCount", + ] { + if let Some(value) = detail.get(key) { + safe.insert(key.to_string(), value.as_u64()?.into()); + } + } + if let Some(value) = detail.get("recoveredFromCheckpoint") { + safe.insert( + "recoveredFromCheckpoint".to_string(), + value.as_bool()?.into(), + ); + } + if !agent_runtime_action_receipt_detail_fits(&safe) { + return None; + } + + if let Some(image_ids) = detail.get("imageIds") { + let image_ids = image_ids.as_array()?; + if image_ids.is_empty() || image_ids.len() > AGENT_RUNTIME_UI_DESIGN_SAFE_IMAGE_ID_LIMIT { + return None; + } + let image_ids = image_ids + .iter() + .map(|value| { + agent_runtime_action_receipt_identity_text( + root, + value.as_str()?, + AGENT_RUNTIME_UI_DESIGN_SAFE_ID_MAX_CHARS, + "imageIds", + ) + .ok() + }) + .collect::>>()?; + agent_runtime_action_receipt_insert_while_fits( + &mut safe, + "imageIds", + serde_json::json!(image_ids), + ); + } + if let Some(errors) = detail.get("backfillErrors") { + let errors = errors.as_array()?; + let mut safe_errors = Vec::new(); + for value in errors { + // 回填说明是自由文本:绝对路径按既有口径脱敏成占位符,控制字符或超长则整条明细失败关闭。 + let raw = value.as_str()?.trim(); + let error = agent_runtime_action_receipt_safe_text( + root, + raw, + AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_MAX_CHARS, + None, + )?; + if sanitize_agent_runtime_text(&error, AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_MAX_CHARS) + != error + { + return None; + } + let mut candidate = safe_errors.clone(); + candidate.push(serde_json::Value::String(error)); + let mut probe = safe.clone(); + probe.insert("backfillErrors".to_string(), serde_json::json!(candidate)); + probe.insert( + "backfillErrorCount".to_string(), + serde_json::json!(errors.len()), + ); + if !agent_runtime_action_receipt_detail_fits(&probe) { + break; + } + safe_errors = candidate; + } + if !safe_errors.is_empty() + && serde_json::to_string(&serde_json::json!(safe_errors)) + .map(|text| text.chars().count()) + .unwrap_or(0) + <= AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_CHAR_BUDGET + { + safe.insert("backfillErrors".to_string(), serde_json::json!(safe_errors)); + } + safe.insert( + "backfillErrorCount".to_string(), + serde_json::json!(errors.len()), + ); + } + serde_json::to_string(&serde_json::Value::Object(safe)).ok() +} + +/// 代码生成回执:相对路径与计数;导出名清单只用来核对数量,不逐项外传。 +fn agent_runtime_ui_design_code_safe_detail(root: &Path, detail: Option<&str>) -> Option { + let detail = serde_json::from_str::(detail.unwrap_or_default()).ok()?; + let relative_path = + agent_runtime_ui_design_safe_relative_path(root, detail.get("relativePath")?.as_str()?)?; + let tree_count = detail.get("treeCount")?.as_u64()?; + let node_count = detail.get("nodeCount")?.as_u64()?; + if usize::try_from(tree_count).ok()? != detail.get("treeExports")?.as_array()?.len() { + return None; + } + let safe = serde_json::to_string(&serde_json::json!({ + "relativePath": relative_path, + "treeCount": tree_count, + "nodeCount": node_count, + })) + .ok()?; + (sanitize_agent_runtime_text(&safe, AGENT_RUNTIME_ACTION_RECEIPT_SAFE_DETAIL_MAX_CHARS) == safe) + .then_some(safe) +} + +/// `ui/` 下的项目相对路径:绝对路径、反斜杠、上跳与超长一律拒绝。 +fn agent_runtime_ui_design_safe_relative_path(root: &Path, value: &str) -> Option { + let path = normalize_relative_path(value).ok()?; + if !path.starts_with("ui/") { + return None; + } + agent_runtime_action_receipt_identity_text( + root, + &path, + AGENT_RUNTIME_UI_DESIGN_SAFE_RELATIVE_PATH_MAX_CHARS, + "relativePath", + ) + .ok() +} + +fn agent_runtime_action_receipt_detail_fits( + detail: &serde_json::Map, +) -> bool { + serde_json::to_string(&serde_json::Value::Object(detail.clone())).is_ok_and(|text| { + sanitize_agent_runtime_text(&text, AGENT_RUNTIME_ACTION_RECEIPT_SAFE_DETAIL_MAX_CHARS) + == text + }) +} + +fn agent_runtime_action_receipt_insert_while_fits( + detail: &mut serde_json::Map, + key: &str, + value: serde_json::Value, +) { + let mut candidate = detail.clone(); + candidate.insert(key.to_string(), value); + if agent_runtime_action_receipt_detail_fits(&candidate) { + *detail = candidate; + } +} + pub(in crate::agent) fn agent_runtime_public_action_input_summary( root: &Path, tool: &str, @@ -1060,6 +1247,9 @@ pub(in crate::agent) fn agent_runtime_public_action_input_summary( | "agent.schedule_ready" | "agent.action_history" | "agent.run_status" + | "ui-design-doc.from-images" + | "ui-design-doc.run-workflow" + | "ui-design-doc.into-js" ); if public_shape_only { return agent_runtime_action_receipt_safe_text(root, input_summary, 320, None); @@ -1593,8 +1783,82 @@ pub(crate) fn agent_runtime_tool_action_input_summary( text(&["agentId", "agent_id", "targetAgentId", "target_agent_id"]), text(&["delegationId", "delegation_id"]) ), + // UI 设计文档三兄弟:入参只有设计图引用或目标文档 id,直接给出可读摘要, + // 不再退化成"只报哈希"——否则模型看不到自己这次到底传了什么。 + "ui-design-doc.from-images" => ui_design_doc_images_input_summary(input), + "ui-design-doc.run-workflow" | "ui-design-doc.into-js" => format!( + "designDocAssetId={}", + text(&["designDocAssetId", "design_doc_asset_id", "assetId", "asset_id"]) + ), _ => String::new(), }; let summary = redact_agent_runtime_project_paths(root, &summary, 320); (!summary.trim().is_empty()).then_some(summary) } + +/// 新建文档工具的设计图入参摘要:逐张给出身份(素材 id 或相对路径),绝对路径按既有口径降级成拒绝标记。 +fn ui_design_doc_images_input_summary(input: &serde_json::Value) -> String { + let Some(images) = input.get("images").and_then(serde_json::Value::as_array) else { + return String::new(); + }; + let described = images + .iter() + .map(|image| { + if let Some(asset_id) = image.get("assetId").and_then(serde_json::Value::as_str) { + return format!("assetId={asset_id}"); + } + match image.get("path").and_then(serde_json::Value::as_str) { + Some(path) if Path::new(path).is_absolute() => { + "[absolute path rejected]".to_string() + } + Some(path) => format!("path={path}"), + None => "input".to_string(), + } + }) + .collect::>() + .join(", "); + format!("images={} · {described}", images.len()) +} + +#[cfg(test)] +mod ui_design_doc_public_input_summary_tests { + use super::*; + + #[test] + fn ui_design_doc_input_summaries_are_readable_not_hashed() { + let root = Path::new("/nonexistent-ui-design-doc-input-summary-project"); + for (tool, summary) in [ + ( + "ui-design-doc.from-images", + "images=2 · assetId=image-1, path=assets/two.png", + ), + ( + "ui-design-doc.run-workflow", + "designDocAssetId=generated-ui-design-1", + ), + ( + "ui-design-doc.into-js", + "designDocAssetId=generated-ui-design-1", + ), + ] { + let public = agent_runtime_public_action_input_summary(root, tool, Some(summary)) + .expect("input summary"); + assert_eq!(public, summary, "{tool} 的入参不能被压成摘要哈希"); + } + } + + #[test] + fn from_images_input_summary_lists_each_image_reference() { + let input = serde_json::json!({ + "images": [ + {"assetId": "image-1"}, + {"path": "assets/two.png"}, + {"path": "/tmp/host-only.png"}, + ] + }); + assert_eq!( + ui_design_doc_images_input_summary(&input), + "images=3 · assetId=image-1, path=assets/two.png, [absolute path rejected]" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index bbc5186fb..780f589d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -2230,3 +2230,145 @@ fn seed_refresh_preserves_completed_visual_tasks_when_registered_file_is_missing fs::remove_dir_all(root).ok(); } + +#[test] +fn ui_design_doc_receipts_keep_safe_detail_instead_of_dropping_it() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "UI 设计文档回执脱敏测试") + .expect("project init"); + + let from_images = AgentRuntimeToolObservation { + tool: "ui-design-doc.from-images".to_string(), + status: "ok".to_string(), + summary: "已新建 UI 设计文档".to_string(), + detail: Some( + serde_json::json!({ + "assetId": "generated-ui-design-1", + "relativePath": "ui/UI 设计 1.json", + "imageIds": ["image-1", "image-2"], + "revisionAdvanceCount": 2, + }) + .to_string(), + ), + }; + let public = agent_runtime_action_receipt_public_safe_detail_for_test(&root, &from_images) + .expect("from-images 必须留下可用明细"); + assert!(public.chars().count() <= 500, "{public}"); + let public = serde_json::from_str::(&public).expect("parse from-images detail"); + assert_eq!(public["assetId"], "generated-ui-design-1"); + assert_eq!(public["relativePath"], "ui/UI 设计 1.json"); + assert_eq!(public["imageIds"].as_array().map(Vec::len), Some(2)); + assert_eq!(public["revisionAdvanceCount"], 2); + + let run_workflow = AgentRuntimeToolObservation { + tool: "ui-design-doc.run-workflow".to_string(), + status: "ok".to_string(), + summary: "UI 设计文档工作流完成".to_string(), + detail: Some( + serde_json::json!({ + "assetId": "generated-ui-design-1", + "relativePath": "ui/UI 设计 1.json", + "revision": 3, + "recoveredFromCheckpoint": true, + "recognizedTreeCount": 2, + "boundNodeCount": 9, + "problematicNodeCount": 1, + "backfillErrors": [ + "未能登记自动切分素材图片:assets/cut.png", + format!("读取资源失败:{}", root.join("assets/cut.png").display()), + ], + "revisionAdvanceCount": 3, + }) + .to_string(), + ), + }; + let public = agent_runtime_action_receipt_public_safe_detail_for_test(&root, &run_workflow) + .expect("run-workflow 必须留下可用明细"); + assert!(public.chars().count() <= 500, "{public}"); + assert!( + !public.contains(root.to_string_lossy().as_ref()), + "回填说明里的宿主路径必须脱敏:{public}" + ); + let public = serde_json::from_str::(&public).expect("parse run-workflow detail"); + assert_eq!(public["recoveredFromCheckpoint"], true); + assert_eq!(public["problematicNodeCount"], 1); + assert_eq!(public["backfillErrorCount"], 2); + assert_eq!( + public["backfillErrors"][0], + "未能登记自动切分素材图片:assets/cut.png" + ); + + let into_js = AgentRuntimeToolObservation { + tool: "ui-design-doc.into-js".to_string(), + status: "ok".to_string(), + summary: "已生成 UI 设计代码".to_string(), + detail: Some( + serde_json::json!({ + "relativePath": "ui/generated-ui-design-1-0123456789abcdef.js", + "treeExports": ["treeA", "treeB"], + "treeCount": 2, + "nodeCount": 12, + }) + .to_string(), + ), + }; + let public = agent_runtime_action_receipt_public_safe_detail_for_test(&root, &into_js) + .expect("into-js 必须留下可用明细"); + let public = serde_json::from_str::(&public).expect("parse into-js detail"); + assert_eq!(public["treeCount"], 2); + assert_eq!(public["nodeCount"], 12); + assert!( + public.get("treeExports").is_none(), + "导出名清单只用于核对数量,不逐项外传:{public}" + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn ui_design_doc_receipts_reject_out_of_scope_paths_and_malformed_detail() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "UI 设计文档回执边界测试") + .expect("project init"); + + let outside_ui_directory = AgentRuntimeToolObservation { + tool: "ui-design-doc.from-images".to_string(), + status: "ok".to_string(), + summary: "已新建 UI 设计文档".to_string(), + detail: Some( + serde_json::json!({ + "assetId": "generated-ui-design-1", + "relativePath": "game/UI 设计 1.json", + "imageIds": ["image-1"], + "revisionAdvanceCount": 1, + }) + .to_string(), + ), + }; + assert!( + agent_runtime_action_receipt_public_safe_detail_for_test(&root, &outside_ui_directory) + .is_none(), + "不在 ui/ 下的相对路径必须失败关闭" + ); + + let absolute_document_id = AgentRuntimeToolObservation { + tool: "ui-design-doc.run-workflow".to_string(), + status: "ok".to_string(), + summary: "UI 设计文档工作流完成".to_string(), + detail: Some( + serde_json::json!({ + "assetId": root.join("ui/UI 设计 1.json").to_string_lossy(), + "relativePath": "ui/UI 设计 1.json", + "revisionAdvanceCount": 1, + }) + .to_string(), + ), + }; + assert!( + agent_runtime_action_receipt_public_safe_detail_for_test(&root, &absolute_document_id) + .is_none(), + "身份字段不能是宿主路径" + ); + + fs::remove_dir_all(root).ok(); +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 69189efdd..5201f9fc6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9275,6 +9275,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/src-tauri/src/{main.rs,ui_editor/**}`、`src/features/ui-editor/**`、`src/view/ui-editor/**`、`src/view/project-development/index.tsx`、`tests/{uiEditorPage,uiEditorState,previewWorkspaceZoom}.test.*`、`docs/technical/【技术方案】UI编辑器代码地图与模块职责-2026-09-23.md`、`docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`、`docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md`、`docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md`。 - 验证:`cargo check` 与 `cargo test --bin genarrative-ai-game-creator-shell ui_editor`(160 passed)通过,ts-rs 重新导出 `types/UIDesignImage.ts` 并删除三个已退役类型;`npx vitest run` 定向 `uiEditorPage` / `uiEditorState` / `uiDesignStateStore` / `previewWorkspaceZoom` / `appSurface` 全绿;AGC `tsc --noEmit`、改动文件 eslint、`cargo fmt --check`、`check:encoding`、`git diff --check` 通过。整套 Rust 测试在本容器仍有 60 条环境性失败(`/sbin -> usr/bin` 让 `command.exec` 沙箱 merged-usr 预检失败),与本次改动无关。 +## 2026-09-24 UI 设计文档三个工具的持久回执明细与入参摘要改为可读白名单 + +- 背景:动作回执的"安全明细"是一份白名单——只有认识的工具才把明细整理成安全字段。`ui.workflow.run` 退役时删掉了它那段分支,接管的 `ui-design-doc.*` 三个工具没补,于是统一落到兜底:明细整块变成 `detailUnavailable`,模型与审计都看不到;入参那一栏也只剩哈希。原始明细既不落盘也不给模型,所以**没有宿主路径泄露**,丢的是可用性(例如切分没登记上的素材清单)。 +- 决策:按既有白名单口径给三个工具各补一条分支。`from-images` 放 `assetId` / `relativePath` / `imageIds` / `revisionAdvanceCount`;`run-workflow` 再放文档 `revision`、恢复标记、识别与绑定计数、`backfillErrors` 与总数;`into-js` 只放相对路径与计数(导出名清单只用来核对数量,不逐项外传)。 +- 决策(校验口径):身份字段走 `agent_runtime_action_receipt_identity_text`(禁控制字符、限长、含 file URI 或绝对路径即失败关闭);相对路径必须归一化后落在 `ui/` 下;回填说明是自由文本,按既有口径把绝对路径脱敏成占位符,含控制字符或超长则整条明细失败关闭。 +- 决策(体积):回执明细总长仍受 `AGENT_RUNTIME_ACTION_RECEIPT_SAFE_DETAIL_MAX_CHARS`(500)约束。设计图身份与回填说明按长度上限能放多少放多少,放不下的部分用 `backfillErrorCount` 表达总数;必需字段本身就超限时整条明细不可用。 +- 决策(入参摘要):`agent_runtime_tool_action_input_summary` 为三个工具产出可读摘要(设计图逐张身份或目标文档 id),并把工具名加进 `agent_runtime_public_action_input_summary` 的可读名单,不再退化成只报哈希。 +- 验证方式:`tests::runtime_actions::action_execution::ui_design_doc_receipts_*` 两条用例(三个工具都能留下可用明细且不超长;宿主路径被脱敏;`ui/` 之外与宿主路径身份失败关闭)与 `action_audit::ui_design_doc_public_input_summary_tests` 两条用例;`cargo test --bin genarrative-ai-game-creator-shell receipt` 55 条全绿。 + ## 2026-09-24 UI 设计文档三个工具纳入项目变更门禁(成功返回即算改过项目) - 背景:`ui.workflow.run` 退役时,项目变更门禁的两处工具名单(`agent/runtime_actions/project_gates.rs` 的 `is_agent_runtime_project_mutation_observation` 与 `agent_runtime_observation_advances_project_revision`)只删未补,新接管的 `ui-design-doc.from-images` / `ui-design-doc.run-workflow` / `ui-design-doc.into-js` 都没登记。后果:改完项目可能被判定"没改过",于是不要求验证就判完成、自动模式的 liveness 判据看不到进展;`agent_runtime_pending_expected_project_revision` 少算推进量又会误报 `pending_project_revision_drift`("并行项目变更使旧动作过期")。