放开策划 Agent 文件工具的绝对路径与工作区外路径
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / Backend tests (pull_request) Successful in 3m58s
Project CI / Frontend tests (pull_request) Successful in 2m11s
Project CI / Native shell tests (pull_request) Successful in 6m3s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m53s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m2s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / Repository checks (pull_request) Successful in 2m15s

- design_tools 的 list_dir、read_file、write_file、patch_file、delete_path、search_text 接受绝对路径
- 相对路径仍以 design_artifacts 为基准,允许 .. 离开工作区
- 工作区内写入继续走私有文件写入,工作区外按普通文件创建父目录并写入
- 用户工作区浏览和附件导入仍限定在 design_artifacts
- 删除仍不跟随符号链接,也不删除工作区根目录
- 既有测试改为期望 ../secret.md 写入成功
- 同步策划 Agent 方案、decision-log 与 pitfalls
- 提示词未改
This commit is contained in:
kdletters
2026-09-29 08:15:05 +01:00
parent 80bf54c39d
commit 5c601300be
5 changed files with 95 additions and 40 deletions
@@ -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,7 +403,7 @@ pub(crate) fn execute_design_file_tool(
}
"delete_path" => {
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 display == "." {
return Err("不能删除工作区根目录".to_string());
}
@@ -426,7 +426,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 +768,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()
@@ -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,7 @@ mod tests {
}
#[test]
fn file_tools_stay_inside_workspace() {
fn file_tools_edit_workspace_and_outside_files() {
let temp = test_root();
let root = temp.path();
execute_design_file_tool(
@@ -907,13 +953,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",
@@ -1,5 +1,11 @@
# 决策记录
## 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 的更新器又把新版本装进新目录,于是用户看到「更新后自动启动新版、桌面快捷方式打开的还是旧的」。
@@ -104,7 +104,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 契约检查
@@ -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` 文件。