diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs index 4f49580a8..d1539d140 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -17,6 +17,12 @@ pub(crate) const AGENT_RUNTIME_ERROR_SCHEMA_VERSION: &str = "agent-runtime-error /// sidecar 里 `message` 的字符上限。 pub(crate) const AGENT_RUNTIME_ERROR_MAX_MESSAGE_CHARS: usize = 8 * 1024; +/// sidecar 里 `error` 每个字符串叶子的字符上限。 +/// +/// `error` 要留住 typed 错误的 JSON 形状(键、类型、数组长度),但叶子上的自由文本同样可能 +/// 带绝对路径或凭据 URL,落盘前必须和 `message` 走同一条脱敏流水线。 +pub(crate) const AGENT_RUNTIME_ERROR_MAX_ERROR_TEXT_CHARS: usize = 4_000; + /// 应用日志里 `message` / `metadata` 的字符预算。 /// /// `application.log` 的每一行在落盘前还会被 `sanitize_diagnostic_message` 截到 2 KiB, @@ -28,6 +34,32 @@ pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS: usize = 200; static ERROR_EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1); +/// 递归脱敏 `error` 的字符串叶子,保留 JSON 形状:键、类型、数组长度都不动。 +/// +/// 键不参与脱敏:它们来自 typed 错误的变体名与字段名,是结构本身,不是调用方自由文本。 +fn redact_agent_runtime_error_json(root: &Path, value: &Value) -> Value { + match value { + Value::String(text) => Value::String(redact_agent_runtime_error( + root, + text, + AGENT_RUNTIME_ERROR_MAX_ERROR_TEXT_CHARS, + )), + Value::Array(items) => Value::Array( + items + .iter() + .map(|item| redact_agent_runtime_error_json(root, item)) + .collect(), + ), + Value::Object(fields) => Value::Object( + fields + .iter() + .map(|(key, value)| (key.clone(), redact_agent_runtime_error_json(root, value))) + .collect(), + ), + other => other.clone(), + } +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub(crate) struct AgentRuntimeErrorEvent { pub schema_version: &'static str, @@ -39,8 +71,8 @@ pub(crate) struct AgentRuntimeErrorEvent { pub occurred_at_unix_nanos: String, pub elapsed_ms: Option, pub message: String, - /// 序列化后的 typed 错误 enum(一个 case 一个变体)。这是开发者侧的结构化事实; - /// 没有 typed 错误的调用方传 `Value::Null`。 + /// 序列化后的 typed 错误 enum(一个 case 一个变体)。这是开发者侧的结构化事实;落盘前 + /// 叶子上的自由文本会脱敏,形状不变。没有 typed 错误的调用方传 `Value::Null`。 pub error: Value, pub detail_ref: String, pub persistence_failed: bool, @@ -67,6 +99,8 @@ pub(crate) fn persist_agent_runtime_error( let detail_ref = format!(".agent/runtime/errors/{event_id}.json"); let safe_message = redact_agent_runtime_error(root, message, AGENT_RUNTIME_ERROR_MAX_MESSAGE_CHARS); + // `error` 是开发者侧的结构化事实,形状必须留住;叶子上的自由文本按同一条流水线脱敏。 + let safe_error = redact_agent_runtime_error_json(root, &error); let diagnostic = serde_json::json!({ "schemaVersion": AGENT_RUNTIME_ERROR_SCHEMA_VERSION, "eventId": event_id, @@ -77,7 +111,7 @@ pub(crate) fn persist_agent_runtime_error( "occurredAtUnixNanos": occurred_at_unix_nanos.to_string(), "elapsedMs": elapsed_ms, "message": safe_message, - "error": error, + "error": safe_error.clone(), "metadata": metadata, }); // 统一错误事件的项目内 sidecar 只在项目目录可见:用户提交错误报告时上传的是 AppData @@ -116,7 +150,7 @@ pub(crate) fn persist_agent_runtime_error( occurred_at_unix_nanos: occurred_at_unix_nanos.to_string(), elapsed_ms, message: message.to_string(), - error, + error: safe_error, detail_ref, persistence_failed: false, metadata, @@ -239,7 +273,7 @@ mod tests { assert_eq!(event.message, "本轮没有收到完成事件 C:\\Users\\private\\project https://provider.example/a?token=secret"); let path = root.join(&event.detail_ref); let text = std::fs::read_to_string(path).expect("diagnostic"); - // typed 错误原样进 sidecar:开发侧按变体与字段定位,不解析那句中文。 + // typed 错误进 sidecar 时保留变体与字段(shape 不变),只有叶子上的自由文本脱敏。 assert!(text.contains("timedOut"), "{text}"); assert!(text.contains("idle"), "{text}"); assert!(text.contains("")); @@ -247,6 +281,42 @@ mod tests { assert!(!text.contains("token=secret")); } + #[test] + fn error_event_redacts_typed_error_leaves_and_keeps_its_shape() { + let parent = tempfile::tempdir().expect("temp root"); + let root = parent.path().join("project"); + crate::project::init_local_game_project_at(&root, "runtime-error", "错误事件") + .expect("init project"); + let event = persist_agent_runtime_error( + &root, + None, + "direct-codex", + "turn-setup", + "turn-failed", + "回合失败", + serde_json::json!({ + "PermissionRejected": { + "policy_detail": "拒绝点位 C:\\Users\\private\\project https://provider.example/a?token=secret", + "attempts": 2, + "dismissed": false, + } + }), + None, + serde_json::json!({ "legacyDiagnosticWritten": true }), + ) + .expect("persist event"); + let text = std::fs::read_to_string(root.join(&event.detail_ref)).expect("diagnostic"); + // 形状留住:变体名、字段名、数字与布尔都不动。 + assert!(text.contains("PermissionRejected"), "{text}"); + assert!(text.contains("policy_detail"), "{text}"); + assert!(text.contains("\"attempts\": 2"), "{text}"); + assert!(text.contains("\"dismissed\": false"), "{text}"); + // 叶子脱敏:和 message 走同一条流水线。 + assert!(!text.contains("token=secret"), "{text}"); + assert!(text.contains(""), "{text}"); + assert!(text.contains(""), "{text}"); + } + #[test] fn error_event_app_log_lines_keep_identity_and_redact_detail() { let parent = tempfile::tempdir().expect("temp root");