错误事件 sidecar 的 error 字段递归脱敏,保留 typed 错误的 JSON 形状

- persist_agent_runtime_error 落盘前对 error 的字符串叶子走与 message 同一条脱敏流水线(URL/项目路径/绝对路径/密钥),键、类型、数组长度都不动
- 每个叶子有 4000 字符预算(AGENT_RUNTIME_ERROR_MAX_ERROR_TEXT_CHARS),sidecar 本身的 16 KiB 上限不变
- 返回的 AgentRuntimeErrorEvent.error 与落盘内容一致;应用日志那条 error 行原本就已脱敏,行为不变
- 新增回归测试:形状留住(变体名/字段名/数字/布尔),叶子脱敏(<absolute-path>、<redacted-url>、无 token)
- 更新原测试里「typed 错误原样进 sidecar」的注释

验证:cargo test --bin genarrative-ai-game-creator-shell -- agent::runtime_error:: 4 passed / 0 failed;cargo fmt --check 通过
This commit is contained in:
2026-10-01 17:12:32 +08:00
parent c7da67ed47
commit 5b59d50a8c
@@ -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<u64>,
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("<absolute-path>"));
@@ -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("<absolute-path>"), "{text}");
assert!(text.contains("<redacted-url>"), "{text}");
}
#[test]
fn error_event_app_log_lines_keep_identity_and_redact_detail() {
let parent = tempfile::tempdir().expect("temp root");