Remove Absolute and External Path Restrictions of Design Agent. Fix mac runtime failure (#528)
Project CI / AI game creator shell Rust smoke (push) Successful in 1m19s
Project CI / AI game creator shell Rust crates (push) Successful in 1m7s
Project CI / Backend tests (push) Successful in 3m49s
Project CI / Frontend tests (push) Successful in 2m8s
Project CI / Native shell tests (push) Successful in 6m3s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m25s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m31s
Project CI / AI game creator shell web tests (push) Successful in 1m35s
Project CI / Repository checks (push) Successful in 2m15s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m19s
Project CI / AI game creator shell Rust crates (push) Successful in 1m7s
Project CI / Backend tests (push) Successful in 3m49s
Project CI / Frontend tests (push) Successful in 2m8s
Project CI / Native shell tests (push) Successful in 6m3s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m25s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m31s
Project CI / AI game creator shell web tests (push) Successful in 1m35s
Project CI / Repository checks (push) Successful in 2m15s
Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: #528 Co-authored-by: lhk <ink29535@proton.me> Co-committed-by: lhk <ink29535@proton.me>
This commit was merged in pull request #528.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
|
||||
你是游戏策划协作 Agent,与用户持续协作完成游戏设计。像普通策划同事一样交流,使用工作区文件工具读写资料;所有文件路径使用相对路径。根据当前对话、阶段上下文和已有文档决定下一步行动。
|
||||
你是游戏策划协作 Agent,与用户持续协作完成游戏设计。像普通策划同事一样交流,使用工作区文件工具读写资料,策划文件放在工作区内并使用相对路径。根据当前对话、阶段上下文和已有文档决定下一步行动。
|
||||
|
||||
优先完成能够依据已有信息推进的工作。局部、可逆的问题可以先提出合理方案并标为暂定。会影响当前阶段范围、关键规则、下游实现或其他重要方向,且必须由用户决定的问题,应先通过纯文本或问询工具询问,等待用户回答。决定稳定后,再更新受影响的正式产物和必要的过程记录,并完成阶段审批前的检查。
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
{"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,返回阶段列表、当前阶段、已批准阶段和待审批阶段。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"list_resources","description":"列出只读随包文档的逻辑目录、资源 ID、标题和简介;使用返回的资源 ID 读取文档。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到内容缺失或不完整的文档时,由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"write_file","description":"创建或覆盖工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"search_text","description":"在工作目录内搜索文本。","parameters":{"type":"object","properties":{"query":{"type":"string"},"path":{"type":"string"}},"required":["query"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。不能删除工作区根目录或包含它的上级目录。允许路径经过链接;删除链接本身只移除链接,递归删除目录时不跟随其中的目录链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"list_dir","description":"列出目录中的文件和目录。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"read_file","description":"读取 UTF-8 文本文件。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"write_file","description":"创建或覆盖 UTF-8 文本文件。","parameters":{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"search_text","description":"搜索文本。","parameters":{"type":"object","properties":{"query":{"type":"string"},"path":{"type":"string"}},"required":["query"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"ask_clarification","description":"向用户展示多选项问询澄清卡片,选项数2-4。多选一场景时优先使用本工具,其他场景可以纯文本进行问询。每轮最多调用一次。","parameters":{"type":"object","properties":{"question":{"type":"string"},"options":{"type":"array","items":{"type":"string"}}},"required":["question"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"submit_phase_for_approval","description":"提交五个策划阶段中的当前阶段供用户审批。当你判断当前阶段已经完成并准备交用户检阅时必须调用。用户批准后进入下一阶段。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}
|
||||
]
|
||||
|
||||
@@ -2167,6 +2167,48 @@ fn configure_game_creator_codex_app_server_command_for_mode(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 新建的私有运行目录必须已经是普通目录。解析掉系统临时目录上的符号链接
|
||||
/// (macOS 的 `/var`、`/tmp`),后续私有子目录在真实路径上创建。祖先检查本身不放宽。
|
||||
fn canonical_codex_private_runtime_dir(
|
||||
path: &std::path::Path,
|
||||
) -> Result<std::path::PathBuf, String> {
|
||||
let metadata = std::fs::symlink_metadata(path)
|
||||
.map_err(|error| format!("读取 Codex 私有运行目录失败:{}: {error}", path.display()))?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(format!(
|
||||
"Codex 私有运行目录必须是普通目录:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return Err(format!(
|
||||
"Codex 私有运行目录不能是 Windows reparse point:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
let canonical = std::fs::canonicalize(path)
|
||||
.map_err(|error| format!("解析 Codex 私有运行目录失败:{}: {error}", path.display()))?;
|
||||
Ok(normalize_codex_private_runtime_path(canonical))
|
||||
}
|
||||
|
||||
fn normalize_codex_private_runtime_path(path: std::path::PathBuf) -> std::path::PathBuf {
|
||||
if let Some(value) = path.to_str() {
|
||||
// UNC 命名空间必须恢复网络共享根,不能只去掉设备前缀后变成相对路径。
|
||||
if let Some(rest) = value.strip_prefix(r"\\?\UNC\") {
|
||||
return std::path::PathBuf::from(format!(r"\\{rest}"));
|
||||
}
|
||||
if let Some(rest) = value.strip_prefix(r"\\?\") {
|
||||
return std::path::PathBuf::from(rest);
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn prepare_isolated_game_creator_codex_home(
|
||||
root: &std::path::Path,
|
||||
credential: &CodexAppServerCredential,
|
||||
@@ -2590,6 +2632,14 @@ impl CodexAppServerConnection {
|
||||
Some(working_dir.path()),
|
||||
))
|
||||
})?;
|
||||
// 叶子已确认是普通目录。先去掉系统临时目录上的符号链接,再创建私有子目录。
|
||||
let private_runtime_dir =
|
||||
canonical_codex_private_runtime_dir(working_dir.path()).map_err(|error| {
|
||||
platform_llm::LlmError::Transport(crate::sanitize_diagnostic_message(
|
||||
&error,
|
||||
Some(working_dir.path()),
|
||||
))
|
||||
})?;
|
||||
let (direct_provider_route, main_site_upstream) = match credential {
|
||||
CodexAppServerCredential::PlatformSession {
|
||||
api_base_url,
|
||||
@@ -2614,11 +2664,11 @@ impl CodexAppServerConnection {
|
||||
let remote_control_disable_reason =
|
||||
credential.remote_control_disable_reason(direct_provider_route.is_some());
|
||||
let isolated_codex_home = prepare_isolated_game_creator_codex_home(
|
||||
working_dir.path(),
|
||||
&private_runtime_dir,
|
||||
credential,
|
||||
direct_provider_route.is_some(),
|
||||
)?;
|
||||
let isolated_workspace = working_dir.path().join("workspace");
|
||||
let isolated_workspace = private_runtime_dir.join("workspace");
|
||||
if workspace_override.is_none() {
|
||||
std::fs::create_dir(&isolated_workspace).map_err(|error| {
|
||||
platform_llm::LlmError::Transport(format!(
|
||||
@@ -2668,7 +2718,7 @@ impl CodexAppServerConnection {
|
||||
&client_mcp_servers,
|
||||
)?;
|
||||
}
|
||||
let isolated_os_home = working_dir.path().join("home");
|
||||
let isolated_os_home = private_runtime_dir.join("home");
|
||||
let isolated_app_data = isolated_os_home.join("appdata");
|
||||
let isolated_local_app_data = isolated_os_home.join("local-appdata");
|
||||
for path in [
|
||||
@@ -2680,7 +2730,7 @@ impl CodexAppServerConnection {
|
||||
|error| {
|
||||
platform_llm::LlmError::Transport(format!(
|
||||
"创建 Codex app-server 隔离用户目录失败:{}",
|
||||
crate::sanitize_diagnostic_message(&error, Some(working_dir.path()))
|
||||
crate::sanitize_diagnostic_message(&error, Some(&private_runtime_dir))
|
||||
))
|
||||
},
|
||||
)?;
|
||||
@@ -6652,6 +6702,67 @@ mod tests {
|
||||
assert_eq!(resolved, outside.canonicalize().expect("canonical outside"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_private_runtime_path_preserves_unc_and_drive_roots() {
|
||||
for (input, expected) in [
|
||||
(r"\\?\UNC\server\share\session", r"\\server\share\session"),
|
||||
(r"\\?\C:\Temp\session", r"C:\Temp\session"),
|
||||
(r"\\server\share\session", r"\\server\share\session"),
|
||||
(r"C:\Temp\session", r"C:\Temp\session"),
|
||||
("/private/tmp/session", "/private/tmp/session"),
|
||||
] {
|
||||
let normalized = normalize_codex_private_runtime_path(input.into());
|
||||
assert_eq!(normalized, std::path::PathBuf::from(expected));
|
||||
#[cfg(windows)]
|
||||
if input.starts_with('\\') || input.starts_with("C:") {
|
||||
assert!(normalized.is_absolute());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn codex_private_runtime_dir_resolves_a_symlink_ancestor_and_rejects_an_inner_link() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let real = temp.path().join("real-temp");
|
||||
let link = temp.path().join("link-temp");
|
||||
std::fs::create_dir(&real).expect("real temp");
|
||||
symlink(&real, &link).expect("temp symlink");
|
||||
let session = link.join("session");
|
||||
std::fs::create_dir(&session).expect("session through symlink");
|
||||
|
||||
let unresolved = crate::ensure_game_creator_private_directory_tree(
|
||||
&session.join("home"),
|
||||
"Codex 隔离用户目录",
|
||||
);
|
||||
assert!(unresolved
|
||||
.expect_err("symlink ancestor")
|
||||
.contains("路径不能包含符号链接"));
|
||||
|
||||
let resolved = canonical_codex_private_runtime_dir(&session).expect("resolve runtime dir");
|
||||
assert_eq!(
|
||||
resolved,
|
||||
std::fs::canonicalize(&session).expect("canonical session")
|
||||
);
|
||||
assert!(crate::ensure_game_creator_private_directory_tree(
|
||||
&resolved.join("home"),
|
||||
"Codex 隔离用户目录",
|
||||
)
|
||||
.is_ok());
|
||||
|
||||
let planted = resolved.join("planted");
|
||||
std::fs::create_dir(&planted).expect("planted dir");
|
||||
symlink(&planted, resolved.join("alias")).expect("inner symlink");
|
||||
assert!(crate::ensure_game_creator_private_directory_tree(
|
||||
&resolved.join("alias").join("secret"),
|
||||
"Codex 隔离用户目录",
|
||||
)
|
||||
.expect_err("inner symlink")
|
||||
.contains("符号链接"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn direct_project_pool_identity_follows_the_canonical_project_target() {
|
||||
|
||||
@@ -3219,14 +3219,13 @@ mod tests {
|
||||
.iter()
|
||||
.any(|message| message.text.contains("读取资源失败")
|
||||
|| message.text.contains("未知资源")));
|
||||
assert!(view
|
||||
.messages
|
||||
.iter()
|
||||
.any(|message| message.text.contains("失败") && message.text.contains("路径")));
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("secret.md")).expect("outside write"),
|
||||
"no"
|
||||
);
|
||||
assert!(root
|
||||
.join("design_artifacts/project/00_concept/design.md")
|
||||
.is_file());
|
||||
assert!(!root.join("secret.md").exists());
|
||||
|
||||
let request = request_id(&view);
|
||||
let next = decide_design_phase_at(&root, &resources, "t-retry", &request, true, |_| {})
|
||||
|
||||
@@ -204,7 +204,7 @@ pub(crate) fn execute_design_file_tool(
|
||||
match name {
|
||||
"list_dir" => {
|
||||
let relative = optional_tool_path(args)?;
|
||||
let (display, path) = resolve_design_workspace_path(root, &relative)?;
|
||||
let (display, path) = resolve_design_tool_path(root, &relative)?;
|
||||
if !path.is_dir() {
|
||||
return Ok(Value::String("不是目录".to_string()));
|
||||
}
|
||||
@@ -246,7 +246,7 @@ pub(crate) fn execute_design_file_tool(
|
||||
}
|
||||
"read_file" => {
|
||||
let relative = required_tool_path(args)?;
|
||||
let (display, path) = resolve_design_workspace_path(root, &relative)?;
|
||||
let (display, path) = resolve_design_tool_path(root, &relative)?;
|
||||
if !path.is_file() {
|
||||
return Err(format!("不是文件:{display}"));
|
||||
}
|
||||
@@ -260,8 +260,8 @@ pub(crate) fn execute_design_file_tool(
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("content 必须是字符串")?;
|
||||
let (display, path) = resolve_design_workspace_path(root, &relative)?;
|
||||
crate::write_game_creator_private_file(&path, content.as_bytes(), "策划工作区文件")?;
|
||||
let (display, path) = resolve_design_tool_path(root, &relative)?;
|
||||
write_design_tool_bytes(root, &path, content.as_bytes())?;
|
||||
Ok(Value::String(format!("已写入 {display}")))
|
||||
}
|
||||
"patch_file" => {
|
||||
@@ -302,7 +302,7 @@ pub(crate) fn execute_design_file_tool(
|
||||
}
|
||||
vec![(old.to_string(), new.to_string())]
|
||||
};
|
||||
let (display, path) = resolve_design_workspace_path(root, &relative)?;
|
||||
let (display, path) = resolve_design_tool_path(root, &relative)?;
|
||||
if !path.is_file() {
|
||||
return Err(format!("文件不存在:{display}"));
|
||||
}
|
||||
@@ -395,7 +395,7 @@ pub(crate) fn execute_design_file_tool(
|
||||
if updated == content {
|
||||
return Err(format!("没有产生修改:{display}"));
|
||||
}
|
||||
crate::write_game_creator_private_file(&path, updated.as_bytes(), "策划工作区文件")?;
|
||||
write_design_tool_bytes(root, &path, updated.as_bytes())?;
|
||||
Ok(Value::String(format!(
|
||||
"已局部修改 {display}({} 处)",
|
||||
normalized.len()
|
||||
@@ -403,21 +403,11 @@ pub(crate) fn execute_design_file_tool(
|
||||
}
|
||||
"delete_path" => {
|
||||
let relative = required_tool_path(args)?;
|
||||
let (display, path) = resolve_design_workspace_path(root, &relative)?;
|
||||
if display == "." {
|
||||
return Err("不能删除工作区根目录".to_string());
|
||||
}
|
||||
if design_path_is_link(&path) {
|
||||
return Err("删除请使用目标的直接路径,不经过链接或路径折叠".to_string());
|
||||
}
|
||||
if !path.exists() {
|
||||
return Err(format!("路径不存在:{display}"));
|
||||
}
|
||||
if path.is_dir() {
|
||||
remove_design_dir(&path)?;
|
||||
} else {
|
||||
fs::remove_file(&path).map_err(|error| format!("删除失败:{error}"))?;
|
||||
}
|
||||
let workspace = ensure_design_workspace(root)?;
|
||||
// 保留 .. 的文件系统语义;祖先链接可以指向工作区外,不能提前词法折叠。
|
||||
let path = workspace.join(relative.replace('\\', "/"));
|
||||
let display = design_tool_location(root, &path);
|
||||
remove_design_path(&workspace, &path)?;
|
||||
Ok(Value::String(format!("已删除 {display}")))
|
||||
}
|
||||
"search_text" => {
|
||||
@@ -426,7 +416,7 @@ pub(crate) fn execute_design_file_tool(
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("缺少 query")?;
|
||||
let relative = optional_tool_path(args)?;
|
||||
let (_, path) = resolve_design_workspace_path(root, &relative)?;
|
||||
let (_, path) = resolve_design_tool_path(root, &relative)?;
|
||||
let mut hits = Vec::new();
|
||||
search_design_text(root, &path, query, &mut hits)?;
|
||||
Ok(Value::String(if hits.is_empty() {
|
||||
@@ -768,6 +758,63 @@ fn resolve_design_workspace_path(root: &Path, relative: &str) -> Result<(String,
|
||||
Ok((normalized, path))
|
||||
}
|
||||
|
||||
/// 文件工具使用的路径。相对路径以策划工作区为基准,允许 `..` 离开工作区,也接受绝对路径。
|
||||
/// 用户浏览和附件导入仍走 `resolve_design_workspace_path`。
|
||||
fn resolve_design_tool_path(root: &Path, raw: &str) -> Result<(String, PathBuf), String> {
|
||||
let relative = raw.trim().replace('\\', "/");
|
||||
if relative.is_empty() || relative == "." {
|
||||
return Ok((".".to_string(), ensure_design_workspace(root)?));
|
||||
}
|
||||
if Path::new(&relative).is_absolute() {
|
||||
let path = PathBuf::from(&relative);
|
||||
return Ok((design_tool_location(root, &path), path));
|
||||
}
|
||||
if !relative.split('/').any(|part| part == "..") {
|
||||
return resolve_design_workspace_path(root, &relative);
|
||||
}
|
||||
let mut path = ensure_design_workspace(root)?;
|
||||
for part in relative.split('/') {
|
||||
match part {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
path.pop();
|
||||
}
|
||||
other => path.push(other),
|
||||
}
|
||||
}
|
||||
Ok((design_tool_location(root, &path), path))
|
||||
}
|
||||
|
||||
fn design_tool_location(root: &Path, path: &Path) -> String {
|
||||
let Ok(workspace) = resolve_local_project_path(root, DESIGN_WORKSPACE_ROOT) else {
|
||||
return path.to_string_lossy().replace('\\', "/");
|
||||
};
|
||||
match path.strip_prefix(&workspace) {
|
||||
Ok(relative) if relative.as_os_str().is_empty() => ".".to_string(),
|
||||
Ok(relative) => relative.to_string_lossy().replace('\\', "/"),
|
||||
Err(_) => path.to_string_lossy().replace('\\', "/"),
|
||||
}
|
||||
}
|
||||
|
||||
fn design_tool_path_inside_workspace(root: &Path, path: &Path) -> bool {
|
||||
resolve_local_project_path(root, DESIGN_WORKSPACE_ROOT)
|
||||
.ok()
|
||||
.is_some_and(|workspace| path.starts_with(&workspace))
|
||||
}
|
||||
|
||||
fn write_design_tool_bytes(root: &Path, path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
if design_tool_path_inside_workspace(root, path) {
|
||||
return crate::write_game_creator_private_file(path, bytes, "策划工作区文件");
|
||||
}
|
||||
if let Some(parent) = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
fs::create_dir_all(parent).map_err(|error| format!("创建目录失败:{error}"))?;
|
||||
}
|
||||
fs::write(path, bytes).map_err(|error| format!("写入失败:{error}"))
|
||||
}
|
||||
|
||||
fn workspace_display_path(parent: &str, name: &str) -> String {
|
||||
if parent == "." || parent.is_empty() {
|
||||
name.to_string()
|
||||
@@ -795,23 +842,33 @@ fn design_path_is_link(path: &Path) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_design_dir(path: &Path) -> Result<(), String> {
|
||||
fn remove_design_path(workspace: &Path, path: &Path) -> Result<(), String> {
|
||||
let metadata =
|
||||
fs::symlink_metadata(path).map_err(|error| format!("读取删除目标失败:{error}"))?;
|
||||
if design_path_is_link(path) {
|
||||
return Err("删除请使用目标的直接路径,不经过链接或路径折叠".to_string());
|
||||
}
|
||||
for entry in fs::read_dir(path).map_err(|error| format!("删除失败:{error}"))? {
|
||||
let entry = entry.map_err(|error| format!("删除失败:{error}"))?;
|
||||
let child = entry.path();
|
||||
if design_path_is_link(&child) {
|
||||
return Err("删除请使用目标的直接路径,不经过链接或路径折叠".to_string());
|
||||
}
|
||||
if child.is_dir() {
|
||||
remove_design_dir(&child)?;
|
||||
} else {
|
||||
fs::remove_file(&child).map_err(|error| format!("删除失败:{error}"))?;
|
||||
// Windows 目录链接 / junction 使用 RemoveDirectory,且不能解析到链接目标。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
|
||||
if metadata.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0 {
|
||||
return fs::remove_dir(path).map_err(|error| format!("删除失败:{error}"));
|
||||
}
|
||||
}
|
||||
return fs::remove_file(path).map_err(|error| format!("删除失败:{error}"));
|
||||
}
|
||||
fs::remove_dir(path).map_err(|error| format!("删除失败:{error}"))
|
||||
if metadata.is_dir() {
|
||||
let target =
|
||||
fs::canonicalize(path).map_err(|error| format!("解析删除目标失败:{error}"))?;
|
||||
let workspace =
|
||||
fs::canonicalize(workspace).map_err(|error| format!("解析策划工作区失败:{error}"))?;
|
||||
if workspace.starts_with(&target) {
|
||||
return Err("不能删除工作区根目录或包含它的上级目录".to_string());
|
||||
}
|
||||
// 标准递归删除只移除目录内链接本身,不遍历链接目标。
|
||||
return fs::remove_dir_all(target).map_err(|error| format!("删除失败:{error}"));
|
||||
}
|
||||
fs::remove_file(path).map_err(|error| format!("删除失败:{error}"))
|
||||
}
|
||||
|
||||
fn search_design_text(
|
||||
@@ -827,7 +884,7 @@ fn search_design_text(
|
||||
let Ok(text) = fs::read_to_string(path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let display = design_workspace_relative(root, path)?;
|
||||
let display = design_tool_location(root, path);
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
if line.contains(query) {
|
||||
hits.push(format!("{display}:{}: {line}", index + 1));
|
||||
@@ -855,17 +912,6 @@ fn search_design_text(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn design_workspace_relative(root: &Path, path: &Path) -> Result<String, String> {
|
||||
let workspace = resolve_local_project_path(root, DESIGN_WORKSPACE_ROOT)?;
|
||||
let relative = path
|
||||
.strip_prefix(&workspace)
|
||||
.map_err(|_| "路径超出工作目录".to_string())?;
|
||||
if relative.as_os_str().is_empty() {
|
||||
return Ok(".".to_string());
|
||||
}
|
||||
Ok(relative.to_string_lossy().replace('\\', "/"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -887,7 +933,69 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_tools_stay_inside_workspace() {
|
||||
fn delete_protects_workspace_before_removing_any_contents() {
|
||||
let temp = test_root();
|
||||
let root = temp.path();
|
||||
let workspace = ensure_design_workspace(root).unwrap();
|
||||
fs::create_dir(workspace.join("child")).unwrap();
|
||||
fs::write(workspace.join("keep.md"), "keep").unwrap();
|
||||
for path in [
|
||||
PathBuf::from("."),
|
||||
PathBuf::from(".."),
|
||||
workspace.clone(),
|
||||
workspace.join("child/.."),
|
||||
] {
|
||||
let error = execute_design_file_tool(root, "delete_path", &json!({"path":path}))
|
||||
.expect_err("protect workspace and ancestors");
|
||||
assert!(error.contains("不能删除工作区根目录"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(workspace.join("keep.md")).unwrap(),
|
||||
"keep"
|
||||
);
|
||||
assert!(workspace.join("child").is_dir());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn delete_allows_ancestor_links_and_unlinks_without_following_targets() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = test_root();
|
||||
let root = temp.path();
|
||||
let workspace = ensure_design_workspace(root).unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
fs::write(outside.path().join("delete.md"), "delete").unwrap();
|
||||
fs::write(outside.path().join("keep.md"), "keep").unwrap();
|
||||
symlink(outside.path(), workspace.join("alias")).unwrap();
|
||||
execute_design_file_tool(root, "delete_path", &json!({"path":"alias/delete.md"}))
|
||||
.expect("delete through ancestor link");
|
||||
assert!(!outside.path().join("delete.md").exists());
|
||||
|
||||
symlink(&workspace, workspace.join("self")).unwrap();
|
||||
execute_design_file_tool(root, "delete_path", &json!({"path":"self/."}))
|
||||
.expect_err("workspace alias is protected");
|
||||
execute_design_file_tool(root, "delete_path", &json!({"path":"self"}))
|
||||
.expect("unlink workspace alias only");
|
||||
execute_design_file_tool(root, "delete_path", &json!({"path":"alias"}))
|
||||
.expect("unlink directory alias only");
|
||||
|
||||
fs::create_dir(workspace.join("remove")).unwrap();
|
||||
symlink(outside.path(), workspace.join("remove/alias")).unwrap();
|
||||
symlink(outside.path().join("missing"), workspace.join("dangling")).unwrap();
|
||||
for path in ["remove", "dangling"] {
|
||||
execute_design_file_tool(root, "delete_path", &json!({"path":path})).unwrap();
|
||||
assert!(fs::symlink_metadata(workspace.join(path)).is_err());
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read_to_string(outside.path().join("keep.md")).unwrap(),
|
||||
"keep"
|
||||
);
|
||||
assert!(workspace.is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_tools_edit_workspace_and_outside_files() {
|
||||
let temp = test_root();
|
||||
let root = temp.path();
|
||||
execute_design_file_tool(
|
||||
@@ -907,13 +1015,17 @@ mod tests {
|
||||
.to_string();
|
||||
assert!(listing.contains("[目录] notes"));
|
||||
assert!(!listing.contains(".agent"));
|
||||
let escaped = execute_design_file_tool(
|
||||
let outside = execute_design_file_tool(
|
||||
root,
|
||||
"write_file",
|
||||
&json!({"path":"../secret.md","content":"no"}),
|
||||
)
|
||||
.expect_err("escape");
|
||||
assert!(escaped.contains("路径"));
|
||||
.expect("write outside workspace");
|
||||
assert!(outside.as_str().unwrap().contains("已写入"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("secret.md")).expect("read outside"),
|
||||
"no"
|
||||
);
|
||||
let mismatch = execute_design_file_tool(
|
||||
root,
|
||||
"patch_file",
|
||||
|
||||
Reference in New Issue
Block a user