From 9fb37213d490b9586c90ab2a4bfb9fec2c9bb8a1 Mon Sep 17 00:00:00 2001 From: lhk Date: Thu, 1 Oct 2026 19:22:17 +0100 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A1=B9=E7=9B=AE=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=88=97=E4=B8=BE=E8=A2=AB=E6=97=A0=E5=85=B3=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E9=94=81=E5=88=A0=E9=99=A4=E6=89=93=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在读取元数据前按目标范围和受保护路径裁剪遍历 跳过枚举后消失的文件和目录并保留其它 IO 错误 补充真实写锁并发、范围过滤、分页与链接边界回归测试 同步 AGC 文件列举契约与共享排障记录 --- .../src-tauri/src/agent/direct_tool_bridge.rs | 85 +++++- .../src-tauri/src/project/filesystem.rs | 83 ++++- .../src/project/filesystem/listing_tests.rs | 288 ++++++++++++++++++ .../src-tauri/src/tests/project.rs | 17 ++ docs/project-memory/shared-memory/pitfalls.md | 6 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 8 + 6 files changed, 459 insertions(+), 28 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/filesystem/listing_tests.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 4940fbfec..82b354d67 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1288,23 +1288,15 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { let scope_relative = scope .and_then(|scope| scope.project_relative) .filter(|path| !path.is_empty()); - let listed = list_local_project_files_at(root)?; - let scope_prefix = scope_relative.as_ref().map(|path| format!("{path}/")); + let listed = list_local_project_files_scoped_at(root, scope_relative.as_deref(), |path| { + bridge_project_file_is_hidden_control_path(path) + || should_skip_project_snapshot_path(path) + || reject_sensitive_project_file_read(path).is_err() + })?; let mut files = listed .files .into_iter() .filter(|file| file.kind == "file") - .filter(|file| !bridge_project_file_is_hidden_control_path(&file.path)) - .filter(|file| !should_skip_project_snapshot_path(&file.path)) - .filter(|file| reject_sensitive_project_file_read(&file.path).is_ok()) - .filter(|file| { - scope_relative.as_ref().is_none_or(|scope| { - file.path == *scope - || scope_prefix - .as_ref() - .is_some_and(|prefix| file.path.starts_with(prefix)) - }) - }) .filter(|file| { let (category, _) = bridge_project_file_class(&file.path); requested_kind == "all" || requested_kind == category @@ -4193,6 +4185,73 @@ mod tests { ); } + #[test] + fn bridge_project_file_listing_scopes_filters_and_paginates_visible_files() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + init_local_game_project_at(root, "scoped-file-listing", "定向文件列举").unwrap(); + for path in [ + "assets/z.png", + "assets/nested/b.png", + "assets/a.png", + "assets/notes.txt", + "assets-other/wrong.png", + "unrelated/wrong.png", + ".agent/hidden.png", + "assets/.env", + "assets/secret.key", + "assets/.codex/hidden.png", + "assets/node_modules/hidden.png", + "assets/.git/hidden.png", + ] { + let target = root.join(path); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(target, b"test-only").unwrap(); + } + let payload = |arguments: Value| { + let result = bridge_list_project_files(root, &arguments); + assert_eq!(result["isError"], false, "{result}"); + serde_json::from_str::(result["content"][0]["text"].as_str().unwrap()).unwrap() + }; + let first = payload(json!({ "path": "assets", "kind": "image", "limit": 2 })); + assert_eq!(first["total"], 3); + assert_eq!(first["offset"], 0); + assert_eq!(first["limit"], 2); + assert_eq!(first["nextOffset"], 2); + assert_eq!(first["files"][0]["path"], "assets/a.png"); + assert_eq!(first["files"][1]["path"], "assets/nested/b.png"); + let last = payload(json!({ "path": "assets", "kind": "image", "offset": 2, "limit": 2 })); + assert_eq!(last["total"], 3); + assert_eq!(last["files"].as_array().unwrap().len(), 1); + assert_eq!(last["files"][0]["path"], "assets/z.png"); + assert!(last["nextOffset"].is_null()); + for path in ["assets/nested", "assets/nested/b.png"] { + let page = payload(json!({ "path": path })); + assert_eq!(page["total"], 1); + assert_eq!(page["files"][0]["path"], "assets/nested/b.png"); + } + assert_eq!( + payload(json!({ "path": "assets", "query": "NESTED" }))["total"], + 1 + ); + assert_eq!(payload(json!({ "path": "assets" }))["total"], 4); + assert_eq!(payload(json!({ "path": "missing" }))["total"], 0); + let beyond = payload(json!({ "path": "assets", "offset": 20 })); + assert!(beyond["files"].as_array().unwrap().is_empty()); + assert!(beyond["nextOffset"].is_null()); + for path in [ + ".agent", + "assets/.env", + "assets/.codex", + "assets/node_modules", + ] { + assert_eq!( + bridge_list_project_files(root, &json!({ "path": path }))["isError"], + true + ); + } + } + #[tokio::test] async fn analytics_real_file_write_preserves_original_identity_and_failed_run_revision() { use crate::analytics::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index dd1c045bb..d5089ff54 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -1,5 +1,8 @@ use super::*; +#[cfg(test)] +mod listing_tests; + /** * Cocos Creator 工程根目录下的**生成目录**(导入缓存、构建临时目录、编辑器本地配置)。 * @@ -18,6 +21,24 @@ fn is_engine_generated_root_directory(relative_path: &str) -> bool { pub(crate) fn list_local_project_files_at( root: &Path, +) -> Result { + list_local_project_files_scoped_at(root, None, |_| false) +} + +/// 在读取元数据前裁剪范围;调用方的可见性规则不改变通用文件树。 +pub(crate) fn list_local_project_files_scoped_at( + root: &Path, + scope: Option<&str>, + exclude: impl Fn(&str) -> bool, +) -> Result { + list_local_project_files_with_metadata(root, scope, exclude, |path| fs::symlink_metadata(path)) +} + +fn list_local_project_files_with_metadata( + root: &Path, + scope: Option<&str>, + exclude: impl Fn(&str) -> bool, + mut read_metadata: impl FnMut(&Path) -> std::io::Result, ) -> Result { validate_project_root(root)?; if !root.exists() { @@ -37,28 +58,58 @@ pub(crate) fn list_local_project_files_at( */ let skip_engine_generated_directories = discover_local_cocos_project_root(root)?.is_some(); let is_unity_project = discover_local_unity_project_root(root)?.is_some(); + let scope_prefix = scope.map(|path| format!("{path}/")); let mut files = Vec::new(); let mut dirs = vec![root.to_path_buf()]; while let Some(dir) = dirs.pop() { - for entry in fs::read_dir(&dir) - .map_err(|error| format!("读取项目目录失败:{}: {error}", dir.display()))? - { + // 目录排队后可能消失或被换成链接,进入前重新核验。 + let metadata = match read_metadata(&dir) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(format!("读取文件元数据失败:{}: {error}", dir.display())), + }; + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + continue; + } + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(format!("读取项目目录失败:{}: {error}", dir.display())), + }; + for entry in entries { let entry = entry.map_err(|error| format!("读取项目文件失败:{}: {error}", dir.display()))?; let path = entry.path(); - let metadata = fs::symlink_metadata(&path) - .map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?; - if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + let relative_path = relative_project_path(root, &path)?; + let in_scope = scope.is_none_or(|scope| { + relative_path == scope + || scope_prefix + .as_ref() + .is_some_and(|prefix| relative_path.starts_with(prefix)) + }); + let scope_ancestor = + scope.is_some_and(|scope| scope.starts_with(&format!("{relative_path}/"))); + if (!in_scope && !scope_ancestor) || exclude(&relative_path) { continue; } - let file_type = metadata.file_type(); - let relative_path = relative_project_path(root, &path)?; if is_agent_runtime_private_control_path(&relative_path) || is_agent_checkpoint_control_path(&relative_path) || is_agent_workbench_control_path(&relative_path) { continue; } + let metadata = match read_metadata(&path) { + Ok(metadata) => metadata, + // read_dir 只观察名字,不保证随后还能读取该条目。 + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(format!("读取文件元数据失败:{}: {error}", path.display())) + } + }; + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + continue; + } + let file_type = metadata.file_type(); let modified_at = metadata .modified() .ok() @@ -80,14 +131,16 @@ pub(crate) fn list_local_project_files_at( { continue; } - files.push(LocalProjectFileEntry { - path: relative_path, - kind: "directory".to_string(), - size: 0, - modified_at, - }); + if in_scope { + files.push(LocalProjectFileEntry { + path: relative_path, + kind: "directory".to_string(), + size: 0, + modified_at, + }); + } dirs.push(path); - } else if file_type.is_file() { + } else if file_type.is_file() && in_scope { let size = metadata.len(); files.push(LocalProjectFileEntry { path: relative_path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem/listing_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem/listing_tests.rs new file mode 100644 index 000000000..2bf6134f1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem/listing_tests.rs @@ -0,0 +1,288 @@ +use super::*; +use std::io::{Error, ErrorKind}; +use std::sync::mpsc; +use std::time::Duration; + +#[test] +fn project_file_listing_survives_lock_release_between_enumeration_and_metadata() { + for scope in [None, Some("assets")] { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().canonicalize().unwrap(); + fs::create_dir(root.join("assets")).unwrap(); + fs::write(root.join("assets/hero.png"), b"image").unwrap(); + let (ready_tx, ready_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (released_tx, released_rx) = mpsc::channel(); + let timeout = Duration::from_secs(10); + + std::thread::scope(|threads| { + let owner_root = &root; + let owner = threads.spawn(move || { + let lock = acquire_project_write_lock(owner_root, "file-listing-race").unwrap(); + ready_tx.send(()).unwrap(); + release_rx.recv_timeout(timeout).unwrap(); + drop(lock); + released_tx.send(()).unwrap(); + }); + ready_rx.recv_timeout(timeout).unwrap(); + let mut observed_gap = false; + let listed = list_local_project_files_with_metadata( + &root, + scope, + |_| false, + |path| { + if scope.is_some() { + assert!( + !path.starts_with(root.join(".agent")), + "定向列举不得检查锁目录" + ); + } + let trigger = if scope.is_some() { + root.join("assets") + } else { + root.join(PROJECT_WRITE_LOCK_PATH) + }; + if path == trigger && !observed_gap { + // 全量列举复现锁的 TOCTOU;定向列举在访问 assets 时同时释放锁。 + release_tx.send(()).unwrap(); + released_rx.recv_timeout(timeout).unwrap(); + let result = fs::symlink_metadata(path); + if scope.is_none() { + assert_eq!(result.as_ref().unwrap_err().kind(), ErrorKind::NotFound); + } + observed_gap = true; + return result; + } + fs::symlink_metadata(path) + }, + ) + .unwrap(); + owner.join().unwrap(); + assert!(observed_gap, "夹具必须在列举期间释放真实锁"); + assert!(listed + .files + .iter() + .any(|file| file.path == "assets/hero.png")); + assert!(!listed + .files + .iter() + .any(|file| file.path == PROJECT_WRITE_LOCK_PATH)); + }); + } +} + +#[test] +fn project_file_listing_prunes_scope_and_private_paths_before_metadata() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + for path in [ + "assets/nested", + "assets-other", + ".agent/runtime", + ".agent/checkpoints", + ".agent/workbench", + ] { + fs::create_dir_all(root.join(path)).unwrap(); + } + fs::write(root.join("assets/nested/hero.png"), b"image").unwrap(); + fs::write(root.join("assets/other.png"), b"other").unwrap(); + fs::write(root.join(".env"), b"test-only").unwrap(); + for scope in ["assets/nested", "assets/nested/hero.png"] { + let listed = list_local_project_files_with_metadata( + root, + Some(scope), + |_| false, + |path| { + if path != root + && path != root.join("assets") + && !path.starts_with(root.join("assets/nested")) + { + panic!("不应读取范围外元数据:{}", path.display()); + } + fs::symlink_metadata(path) + }, + ) + .unwrap(); + assert_eq!( + listed + .files + .iter() + .filter(|file| file.kind == "file") + .map(|file| file.path.as_str()) + .collect::>(), + ["assets/nested/hero.png"] + ); + } + let listed = list_local_project_files_with_metadata( + root, + None, + |path| path == ".env", + |path| { + for excluded in [ + ".env", + ".agent/runtime", + ".agent/checkpoints", + ".agent/workbench", + ] { + assert!( + !path.starts_with(root.join(excluded)), + "过滤必须早于元数据读取" + ); + } + fs::symlink_metadata(path) + }, + ) + .unwrap(); + assert!( + listed.files.iter().any(|file| file.path == ".agent"), + "通用文件树仍可见普通控制面目录" + ); +} + +#[test] +fn project_file_listing_skips_disappearing_entries_but_preserves_io_errors() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + fs::create_dir(root.join("assets")).unwrap(); + let target = root.join("assets/hero.png"); + fs::write(&target, b"image").unwrap(); + for kind in [ErrorKind::PermissionDenied, ErrorKind::Other] { + let error = list_local_project_files_with_metadata( + root, + Some("assets"), + |_| false, + |path| { + if path == target { + return Err(Error::new(kind, "listing-error-sentinel")); + } + fs::symlink_metadata(path) + }, + ) + .unwrap_err(); + assert!( + error.contains("hero.png") && error.contains("listing-error-sentinel"), + "{error}" + ); + } + let listed = list_local_project_files_with_metadata( + root, + Some("assets"), + |_| false, + |path| { + if path == target { + fs::remove_file(path).unwrap(); + } + fs::symlink_metadata(path) + }, + ) + .unwrap(); + assert!(!listed.files.iter().any(|file| file.kind == "file")); +} + +#[test] +fn project_file_listing_handles_queued_directory_disappearance_and_type_change() { + for change in ["before-metadata", "before-read-dir", "replace-with-file"] { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + let target = root.join("assets"); + fs::create_dir(&target).unwrap(); + let mut visits = 0; + let result = list_local_project_files_with_metadata( + root, + Some("assets"), + |_| false, + |path| { + if path == target { + visits += 1; + if visits == 2 { + let previous = fs::symlink_metadata(path); + fs::remove_dir(path).unwrap(); + if change == "before-read-dir" { + return previous; + } + if change == "replace-with-file" { + fs::write(path, b"not a directory").unwrap(); + } + } + } + fs::symlink_metadata(path) + }, + ); + assert_eq!(visits, 2); + if change == "replace-with-file" { + assert!(result.unwrap_err().contains("读取项目目录失败")); + } else { + assert!(!result.unwrap().files.iter().any(|file| file.kind == "file")); + } + } +} + +#[cfg(unix)] +#[test] +fn project_file_listing_reports_target_permission_errors_but_ignores_unrelated_ones() { + use std::os::unix::fs::PermissionsExt; + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + let blocked = root.join("blocked"); + fs::create_dir(&blocked).unwrap(); + fs::create_dir(root.join("assets")).unwrap(); + fs::write(root.join("assets/hero.png"), b"image").unwrap(); + fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).unwrap(); + // 先恢复权限再断言,避免失败时影响临时目录回收。 + let scoped = list_local_project_files_scoped_at(root, Some("assets"), |_| false); + let blocked_result = list_local_project_files_scoped_at(root, Some("blocked"), |_| false); + let permission_denied = fs::read_dir(&blocked).is_err(); + fs::set_permissions(&blocked, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(scoped + .unwrap() + .files + .iter() + .any(|file| file.path == "assets/hero.png")); + // root / CAP_DAC_OVERRIDE 环境不能制造真实 EACCES;注入用例仍覆盖错误分支。 + if permission_denied { + let error = blocked_result.unwrap_err(); + assert!( + error.contains("读取项目目录失败") && error.contains("blocked"), + "{error}" + ); + } +} + +#[cfg(unix)] +#[test] +fn project_file_listing_never_enters_linked_or_replaced_directories() { + use std::os::unix::fs::symlink; + let temporary = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let root = temporary.path(); + fs::write(outside.path().join("private.txt"), b"outside").unwrap(); + symlink(outside.path(), root.join("linked")).unwrap(); + fs::create_dir(root.join("assets")).unwrap(); + let mut visits = 0; + let listed = list_local_project_files_with_metadata( + root, + None, + |_| false, + |path| { + if path == root.join("assets") { + visits += 1; + if visits == 2 { + fs::remove_dir(path).unwrap(); + symlink(outside.path(), path).unwrap(); + } + } + fs::symlink_metadata(path) + }, + ) + .unwrap(); + assert_eq!(visits, 2); + assert!(!listed.files.iter().any(|file| file.kind == "file")); + for scope in ["linked", "linked/private.txt", "assets/private.txt"] { + assert!( + list_local_project_files_scoped_at(root, Some(scope), |_| false) + .unwrap() + .files + .is_empty() + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index f1ed549ff..0ec6629fe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -3860,6 +3860,23 @@ fn local_project_file_listing_skips_engine_generated_directories_only_for_engine .expect("write cocos package.json"); let listed = list_local_project_files_at(cocos).expect("list cocos project files"); + for scope in ["library", "library/imported/hero.json", "temp/programming"] { + assert!( + list_local_project_files_scoped_at(cocos, Some(scope), |_| false) + .expect("list generated scope") + .files + .is_empty() + ); + } + fs::create_dir_all(cocos.join("assets/library")).unwrap(); + fs::write(cocos.join("assets/library/kept.png"), b"image").unwrap(); + assert!( + list_local_project_files_scoped_at(cocos, Some("assets/library"), |_| false) + .unwrap() + .files + .iter() + .any(|file| file.path == "assets/library/kept.png") + ); let paths = listed .files .iter() diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index cb554f6da..ef8834e21 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -47,6 +47,12 @@ - **判据/取证**:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell direct_thread_delta`——新增 `direct_thread_delta_sanitization_preserves_line_breaks` 钉住「逐段脱敏 == 整段脱敏」,去掉 `split_inclusive` 即红;真实文本的回归用渲染侧夹具复核(修复前 table/li/h2 全 0,修复后与整段脱敏一致:1 个 table / 3 个 th / 4 个 h2)。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/generation/prompt_context.rs`、`.../agent/thread_manager/wire.rs`、`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts`、`#384`。 +## 2026-10-01 文件列举被无关临时锁删除打断 + +- `read_dir` 返回名字后,文件可能在 `symlink_metadata` 前正常消失;`.agent/project.lock` 的正常释放就能触发这类竞态。不要先全项目扫描再按 Agent 的 `path` 和可见性过滤。 +- 项目内列举在元数据读取前裁剪范围和受保护路径,只进入目标子树及必要祖先;共享文件树保留自己的可见性策略。枚举后消失的文件/目录仅跳过 `NotFound`,其它 IO 错误仍可诊断,进入排队目录前复核链接/重解析点。 +- 并发回归用通道协调真实写锁的释放与元数据读取,不靠高频循环碰撞;分页按本次观察到的可见文件排序,不保证跨请求快照。完整合同见 AGC 实施计划“项目文件列举的范围与并发边界”。 + ## 2026-09-29 Game Agent 读工具被项目相对路径规则拦住 - 项目外读取不能只依赖末段 `O_NOFOLLOW`:父目录符号链接可隐藏 `.ssh` 等受保护名字。文件读取和目录列表在访问前逐段检查原始路径,拒绝符号链接与 Windows 重解析点;目录扫描对子目录再次检查。系统临时目录若含平台别名(例如 macOS `/var`),普通读取测试使用临时目录的 canonical 路径,不能通过 canonicalize 待读路径来抹掉待检测链接。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index d2ab76784..325fe7e12 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,13 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-10-01 项目文件列举的范围与并发边界 + +`agc_list_project_files` 的项目内 `path` 在遍历时限制到目标文件、目标目录及其必要祖先;不递归无关子树,也不读取无关条目的元数据。Agent 的控制面、敏感文件和快照排除规则在读取条目元数据之前生效;通用 UI 文件树保持原有可见范围。引擎生成目录仍按项目根身份排除,不能因定向列举改变判定基准。 + +文件列举是实时观察,不是文件系统快照。枚举后消失的文件或待进入目录(`NotFound`)跳过;权限、非目录和其它 IO 错误保留路径及原因,不统一吞错或重试。目标不存在时继续返回空列表;符号链接和 Windows 重解析点不得作为遍历入口。过滤后按项目相对路径排序,再计算 `total`、`offset`、`limit` 和 `nextOffset`;多次分页之间的并发变更不承诺快照一致性。 + +本修复不调整项目写锁等待、写入许可、项目外读取或工具 DTO。验收通过受控的枚举/元数据间隙释放真实项目锁,并覆盖定向范围、消失目录、真实 IO 错误、受保护路径、链接、引擎目录及分页;不依赖概率性循环碰撞。 + ## 2026-09-29 Codex 私有运行目录路径解析 新建的私有运行目录先确认是普通目录并收紧权限,再解析真实路径,后续 `codex-home`、`workspace` 和隔离用户目录均在真实路径下创建,避免 macOS 系统临时目录的符号链接阻断启动。Windows 扩展 UNC 路径 `\\?\UNC\server\share\...` 必须转换为 `\\server\share\...`,盘符路径才直接去掉 `\\?\` 前缀;转换后保留绝对路径语义。私有子目录仍执行原有祖先符号链接与 reparse point 检查。 -- 2.52.0