修复 AGC 文件列举被无关临时锁删除打断(#552) (#580)
Project CI / AI game creator shell Rust smoke (push) Successful in 2m6s
Project CI / AI game creator shell Rust crates (push) Successful in 1m10s
Project CI / Backend tests (push) Successful in 4m15s
Project CI / Native shell tests (push) Successful in 6m43s
Project CI / Frontend tests (push) Successful in 2m54s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 11m2s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 10m12s
Project CI / AI game creator shell web tests (push) Successful in 2m23s
Project CI / Repository checks (push) Successful in 3m14s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m6s
Project CI / AI game creator shell Rust crates (push) Successful in 1m10s
Project CI / Backend tests (push) Successful in 4m15s
Project CI / Native shell tests (push) Successful in 6m43s
Project CI / Frontend tests (push) Successful in 2m54s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 11m2s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 10m12s
Project CI / AI game creator shell web tests (push) Successful in 2m23s
Project CI / Repository checks (push) Successful in 3m14s
列举 `assets` 时,原实现先递归扫描整个项目,再过滤范围;`.agent/project.lock` 在枚举与读取元数据之间正常删除会使整次查询失败。 现在在读取条目元数据前裁剪目标范围与受保护路径,只遍历目标子树及必要祖先。枚举后消失的文件/目录仅跳过 `NotFound`,其余权限与 IO 错误仍返回;进入排队目录前复核符号链接与 Windows 重解析点。通用文件树保留原有可见范围,Agent 结果继续过滤、排序后分页。 通过通道协调真实项目写锁释放,确定性覆盖全量扫描的 TOCTOU 及定向 `assets` 扫描;另覆盖目录消失/类型变化、权限错误、私有路径、链接、引擎目录和分页。同步更新 AGC 契约与共享排障记录。 验证: - 15 条定向 Rust 测试通过:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- --test-threads=1 project_file_listing generic_file_tools local_project_file_commands external_listing` - 生产 Rust 检查通过:`cargo check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell` - `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。 - 本地验证在 Linux 执行,未运行 Windows 专属运行时验证;分页仍是实时观察,不承诺跨请求快照一致性。 Fixes #552 Reviewed-on: #580
This commit was merged in pull request #580.
This commit is contained in:
@@ -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
|
||||
@@ -4195,6 +4187,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::<Value>(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::{
|
||||
|
||||
@@ -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<ListLocalProjectFilesResult, String> {
|
||||
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<ListLocalProjectFilesResult, String> {
|
||||
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<fs::Metadata>,
|
||||
) -> Result<ListLocalProjectFilesResult, String> {
|
||||
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,
|
||||
|
||||
@@ -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::<Vec<_>>(),
|
||||
["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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 待读路径来抹掉待检测链接。
|
||||
|
||||
@@ -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 检查。
|
||||
|
||||
Reference in New Issue
Block a user