diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/system-prompt.md b/apps/ai-game-creator-shell/src-tauri/design-agent/system-prompt.md index ac23c9701..892129839 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/system-prompt.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/system-prompt.md @@ -1,5 +1,5 @@ -你是游戏策划协作 Agent,与用户持续协作完成游戏设计。像普通策划同事一样交流,使用工作区文件工具读写资料;所有文件路径使用相对路径。根据当前对话、阶段上下文和已有文档决定下一步行动。 +你是游戏策划协作 Agent,与用户持续协作完成游戏设计。像普通策划同事一样交流,使用工作区文件工具读写资料,策划文件放在工作区内并使用相对路径。根据当前对话、阶段上下文和已有文档决定下一步行动。 优先完成能够依据已有信息推进的工作。局部、可逆的问题可以先提出合理方案并标为暂定。会影响当前阶段范围、关键规则、下游实现或其他重要方向,且必须由用户决定的问题,应先通过纯文本或问询工具询问,等待用户回答。决定稳定后,再更新受影响的正式产物和必要的过程记录,并完成阶段审批前的检查。 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json index 5576758e1..cc06a7600 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json @@ -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}}} ] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index e646e1055..0a5995554 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -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 { + 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() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 9e1eafc0f..960aff342 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -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, |_| {}) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index 460ac0dad..cc0d36ddc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -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 { - 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", diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7f411537f..1f6807071 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,24 @@ # 决策记录 +## 2026-09-29 Codex 私有运行目录先解析系统临时路径上的符号链接 + +- 决策:新建的 Codex 临时目录确认是普通目录后,先解析为真实路径并去掉 Windows `\\?\` 前缀,再创建 `codex-home`、`workspace` 和隔离用户目录。macOS 的 `/var`、`/tmp` 这类系统符号链接不再阻断 Game Agent 启动。 +- Windows 路径转换必须先将 `\\?\UNC\server\share\...` 恢复为 `\\server\share\...`;不能只删除 `\\?\`,否则网络共享路径会变成相对路径。盘符路径继续去掉 `\\?\` 前缀。 +- 范围:`validate_game_creator_private_path_ancestors` 不放宽。AppData、客户端配置,以及临时目录内部新出现的符号链接,仍然拒绝。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`。 + +## 2026-09-29 策划文件工具说明去掉路径硬限制 + +- 决策:常驻提示词仍要求策划文件放在工作区内并使用相对路径。`read_file`、`write_file`、`list_dir`、`search_text`、`patch_file`、`delete_path` 的工具说明不再写「工作目录内」或「path 使用相对路径」。说明不宣布可以访问绝对路径或工作区外路径。 +- `delete_path` 保留工作区根目录及其上级目录保护,按真实路径在删除前判定;允许经过祖先链接,删除链接本身只移除链接,递归删除不跟随目录内的链接。阶段产物、共享过程文件和速览卡链接的相对路径约定不变。 +- 关联文档:[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 + +## 2026-09-29 策划文件工具接受绝对路径和工作区外路径 + +- 决策:Design Agent 的 `list_dir`、`read_file`、`write_file`、`patch_file`、`delete_path`、`search_text` 可以读写绝对路径,以及离开 `design_artifacts` 的路径。相对路径仍以策划工作区为基准,`..` 可以离开工作区。工作区内写入继续走现有私有文件写入;工作区外写入按普通文件创建父目录并写入。 +- 范围:用户工作区浏览和附件导入仍只使用 `design_artifacts`。阶段必需产物仍按工作区相对路径检查。删除允许经过祖先链接;删除链接本身只移除链接,递归删除不跟随目录内的链接。工作区根目录及其上级目录按真实路径保护。提示词本轮未改。 +- 关联文档:[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 + ## 2026-09-29 dev 渠道改名迁移放进安装器钩子,且只注入 dev - 背景:dev 渠道展示名从 `陶泥儿` 改成 `陶泥儿开发版`(`identifier` 不变)后,更新路径不会重建快捷方式,旧桌面图标继续指向旧安装目录里的旧 exe,旧 exe 的更新器又把新版本装进新目录,于是用户看到「更新后自动启动新版、桌面快捷方式打开的还是旧的」。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 802069341..1086146c7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,12 @@ # 踩坑与排障记录 +## 2026-09-29 Codex 隔离用户目录被系统临时目录的符号链接拦下 + +- **现象**:Game Agent 回合还没改项目文件就失败。卡片是 `direct-codex-failure:v2 stage=code-generation code=runtime-failure`,摘要为「创建 Codex app-server 隔离用户目录失败:Codex 隔离用户目录 路径不能包含符号链接:」。诊断文件同样把路径脱敏,看不出是哪一级目录。 +- **原因**:临时目录保留 `TMPDIR` 或 `/tmp` 的原始路径。macOS 上 `/var` 指向 `/private/var`,未设置 `TMPDIR` 时 `/tmp` 指向 `/private/tmp`。隔离用户目录会检查全部现存祖先,把这些系统符号链接当成私有目录里的链接拒绝。Linux 和 Windows 只在临时路径上确实有符号链接时同样失败。 +- **处理**:临时目录确认是普通目录后先解析成真实路径,再创建 `codex-home`、`workspace` 和 `home`。祖先检查不放宽;临时目录内部的符号链接仍然拒绝。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs` 的 `canonical_codex_private_runtime_dir`。 + ## 2026-09-29 dev 渠道改名后,更新路径不会重建快捷方式(旧图标一直指向旧安装) - **现象**:改名前的 dev 客户端(展示名 `陶泥儿`,0.1.126)点更新后自动启动的是新版(`陶泥儿开发版`,0.1.160),但桌面/开始菜单里的 `陶泥儿.lnk` 打开的还是旧版。`%LOCALAPPDATA%` 下同时留着 `陶泥儿` 与 `陶泥儿开发版` 两个安装目录,`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall` 下同时留着 `陶泥儿`(0.1.109)与 `陶泥儿开发版`(0.1.160)两个卸载项,而新版一份快捷方式都没有。 @@ -104,7 +111,7 @@ ## 策划 Agent 提示词中的相对路径不要当作内部实现删去 -`project/...` 是 Agent 读写策划工作区的目标路径,`resources/...` 是查找内置分册、模板和例子的资源定位;即使阶段上下文也注入了同一产物路径,提示词里的路径仍是 Agent 需要的契约。清理宿主实现细节时不要误删这些相对路径,具体用法见[策划 Agent 路径说明](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md#6-阶段与提示词注入)。 +`project/...` 是提示词里读写策划工作区的目标路径,`resources/...` 是查找内置分册、模板和例子的资源定位;即使阶段上下文也注入了同一产物路径,提示词里的路径仍是 Agent 需要的契约。清理宿主实现细节时不要误删这些相对路径。Runtime 文件工具另外接受绝对路径和离开 `design_artifacts` 的路径。具体用法见[策划 Agent 路径说明](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md#6-阶段与提示词注入)。 ## AGC 素材直传的 OSS 权限必须同步到 Native shell 契约检查 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8ee04d922..5dd210419 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,11 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-09-29 Codex 私有运行目录路径解析 + +新建的私有运行目录先确认是普通目录并收紧权限,再解析真实路径,后续 `codex-home`、`workspace` 和隔离用户目录均在真实路径下创建,避免 macOS 系统临时目录的符号链接阻断启动。Windows 扩展 UNC 路径 `\\?\UNC\server\share\...` 必须转换为 `\\server\share\...`,盘符路径才直接去掉 `\\?\` 前缀;转换后保留绝对路径语义。私有子目录仍执行原有祖先符号链接与 reparse point 检查。 + +定向验证使用 Rust 单测过滤器 `codex_private_runtime_`,覆盖 UNC、盘符和普通路径转换,以及 Unix 祖先链接解析与内部链接拒绝;Windows 绝对路径断言在 Windows 测试环境执行。 + ## 2026-09-28 Web 环境版本探测的用户目录隔离 Node/npm 版本探测清空继承环境后,必须设置客户端创建的临时 `HOME`、`USERPROFILE`、`APPDATA` 和 `LOCALAPPDATA`,并指定空的用户及全局 npm 配置、临时缓存和临时工作目录。临时目录保留到探测子进程退出,不依赖 Windows 用户资料查询,也不读取用户或项目的 `.npmrc`。探测继续使用校验后的客户端运行时;初始化临时环境失败返回 `runtime-probe-home-unavailable`,不回退到系统 Node 或真实用户目录。 diff --git a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md index 5702da67e..ee0f84c68 100644 --- a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md +++ b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md @@ -1,6 +1,6 @@ # 策划 Agent 生产迁移与工作区浏览方案 -更新时间:2026-09-23 +更新时间:2026-09-29 状态:已完成(2026-09-18) > 现状说明(2026-09-18):本文记录的迁移已完成,当前策划入口统一使用 Design Agent。旧 Planning V1/V2 会话、专用命令、审批卡和展示适配已删除;文中提到的 V2 文件仅代表迁移时的参考来源,不得作为现行实现、回退路径或测试迁移目标。 @@ -14,7 +14,7 @@ 迁移后,策划 Agent 应能: - 持续接收用户自然语言指示; -- 自由读取、创建、写入、局部修改、删除和搜索工作区文件; +- 自由读取、创建、写入、局部修改、删除和搜索工作区文件,并对绝对路径和工作区外路径做同样的读写; - 按五个策划阶段推进,并在最后进入顾问态; - 按阶段注入提示词和明确要求的必读资源; - 通过澄清卡或普通文本向用户询问; @@ -45,7 +45,7 @@ | Provider 重试 | 复用瞬态错误识别、退避、最大重试次数和失败持久化 | | 会话持久化 | 复用项目级会话目录、原子写入和恢复入口,但使用新的设计会话数据结构 | | 并发保护 | 保留项目级短时写锁和会话活跃保护,防止文件或状态写入损坏 | -| 文件底层能力 | 按原型工具契约筛选已有底层函数;绑定工作区,剥离旧业务门禁;缺少的目录删除、搜索等能力局部补齐 | +| 文件底层能力 | 按原型工具契约筛选已有底层函数;工作区内沿用原写入,并接受绝对路径与离开工作区的路径;缺少的目录删除、搜索等能力局部补齐 | | 审计与 debug | 复用正式动作记录和诊断采集;恢复或审计必需资料保存在 `.agent`,额外 debug 改为只写、可删除、不阻塞的旁路 | | Tauri 通信 | 复用命令注册、事件流、会话恢复通知和前端状态同步机制 | | 用户文件浏览 | 复用 Game Agent 的文件列表、文件读取和工作区刷新模式 | @@ -68,7 +68,7 @@ - 旧的“批准 / 修改 / 退回重做”审批语义; - 多 Agent、Wiki、知识库、外部搜索和自动任务编排。 -路径穿越、绝对路径、控制目录访问和凭据泄露防护属于安全边界,可以保留;它们不能扩展成限制正常策划创作的业务门禁。 +2026-09-29:策划文件工具可以读取和写入绝对路径,以及 `design_artifacts` 之外的路径。相对路径仍以策划工作区为基准,`..` 可以离开工作区。用户工作区浏览和附件导入仍只使用 `design_artifacts`。删除允许路径经过祖先链接;删除目标本身是链接时只移除链接(包括悬空链接),递归删除目录不跟随其中的目录链接。删除普通目录前以真实路径检查目标,拒绝工作区根目录及其上级目录,绝对路径中的 `..`、大小写或祖先链接别名均不得绕过;检查必须在删除任何内容前完成。相对删除路径保留 `..`,按文件系统实际链接目标解析,不提前做字符串折叠。凭据不写入提示词或日志。 当前实现入口如下: @@ -76,8 +76,8 @@ | --- | --- | | `apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs` | Provider 请求、工具循环、重试、阶段审批与会话恢复 | | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs` | 设计会话、阶段状态与待交互请求的持久化 | -| `apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs` | 固定资源包、工作区文件读写、补丁、删除、搜索与路径安全边界 | -| `apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs` | 路径解析、文件列出和读取、基础写入;内部绝对路径字段不得传给模型 | +| `apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs` | 固定资源包与文件工具。文件工具接受工作区相对路径、离开工作区的相对路径和绝对路径;用户浏览与附件导入仍限定在策划工作区 | +| `apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs` | 项目内路径解析、文件列出和读取、基础写入。该模块返回给界面的内部绝对路径字段仍不是模型输入 | | `apps/ai-game-creator-shell/src/App.tsx` | Design Agent IPC、事件订阅、文件刷新、会话恢复与输入提交 | | `apps/ai-game-creator-shell/src/features/project-workspace/DesignWorkspacePanel.tsx` | 策划工作区文件浏览与正文展示,直接打开文件视图 | | `apps/ai-game-creator-shell/src/features/project-workspace/DesignAgentSurface.tsx` | 消息、reasoning、澄清与阶段审批交互 | @@ -223,7 +223,7 @@ submit_phase_for_approval get_workflow_status ``` -工具使用相对工作区路径。工具执行结果继续通过 Runtime 统一记录和展示,但不向 Agent 暴露宿主绝对路径。 +文件工具的 `path` 可以是工作区相对路径、含 `..` 的路径或绝对路径。相对路径以 `design_artifacts` 为基准。工具结果按实际目标返回:工作区内用工作区相对路径,工作区外用解析后的绝对路径,并写入 Runtime 会话记录。 `patch_file` 保留原型按唯一原文匹配、范围不重叠、全部通过才原子写入的语义、换行归一化和缺文件错误。批量 edits 会一次性完成全部校验,并把未找到、多处匹配、重叠等失败项汇总返回;未找到时同时给出候选行号和可见化缩进提示,帮助 Provider 基于当前文件修正锚点。正常工作区写入与删除不逐次请求用户审批;阶段审批不能被复用为文件操作许可。 @@ -293,7 +293,7 @@ concept → top_design → architecture → systems → tdd → consultant - `project/...` 以策划工作区为根,Agent 将其传给 `read_file`、`write_file`、`patch_file` 等文件工具来读取和维护正式产物及过程文件。宿主将它映射到项目的 `design_artifacts/project/...`;阶段审批按登记的相对路径检查必需产物。提示词写明 `project/速览卡.md`、`project/analysis.md` 等目标位置,是在告诉 Agent 文件应写在哪里,并非泄露宿主绝对路径。 - `resources/skills/...`、`resources/templates/...`、`resources/exemplars/...` 和 `resources/modules/system-types/...` 指向随应用发布的固定策划资源包,用于定位分册、模板、例子及系统类型资料,不是策划工作区的写入目标。资源目录在 `resources/catalog.json` 中登记相对路径与资源 ID;Agent 可用 `list_resources` 查 ID,再用 `read_resource` 按 ID 读取。分册中省略 `resources/` 前缀的 `templates/...` 等写法仍指同一资源包内的位置。 -这些路径直接服务于 Agent 的文件操作和资源查阅,属于提示词应保留的契约;即使阶段上下文也注入了某条产物路径,分册和模板中的路径仍提供目标文件与交叉引用的具体定位。去除客户端与宿主实现细节时,不应把这类相对路径当作意外暴露的内部实现;宿主安装目录、项目绝对路径及会话控制文件位置才不属于 Agent 的操作输入。 +这些路径直接服务于 Agent 的文件操作和资源查阅,属于提示词应保留的契约;即使阶段上下文也注入了某条产物路径,分册和模板中的路径仍提供目标文件与交叉引用的具体定位。去除客户端与宿主实现细节时,仍保留这类相对路径。Runtime 文件工具同时接受绝对路径和离开 `design_artifacts` 的路径,包括项目内其他目录和宿主上的其他位置。常驻提示词仍要求策划文件放在工作区内并使用相对路径。六个文件工具的说明不再把路径限定为相对路径或工作目录内。正式产物的相对路径约定保持不变。 共享文档按以下职责维护,文件路径和审批必需产物清单保持不变: @@ -388,7 +388,7 @@ UI 使用“批准”和“继续修改”两个文字按钮,分别配 Lucide ## 8. 用户工作区浏览 -`design_artifacts` 同时是 Agent 工作区和用户查看策划资料的文件区。用户不需要通过聊天请求 Agent 才能看到文件。 +`design_artifacts` 是用户查看策划资料的文件区,也是 Agent 的默认工作区。用户不需要通过聊天请求 Agent 才能看到这里的文件。用户文件树只列出该目录。Agent 文件工具还可以读写绝对路径和该目录之外的路径,那些路径不因此出现在用户文件树中。 第一版提供只读浏览: @@ -444,7 +444,7 @@ UI 使用“批准”和“继续修改”两个文字按钮,分别配 Lucide 1. 核对原型行为基线,选择生产 Provider、恢复、审计、文件和事件通信的可复用函数;仅拆分实际阻碍复用的局部业务耦合。 2. 新增独立的设计会话状态结构和持久化路径。 3. 新增自由策划 Agent Provider 回合循环。 -4. 将文件工具绑定到 `design_artifacts`,移除旧 Planning V2 的业务产物门禁。 +4. 将文件工具绑定到 `design_artifacts`,移除旧 Planning V2 的业务产物门禁。2026-09-29 起,这一绑定只保留给用户文件浏览和附件导入;策划文件工具同时接受绝对路径和 `design_artifacts` 之外的路径。 5. 接入阶段提示、必读资源注入和 `get_workflow_status`。 6. 接入 `submit_phase_for_approval` 和 ✅/❌ 审批事件。 7. 复用 Game Agent 文件浏览实现,让用户查看 `design_artifacts` 文件。