From 68cb41e3324a31ea1abeea4da408abbe7787ac36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 11:53:01 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9AThreadManager=20?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E5=AF=B9=E7=BA=BF=E7=A8=8B=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=E8=84=B1=E6=95=8F=E4=B8=8E=E9=99=90=E9=95=BF=EF=BC=8C=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=8E=9F=E6=A0=B7=E9=80=8F=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 wire/items.rs 的 bounded / detail_text / sanitize_detail_text / relativize_project_root_paths / thread_delta_text 与三个字符上限常量 - thread_item_from_value / thread_items_from_history / 运行态条目 / 流式增量签名去掉 root,字段原样透传,脱敏交给事件进前端状态时统一做 - dispatch.rs 写历史失败不再预脱敏,原文交给 classify 投影成失败载荷时统一脱敏 + 截断 - 用例改为"原样透传"口径,删除已无对象的流式脱敏用例 - 失败载荷(错误文案)的脱敏保留 --- .../src/agent/codex_app_server/mod.rs | 95 ++------ .../src/agent/generation/prompt_context.rs | 2 +- .../src/agent/thread_manager/dispatch.rs | 13 +- .../src/agent/thread_manager/wire/items.rs | 218 +++--------------- .../src/agent/thread_manager/wire/tests.rs | 28 +-- .../src-tauri/src/commands/desktop.rs | 2 +- 6 files changed, 67 insertions(+), 291 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index b6f22d0f8..7083d9385 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -794,14 +794,11 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat direct_codex_safe_activity_for_item(item_type) } -/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到身份或类型就整条跳过。 +/// 运行态事件载荷:与历史切片同形的原始条目;拿不到身份或类型就整条跳过。 /// -/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责。 -fn direct_thread_event_item( - root: &std::path::Path, - item: &serde_json::Value, -) -> Option { - thread_item_from_value(root, item, now_ms()) +/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责;脱敏也在前端做。 +fn direct_thread_event_item(item: &serde_json::Value) -> Option { + thread_item_from_value(item, now_ms()) } /// 运行态条目投影:Codex 回显的用户消息整条跳过。 @@ -813,14 +810,11 @@ fn direct_thread_event_item( /// `append_direct_project_history_item_at` 过滤,运行态必须用同一口径过滤,否则前端会多 /// 渲染出两条没有历史对应的孤儿用户气泡,各自开出一个耗时 0 秒的假回合,直到重进页面才 /// 恢复(那时读的是同一份已过滤的 `project.jsonl`)。 -fn direct_thread_visible_item( - root: &std::path::Path, - item: &serde_json::Value, -) -> Option { +fn direct_thread_visible_item(item: &serde_json::Value) -> Option { if is_direct_project_codex_user_item(item) { return None; } - direct_thread_event_item(root, item) + direct_thread_event_item(item) } /// 下发本轮的开口用户条目:放行之后、起 codex 之前的第一条运行态条目。 @@ -837,7 +831,7 @@ pub(crate) fn emit_direct_thread_user_item( root: &std::path::Path, item: &serde_json::Value, ) -> Option { - let entry_item = direct_thread_event_item(root, item)?; + let entry_item = direct_thread_event_item(item)?; // 条目时间是落盘 / 观测时间。前端乐观用户气泡已删(ADR「DirectProject命令入队化」), // 所以这就是界面显示这条用户消息的唯一时间口径:它晚于用户按下发送,但不再有第二份更早的时间。 let at = entry_item.at(); @@ -1185,14 +1179,14 @@ fn direct_codex_reasoning_delta_event( }) } -/// DirectProject 的流式正文在进入 Thread Manager 前就完成脱敏;前端不得接触原始增量。 +/// DirectProject 的流式正文原样进事件流;脱敏在事件进前端状态时统一做(前端 +/// `directThreadSanitize`)。 fn direct_codex_thread_delta_event( - root: &std::path::Path, item_id: String, kind: ThreadDeltaKind, delta: &str, ) -> ThreadEvent { - ThreadEvent::item_delta(item_id, kind, thread_delta_text(root, delta)) + ThreadEvent::item_delta(item_id, kind, delta.to_string()) } /// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。 /// @@ -3712,7 +3706,6 @@ impl CodexAppServerConnection { // 事件自足:增量自带 item 身份与正文类别(正文 / 思考), // 前端 reducer 不允许靠猜 itemId 的来源决定 kind。 direct_codex_thread_delta_event( - history_root, item_id.clone(), ThreadDeltaKind::Message, &delta, @@ -3745,7 +3738,6 @@ impl CodexAppServerConnection { append_thread_event( &direct_thread_id, direct_codex_thread_delta_event( - history_root, item_id, ThreadDeltaKind::Reasoning, &delta, @@ -3765,7 +3757,7 @@ impl CodexAppServerConnection { "rawResponseItem/completed 缺少 item".to_string(), ))); } - let entry_item = direct_thread_visible_item(history_root, &item); + let entry_item = direct_thread_visible_item(&item); let history_root = history_root.to_path_buf(); let history_item = item.clone(); tokio::task::spawn_blocking(move || { @@ -3882,9 +3874,7 @@ impl CodexAppServerConnection { && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - if let Some(entry_item) = - direct_thread_visible_item(history_root, item) - { + if let Some(entry_item) = direct_thread_visible_item(item) { // `item/started` 的通知层带 `startedAtMs`:这是工具真正 // 开始的阶段时间,优先于条目展示时间与宿主钟。 append_thread_event( @@ -5752,8 +5742,8 @@ mod tests { "arguments": { "path": "game/index.html", "token": "secret" }, "result": { "content": "large output" } }); - // 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。 - let projected = direct_thread_event_item(std::path::Path::new("."), &item).expect("item"); + // 运行态事件必须自足:载荷是原始条目,前端不需要再按 itemId 取快照。 + let projected = direct_thread_event_item(&item).expect("item"); assert_eq!(projected.item_id(), "item-1"); let payload = serde_json::to_value(&projected).expect("payload"); assert_eq!( @@ -5769,12 +5759,11 @@ mod tests { assert!(payload.get("title").is_none(), "{payload}"); assert!(payload.get("summary").is_none(), "{payload}"); assert!(payload.get("kind").is_none(), "{payload}"); - // 参数里的密钥不得随载荷下发(脱敏占位符可以保留,明文不行)。 - let arguments = payload - .get("arguments") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - assert!(!arguments.contains("\"secret\""), "{payload}"); + // 脱敏不在这一层做:原始参数原样下发,密钥由前端"事件进状态"时统一抹掉。 + assert_eq!( + payload.get("arguments").and_then(serde_json::Value::as_str), + Some("{\n \"path\": \"game/index.html\",\n \"token\": \"secret\"\n}") + ); } #[test] @@ -6037,56 +6026,18 @@ mod tests { } #[test] - fn direct_thread_message_and_reasoning_deltas_are_sanitized_before_enqueue() { - let root = std::path::Path::new("/workspace/direct-project"); + fn direct_thread_message_and_reasoning_deltas_are_passed_through_verbatim() { let raw = "key=sk-abcdefghijklmnop project=/workspace/direct-project/assets/a.png private=/root/secret.txt"; for kind in [ThreadDeltaKind::Message, ThreadDeltaKind::Reasoning] { - let event = direct_codex_thread_delta_event(root, "item-1".to_string(), kind, raw); + let event = direct_codex_thread_delta_event("item-1".to_string(), kind, raw); let wire = serde_json::to_string(&event).expect("serialize direct thread delta"); assert!( - !wire.contains("sk-abcdefghijklmnop"), - "不得泄漏密钥:{wire}" - ); - assert!( - !wire.contains("/root/secret.txt"), - "不得泄漏绝对路径:{wire}" - ); - assert!( - wire.contains("assets/a.png"), - "项目内绝对路径应归一成相对路径:{wire}" + wire.contains(raw), + "增量原样进事件流,脱敏由前端统一做:{wire}" ); } } - /// 流式脱敏必须保留增量自带的换行:前端把每个 `item.delta` 的脱敏结果**直接拼接**成一条 - /// 消息再交给 Markdown 渲染,所以「逐段脱敏」必须与「整段脱敏」同形,不能吃掉段尾换行。 - /// - /// 回归背景:`sanitize_error_context` 曾用 `lines()` + `join("\n")` 往返,把以换行结尾的段 - /// 的末尾换行吃掉。真实会话里段落、列表项和表格行会因此并进同一行(表头、`|---|---|` 与 - /// 数据行挤成一段正文),整条汇报的 Markdown 结构——尤其表格——直接失效。 - #[test] - fn direct_thread_delta_sanitization_preserves_line_breaks() { - let root = std::path::Path::new("/workspace/direct-project"); - let chunks = [ - "共 4 项验收要求。\n", - "\n", - "| 素材 | 用途 |\n", - "|---|---|\n", - "| 飞鸟角色 | 玩家角色 |\n", - ]; - let streamed: String = chunks - .iter() - .map(|chunk| thread_delta_text(root, chunk)) - .collect(); - let whole = chunks.concat(); - assert_eq!(streamed, whole, "逐段脱敏不得吃掉段尾换行"); - assert_eq!( - thread_delta_text(root, &whole), - streamed, - "逐段脱敏与整段脱敏必须同形" - ); - } - /// 判据:读取器与单测共用同一个分类函数,这些分支的行为被钉在这里。 /// /// 参数:method / params / 正文候选 / 安全活动类别 / turnId。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 1bffed9d3..b65715d49 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -625,7 +625,7 @@ const ERROR_REDACTED_KEY: &str = "[redacted-sensitive-field]"; /// information whenever a safe error line mentions a credential field. /// /// 逐行脱敏,但**保留每个 chunk 末尾的换行**:本函数会被流式增量逐段调用 -/// (`thread_delta_text` → 前端把各段拼成一条消息再交给 Markdown 渲染)。用 +/// (脱敏后的各段由前端拼成一条消息再交给 Markdown 渲染)。用 /// `lines()` + `join("\n")` 会把「以换行结尾的段」的末尾换行吃掉,拼接后段落、列表项和表格行 /// 会并进同一行,整条消息的 Markdown 结构(尤其表格)就作废了。 pub(crate) fn sanitize_error_context(value: &str) -> String { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index 10f69bf6f..64d2d9220 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -20,9 +20,9 @@ use crate::agent::PendingTurn; use crate::agent::{ append_direct_project_user_message_at, claim_pending_turn, complete_turn_if_reserved, direct_codex_user_item_to_prompt, now_ms, record_direct_codex_failure, - redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter, - thread_id_for_project, DirectGameCreatorTurnUpdateEmitter, DispatchedTurn, FailureStage, - TurnCompletion, TurnError, TurnErrorClassified, + run_direct_game_creator_turn_at_with_creation_type_and_emitter, thread_id_for_project, + DirectGameCreatorTurnUpdateEmitter, DispatchedTurn, FailureStage, TurnCompletion, TurnError, + TurnErrorClassified, }; /// 一次放行的占用。持有它就代表这一轮还没收口。 @@ -233,11 +233,8 @@ async fn run_dispatched_direct_turn( if let Err(error) = append_direct_project_user_message_at(&root, &canonical_user_item) { // 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口用户 // 消息的助手回复,而且失败会被静默掉。 - let failure = TurnError::environment_not_ready(redact_agent_runtime_error( - &root, - &format!("写入本项目对话历史失败:{error}"), - 600, - )); + // 原文不在这里脱敏:`classify` 在投影成失败载荷时统一脱敏 + 截断(错误文案的脱敏保留)。 + let failure = TurnError::environment_not_ready(format!("写入本项目对话历史失败:{error}")); finish_turn_failure(&reservation, &failure, &root); return; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/items.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/items.rs index cec623fb7..2d94f084b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/items.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/items.rs @@ -1,18 +1,7 @@ -use crate::agent::redact_secret_tokens; -use crate::agent::sanitize_error_context; -use crate::redact_absolute_path_tokens; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::path::Path; use ts_rs::TS; -/// 正文(消息 / 思考)上限。 -const THREAD_TEXT_MAX_CHARS: usize = 8_000; -/// 工具明细(命令 / 参数 / 输出)上限。 -const THREAD_DETAIL_MAX_CHARS: usize = 4_000; -/// 单条变更路径上限。 -const THREAD_PATH_MAX_CHARS: usize = 300; - /// 一条文件变更。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -23,7 +12,7 @@ pub(crate) struct ThreadFileChange { pub(crate) kind: String, } -/// 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。 +/// 聊天视图的输入条目:一条 Codex 原始条目的原样投影(脱敏与限长在事件进前端状态时统一做)。 /// /// `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 /// 未识别的类型走 [`ThreadItem::Other`],Rust 不替前端决定它是否可见。 @@ -198,20 +187,7 @@ impl ThreadRequestKind { } } -fn bounded(value: &str, max_chars: usize) -> String { - if value.chars().count() <= max_chars { - return value.to_string(); - } - let mut truncated = value.chars().take(max_chars).collect::(); - truncated.push('…'); - truncated -} - -fn detail_text(root: &Path, value: &str) -> String { - bounded(&sanitize_detail_text(root, value), THREAD_DETAIL_MAX_CHARS) -} - -/// 原始值转文本:字符串原样,其它 JSON 值序列化(调用方随后脱敏)。 +/// 原始值转文本:字符串原样,其它 JSON 值序列化。脱敏不在这一层做。 fn value_text(value: &Value) -> Option { match value { Value::Null => None, @@ -220,9 +196,8 @@ fn value_text(value: &Value) -> Option { } } -fn item_text(root: &Path, item: &Value) -> Option { - let raw = item - .get("text") +fn item_text(item: &Value) -> Option { + item.get("text") .and_then(Value::as_str) // 空 / 全空白的 `text` 要在**回落之前**判掉:否则 `Some("")` 会短路 `content` / `summary` // 兜底,末尾那条非空过滤再把整条条目丢掉(message / reasoning 的正文就此消失)。 @@ -243,16 +218,7 @@ fn item_text(root: &Path, item: &Value) -> Option { } } None - })?; - Some(bounded( - &sanitize_detail_text(root, &raw), - THREAD_TEXT_MAX_CHARS, - )) -} - -/// 流式正文与完成态条目使用同一套脱敏和字符预算,避免增量文本绕过历史投影的安全边界。 -pub(crate) fn thread_delta_text(root: &Path, value: &str) -> String { - bounded(&sanitize_detail_text(root, value), THREAD_TEXT_MAX_CHARS) + }) } fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { @@ -349,7 +315,7 @@ pub(crate) fn thread_item_identity(item: &Value) -> Option { call_id.or(id) } -fn item_changes(root: &Path, item: &Value) -> Vec { +fn item_changes(item: &Value) -> Vec { item.get("changes") .and_then(Value::as_array) .map(|changes| { @@ -362,11 +328,11 @@ fn item_changes(root: &Path, item: &Value) -> Vec { .map(str::trim) .filter(|path| !path.is_empty())?; Some(ThreadFileChange { - path: bounded(&sanitize_detail_text(root, path), THREAD_PATH_MAX_CHARS), + path: path.to_string(), kind: change .get("kind") .and_then(Value::as_str) - .map(|kind| bounded(kind, THREAD_DETAIL_MAX_CHARS)) + .map(str::to_string) .unwrap_or_else(|| "update".to_string()), }) }) @@ -375,20 +341,17 @@ fn item_changes(root: &Path, item: &Value) -> Vec { .unwrap_or_default() } -fn field_text(root: &Path, item: &Value, key: &str) -> Option { - item.get(key) - .and_then(value_text) - .map(|value| detail_text(root, &value)) +fn field_text(item: &Value, key: &str) -> Option { + item.get(key).and_then(value_text) } /// 把一条 Codex 原始条目投影成线上条目;拿不到身份或类型时返回 `None`。 /// /// `observed_at_ms` 只在条目自带时间缺失时兜底(运行态用当前时间,历史用文件记录时间)。 -pub(crate) fn thread_item_from_value( - root: &Path, - item: &Value, - observed_at_ms: u64, -) -> Option { +/// +/// 这一层**原样透传**字段,不做脱敏、也不限长:脱敏在事件进前端状态时统一做(前端 +/// `directThreadSanitize`)。Thread Manager 只归一身形与身份,不替前端决定什么能看。 +pub(crate) fn thread_item_from_value(item: &Value, observed_at_ms: u64) -> Option { if !item.is_object() { return None; } @@ -399,7 +362,7 @@ pub(crate) fn thread_item_from_value( .map(str::trim) .filter(|value| !value.is_empty())?; let at = item_at_ms(item, observed_at_ms); - let text = item_text(root, item); + let text = item_text(item); let role = item .get("role") .and_then(Value::as_str) @@ -430,14 +393,14 @@ pub(crate) fn thread_item_from_value( name: item .get("name") .and_then(Value::as_str) - .map(|name| detail_text(root, name)) + .map(str::to_string) .unwrap_or_default(), - arguments: field_text(root, item, "arguments").unwrap_or_default(), + arguments: field_text(item, "arguments").unwrap_or_default(), at, }, "function_call_output" => ThreadItem::FunctionCallOutput { item_id, - output: field_text(root, item, "output").unwrap_or_default(), + output: field_text(item, "output").unwrap_or_default(), at, }, "commandExecution" => ThreadItem::CommandExecution { @@ -447,21 +410,21 @@ pub(crate) fn thread_item_from_value( .and_then(Value::as_str) .map(str::trim) .filter(|command| !command.is_empty()) - .map(|command| detail_text(root, command)) + .map(str::to_string) .unwrap_or_default(), output: ["aggregatedOutput", "output", "error"] .iter() - .find_map(|key| field_text(root, item, key)), + .find_map(|key| field_text(item, key)), status: item .get("status") .and_then(Value::as_str) - .map(|status| bounded(status, THREAD_DETAIL_MAX_CHARS)), + .map(str::to_string), exit_code: item.get("exitCode").and_then(Value::as_i64), at, }, "fileChange" => ThreadItem::FileChange { item_id, - changes: item_changes(root, item), + changes: item_changes(item), at, }, "mcpToolCall" => ThreadItem::McpToolCall { @@ -471,33 +434,32 @@ pub(crate) fn thread_item_from_value( .and_then(Value::as_str) .map(str::trim) .filter(|tool| !tool.is_empty()) - .map(|tool| detail_text(root, tool)) + .map(str::to_string) .unwrap_or_default(), - arguments: field_text(root, item, "arguments").unwrap_or_default(), + arguments: field_text(item, "arguments").unwrap_or_default(), output: ["result", "error"] .iter() - .find_map(|key| field_text(root, item, key)), + .find_map(|key| field_text(item, key)), status: item .get("status") .and_then(Value::as_str) - .map(|status| bounded(status, THREAD_DETAIL_MAX_CHARS)), + .map(str::to_string), at, }, "webSearch" => ThreadItem::WebSearch { item_id, - query: field_text(root, item, "query").or_else(|| { + query: field_text(item, "query").or_else(|| { item.get("action") .and_then(|action| action.get("query")) .and_then(value_text) - .map(|query| detail_text(root, &query)) }), - output: field_text(root, item, "output"), + output: field_text(item, "output"), at, }, "contextCompaction" => ThreadItem::ContextCompaction { item_id, at }, other => ThreadItem::Other { item_id, - raw_type: bounded(other, THREAD_DETAIL_MAX_CHARS), + raw_type: other.to_string(), at, }, }) @@ -507,131 +469,11 @@ pub(crate) fn thread_item_from_value( /// /// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 pub(crate) fn thread_items_from_history( - root: &Path, items: &[Value], timestamp_of: impl Fn(&Value) -> u64, ) -> Vec { items .iter() - .filter_map(|item| thread_item_from_value(root, item, timestamp_of(item))) + .filter_map(|item| thread_item_from_value(item, timestamp_of(item))) .collect() } - -/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 -fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { - let mut index = start; - let mut relative = String::new(); - while index < value.len() { - let character = value[index..].chars().next().unwrap_or_default(); - if matches!(character, '/' | '\\') { - if !relative.is_empty() { - relative.push('/'); - } - index += character.len_utf8(); - continue; - } - if character.is_whitespace() - || matches!( - character, - '\'' | '"' - | '`' - | ',' - | ';' - | '|' - | '&' - | '(' - | ')' - | '[' - | ']' - | '{' - | '}' - | '<' - | '>' - | ':' - ) - { - break; - } - relative.push(character); - index += character.len_utf8(); - } - while relative.ends_with('/') { - relative.pop(); - } - (index, relative) -} - -/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 -/// -/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 -/// ``,之后就再也认不出哪些路径在项目内了。 -/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 -fn relativize_project_root_paths(root: &Path, value: &str) -> String { - let root_text = root.to_string_lossy(); - let root_text = root_text.trim_end_matches(['/', '\\']); - if root_text.is_empty() { - return value.to_string(); - } - let mut needles = [ - root_text.to_string(), - root_text.replace('\\', "/"), - root_text.replace('/', "\\"), - ] - .into_iter() - .map(|needle| needle.to_ascii_lowercase()) - .filter(|needle| !needle.is_empty()) - .collect::>(); - needles.sort(); - needles.dedup(); - let lower = value.to_ascii_lowercase(); - - let mut output = String::with_capacity(value.len()); - let mut cursor = 0usize; - while cursor < value.len() { - let mut hit: Option<(usize, usize)> = None; - for needle in &needles { - let mut search = cursor; - while let Some(relative) = lower[search..].find(needle.as_str()) { - let start = search + relative; - let end = start + needle.len(); - let left_is_boundary = start == 0 - || lower[..start].chars().next_back().is_some_and(|character| { - !character.is_alphanumeric() && character != '_' && character != '-' - }); - if left_is_boundary && value[end..].starts_with(['/', '\\']) { - if hit.is_none_or(|(best_start, _)| start < best_start) { - hit = Some((start, end)); - } - break; - } - search = end; - } - } - let Some((start, end)) = hit else { - break; - }; - output.push_str(&value[cursor..start]); - let (consumed, relative) = project_relative_path_segment(value, end); - if relative.is_empty() { - // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 - output.push_str(""); - } else { - output.push_str(&relative); - } - cursor = consumed; - } - output.push_str(&value[cursor..]); - output -} - -/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 -/// 错误上下文脱敏。 -/// -/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 -/// 仍然会留下;这里先归一化路径 token,再处理密钥。 -pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { - let without_project_root = relativize_project_root_paths(root, value); - let without_absolute = redact_absolute_path_tokens(&without_project_root); - let without_secret = redact_secret_tokens(&without_absolute); - sanitize_error_context(&without_secret) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/tests.rs index 37fd9a3f7..e809cc22f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/tests.rs @@ -1,16 +1,10 @@ use super::clock::*; use super::{failure::*, items::*, turn::*}; use serde_json::json; -use std::path::Path; - -fn root() -> &'static Path { - Path::new(".") -} #[test] fn message_item_carries_role_text_and_turn() { let item = thread_item_from_value( - root(), &json!({ "id": "direct-codex:turn-1:user", "type": "message", @@ -34,7 +28,6 @@ fn message_item_carries_role_text_and_turn() { #[test] fn app_server_agent_message_defaults_to_assistant_role() { let item = thread_item_from_value( - root(), &json!({"id": "msg-1", "type": "agentMessage", "text": "已执行"}), 1000, ) @@ -49,7 +42,6 @@ fn app_server_agent_message_defaults_to_assistant_role() { fn blank_message_text_falls_back_to_content_parts() { // 空 / 全空白的 `text` 不能短路 `content` 兜底:否则整条条目会被判成"没有正文"直接丢掉。 let item = thread_item_from_value( - root(), &json!({ "id": "msg-blank-text", "type": "message", @@ -69,7 +61,6 @@ fn blank_message_text_falls_back_to_content_parts() { #[test] fn tool_item_identity_is_normalized_to_one_id() { let item = thread_item_from_value( - root(), &json!({ "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", @@ -93,7 +84,6 @@ fn tool_item_identity_is_normalized_to_one_id() { #[test] fn command_execution_keeps_raw_status_and_exit_code() { let item = thread_item_from_value( - root(), &json!({ "id": "call-1", "type": "commandExecution", @@ -118,14 +108,15 @@ fn command_execution_keeps_raw_status_and_exit_code() { } #[test] -fn secrets_and_absolute_paths_are_not_leaked() { +fn item_text_is_passed_through_verbatim() { + // Thread Manager 不再脱敏 / 限长:原始正文原样下发,脱敏在事件进前端状态时统一做。 + let raw = "key=sk-abcdefghijklmnop at /root/secret/x"; let item = thread_item_from_value( - root(), &json!({ "id": "msg-1", "type": "message", "role": "assistant", - "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop at /root/secret/x"}], + "content": [{"type": "output_text", "text": raw}], }), 0, ) @@ -133,11 +124,7 @@ fn secrets_and_absolute_paths_are_not_leaked() { let ThreadItem::Message { text, .. } = item else { panic!("message item"); }; - assert!( - !text.contains("sk-abcdefghijklmnop"), - "不得泄漏明文密钥:{text}" - ); - assert!(!text.contains("/root/secret"), "不得泄漏绝对路径:{text}"); + assert_eq!(text, raw); } #[test] @@ -157,7 +144,7 @@ fn history_keeps_call_and_output_as_two_items_with_one_identity() { "output": "assets\ngame\n", }), ]; - let projected = thread_items_from_history(root(), &items, |_| 0); + let projected = thread_items_from_history(&items, |_| 0); assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); assert!(matches!(projected[0], ThreadItem::FunctionCall { .. })); assert!(matches!( @@ -172,7 +159,6 @@ fn history_keeps_call_and_output_as_two_items_with_one_identity() { #[test] fn unknown_item_types_are_passed_through_without_body() { let item = thread_item_from_value( - root(), &json!({"id": "plan-1", "type": "plan", "text": "内部计划"}), 0, ) @@ -198,7 +184,7 @@ fn event_stage_time_is_independent_from_item_display_time() { "startedAtMs": 1_000u64, }, }); - let item = thread_item_from_value(root(), ¶ms["item"], 7_777).expect("item"); + let item = thread_item_from_value(¶ms["item"], 7_777).expect("item"); // 条目展示时间不受事件级时间影响,仍按条目自己的字段推导。 assert_eq!(item.at(), 1_000); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs index 13fd24ccd..0ab1922ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs @@ -1400,7 +1400,7 @@ pub(crate) async fn read_direct_project_history_slice( }; let (raw_items, has_more, recorded_at_ms, first_item_id) = read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?; - let items = thread_items_from_history(root, &raw_items, |item| { + let items = thread_items_from_history(&raw_items, |item| { thread_item_identity(item) .and_then(|identity| recorded_at_ms.get(&identity).copied()) .unwrap_or_default()