diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs index cd83485d2..005e9e333 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs @@ -9,6 +9,7 @@ //! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 use crate::agent::redact_secret_tokens; +use crate::agent::sanitize_error_context; use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; use crate::project::{enforce_project_permission_policy, project_append_lock_for}; use crate::redact_absolute_path_tokens; @@ -85,18 +86,130 @@ fn tool_calls_path(root: &Path) -> PathBuf { root.join(".agent/conversations/tool-calls.jsonl") } -/// 脱敏:项目内相对路径保留,其余按「先抹绝对路径、再抹密钥」处理。 -/// -/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 TOKEN 换成占位符,但绝对路径里的 -/// 用户名目录仍然会留下;这里先归一化路径 token,再处理密钥。 -fn sanitize_detail_text(root: &Path, value: &str) -> String { - let without_absolute = redact_absolute_path_tokens(value); - let without_secret = redact_secret_tokens(&without_absolute); - let root = root.to_string_lossy(); - if root.is_empty() { - return without_secret; +/// 项目根目录之后的路径 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(); } - without_secret.replace(root.as_ref(), "") + 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,再处理密钥。 +/// +/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context` +/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` + +/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖 +/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据; +/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed +/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。 +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) } /// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。 @@ -266,15 +379,17 @@ pub(crate) fn direct_tool_call_from_item( }) .collect::>(); + let tool = item + .get("tool") + .and_then(Value::as_str) + .map(str::trim) + .filter(|tool| !tool.is_empty()) + .map(|tool| sanitize_detail_text(root, tool)); + // `summary` 会落到卡片与落盘文件,它的兜底来源同样必须脱敏。 let summary_source = command .as_deref() .or_else(|| changes.first().map(|change| change.path.as_str())) - .or_else(|| { - item.get("tool") - .and_then(Value::as_str) - .map(str::trim) - .filter(|tool| !tool.is_empty()) - }) + .or(tool.as_deref()) .unwrap_or_default(); let summary = first_line_bounded(summary_source, DIRECT_TOOL_CALL_SUMMARY_MAX_CHARS); @@ -329,23 +444,40 @@ fn read_tool_call_lines(path: &Path) -> Vec { let Ok(file) = File::open(path) else { return Vec::new(); }; + let mut reader = BufReader::new(file); + let mut buffer = Vec::new(); let mut calls = Vec::new(); - for line in BufReader::new(file).lines() { - let Ok(line) = line else { - break; - }; - if let Some(call) = tool_call_from_line(&line) { - calls.push(call); + loop { + buffer.clear(); + match reader.read_until(b'\n', &mut buffer) { + Ok(0) => break, + // 单行解码失败(非法 UTF-8)只跳过这一行,继续读后面的行; + // 契约要求「单行损坏跳过该行继续」,不能把后续记录一起丢掉。 + Ok(_) => match std::str::from_utf8(&buffer) { + Ok(line) => { + if let Some(call) = tool_call_from_line(line) { + calls.push(call); + } + } + Err(_) => continue, + }, + // 读 I/O 错误:无法再定位下一行边界,停止读取(已读到的照常返回)。 + Err(_) => break, } } calls } -/// 按 id 归并(后写覆盖先写),再按时间正序裁剪到最近 `DIRECT_TOOL_CALL_LIMIT` 条。 +/// 按 id 归并(同 id 按 `updatedAt` 单调合并),再按时间正序裁剪到最近 +/// `DIRECT_TOOL_CALL_LIMIT` 条。 fn normalize_tool_calls(calls: Vec) -> Vec { let mut by_id: BTreeMap = BTreeMap::new(); for call in calls { - by_id.insert(call.id.clone(), call); + let merged = match by_id.remove(&call.id) { + Some(previous) => merge_tool_call_snapshot(&previous, &call), + None => call, + }; + by_id.insert(merged.id.clone(), merged); } let mut normalized = by_id.into_values().collect::>(); normalized.sort_by(|left, right| { @@ -368,20 +500,43 @@ pub(crate) fn read_direct_tool_calls_at(root: &Path) -> Result) { - let Some(existing) = existing else { - return; - }; - if existing.started_at > 0 - && (incoming.started_at == 0 || existing.started_at < incoming.started_at) - { - incoming.started_at = existing.started_at; +/// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。 +fn status_certainty(status: &str) -> u8 { + match status { + "completed" | "failed" => 1, + _ => 0, } } -/// 幂等 upsert:同 id 只保留一行,`completed` 覆盖 `started`。 +/// 同一 id 的两条快照按 `updatedAt` 做**单调合并**。 +/// +/// - `startedAt` 取最早的非零值:`item/completed` 事件不一定带 `startedAtMs`, +/// 不能让 completed 覆盖掉 started 记下的起点(卡片时间序依赖它)。 +/// - `updatedAt` 更旧的快照不得覆盖更新的状态与 `updatedAt`:`direct_runtime.rs` 里 +/// 「回合末整批落盘」与「逐条快照落盘(spawn_blocking)」两条路径竞争时,后到的 +/// 旧快照不能把已经 `completed` / `failed` 的卡片打回 `running`。 +/// - `updatedAt` 相同时终态优先,避免同一毫秒内的旧快照回退状态。 +fn merge_tool_call_snapshot( + existing: &DirectToolCall, + incoming: &DirectToolCall, +) -> DirectToolCall { + let take_incoming = incoming.updated_at > existing.updated_at + || (incoming.updated_at == existing.updated_at + && status_certainty(&incoming.status) > status_certainty(&existing.status)); + let mut merged = if take_incoming { + incoming.clone() + } else { + existing.clone() + }; + merged.started_at = [merged.started_at, existing.started_at, incoming.started_at] + .into_iter() + .filter(|started_at| *started_at > 0) + .min() + .unwrap_or_default(); + merged +} + +/// 幂等 upsert:同 id 只保留一行,快照按 `updatedAt` 单调合并(旧快照不得回退状态)。 /// /// 单次尝试的顺序是「取项目锁 + append 锁 → 锁内读 → 整文件原子替换」。 /// 工具调用是**追加 + 就地更新**混用的数据,没有纯追加的 JSONL 语义,所以只能整文件重写; @@ -399,9 +554,11 @@ fn upsert_direct_tool_call_once(root: &Path, call: &DirectToolCall) -> Result<() .iter() .find(|existing| existing.id == call.id) .cloned(); - let mut incoming = call.clone(); - preserve_started_at(&mut incoming, existing.as_ref()); - calls.retain(|existing| existing.id != call.id); + let incoming = match existing.as_ref() { + Some(existing) => merge_tool_call_snapshot(existing, call), + None => call.clone(), + }; + calls.retain(|existing| existing.id != incoming.id); calls.push(incoming); let normalized = normalize_tool_calls(calls); let mut body = String::new(); @@ -440,11 +597,13 @@ pub(crate) fn persist_direct_tool_calls_at( let mut existing = read_tool_call_lines(&path); let mut incoming = calls.to_vec(); for call in incoming.iter_mut() { - let previous = existing + let merged = existing .iter() - .find(|existing| existing.id == call.id) - .cloned(); - preserve_started_at(call, previous.as_ref()); + .find(|row| row.id == call.id) + .map(|previous| merge_tool_call_snapshot(previous, call)); + if let Some(merged) = merged { + *call = merged; + } } let ids = incoming .iter() @@ -474,11 +633,31 @@ pub(crate) fn direct_tool_call_now_ms() -> u64 { mod tests { use super::{ direct_tool_call_from_item, direct_tool_call_now_ms, persist_direct_tool_call_at, - persist_direct_tool_calls_at, read_direct_tool_calls_at, tool_calls_path, - DIRECT_TOOL_CALL_LIMIT, + persist_direct_tool_calls_at, read_direct_tool_calls_at, sanitize_detail_text, + tool_calls_path, DIRECT_TOOL_CALL_LIMIT, }; use serde_json::json; + /// 一行合法的落盘信封(回读用例的夹具)。 + fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String { + serde_json::to_string(&json!({ + "type": "tool_call_item", + "payload": { + "schemaVersion": "agc-tool-call.v1", + "id": id, + "turnId": "turn-1", + "kind": "command", + "title": "执行命令", + "summary": "npm run build", + "status": "completed", + "detail": {"command": "npm run build"}, + "startedAt": started_at, + "updatedAt": updated_at + } + })) + .expect("serialize tool call row") + } + fn init_tool_call_project(name: &str) -> tempfile::TempDir { let root = tempfile::tempdir().expect("temp project"); crate::init_local_game_project_at(root.path(), name, "工具调用卡片测试") @@ -750,4 +929,383 @@ mod tests { ); } } + /// 五类必须脱敏的凭据形状(审查报告实测泄漏的那五类)。 + const CREDENTIAL_CANARIES: [&str; 5] = [ + "canary-bearer-value", + "canary-cookie-value", + "canary-api-key-value", + "canary-client-secret-value", + "canary-password-value", + ]; + + /// 判据:`Authorization: Bearer` / `Cookie: session=` / `api_key=` / `client_secret=` / + /// `--password <值>` 五类凭据在投影结果与落盘行里都不得出现原始值。 + #[test] + fn tool_call_redacts_extended_credential_shapes() { + let root = init_tool_call_project("tool-call-credential-shapes"); + let command = [ + "curl -H 'Authorization: Bearer canary-bearer-value' https://example.com", + "curl -b 'Cookie: session=canary-cookie-value' https://example.com", + "curl -d api_key=canary-api-key-value https://example.com", + "curl -d client_secret=canary-client-secret-value https://example.com", + "vault login --password canary-password-value --env prod", + ] + .join("\n"); + let output = [ + "Authorization: Bearer canary-output-bearer-value", + "Cookie: session=canary-output-cookie-value", + ] + .join("\n"); + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-credentials", + "type": "commandExecution", + "command": command, + "aggregatedOutput": output, + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("credential tool call"); + + let projected_command = call.detail.command.as_deref().expect("command"); + let projected_output = call.detail.output.as_deref().expect("output"); + for canary in CREDENTIAL_CANARIES { + assert!( + !projected_command.contains(canary), + "命令投影里不得出现原始凭据 {canary}:{projected_command}" + ); + } + for canary in ["canary-output-bearer-value", "canary-output-cookie-value"] { + assert!( + !projected_output.contains(canary), + "输出投影里不得出现原始凭据 {canary}:{projected_output}" + ); + } + assert!( + !call.summary.contains("canary-bearer-value"), + "摘要取自命令首行,同样不得带原始凭据:{}", + call.summary + ); + + persist_direct_tool_call_at(root.path(), &call).expect("persist credential call"); + let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); + for canary in CREDENTIAL_CANARIES { + assert!( + !raw.contains(canary), + "落盘行里不得出现原始凭据 {canary}:{raw}" + ); + } + for canary in ["canary-output-bearer-value", "canary-output-cookie-value"] { + assert!( + !raw.contains(canary), + "落盘行里不得出现原始凭据 {canary}:{raw}" + ); + } + } + + /// 判据:脱敏不误伤正常内容、既有前缀脱敏不回退、且幂等(连跑两次结果一致)。 + #[test] + fn tool_call_redaction_keeps_normal_text_and_is_idempotent() { + let root = init_tool_call_project("tool-call-redaction-idempotent"); + let sanitize = |value: &str| sanitize_detail_text(root.path(), value); + + // 出现 `password` 单词但没有赋值 → 属于正常内容,不得脱敏。 + let plain = "grep -n password game/src/config.ts"; + let once = sanitize(plain); + assert_eq!(once, plain, "没有赋值的 password 单词不得被脱敏"); + assert_eq!(sanitize(&once), once, "脱敏必须幂等"); + + // 既有前缀脱敏(sk-…)不得回退。 + let prefixed = "curl -H 'X-Api-Key: sk-canary-prefix-key' https://example.com"; + let once = sanitize(prefixed); + assert!( + !once.contains("sk-canary-prefix-key"), + "既有前缀脱敏不得回退:{once}" + ); + assert_eq!(sanitize(&once), once, "脱敏必须幂等"); + + // `--password <值>`:沿用既有 fail-closed 约定(含敏感 CLI 标志的行整行替换), + // 原始值随之消失,且再次脱敏结果不变。 + let with_secret = "vault login --password canary-password-value --env prod"; + let once = sanitize(with_secret); + assert!( + !once.contains("canary-password-value"), + "`--password <值>` 不得落盘明文:{once}" + ); + assert_eq!(sanitize(&once), once, "脱敏必须幂等"); + + // `--password $ENV`:占位符不是密钥,但既有 `contains_sensitive_cli_flag` 按标志 + // fail-closed 整行替换(与 sanitize_error_context 一致),本次属契约内行为。 + let placeholder = "vault login --password $DEPLOY_PASSWORD --env prod"; + let once = sanitize(placeholder); + assert_eq!( + once, "[redacted sensitive context]", + "含敏感 CLI 标志的行按既有约定整行替换" + ); + assert_eq!(sanitize(&once), once, "脱敏必须幂等"); + } + + /// 判据:同 id 的快照按 `updatedAt` 单调合并——后到的旧快照不得把终态打回 `running`, + /// 也不得回退 `updatedAt`;`startedAt` 仍取最早。 + #[test] + fn tool_call_persist_keeps_newest_snapshot_per_item() { + let root = init_tool_call_project("tool-call-monotonic"); + let running = direct_tool_call_from_item( + root.path(), + &command_item("item-1", "npm run build"), + "turn-1", + false, + 1000, + ) + .expect("running tool call"); + assert_eq!(running.status, "running"); + let completed = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-1", + "type": "commandExecution", + "command": "npm run build", + "exitCode": 0, + "completedAtMs": 2000, + }), + "turn-1", + true, + 2000, + ) + .expect("completed tool call"); + assert_eq!(completed.status, "completed"); + assert_eq!(completed.updated_at, 2000); + + persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first"); + persist_direct_tool_call_at(root.path(), &running).expect("persist stale running"); + let calls = read_direct_tool_calls_at(root.path()).expect("read after stale single write"); + assert_eq!(calls.len(), 1, "同一 id 只能有一行"); + assert_eq!( + calls[0].status, "completed", + "后到的旧快照不得把 completed 打回 running" + ); + assert_eq!(calls[0].updated_at, 2000, "旧快照不得回退 updatedAt"); + assert_eq!(calls[0].started_at, 1000, "startedAt 仍取最早"); + + // 回合末整批落盘那条路径同样不得回退。 + persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running)) + .expect("persist stale running batch"); + let calls = read_direct_tool_calls_at(root.path()).expect("read after stale batch write"); + assert_eq!( + calls[0].status, "completed", + "整批落盘路径同样不得把 completed 打回 running" + ); + assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt"); + } + + /// 判据:读回时同 id 的重复行也按 `updatedAt` 单调合并(磁盘上留有旧快照不得回退状态)。 + #[test] + fn tool_call_read_merges_duplicate_rows_monotonically() { + let root = init_tool_call_project("tool-call-read-monotonic"); + let path = tool_calls_path(root.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + let completed = tool_call_row("item-1", 1000, 2000); + let stale_running = tool_call_row("item-1", 1000, 1000) + .replace("\"status\":\"completed\"", "\"status\":\"running\""); + assert!(stale_running.contains("\"status\":\"running\"")); + std::fs::write(&path, format!("{completed}\n{stale_running}\n")).expect("write fixture"); + + let calls = read_direct_tool_calls_at(root.path()).expect("read duplicate rows"); + assert_eq!(calls.len(), 1, "同 id 归并成一条"); + assert_eq!( + calls[0].status, "completed", + "磁盘上更旧的快照不得把状态打回 running" + ); + assert_eq!(calls[0].updated_at, 2000, "归并保留更新的 updatedAt"); + assert_eq!(calls[0].started_at, 1000); + } + + /// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。 + #[test] + fn tool_call_paths_become_project_relative() { + let root = init_tool_call_project("tool-call-path-shape"); + let root_display = root.path().to_string_lossy().to_string(); + let inside = root + .path() + .join("game/src/x.ts") + .to_string_lossy() + .to_string(); + let outside = if cfg!(windows) { + r"C:\Windows\Temp\canary-outside.ts".to_string() + } else { + "/opt/canary/outside.ts".to_string() + }; + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-paths", + "type": "fileChange", + "changes": [ + {"path": inside, "kind": "update"}, + {"path": outside, "kind": "add"}, + ], + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("path tool call"); + let paths = call + .detail + .changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + assert_eq!( + paths[0], "game/src/x.ts", + "项目内绝对路径必须落成项目相对路径(不能是占位符)" + ); + assert_eq!(paths[1], "", "项目外绝对路径保持占位形状"); + assert_eq!(call.summary, "game/src/x.ts", "摘要取首个变更路径"); + + persist_direct_tool_call_at(root.path(), &call).expect("persist path call"); + let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); + assert!( + !raw.contains(&root_display), + "落盘不得残留项目根目录:{raw}" + ); + } + + /// 判据:单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回。 + #[test] + fn tool_call_read_skips_invalid_utf8_line() { + let root = init_tool_call_project("tool-call-invalid-utf8"); + let path = tool_calls_path(root.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + + // 形态一(契约原文):合法行 + 非法字节行 + 合法行 → 读回 2 条。 + let mut bytes = Vec::new(); + bytes.extend_from_slice(tool_call_row("item-a", 1000, 1000).as_bytes()); + bytes.push(b'\n'); + bytes.extend_from_slice(&[0xff, 0xfe, b'\n']); + bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes()); + bytes.push(b'\n'); + std::fs::write(&path, &bytes).expect("write invalid utf8 fixture"); + let calls = read_direct_tool_calls_at(root.path()).expect("read with invalid utf8"); + assert_eq!( + calls.len(), + 2, + "非法 UTF-8 行只跳过该行,后面的合法记录必须读回" + ); + assert_eq!(calls[0].id, "item-a"); + assert_eq!(calls[1].id, "item-b"); + + // 形态二:损坏行缺换行(写入被截断),与紧随其后的记录黏成一行。 + // 此时被丢掉的只有黏连的那一行,其后的合法记录必须继续读回。 + let mut bytes = Vec::new(); + bytes.extend_from_slice(tool_call_row("item-a", 1000, 1000).as_bytes()); + bytes.push(b'\n'); + bytes.push(0xff); + bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes()); + bytes.push(b'\n'); + bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes()); + bytes.push(b'\n'); + std::fs::write(&path, &bytes).expect("write truncated utf8 fixture"); + let calls = read_direct_tool_calls_at(root.path()).expect("read with truncated line"); + assert_eq!( + calls.len(), + 2, + "损坏行缺换行时只丢黏连的那一行,其后的合法记录必须继续读回" + ); + assert_eq!(calls[0].id, "item-a"); + assert_eq!(calls[1].id, "item-c"); + } + + /// 判据:200 条上限是「按时间保留最新 200 条」,超出时更早回合的卡片会被静默丢弃 + /// (契约内行为,不是缺陷)。本用例只钉住现状与时间正序。 + #[test] + fn tool_call_cap_drops_oldest_turn_cards() { + let root = init_tool_call_project("tool-call-cap-oldest"); + let old_turn = (0..DIRECT_TOOL_CALL_LIMIT) + .map(|index| { + direct_tool_call_from_item( + root.path(), + &json!({ + "id": format!("item-{index:04}"), + "type": "commandExecution", + "command": format!("run {index}"), + "startedAtMs": 1000 + index as u64, + }), + "turn-old", + false, + 1000 + index as u64, + ) + .expect("old turn tool call") + }) + .collect::>(); + persist_direct_tool_calls_at(root.path(), &old_turn).expect("persist old turn"); + let newest = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-newest", + "type": "commandExecution", + "command": "run newest", + "startedAtMs": 90_000, + }), + "turn-new", + false, + 90_000, + ) + .expect("newest tool call"); + persist_direct_tool_call_at(root.path(), &newest).expect("persist newest"); + + let read = read_direct_tool_calls_at(root.path()).expect("read capped"); + assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条"); + assert_eq!( + read.last().expect("last").id, + "item-newest", + "最新回合的卡片必须在" + ); + assert_eq!( + read.first().expect("first").id, + "item-0001", + "最旧回合的卡片被静默丢弃(老回合卡片会消失)" + ); + assert!( + read.windows(2) + .all(|pair| pair[0].timestamp() <= pair[1].timestamp()), + "回读必须按时间正序" + ); + } + + /// 判据:项目内路径的 `\` / `/` 两种写法与大小写变体都要落成同一份项目相对路径 + /// (Codex 上报的路径分隔符与盘符大小写不受我们控制)。 + #[test] + fn tool_call_paths_normalize_separators_and_case() { + let root = init_tool_call_project("tool-call-path-variants"); + let native = root.path().to_string_lossy().to_string(); + let variants = [native.replace('\\', "/"), native.to_ascii_uppercase()]; + let mut paths = Vec::new(); + for (index, variant) in variants.into_iter().enumerate() { + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": format!("item-path-variant-{index}"), + "type": "fileChange", + "changes": [{"path": format!("{variant}/game/src/y.ts"), "kind": "update"}], + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("variant path tool call"); + paths.push(call.detail.changes[0].path.clone()); + } + assert_eq!(paths[0], "game/src/y.ts", "`/` 写法同样要落成项目相对路径"); + assert_eq!( + paths[1], "game/src/y.ts", + "大小写变体同样要落成项目相对路径" + ); + } } diff --git a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md index 9d4ecb2d6..3081ffb1d 100644 --- a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md +++ b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md @@ -24,10 +24,16 @@ ``` - `id`:Codex item 的 id;同一 item 的 `started` 与 `completed` 必须落成**同一条**(按 id 幂等 upsert,不允许写两行)。 +- **状态单调**:同一 `id` 的每条快照按 `updatedAt` 合并落盘——`updatedAt` 更旧的快照不得覆盖更新的 `status` 与 `updatedAt`。逐条快照落盘与回合末整批落盘两条路径会并发竞争,后到的旧快照不能把已经 `completed` / `failed` 的卡片打回 `running`;`updatedAt` 相同时终态优先;`startedAt` 取最早的非零值(`item/completed` 不一定带 `startedAtMs`)。 - `title` 是折叠态的一行标题,按 kind 固定:`command` → `执行命令`、`file_change` → `编辑 N 个文件`(N = changes 去重后数量)、其余见 kind 枚举。 -- `summary` 是折叠态标题后面的短摘要:命令取命令首行(截断 120 字符),`file_change` 取首个变更路径。 -- `detail.command` / `detail.output` 各截断到 4000 字符;`detail.changes[].path` 用项目相对路径。 -- **必须脱敏**:沿用既有 `codex_app_server.rs` 里对 command/tool 参数的安全处理,不得把 API Key、Token、Cookie、绝对用户目录写进 `detail`。 +- `summary` 是折叠态标题后面的短摘要:命令取命令首行(截断 120 字符),`file_change` 取首个变更路径。`summary` 的每个来源(命令、变更路径、`tool`)都必须先脱敏再落盘。 +- `detail.command` / `detail.output` 各截断到 4000 字符。 +- **路径形状**:`detail.changes[].path` 用**项目相对路径**(如 `game/src/x.ts`,分隔符统一成 `/`);项目外的绝对路径落成 `` 占位。任何情况下都不得写出项目根目录本身、用户家目录或绝对路径的原始值。 +- **必须脱敏**(落盘前统一走 `agent/direct_tool_calls.rs` 的 `sanitize_detail_text`,顺序:项目路径归一化 → `redact_absolute_path_tokens` → `redact_secret_tokens` → `sanitize_error_context`): + - 前缀型密钥沿用 `redact_secret_tokens`(`sk-…`、`tnr_sk_…`、`ghp_…`、`AKIA…`、`eyJ…` 等); + - 键值型凭据沿用 `sanitize_error_context`(= `redact_secret_tokens` + `redact_error_sensitive_assignments` + `redact_error_bearer_values` + `redact_error_config_names` 的既有组合),覆盖 `Authorization: Bearer …`、`Cookie: session=…`、`api_key=…`、`client_secret=…`、`token=…` 等形状; + - 含 `--password` / `--token` / `--secret` / `--api-key` 这类敏感 CLI 标志的行按既有 fail-closed 约定**整行**替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致;即使标志后面只是 `$VAR` 占位符也整行替换,占位符本身不会保留); + - 脱敏必须幂等(同一段文本连跑两次结果一致),且不得把未脱敏文本写进 `detail` / `summary`。 ### 2. 实时事件(新增字段,不改既有字段语义) @@ -43,10 +49,11 @@ toolCalls?: DirectTurnToolCall[] | null; ### 3. 回读命令 -新增 Tauri 命令 `read_direct_tool_calls(projectPath)`,返回按时间正序的 `DirectTurnToolCall[]`,最多最近 200 条(超出截断,保留最新)。 +新增 Tauri 命令 `read_direct_tool_calls(projectPath)`,返回按时间正序的 `DirectTurnToolCall[]`。 +- **上限语义**:200 条是「按时间保留最新 200 条」。超出时更早回合的卡片会被**静默丢弃**(老回合卡片会消失),不做分页、不做历史回填;同一 `id` 的多条记录先按 `updatedAt` 合并,再按时间正序裁剪。 - 历史文件缺失 → 返回空数组,不报错。 -- 单行损坏 → 跳过该行继续,不整体失败(与 Codex item 流一样是"尽力而为"的展示数据,不是业务真相)。 +- 单行损坏 → 逐行读字节并逐行解码,跳过该行继续,不整体失败;只有损坏字节与下一行黏成一行(例如写入被截断、缺失换行)时,被丢掉的也只是那**一行**,其后的合法记录必须继续读回(与 Codex item 流一样是"尽力而为"的展示数据,不是业务真相)。 ### 4. 前端合并与渲染(2026-09 修订:一回合一个折叠块) @@ -116,7 +123,7 @@ toolCalls?: DirectTurnToolCall[] | null; - Rust(`apps/ai-game-creator-shell/src-tauri/src/`):`agent/codex_app_server.rs`(item → 结构化采集)、`agent/direct_runtime.rs`(随事件下发 + 回合结束持久化)、`agent/direct_project_history.rs` 或新增 `agent/direct_tool_calls.rs`(upsert 与回读)、`commands.rs`(新增命令注册)。 - 前端(`apps/ai-game-creator-shell/src/`):`app/types.ts`(新增字段与类型)、`App.tsx`(订阅、累加、加载时合并)、`features/agent-runtime/` 或新增 `features/project-workspace/ToolCallCard.tsx`(卡片组件)、`styles.css`(卡片样式,新样式集中在文件末尾中文注释区块)。 -- 测试:Rust 侧单元测试(upsert 幂等、截断、脱敏、损坏行跳过)、前端 `tests/`(卡片渲染、折叠交互、重开项目后合并、老事件兼容)。 +- 测试:Rust 侧单元测试(upsert 幂等、状态按 `updatedAt` 单调合并、截断、脱敏覆盖与幂等性、项目相对路径形状、非法 UTF-8 损坏行跳过、200 条上限保留最新)、前端 `tests/`(卡片渲染、折叠交互、重开项目后合并、老事件兼容)。 ## 实施顺序(每步都要能独立验证)