From 5b59d50a8cf1d4d26ccbef76ca467d5e1c04a44d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 17:12:32 +0800 Subject: [PATCH] =?UTF-8?q?=E9=94=99=E8=AF=AF=E4=BA=8B=E4=BB=B6=20sidecar?= =?UTF-8?q?=20=E7=9A=84=20error=20=E5=AD=97=E6=AE=B5=E9=80=92=E5=BD=92?= =?UTF-8?q?=E8=84=B1=E6=95=8F=EF=BC=8C=E4=BF=9D=E7=95=99=20typed=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=9A=84=20JSON=20=E5=BD=A2=E7=8A=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - persist_agent_runtime_error 落盘前对 error 的字符串叶子走与 message 同一条脱敏流水线(URL/项目路径/绝对路径/密钥),键、类型、数组长度都不动 - 每个叶子有 4000 字符预算(AGENT_RUNTIME_ERROR_MAX_ERROR_TEXT_CHARS),sidecar 本身的 16 KiB 上限不变 - 返回的 AgentRuntimeErrorEvent.error 与落盘内容一致;应用日志那条 error 行原本就已脱敏,行为不变 - 新增回归测试:形状留住(变体名/字段名/数字/布尔),叶子脱敏(、、无 token) - 更新原测试里「typed 错误原样进 sidecar」的注释 验证:cargo test --bin genarrative-ai-game-creator-shell -- agent::runtime_error:: 4 passed / 0 failed;cargo fmt --check 通过 --- .../src-tauri/src/agent/runtime_error.rs | 80 +++++++++++++++++-- 1 file changed, 75 insertions(+), 5 deletions(-) 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");