From b8ff3cfb56fd34887321fcaecc7ae96eff3724bb Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 18:14:15 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E5=8D=A1=E7=89=87=E4=BA=94=E7=B1=BB=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E8=84=B1=E6=95=8F=E8=A6=86=E7=9B=96=E4=B8=8D=E8=B6=B3?= =?UTF-8?q?=EF=BC=88P0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - direct_tool_calls.rs:detail.command / detail.output / detail.changes[].path 不再只走 redact_absolute_path_tokens + 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= 四类键值凭据 - 含 --password / --token / --secret / --api-key 这类敏感 CLI 标志的行沿用既有 fail-closed 约定, 整行替换成 [redacted sensitive context](与 sanitize_agent_runtime_text 行为一致) - summary 的 tool 兜底来源同样先脱敏(同一条落盘链路上的残留未脱敏文本) - 新增单测:tool_call_redacts_extended_credential_shapes(五类凭据在投影与落盘行都不含原始值)、 tool_call_redaction_keeps_normal_text_and_is_idempotent(裸 password 词不误伤、脱敏幂等、sk- 前缀不回退) --- .../src-tauri/src/agent/direct_tool_calls.rs | 161 ++++++++++++++++-- 1 file changed, 143 insertions(+), 18 deletions(-) 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..38cca0e22 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,20 +86,22 @@ 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 { +/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 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_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; - } - without_secret.replace(root.as_ref(), "") + sanitize_error_context(&without_secret) } - /// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。 fn bounded_chars(value: &str, max_chars: usize) -> String { if value.chars().count() <= max_chars { @@ -266,15 +269,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); @@ -474,8 +479,8 @@ 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; @@ -750,4 +755,124 @@ 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, "脱敏必须幂等"); + } + } From a0efd921929db09e60d572e04697d705b5c939e5 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 18:17:54 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E8=90=BD=E7=9B=98=E7=8A=B6=E6=80=81=E6=97=A0?= =?UTF-8?q?=E5=8D=95=E8=B0=83=E6=80=A7=EF=BC=88P1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - direct_tool_calls.rs:把 preserve_started_at(只管 startedAt)换成 merge_tool_call_snapshot, 同一 id 的多条快照按 updatedAt 单调合并:updatedAt 更旧的快照不得覆盖更新的 status 与 updatedAt, updatedAt 相同时终态优先,startedAt 仍取最早的非零值 - upsert_direct_tool_call_once(逐条快照落盘)与 persist_direct_tool_calls_at(回合末整批落盘) 两条竞争路径共用同一合并函数,后到的旧快照不再把 completed / failed 打回 running - normalize_tool_calls(回读归并)同 id 的重复行同样按 updatedAt 合并,磁盘上留下的旧快照不得回退状态 - 新增单测:tool_call_persist_keeps_newest_snapshot_per_item(单条 + 整批两条路径)、 tool_call_read_merges_duplicate_rows_monotonically(回读重复行) --- .../src-tauri/src/agent/direct_tool_calls.rs | 167 +++++++++++++++--- 1 file changed, 147 insertions(+), 20 deletions(-) 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 38cca0e22..7be534b10 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 @@ -346,11 +346,16 @@ fn read_tool_call_lines(path: &Path) -> Vec { 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| { @@ -373,20 +378,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 语义,所以只能整文件重写; @@ -404,9 +432,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(); @@ -445,11 +475,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() @@ -484,6 +516,26 @@ mod tests { }; 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, "工具调用卡片测试") @@ -875,4 +927,79 @@ mod tests { 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); + } + } From bc89bf45c37a3a69fcdf4fe4bd3d6def489f803a Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 18:19:33 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E9=A1=B9=E7=9B=AE=E5=86=85=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=BD=A2=E7=8A=B6=E4=B8=8E=E6=AD=BB=E4=BB=A3=E7=A0=81=EF=BC=88?= =?UTF-8?q?P2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - direct_tool_calls.rs:新增 project_relative_path_segment 与 relativize_project_root_paths, 在 redact_absolute_path_tokens 之前把项目根目录前缀换成项目相对路径(game/src/x.ts), Windows 的 \ 与 / 两种写法都匹配、大小写不敏感、分隔符统一成 / - 项目外绝对路径继续落成 占位;只写了项目根目录本身时也按占位处理 - 删掉永远匹配不到的死代码 without_secret.replace(root, "") (redact_absolute_path_tokens 已经把项目根目录换成 ,契约里的"项目相对路径"在绝对路径场景不成立) - 新增单测:tool_call_paths_become_project_relative(项目内相对路径 + 项目外占位 + 落盘不残留项目根目录) --- .../src-tauri/src/agent/direct_tool_calls.rs | 170 +++++++++++++++++- 1 file changed, 166 insertions(+), 4 deletions(-) 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 7be534b10..5f3cc5dee 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 @@ -86,10 +86,118 @@ fn tool_calls_path(root: &Path) -> PathBuf { root.join(".agent/conversations/tool-calls.jsonl") } -/// 脱敏:先抹绝对路径、再抹密钥前缀,最后走既有的错误上下文脱敏组合。 +/// 项目根目录之后的路径 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,再处理密钥。 /// /// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context` /// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` + @@ -97,11 +205,13 @@ fn tool_calls_path(root: &Path) -> PathBuf { /// `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_absolute = redact_absolute_path_tokens(value); +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),并在真正截断时补省略号。 fn bounded_chars(value: &str, max_chars: usize) -> String { if value.chars().count() <= max_chars { @@ -1002,4 +1112,56 @@ mod tests { 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}" + ); + } + } From 885f2f10f3407d38bfbcd8a56100eb1761d6fa7f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 18:21:12 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E5=9D=8F=E8=A1=8C=E8=AF=BB=E5=8F=96=E6=88=AA?= =?UTF-8?q?=E6=96=AD=E5=90=8E=E7=BB=AD=E8=AE=B0=E5=BD=95=EF=BC=88P2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - direct_tool_calls.rs:read_tool_call_lines 由 BufReader::lines() + let Ok(line) = line else { break } 改成按行读字节(read_until)+ 逐行 from_utf8 解码:非法 UTF-8 行只跳过该行并继续读后续行, 不再把这一行之后的所有记录一起丢掉;只有读 I/O 错误才停止读取 - 契约「单行损坏跳过该行继续」在"损坏行缺换行、与下一行黏成一行"时也成立:丢掉的只是黏连的那一行 - 新增单测:tool_call_read_skips_invalid_utf8_line(合法行 + 非法字节行 + 合法行 → 读回 2 条; 以及损坏行缺换行的黏连形态) --- .../src-tauri/src/agent/direct_tool_calls.rs | 69 +++++++++++++++++-- 1 file changed, 63 insertions(+), 6 deletions(-) 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 5f3cc5dee..fedf2cc12 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 @@ -444,13 +444,25 @@ 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 @@ -1164,4 +1176,49 @@ mod tests { ); } + /// 判据:单行损坏(含非法 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"); + } + } From b395185580e3d8e97111ea093972fb4d2e6af70a Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 18:22:53 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E8=A1=A5=E8=AE=B0=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E5=8D=A1=E7=89=87=E5=A5=91=E7=BA=A6=EF=BC=88?= =?UTF-8?q?=E8=84=B1=E6=95=8F=E8=8C=83=E5=9B=B4=E3=80=81=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=BD=A2=E7=8A=B6=E3=80=81=E7=8A=B6=E6=80=81=E5=8D=95=E8=B0=83?= =?UTF-8?q?=E3=80=81200=20=E6=9D=A1=E4=B8=8A=E9=99=90=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md: 写明同一 id 的快照按 updatedAt 单调合并(旧快照不得把 completed / failed 打回 running, 并发来源是逐条快照落盘与回合末整批落盘两条路径) - 写明 detail.changes[].path 用项目相对路径(分隔符统一成 /)、项目外绝对路径用 占位 - 写明必须脱敏的具体函数链路(sanitize_detail_text:路径归一化 → redact_absolute_path_tokens → redact_secret_tokens → sanitize_error_context),含敏感 CLI 标志的行整行 fail-closed (--password 后面即使只是 $VAR 占位符也整行替换),且脱敏必须幂等 - 写明 200 条上限语义是"按时间保留最新 200 条",超出时更早回合的卡片会被静默丢弃(老回合卡片会消失), 不做分页与历史回填(行为不变,只补文档) - 写明单行损坏(含非法 UTF-8)逐行读取跳过继续,黏连形态只丢那一行 - 新增单测:tool_call_cap_drops_oldest_turn_cards(超出后保留最新 N 条 + 按时间正序) --- .../src-tauri/src/agent/direct_tool_calls.rs | 56 +++++++++++++++++++ ...方案】GameAgent对话工具调用卡片-2026-09-14.md | 19 +++++-- 2 files changed, 69 insertions(+), 6 deletions(-) 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 fedf2cc12..72bd90dfb 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 @@ -1221,4 +1221,60 @@ mod tests { 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()), + "回读必须按时间正序" + ); + } } diff --git a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md index 7cf7c832e..d330a3948 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. 前端合并与渲染 @@ -97,7 +104,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/`(卡片渲染、折叠交互、重开项目后合并、老事件兼容)。 ## 实施顺序(每步都要能独立验证) From 3169baa2eb0ad4be75bf4053ff8e954a1b68da58 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 18:36:18 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E8=A1=A5=E5=85=85=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=86=85=E8=B7=AF=E5=BE=84=E5=BD=A2=E7=8A=B6=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=EF=BC=88/=20=E5=86=99=E6=B3=95=E4=B8=8E=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E5=86=99=E5=8F=98=E4=BD=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - direct_tool_calls.rs:新增单测 tool_call_paths_normalize_separators_and_case, 覆盖 relativize_project_root_paths 的 `/` 分隔符分支与大小写不敏感分支 (Codex 上报的路径分隔符与盘符大小写不受我们控制,这两条分支此前没有用例覆盖) --- .../src-tauri/src/agent/direct_tool_calls.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) 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 72bd90dfb..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 @@ -1277,4 +1277,35 @@ mod tests { "回读必须按时间正序" ); } + + /// 判据:项目内路径的 `\` / `/` 两种写法与大小写变体都要落成同一份项目相对路径 + /// (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", + "大小写变体同样要落成项目相对路径" + ); + } }