diff --git a/doc/architecture.md b/doc/architecture.md index 6e76c362..15a43fcc 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -78,7 +78,7 @@ Retrieval 负责文档索引、向量检索、全文检索和结果融合。检 ### Tool Runtime -Tool Runtime 为 AI 提供文件、终端、浏览器等本地能力。工具实现位于 Main,由各领域服务负责具体操作;通用安全边界、审批和工作区变化追踪由运行时统一处理。 +Tool Runtime 为 AI 提供文件、终端、浏览器等本地能力。工具实现位于 Main,由各领域服务负责具体操作;审批、命令安全策略和工作区变化追踪由运行时统一处理。工作区是默认路径、资料索引与变更追踪范围,不限制工具访问工作区外绝对路径。 内置浏览器由 Electron `WebContentsView` 承载,Stagehand 通过仅绑定本机回环地址的随机 CDP endpoint 精确连接对应 Tab,不依赖固定端口或 URL 猜测页面。 @@ -89,7 +89,7 @@ Workbench 的当前交互状态由 Renderer 管理,并按会话串行写入 SQ ### Workspace Change Tracking -每个 Run 前后扫描工作区,统一确认新增、修改、删除和重命名,并保存可回退的修改前对象。不同文件格式只负责提供对应的预览方式。 +每个 Run 前后只扫描工作区,统一确认新增、修改、删除和重命名,并保存可回退的修改前对象。工作区外绝对路径允许由工具读写,但不进入当前修改记录、预览或回退能力。不同文件格式只负责提供对应的预览方式。 ## AI 架构 @@ -156,7 +156,7 @@ AI 结构化调用按配置和模型记忆成功策略;能力不支持、解 未正常完成的智能模式任务另有一套按会话持久化的恢复快照。快照保存原始任务、历次中断与恢复记录、当前中断原因和 HumanState 进度;同一会话的下一条用户消息会消费当前中断原因、追加恢复记录并启动新的 run。网络错误、超时、用户停止、进程中断、运行上限和未知异常都保留快照,只有任务正常完成或会话被删除时才清理。不同会话的恢复状态严格隔离。 -文件 artifact 是预览、差异与回退所需的内部业务数据;传入模型时只投影必要语义,不暴露内部记录标识。运行失败或取消不会撤销已落地修改,所有终态都保留可查看、可回退的修改记录。 +文件 artifact 是预览、差异与回退所需的内部业务数据;传入模型时只投影必要语义,不暴露内部记录标识。运行失败或取消不会撤销已落地的工作区内修改,所有终态都保留可查看、可回退的工作区内修改记录。 ## 存储架构 @@ -179,6 +179,6 @@ AI 结构化调用按配置和模型记忆成功策略;能力不支持、解 3. AI Runtime 通过 human graph 推进 brain、learning、hand、mouth。 4. 需要资料时进入 Retrieval,读取 SQLite 元数据并检索 LanceDB。 5. 需要本地能力时由 Tool Runtime 调用对应领域服务。 -6. 文件写回后记录统一修改记录和对应预览,供 UI 展示和回退。 +6. 工作区内文件写回后记录统一修改记录和对应预览,供 UI 展示和回退;工作区外写入不记录回退。 7. 最终答复和事件流通过 IPC 回到 Renderer 展示。 8. Run 持久化为 `completed`、`failed` 或 `cancelled` 后,Main 触发一次系统通知;通知点击通过 Preload IPC 打开对应会话。 diff --git a/doc/prd/workbench.md b/doc/prd/workbench.md index 69ace794..1bda4930 100644 --- a/doc/prd/workbench.md +++ b/doc/prd/workbench.md @@ -41,7 +41,7 @@ - 未显式选择参考资料时,智能模式仍可处理通用任务。 - 显式选择目录、文件或网页资料后,任务应优先使用这些资料。只绑定工作区时,默认使用工作区内全部已导入资料;资料不足时,智能模式可用模型通用知识补充通用部分。 - 当前可由 workbench 直接写回并支持回退的受支持文件类型包括 `docx`、`pptx`、`xlsx`、生成的 `png`、`jpg`、`jpeg`、`webp` 图片,以及 Markdown、TXT、配置文件和代码等 UTF-8 普通文本文件;普通文本支持创建、完整改写、唯一文本编辑和全部精确匹配替换。Excel 工具可读取 `xlsx`、`xlsm`、`xltx`、`xltm`,但只允许创建和编辑 `xlsx`,避免写回时丢失宏或模板信息。 -- 当前通过 composer 附件进入对话上下文的文件类型支持图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`、`xlsx`、`xlsm`、`xltx`、`xltm`;这些附件不自动预解析,只保存到当前工作区并把名称与路径交给 AI 后续自行处理。`doc` 只支持读取,`pptx` 可交给 PPT 工具读取、搜索、渲染和编辑,Excel 文件可交给 Excel 工具处理,但只有 `xlsx` 可写。 +- 当前通过 composer 附件进入对话上下文的文件类型支持图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`、`xlsx`、`xlsm`、`xltx`、`xltm`;`+` 选择和拖放文件保留源文件绝对路径,粘贴文件保存副本到当前工作区。附件不自动预解析,只把名称与路径交给 AI 后续自行处理。`doc` 只支持读取,`pptx` 可交给 PPT 工具读取、搜索、渲染和编辑,Excel 文件可交给 Excel 工具处理,但只有 `xlsx` 可写。 - 其他格式不保证直接写回和回退。AI 可以在工作区和审批边界内尝试处理,但不属于受支持文件能力范围。 - 终端执行只允许当前平台安全指令集直接运行;一条 terminal 命令被拆分后的每段都命中安全指令集时可直接执行,任一段未命中则必须先经用户手动批准。安全指令集只决定是否可直接执行,不决定执行模式。 - 搜索模式只检索当前工作区下可检索的已导入资料;`@` 候选仍可展示当前工作区下的全部本地目录和文件,但未索引的本地目录或文件不能产生纯搜索结果,部分可检索目录只覆盖其已索引子文件。 @@ -318,10 +318,10 @@ #### WB-COMPOSER-010 附件保存与传递规则 -- 系统行为:用户通过 `+`、粘贴或拖入中间聊天区添加附件时,附件统一保存到当前工作区的 `/.ncurator/runtime/assets//`;`assetBucketId` 是当前输入草稿生成的 UUID,和会话 ID 解耦,添加附件不会提前创建新会话。文件拖入中间聊天区时显示透明覆盖层“松开以添加文件”,拖离或放下后消失;覆盖层不覆盖左侧栏、顶部栏或右侧资源面板。发送给 AI 的附件上下文只包含顺序、名称和路径。 +- 系统行为:用户通过 `+` 或拖入中间聊天区添加本地文件时,附件记录其源文件绝对路径,不复制文件;粘贴文件因不保证有稳定源路径,保存到当前工作区的 `/.ncurator/runtime/assets//`。`assetBucketId` 是当前输入草稿生成的 UUID,和会话 ID 解耦,添加附件不会提前创建新会话。文件拖入中间聊天区时显示透明覆盖层“松开以添加文件”,拖离或放下后消失;覆盖层不覆盖左侧栏、顶部栏或右侧资源面板。发送给 AI 的附件上下文只包含顺序、名称和路径。 - 验收: - `WB-COMPOSER-010-AC1` 未绑定可用工作区时,不允许添加附件,并提示先选择工作区或完成目录绑定。 - - `WB-COMPOSER-010-AC2` 已绑定工作区后,`+`、粘贴和拖放添加的附件都会保存到 `/.ncurator/runtime/assets//`,且不会提前创建真实会话。 + - `WB-COMPOSER-010-AC2` 已绑定工作区后,`+` 和拖放文件保留源文件绝对路径;粘贴文件保存到 `/.ncurator/runtime/assets//`;三种方式均不会提前创建真实会话。 - `WB-COMPOSER-010-AC3` 当前附件支持范围只有图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`、`xlsx`、`xlsm`、`xltx`、`xltm`;其中 `doc` 只读,`pptx` 可由 PPT 工具读取和编辑,四种 Excel 格式均可读取但只有 `xlsx` 可编辑;其他格式提示不支持且不加入附件列表。 - `WB-COMPOSER-010-AC4` 发送给 AI 的附件上下文只包含顺序、名称和路径,不包含发送前预解析摘要。 - `WB-COMPOSER-010-AC5` 拖放仅接受文件;文件夹不处理。格式校验、大小提示、附件卡片和移除逻辑与 `+`、粘贴一致。 @@ -380,7 +380,7 @@ #### WB-BLOCKER-001 审批阻塞 -- 系统行为:审核权限下,AI 运行收到批量审批事件后,在输入框上方展示紧贴 composer 的审批卡片;卡片一次只展示当前一条审批项,用户可通过上下切换浏览整批,并且必须先完成整批审核后系统才会继续决策是否执行。审批卡片不展示内部策略原因,命令文本与同意/拒绝图标按钮左右排列,指导输入框与提交按钮同排展示;审批不自动超时,必须等待用户明确操作或运行被取消/关闭。完全访问权限下,统一审批入口直接批准该 run 的所有审批项,不创建等待审批状态、不发送审批事件、不展示审批卡片。完全访问只跳过审批,不绕过工作区边界、命令安全限制、取消、文件变更记录或其他运行时保护。 +- 系统行为:审核权限下,AI 运行收到批量审批事件后,在输入框上方展示紧贴 composer 的审批卡片;卡片一次只展示当前一条审批项,用户可通过上下切换浏览整批,并且必须先完成整批审核后系统才会继续决策是否执行。审批卡片不展示内部策略原因,命令文本与同意/拒绝图标按钮左右排列,指导输入框与提交按钮同排展示;审批不自动超时,必须等待用户明确操作或运行被取消/关闭。完全访问权限下,统一审批入口直接批准该 run 的所有审批项,不创建等待审批状态、不发送审批事件、不展示审批卡片。完全访问只跳过审批,不绕过工作区绑定、命令安全限制、取消、工作区内文件变更记录或其他运行时保护。 - 验收: - `WB-BLOCKER-001-AC1` 同一批次全部选择“同意”后,当前运行恢复加载态,并按原 serial 顺序继续执行该批对应命令。 - `WB-BLOCKER-001-AC2` 只要批次中任一项被“拒绝”或填写“告诉馆长该怎么做”,本批命令一条都不会执行,系统回到后续 AI 决策收口。 @@ -513,17 +513,17 @@ #### WB-TERMINAL-001 持久终端会话 -- 系统行为:terminal 使用持久终端会话。新终端从当前 workspace 根目录启动;已有空闲终端可通过 `terminalId` 继续执行并保留路径,运行中的终端不能接受新命令。长命令返回 `command_still_running` 后继续运行,可通过 `terminal_read` 增量读取,通过 `terminal_stop` 停止或关闭。工具不接收 `cwd`,只使用当前终端路径和结构化终端状态。 +- 系统行为:terminal 使用持久终端会话。新终端从当前 workspace 根目录启动;已有空闲终端可通过 `terminalId` 继续执行并保留路径,运行中的终端不能接受新命令。长命令返回 `command_still_running` 后继续运行,可通过 `terminal_read` 增量读取,通过 `terminal_stop` 停止或关闭。工具不接收 `cwd`,只使用当前终端路径和结构化终端状态。绝对路径可以访问工作区外文件;相对路径仍以当前终端路径为起点。 - 验收: - `WB-TERMINAL-001-AC1` 同一 terminalId 连续执行时保留 `cd` 后当前路径;不传 terminalId 会创建从 workspace 根目录开始的新终端。 - `WB-TERMINAL-001-AC2` 长命令固定让出控制权但不被误判为失败;terminal_read 只返回游标后的增量输出和客观状态。 - `WB-TERMINAL-001-AC3` terminal_stop 先中断前台命令,必要时强制结束完整进程树;删除会话、取消所属 run 或退出应用不会遗留进程。 - - `WB-TERMINAL-001-AC4` terminal_run 继续遵守命令审批和 workspace 边界;terminal_read 与 terminal_stop 不能访问其他会话或 workspace 的终端。 + - `WB-TERMINAL-001-AC4` terminal_run 继续遵守命令审批与其他安全策略;terminal_read 与 terminal_stop 不能访问其他会话或 workspace 的终端。 - `WB-TERMINAL-001-AC5` shell 后台符号、nohup 或 Start-Process 不能绕过持久终端管理。 #### WB-APPLY-003 回退本次 AI 修改 -- 系统行为:受控文件工具写回后立即保存本次 run 的修改记录、有效变更校验和所需快照;terminal 每条命令执行前保存工作区基线,命令最终结束、失败或停止后计算该命令窗口内的新增、修改、删除和重命名。终端记录归入发起命令的 run。不同 terminal 命令并发重叠时允许同一变化在多个 run 中重复记录;系统不加锁、不推断唯一归属。AI 或用户在没有受控工具写回和 terminal 命令窗口时产生的外部变化不归入任何 AI Run。基线内容按 SHA-256 保存;文本 Diff 根据不可变的修改前后内容生成并缓存。回退使用修改前对象,不反向计算 Diff。 +- 系统行为:受控文件工具写回工作区内文件时立即保存本次 run 的修改记录、有效变更校验和所需快照;terminal 每条命令执行前保存工作区基线,命令最终结束、失败或停止后计算该命令窗口内的新增、修改、删除和重命名。终端记录归入发起命令的 run。不同 terminal 命令并发重叠时允许同一变化在多个 run 中重复记录;系统不加锁、不推断唯一归属。AI 可以读写工作区外绝对路径,但这些变更不进入当前修改记录与回退能力。AI 或用户在没有受控工具写回和 terminal 命令窗口时产生的外部变化不归入任何 AI Run。基线内容按 SHA-256 保存;文本 Diff 根据不可变的修改前后内容生成并缓存。回退使用修改前对象,不反向计算 Diff。 - 验收: - `WB-APPLY-003-AC1` 未发生后续修改时,回退恢复写回前 checkpoint,记录状态变为 `reverted`。 - `WB-APPLY-003-AC2` 新建文件回退时删除 AI 新建目标文件。 @@ -623,7 +623,7 @@ - `WB-IMAGE-001-AC1` `image_generate` 只接收 `outputPath` 和 `svg`,输出格式由 `.png`、`.jpg`、`.jpeg` 或 `.webp` 扩展名决定。 - `WB-IMAGE-001-AC2` 工具成功后只交付目标图片文件,assistant turn 展示该图片的修改入口,右侧面板可直接预览。 - `WB-IMAGE-001-AC3` 目标路径已存在时自动更新;目标不存在时自动创建,不要求 AI 额外声明 action、format 或 overwrite。 - - `WB-IMAGE-001-AC4` SVG 无法安全解析、渲染或编码,输出格式不受支持,或目标路径越过工作区时,工具返回错误且目标文件保持调用前状态。 + - `WB-IMAGE-001-AC4` SVG 无法安全解析、渲染或编码,或输出格式不受支持时,工具返回错误且目标文件保持调用前状态;工作区外绝对路径可以写入,但不提供修改记录或回退。 ## 9. 非目标 diff --git a/doc/test-plan/ai-test.md b/doc/test-plan/ai-test.md index bedabaf3..e3934f9d 100644 --- a/doc/test-plan/ai-test.md +++ b/doc/test-plan/ai-test.md @@ -28,8 +28,8 @@ - `tool` 只验证真实生产工具调用,不允许 fake。 - `tool / docx` 包含 `docx-edit-serial-actions-stability`,用于守住同一次 `docx_edit(actions)` 内插入、替换、删除后的 block 定位稳定性。 - `tool / text-file` 覆盖普通文本读取、四种写入 action、修改记录、结构化 diff 与回退前置数据。 -- `tool / image-generation` 覆盖 SVG 内部绘制输入、PNG/JPEG/WebP 转码、自动创建或更新、修改记录、Artifact、安全校验和工作区边界。 -- `tool / pdf` 使用真实 `深度学习.pdf`,覆盖元数据、页级正文、literal/regex 搜索、页面 PNG、工作区边界、损坏文件和结果大小限制。 +- `tool / image-generation` 覆盖 SVG 内部绘制输入、PNG/JPEG/WebP 转码、自动创建或更新、工作区内修改记录、Artifact、安全校验和工作区外绝对路径写入不记录回退。 +- `tool / pdf` 使用真实 `深度学习.pdf`,覆盖元数据、页级正文、literal/regex 搜索、页面 PNG、工作区外绝对路径读取、损坏文件和结果大小限制。 - `tool / web-browsing` 包含 `web-crawl-contract`,用于守住 `web_crawl`、`web_search` 和 `browser_use` 工具契约。 - 目标:验证 tool 与 `hand` 执行守卫的生产输入输出。 - 约束:使用真实工具、store、docx apply service 和 artifact 流;禁止 fake `documentsStore`、`embeddingService`、`resolveDocxOpenTarget` 或 tool result。 @@ -634,9 +634,9 @@ AI unit / 辅助测试目录: - 测试类型:`backend` - What:PDF 输入边界会明确失败。 -- Why:防止路径逃逸、伪 PDF、非法 regex 和越界页码进入不确定状态。 +- Why:防止伪 PDF、非法 regex 和越界页码进入不确定状态,同时确认外部附件路径可以读取。 - How:分别传入越界页码、非法 regex、错误文件头以及指向工作区外的链接;Windows 使用无需提升权限的目录 junction,Unix 使用目录 symlink。 -- Oracle:每种输入都返回明确失败,不读取工作区外文件。 +- Oracle:越界页码、非法 regex 和错误文件头返回明确失败;工作区外 PDF 通过符号链接仍可正常读取。 - Not:不做密码破解或损坏文件修复。 #### ppt 类别 @@ -888,9 +888,9 @@ AI unit / 辅助测试目录: ##### `image-generate-safety-and-boundary-contract` - 测试类型:`backend` -- What:危险 SVG、不支持格式和工作区越界请求失败且不产生文件副作用。 +- What:危险 SVG 和不支持格式请求失败;工作区外绝对路径可写入但不产生回退记录。 - Why:SVG 是可包含脚本和外部资源的输入格式,图片工具必须在写文件前完成安全和路径校验。 -- How:分别提交包含脚本、外部图片引用、CSS 外链、CSS import、超大尺寸、malformed SVG、`.gif` 输出路径和工作区外路径的真实工具调用;越界路径同时携带非法 SVG 以确认先检查路径,malformed SVG 使用一份已存在目标文件。 +- How:分别提交包含脚本、外部图片引用、CSS 外链、CSS import、超大尺寸、malformed SVG、`.gif` 输出路径和工作区外路径的真实工具调用;工作区外路径使用安全 SVG,malformed SVG 使用一份已存在目标文件。 - Oracle:所有调用均返回明确错误;新目标文件不存在;已有目标文件保持调用前 bytes;不生成图片修改记录或 `file_artifact`。 - Not:不把内部安全规则写入 Tool 提示词,也不穷举全部 SVG 标签。 diff --git a/doc/test-plan/workbench-test.md b/doc/test-plan/workbench-test.md index 361ae6ec..3c6d9052 100644 --- a/doc/test-plan/workbench-test.md +++ b/doc/test-plan/workbench-test.md @@ -21,7 +21,7 @@ - image generation apply case 需要真实模型学习 `image-generation`、真实调用 `image_generate`、生成可解码图片、展示修改入口和图片预览,并完成新建图片回退。 - `browser-use-task-panel` 需要真实 AI run 触发 `browser_use` 并驱动内置浏览器完成本地网页任务;同时验证多个浏览器 Tab 独立历史、共享持久站点数据、切换文件后网页状态不销毁,以及同 URL 标签不会让 Stagehand 串页,不能用 fake browser 替代。 - `web_search` 与 `web_crawl` 需要分开覆盖:搜索 case 只验证候选网页返回;读取 case 必须验证 AI 继续读取网页正文后再回答。 -- Workbench 附件链路当前支持图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`、`xlsx`、`xlsm`、`xltx`、`xltm`,统一保存到 `/.ncurator/runtime/assets//`;`assetBucketId` 为输入草稿 UUID,发送给 AI 时只传附件名称与路径,不再做发送前识图或文件解析。 +- Workbench 附件链路当前支持图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`、`xlsx`、`xlsm`、`xltx`、`xltm`;`+` 和拖放保留源文件绝对路径,粘贴文件保存到 `/.ncurator/runtime/assets//`。`assetBucketId` 为输入草稿 UUID,发送给 AI 时只传附件名称与路径,不再做发送前识图或文件解析。 - `WB-EXCEL-001-AC1`、`WB-EXCEL-001-AC2`、`WB-EXCEL-001-AC3`、`WB-EXCEL-001-AC4`、`WB-EXCEL-001-AC6`、`WB-EXCEL-001-AC7` 的工具输入输出、完整编辑操作、原子失败、冲突、只读格式、默认 Sheet 和 Sheet 严格校验由 AI tool 回归承担;`WB-EXCEL-002` 的完整 XLSX diff 语义也由 AI tool 解析 diff 文件校验。Workbench 的 Excel apply case 负责真实模型选用工具、正式文件、修改入口、只读单网格预览和回退闭环,不重复枚举底层操作。 - docx、ppt、pdf 也按同一分层:格式专用工具的读写、边界、格式保真和 diff artifact 语义放在 AI tool;DOCX tool 必须覆盖已有表格的单元格内容与格式净变化、精确红绿标记、计数和非空回读;其中 PPT tool 必须完整覆盖 baseline 到最终状态的页面/元素净变化、自然位移、零净变化、lineage、v4 schema 和准确计数。Workbench e2e 只验证真实用户流程、现有预览入口、红绿页面标记、`+N/-N`、差异面板交互和回退,不重复枚举底层元素组合。PDF 当前是只读工具,没有 Workbench apply diff 语义。 - DOCX 工具结果和历史结果按递归 children 树展示,Workbench 必须能显示 visualId、visual kind、nodeId 和统一 style action,不再读取旧 runs/cells/paragraphs history shape。`docx_read_visuals` 使用与 PDF/PPT 视觉读取一致的结果展示。 @@ -197,10 +197,10 @@ tests/case/workbench/ #### `composer-attachments-context` - 测试类型:`e2e / live` -- What:`+`、粘贴与拖入中间聊天区添加附件时,附件会保存到工作区 `.ncurator/runtime/assets//`,不会提前创建真实会话;图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`、`xlsx`、`xlsm`、`xltx`、`xltm` 可以添加,并保持图片预览、文件打开、删除、普通文本粘贴和格式提示行为。 -- Why:附件链路已改为“统一落盘到工作区 + 只把路径交给 AI”,必须用回归测试守住真实保存路径和交互行为。 -- How:创建并通过原生目录选择绑定真实工作区;点击添加附件后,在 Windows 系统文件窗口或 macOS Open 面板中用系统级键鼠选择真实文件,经过 Renderer → IPC → Main → 附件保存链路;另外覆盖大文件 warning,并通过真实模型发送一次带附件消息。 -- Oracle:文件拖入中间聊天区时显示“松开以添加文件”的透明覆盖层,放下后覆盖层消失并出现附件卡片;未绑定工作区时提示先选择工作区且不添加附件;真实添加会落盘且不提前创建会话;图片预览、Excel 格式范围和大文件提示正确;发送后的用户消息保留附件名称,路径全部位于同一 runtime assets 目录;提示词只渲染名称与路径的纯契约由 AI Unit 负责;整体对齐 `WB-COMPOSER-010`。 +- What:`+`、粘贴与拖入中间聊天区添加附件时,不会提前创建真实会话;图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`、`xlsx`、`xlsm`、`xltx`、`xltm` 可以添加,并保持图片预览、文件打开、删除、普通文本粘贴和格式提示行为。 +- Why:`+` 与拖放必须把源文件路径交给 AI,粘贴仍需保存副本,回归必须守住这两条不同链路。 +- How:创建并通过原生目录选择绑定真实工作区;点击添加附件后,在 Windows 系统文件窗口或 macOS Open 面板中用系统级键鼠选择真实文件,经过 Renderer → IPC → Main 源路径登记链路;另外覆盖拖入覆盖层、大文件 warning,并通过真实模型发送一次带附件消息。 +- Oracle:文件拖入中间聊天区时显示“松开以添加文件”的透明覆盖层,放下后覆盖层消失;未绑定工作区时提示先选择工作区且不添加附件;`+` 选择的文件路径等于原始源路径且不提前创建会话;图片预览、Excel 格式范围和大文件提示正确;提示词只渲染名称与路径的纯契约由 AI Unit 负责;整体对齐 `WB-COMPOSER-010`。 - Not:不在跨平台业务回归中验证系统原生文件选择器的视觉细节;不通过 `attachmentPickResponse` 或直接 `attachmentSaveFile` 伪造附件结果。 #### `reference-mention-keyboard` diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index c33200e6..f0c597d4 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -58,6 +58,7 @@ import type { ChatTurnRetractLatestResponse, AttachmentPickRequest, AttachmentPickResponse, + AttachmentCreateFromPathRequest, AttachmentProcessResponse, AttachmentSaveRequest, AiRuntimeSettings, @@ -1651,12 +1652,8 @@ export function registerHandlers(services: { return { canceled: false, items: await Promise.all( - filePaths.map((filePath) => - attachmentService.importAttachmentFromPath({ - workspaceRootPath, - assetBucketId, - sourcePath: filePath - }) + filePaths.map(async (filePath) => + await attachmentService.createSourceAttachmentFromPath({ sourcePath: filePath }) ) ) } @@ -1666,6 +1663,22 @@ export function registerHandlers(services: { } ) + ipcMain.handle( + 'attachment-create-from-path', + async (_event, payload: AttachmentCreateFromPathRequest): Promise => { + try { + const attachment = await attachmentService.createSourceAttachmentFromPath({ + sourcePath: payload.sourcePath, + mimeType: payload.mimeType, + kind: payload.kind + }) + return { success: true, attachment } + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + ) + ipcMain.handle( 'attachment-save-file', async (_event, payload: AttachmentSaveRequest): Promise => { diff --git a/src/main/services/ai/human/constants.ts b/src/main/services/ai/human/constants.ts index 501c1334..1d647716 100644 --- a/src/main/services/ai/human/constants.ts +++ b/src/main/services/ai/human/constants.ts @@ -44,8 +44,7 @@ export const HUMAN_DECISION_HINT_CODES = [ 'brain_output_schema_invalid', 'brain_todolist_incomplete', 'brain_content_safety', - 'hand_call_invalid', - 'hand_workspace_boundary_blocked' + 'hand_call_invalid' ] as const export const HUMAN_MAX_ACTIVE_TOOLS = 25 diff --git a/src/main/services/ai/human/node/handNode/decisionHints.ts b/src/main/services/ai/human/node/handNode/decisionHints.ts index dd82aae6..9175fda3 100644 --- a/src/main/services/ai/human/node/handNode/decisionHints.ts +++ b/src/main/services/ai/human/node/handNode/decisionHints.ts @@ -7,13 +7,6 @@ type IndexedResult = { message: string } -const WORKSPACE_BOUNDARY_PATTERNS = [ - 'workspace boundary check failed:', - 'relative path escapes workspace boundary:', - 'path out of workspace boundary:', - 'cwd is out of workspace boundary' -] as const - const INVALID_INPUT_PATTERNS = ['tool call schema invalid', 'invalid_input', 'empty command'] as const const hasPattern = (value: string, patterns: readonly string[]): boolean => @@ -36,34 +29,18 @@ export const buildHandCallInvalidHint = (items: IndexedResult[]): Extract => ({ - code: 'hand_workspace_boundary_blocked', - detail: { - blockedCalls: items - } -}) - export const deriveGroupedHandFailureHints = (input: { calls: HandCallList results: readonly ToolCallResult[] }): { callInvalidHint?: Extract - workspaceBoundaryHint?: Extract } => { const invalidCalls: IndexedResult[] = [] - const blockedCalls: IndexedResult[] = [] input.results.forEach((result, callIndex) => { if (result.ok) return const message = resolveToolResultErrorText(result) if (!message) return - if (hasPattern(message, WORKSPACE_BOUNDARY_PATTERNS)) { - blockedCalls.push(buildIndexedResult({ callIndex, result, message })) - return - } - if (hasPattern(message, INVALID_INPUT_PATTERNS)) { invalidCalls.push(buildIndexedResult({ callIndex, result, message })) return @@ -72,8 +49,6 @@ export const deriveGroupedHandFailureHints = (input: { }) return { - callInvalidHint: invalidCalls.length > 0 ? buildHandCallInvalidHint(invalidCalls) : undefined, - workspaceBoundaryHint: - blockedCalls.length > 0 ? buildHandWorkspaceBoundaryBlockedHint(blockedCalls) : undefined + callInvalidHint: invalidCalls.length > 0 ? buildHandCallInvalidHint(invalidCalls) : undefined } } diff --git a/src/main/services/ai/human/node/handNode/followupSignals.ts b/src/main/services/ai/human/node/handNode/followupSignals.ts index e4261268..496f9e11 100644 --- a/src/main/services/ai/human/node/handNode/followupSignals.ts +++ b/src/main/services/ai/human/node/handNode/followupSignals.ts @@ -116,9 +116,7 @@ export const deriveHandFollowupSignals = (input: { } return { - decisionHint: - groupedFailureHints.workspaceBoundaryHint - || groupedFailureHints.callInvalidHint + decisionHint: groupedFailureHints.callInvalidHint } } diff --git a/src/main/services/ai/human/prompts/decisionHints.ts b/src/main/services/ai/human/prompts/decisionHints.ts index bcaa6784..50ce11ae 100644 --- a/src/main/services/ai/human/prompts/decisionHints.ts +++ b/src/main/services/ai/human/prompts/decisionHints.ts @@ -78,13 +78,6 @@ export const renderDecisionHintForBrain = (hint: HumanDecisionHint): string => { (item) => `calls[${String(item.callIndex)}] 的工具 \`${item.toolName}\`:${item.message}` ) ].join('\n') - case 'hand_workspace_boundary_blocked': - return [ - '上一轮 hand 工具调用命中了工作区边界保护,因此相关调用没有执行。', - ...hint.detail.blockedCalls.map( - (item) => `calls[${String(item.callIndex)}] 的工具 \`${item.toolName}\`:${item.message}` - ) - ].join('\n') case 'brain_content_safety': return hint.detail.message case 'brain_todolist_incomplete': diff --git a/src/main/services/ai/human/schema.ts b/src/main/services/ai/human/schema.ts index 0ed7aa3e..e4ec41be 100644 --- a/src/main/services/ai/human/schema.ts +++ b/src/main/services/ai/human/schema.ts @@ -231,20 +231,6 @@ export const humanDecisionHintSchema = z.discriminatedUnion('code', [ ) .min(1) }) - }), - z.object({ - code: z.literal('hand_workspace_boundary_blocked'), - detail: z.object({ - blockedCalls: z - .array( - z.object({ - callIndex: z.number().int().min(0), - toolName: z.string().min(1), - message: z.string().min(1) - }) - ) - .min(1) - }) }) ]) diff --git a/src/main/services/ai/human/tools/docx/review/RunDocxEditTracker.ts b/src/main/services/ai/human/tools/docx/review/RunDocxEditTracker.ts index d0e21acd..6308bb8c 100644 --- a/src/main/services/ai/human/tools/docx/review/RunDocxEditTracker.ts +++ b/src/main/services/ai/human/tools/docx/review/RunDocxEditTracker.ts @@ -10,6 +10,7 @@ import type { RunDocxEditDocument } from './types' import { buildDocxChangeSummary, calculateFileStats, sanitizeFileName } from './utils' import { buildDocxApplyArtifactPaths } from './viewProjection' import { projectReviewView } from './viewProjector' +import { isPathInsideRoot } from '../../terminal/commandPolicy' type RunDocxEditState = { sessionId: string @@ -97,12 +98,16 @@ export class RunDocxEditTracker { const state = this.runEdits.get(input.runId) if (!state) return [] - const changedDocuments = [...state.documentBySourcePath.values()].filter( - (document) => - document.dirtyChangeCount > 0 && - document.replaySession && - projectReviewView(document.replaySession).some((node) => node.reviewState !== 'normal') - ) + const changedDocuments = [...state.documentBySourcePath.values()].filter((document) => { + if ( + document.dirtyChangeCount <= 0 || + !document.replaySession || + !projectReviewView(document.replaySession).some((node) => node.reviewState !== 'normal') + ) { + return false + } + return isPathInsideRoot(document.sourcePath, input.workspaceRootPath) + }) if (changedDocuments.length === 0) { this.runEdits.delete(input.runId) return [] diff --git a/src/main/services/ai/human/tools/docx/runtime.ts b/src/main/services/ai/human/tools/docx/runtime.ts index b18a60fd..26ac6d48 100644 --- a/src/main/services/ai/human/tools/docx/runtime.ts +++ b/src/main/services/ai/human/tools/docx/runtime.ts @@ -25,7 +25,7 @@ import type { } from './types' import { getDocxWorkerClient } from './worker/docxWorkerClient' import type { DocxWorkerRequest, DocxWorkerResponse } from './worker/types' -import { resolveWorkspacePath } from '../terminal/commandPolicy' +import { resolveToolPath } from '../terminal/commandPolicy' import { isSemanticSearchableBlock, splitEmbeddingRows } from './semanticSearch' import { createToolEventEmitter } from '../utils/toolEvents' import type { ToolEventEmitter } from '../utils/toolEvents' @@ -324,10 +324,9 @@ export const createDocxToolRuntime = (input: DocxCapabilityToolsInput): DocxTool } const resolveReadableWordPath = (params: { cwd: string; rawPath: string }): string | null => { - const resolved = resolveWorkspacePath({ + const resolved = resolveToolPath({ rawPath: params.rawPath, - cwd: params.cwd, - rootPath: workspaceRootPath + cwd: params.cwd }) if (!resolved) return null if (!isReadableWordPath(resolved)) return null @@ -1331,7 +1330,7 @@ export const createDocxToolRuntime = (input: DocxCapabilityToolsInput): DocxTool toolName: params.toolName, toolCallId: params.toolCallId, reason: 'invalid_input', - error: 'sourcePath must be a workspace-bound .doc or .docx path' + error: 'sourcePath must be a .doc or .docx path' }) } } @@ -1383,7 +1382,7 @@ export const createDocxToolRuntime = (input: DocxCapabilityToolsInput): DocxTool toolName: params.toolName, toolCallId: params.toolCallId, reason: 'invalid_input', - error: 'sourcePath must be a workspace-bound .docx path' + error: 'sourcePath must be a .docx path' }) } } diff --git a/src/main/services/ai/human/tools/docx/tools/writeTools.ts b/src/main/services/ai/human/tools/docx/tools/writeTools.ts index 088af3a3..976af44b 100644 --- a/src/main/services/ai/human/tools/docx/tools/writeTools.ts +++ b/src/main/services/ai/human/tools/docx/tools/writeTools.ts @@ -1008,7 +1008,7 @@ export const buildDocxWriteTools = (runtime: DocxToolRuntime): StructuredToolInt toolName, toolCallId, reason: 'invalid_input', - error: 'sourcePath must be a workspace-bound .docx path' + error: 'sourcePath must be a .docx path' }), activity: { actionType: DOCX_EDIT_TOOL_SPEC.activityActionType! diff --git a/src/main/services/ai/human/tools/excel/runtime.ts b/src/main/services/ai/human/tools/excel/runtime.ts index 658b3741..af9eea31 100644 --- a/src/main/services/ai/human/tools/excel/runtime.ts +++ b/src/main/services/ai/human/tools/excel/runtime.ts @@ -4,7 +4,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import { createHash, randomUUID } from 'node:crypto' import type { JsonObject } from '../../../../../../shared/types' -import { resolveWorkspacePath } from '../terminal/commandPolicy' +import { isPathInsideRoot, resolveToolPath } from '../terminal/commandPolicy' import { createToolEventEmitter } from '../utils/toolEvents' import { emitFileArtifactEvent } from '../../../events/fileArtifacts' import type { ExcelCapabilityToolsInput, ExcelRuntimeState, ExcelToolRuntime } from './types' @@ -684,12 +684,11 @@ export const createExcelToolRuntime = (input: ExcelCapabilityToolsInput): ExcelT sheetLineageByPath: new Map() } const resolvePath = (rawPath: string, mode: 'read' | 'write' = 'read'): string => { - const resolved = resolveWorkspacePath({ + const resolved = resolveToolPath({ rawPath, - cwd: input.workspaceRootPath, - rootPath: input.workspaceRootPath + cwd: input.workspaceRootPath }) - if (!resolved) throw new Error('Excel 文件必须位于当前工作区内') + if (!resolved) throw new Error('Excel 文件路径无效') assertExcelPath(resolved, mode) return resolved } @@ -931,6 +930,29 @@ export const createExcelToolRuntime = (input: ExcelCapabilityToolsInput): ExcelT ) if (operationChanges.length === 0) return toJsonObject({ sourcePath, changed: false, changes: [], postWriteReadback: [] }) + const postWriteReadback = operationChanges.map((change) => { + const cell = afterWorkbook.getWorksheet(change.sheetName)?.getCell(change.address) + return cell + ? { sheetName: change.sheetName, ...getCellSnapshot(cell) } + : { + sheetName: change.sheetName, + address: change.address, + value: null, + displayValue: '', + type: 'blank' + } + }) + if (!isPathInsideRoot(sourcePath, input.workspaceRootPath)) { + await atomicReplace(sourcePath, afterBytes) + state.currentBytesByPath.set(sourcePath, afterBytes) + state.sheetLineageByPath.set(sourcePath, sheetLineage) + return toJsonObject({ + sourcePath, + changed: true, + changes: operationChanges, + postWriteReadback + }) + } const baseline = state.baselineBytesByPath.get(sourcePath) const baselineWorkbook = baseline ? await loadWorkbook(baseline) : null const diffResult = await buildDiffWorkbook({ @@ -1011,18 +1033,6 @@ export const createExcelToolRuntime = (input: ExcelCapabilityToolsInput): ExcelT insertCount: record.insertCount, deleteCount: record.deleteCount }) - const postWriteReadback = operationChanges.map((change) => { - const cell = afterWorkbook.getWorksheet(change.sheetName)?.getCell(change.address) - return cell - ? { sheetName: change.sheetName, ...getCellSnapshot(cell) } - : { - sheetName: change.sheetName, - address: change.address, - value: null, - displayValue: '', - type: 'blank' - } - }) return toJsonObject({ sourcePath, changed: true, diff --git a/src/main/services/ai/human/tools/imageGeneration/runtime.ts b/src/main/services/ai/human/tools/imageGeneration/runtime.ts index 5f8364e2..c56a1b2f 100644 --- a/src/main/services/ai/human/tools/imageGeneration/runtime.ts +++ b/src/main/services/ai/human/tools/imageGeneration/runtime.ts @@ -7,6 +7,7 @@ import { DOMParser } from '@xmldom/xmldom' import type { AiFileChangeOperation, AiFileChangeRecord } from '../../../../../../shared/types' import { emitFileArtifactEvent } from '../../../events/fileArtifacts' import type { WorkspaceCapabilityBuildInput } from '../types' +import { isPathInsideRoot } from '../terminal/commandPolicy' import { resolveSafeTextFilePath, withTextFileMutationQueue } from '../textFile/textFileUtils' import type { ImageGenerateInput, ImageGenerateOutput } from './schemas' @@ -203,6 +204,23 @@ export const generateImage = async ( const operation: AiFileChangeOperation = existingRecord?.operation || (beforeBuffer ? 'updated' : 'created') + if (!isPathInsideRoot(outputPath, buildInput.workspaceRootPath)) { + if (beforeBuffer) { + const currentBuffer = await fs.readFile(outputPath) + if (!currentBuffer.equals(beforeBuffer)) { + throw new Error(`Cannot write ${args.outputPath}: the file changed during the operation.`) + } + await atomicReplaceFile({ + outputPath, + content: outputBuffer, + mode: beforeMode, + operationId + }) + } else { + await fs.writeFile(outputPath, outputBuffer, { flag: 'wx', mode: 0o644 }) + } + return { outputPath: args.outputPath } + } const artifactRoot = path.join( buildInput.workspaceRootPath, '.ncurator', diff --git a/src/main/services/ai/human/tools/imageRecognition/index.ts b/src/main/services/ai/human/tools/imageRecognition/index.ts index 75cfda8b..52d2668a 100644 --- a/src/main/services/ai/human/tools/imageRecognition/index.ts +++ b/src/main/services/ai/human/tools/imageRecognition/index.ts @@ -7,14 +7,6 @@ import type { ToolFamilyDefinition } from '../types' import { IMAGE_ANALYZE_TOOL_SPEC } from './toolSpecs' import { createVisualRecognitionService, getImageMimeType } from '../visualRecognition/service' -const isPathInsideWorkspace = (input: { - workspaceRootPath: string - targetPath: string -}): boolean => { - const relativePath = path.relative(input.workspaceRootPath, input.targetPath) - return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) -} - export const imageRecognitionToolFamily: ToolFamilyDefinition = { familyId: 'image-recognition', buildTools: ({ ctx, runId, taskId }) => { @@ -44,22 +36,6 @@ export const imageRecognitionToolFamily: ToolFamilyDefinition = { }) return payload } - if ( - !ctx.workspace?.rootPath || - !isPathInsideWorkspace({ - workspaceRootPath: ctx.workspace.rootPath, - targetPath: sourcePath - }) - ) { - const payload = { error: '图片路径必须位于当前工作区内' } - session.fail(payload.error, undefined, { - toolName: IMAGE_ANALYZE_TOOL_SPEC.name, - ok: false, - data: payload - }) - return payload - } - try { const result = await visualRecognitionService.recognizeImage({ path: sourcePath, diff --git a/src/main/services/ai/human/tools/pdf/runtime.ts b/src/main/services/ai/human/tools/pdf/runtime.ts index acb7a2d5..705bd377 100644 --- a/src/main/services/ai/human/tools/pdf/runtime.ts +++ b/src/main/services/ai/human/tools/pdf/runtime.ts @@ -4,7 +4,7 @@ import type { AiToolCallResult, JsonObject } from '../../../../../../shared/type import type { PdfOpenResult } from '../../../../pdf/types' import { getPdfWorkerClient } from '../../../../pdf/worker/pdfWorkerClient' import type { PdfWorkerRequest, PdfWorkerResponse } from '../../../../pdf/worker/types' -import { isPathInsideRoot, resolveWorkspacePath } from '../terminal/commandPolicy' +import { resolveToolPath } from '../terminal/commandPolicy' import { createToolEventEmitter } from '../utils/toolEvents' import type { ToolEventEmitter } from '../utils/toolEvents' import type { PdfCapabilityToolsInput } from './types' @@ -96,22 +96,15 @@ export const createPdfToolRuntime = (input: PdfCapabilityToolsInput): PdfToolRun await getPdfWorkerClient().call({ ...request, runId: input.runId } as PdfWorkerRequest) const resolvePdfPath = async (rawPath: string): Promise => { - const lexicalPath = resolveWorkspacePath({ + const lexicalPath = resolveToolPath({ rawPath, - cwd: workspaceRootPath, - rootPath: workspaceRootPath + cwd: workspaceRootPath }) - if (!lexicalPath) throw new Error('PDF 路径必须位于当前工作区内') + if (!lexicalPath) throw new Error('PDF 路径无效') if (path.extname(lexicalPath).toLowerCase() !== '.pdf') throw new Error('sourcePath 必须是 .pdf 文件') - const [realWorkspaceRoot, realSourcePath] = await Promise.all([ - fs.realpath(workspaceRootPath), - fs.realpath(lexicalPath) - ]) - if (!isPathInsideRoot(realSourcePath, realWorkspaceRoot)) { - throw new Error('PDF 真实路径必须位于当前工作区内') - } + const realSourcePath = await fs.realpath(lexicalPath) if (path.extname(realSourcePath).toLowerCase() !== '.pdf') throw new Error('sourcePath 必须是 .pdf 文件') const stat = await fs.stat(realSourcePath) diff --git a/src/main/services/ai/human/tools/ppt/runtime.ts b/src/main/services/ai/human/tools/ppt/runtime.ts index 71095d9c..9a1cd8ae 100644 --- a/src/main/services/ai/human/tools/ppt/runtime.ts +++ b/src/main/services/ai/human/tools/ppt/runtime.ts @@ -96,7 +96,7 @@ import { } from 'pptx-kit' import type { AiFileChangeRecord, JsonObject } from '../../../../../../shared/types' import { fileExists } from '../docx/review/utils' -import { resolveWorkspacePath } from '../terminal/commandPolicy' +import { isPathInsideRoot, resolveToolPath } from '../terminal/commandPolicy' import { compilePptSlideHtml, normalizePptColor, @@ -328,12 +328,11 @@ const resolveAssetBytes = async (input: { throw new Error(`无效的本地 file URL: ${input.rawPath}`) } } - const resolved = resolveWorkspacePath({ + const resolved = resolveToolPath({ rawPath, - cwd: path.dirname(input.sourcePath), - rootPath: input.workspaceRootPath + cwd: path.dirname(input.sourcePath) }) - if (!resolved) throw new Error(`资源路径超出工作区: ${input.rawPath}`) + if (!resolved) throw new Error(`资源路径无效: ${input.rawPath}`) return new Uint8Array(await fs.readFile(resolved)) } @@ -1144,13 +1143,12 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun const workspaceRootPath = path.resolve(input.workspaceRootPath) const resolvePptPath = (rawPath: string): string => { - const resolved = resolveWorkspacePath({ + const resolved = resolveToolPath({ rawPath, - cwd: workspaceRootPath, - rootPath: workspaceRootPath + cwd: workspaceRootPath }) if (!resolved || !resolved.toLowerCase().endsWith('.pptx')) { - throw new Error('sourcePath 必须是当前工作区内的 .pptx 文件') + throw new Error('sourcePath 必须是 .pptx 文件') } return resolved } @@ -1504,6 +1502,13 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun const records: AiFileChangeRecord[] = [] for (const document of documents.values()) { if (!document.dirty) continue + if (!isPathInsideRoot(document.sourcePath, workspaceRootPath)) { + document.dirty = false + document.movedSlideIds.clear() + document.movedElementIds.clear() + document.elementLineage.clear() + continue + } const issues = validatePresentation(document.presentation).filter( (issue) => issue.severity === 'error' ) diff --git a/src/main/services/ai/human/tools/terminal/TerminalSessionManager.ts b/src/main/services/ai/human/tools/terminal/TerminalSessionManager.ts index 8c08185f..bf24b59e 100644 --- a/src/main/services/ai/human/tools/terminal/TerminalSessionManager.ts +++ b/src/main/services/ai/human/tools/terminal/TerminalSessionManager.ts @@ -5,7 +5,6 @@ import { chmod, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises' import * as pty from 'node-pty' import type { IPty } from 'node-pty' import type { AiRuntimePlatform } from '../../../types' -import { isPathInsideAnyRoot } from './commandPolicy' import { buildTerminalEnvironment } from './environment/TerminalEnvironment' import { getTerminalPlatformAdapter, type TerminalPlatformAdapter } from './platform' import { TerminalProtocol, type TerminalProtocolEvent } from './protocol/TerminalProtocol' @@ -71,7 +70,6 @@ type ManagedTerminalSession = { chatSessionId: string workspaceId: string runtimeDirectory: string - allowedRootPaths: string[] adapter: TerminalPlatformAdapter protocol: TerminalProtocol process: IPty @@ -195,8 +193,7 @@ export class TerminalSessionManager { const leadingCwd = await session.adapter.resolveCommandCwd({ command: input.command, - currentPath: session.currentTerminalPath, - allowedRootPaths: session.allowedRootPaths + currentPath: session.currentTerminalPath }) if (leadingCwd) session.currentTerminalPath = leadingCwd @@ -397,14 +394,6 @@ export class TerminalSessionManager { if (activeCount >= this.maxSessionsPerChat) throw new Error('terminal_session_limit_reached') const rootPath = path.resolve(input.workspaceRootPath) - const allowedRootPaths = Array.from( - new Set( - [rootPath, input.userRuntimeRootPath] - .map((root) => String(root || '').trim()) - .filter(Boolean) - .map((root) => path.resolve(root)) - ) - ) const ready = createDeferred() const exited = createDeferred() const runtimeDirectory = await mkdtemp(path.join(os.tmpdir(), 'ncurator-terminal-runtime-')) @@ -434,7 +423,6 @@ export class TerminalSessionManager { chatSessionId: input.chatSessionId, workspaceId: input.workspaceId, runtimeDirectory, - allowedRootPaths, adapter, protocol, process: terminalProcess, @@ -591,7 +579,7 @@ export class TerminalSessionManager { private updateCurrentPath(session: ManagedTerminalSession, encodedCwd: string): void { const nextPath = decodeCwd(encodedCwd) - if (nextPath && isPathInsideAnyRoot(nextPath, session.allowedRootPaths)) { + if (nextPath && path.isAbsolute(nextPath)) { session.currentTerminalPath = path.resolve(nextPath) } } diff --git a/src/main/services/ai/human/tools/terminal/commandPolicy.ts b/src/main/services/ai/human/tools/terminal/commandPolicy.ts index b803056f..9976ce78 100644 --- a/src/main/services/ai/human/tools/terminal/commandPolicy.ts +++ b/src/main/services/ai/human/tools/terminal/commandPolicy.ts @@ -1,4 +1,3 @@ -import os from 'node:os' import path from 'node:path' import type { AiRuntimePlatform } from '../../../types' import { getAiRuntimePlatform } from '../../../utils/platform' @@ -14,33 +13,6 @@ import { parseTerminalCommandTokens as parsePolicyCommandTokens } from './policy/shellSyntax' -type PathArgumentStrategy = 'all_non_option_args' | 'skip_first_non_option_arg' | 'none' - -const COMMAND_PATH_ARGUMENT_STRATEGIES: Partial> = { - echo: 'none', - printf: 'none', - git: 'none', - node: 'none', - python: 'none', - python3: 'none', - rg: 'skip_first_non_option_arg', - grep: 'skip_first_non_option_arg', - sed: 'skip_first_non_option_arg', - 'get-content': 'all_non_option_args', - 'get-childitem': 'all_non_option_args', - 'select-object': 'none', - 'select-string': 'all_non_option_args', - 'test-path': 'all_non_option_args', - 'resolve-path': 'all_non_option_args', - 'copy-item': 'all_non_option_args', - 'move-item': 'all_non_option_args', - 'remove-item': 'all_non_option_args', - 'new-item': 'all_non_option_args', - 'set-content': 'all_non_option_args', - 'add-content': 'all_non_option_args', - 'out-file': 'all_non_option_args' -} - const EXCEL_FILE_PATTERN = /\.(?:xlsx|xlsm|xltx|xltm)(?:['"\s;|&]|$)/i export const isTerminalExcelMutationCommand = (command: string): boolean => { @@ -90,59 +62,6 @@ export const analyzeTerminalCommandPolicy = ( const isRedirectionOperator = (token: string): boolean => token === '>' || token === '>>' || token === '1>' || token === '1>>' -const isRelativeEscapeToken = (token: string): boolean => { - const normalized = String(token || '') - .trim() - .replace(/\\/g, '/') - return ( - normalized === '..' || - normalized.startsWith('../') || - normalized.includes('/../') || - normalized.endsWith('/..') - ) -} - -const collectBoundaryPathTokens = (tokens: string[]): string[] => { - if (tokens.length <= 1) return [] - - const command = String(tokens[0] || '').toLowerCase() - const strategy = COMMAND_PATH_ARGUMENT_STRATEGIES[command] || 'all_non_option_args' - const pathTokens: string[] = [] - let skippedNonOptionCount = 0 - - for (let index = 1; index < tokens.length; index += 1) { - const token = tokens[index] - if (!token) continue - - if (isRedirectionOperator(token)) { - const next = tokens[index + 1] - if (next && !next.startsWith('-')) { - pathTokens.push(next) - index += 1 - } - continue - } - - const inlineRedirection = token.match(/^(?:1?>>?)(.+)$/) - if (inlineRedirection?.[1]) { - pathTokens.push(inlineRedirection[1]) - continue - } - - if (token.startsWith('-')) continue - if (strategy === 'none') continue - if (strategy === 'skip_first_non_option_arg' && skippedNonOptionCount === 0) { - skippedNonOptionCount += 1 - continue - } - - skippedNonOptionCount += 1 - pathTokens.push(token) - } - - return pathTokens -} - export const isPathInsideRoot = (candidatePath: string, rootPath: string) => { const useWindowsPaths = /^[a-z]:[\\/]/i.test(candidatePath) || @@ -156,56 +75,14 @@ export const isPathInsideRoot = (candidatePath: string, rootPath: string) => { return rel === '' || (!rel.startsWith('..') && !pathApi.isAbsolute(rel)) } -export const isPathInsideAnyRoot = (candidatePath: string, rootPaths: readonly string[]): boolean => - rootPaths.some((rootPath) => isPathInsideRoot(candidatePath, rootPath)) - -export const resolveWorkspacePath = (input: { +export const resolveToolPath = (input: { rawPath: string cwd: string - rootPath: string }): string | null => { const raw = String(input.rawPath || '').trim() if (!raw) return null - const base = raw.startsWith('/') ? raw : path.resolve(input.cwd, raw) - const resolved = path.resolve(base) - if (!isPathInsideRoot(resolved, input.rootPath)) return null - return resolved -} - -export const checkWorkspaceBoundary = ( - command: string, - rootPaths: string | readonly string[], - platform: AiRuntimePlatform = getAiRuntimePlatform() -): { ok: true } | { ok: false; reason: string } => { - const allowedRootPaths = (Array.isArray(rootPaths) ? rootPaths : [rootPaths]) - .map((rootPath) => String(rootPath || '').trim()) - .filter(Boolean) - if (allowedRootPaths.length === 0) { - return { ok: false, reason: 'allowed root path is missing' } - } - - for (const segment of parseTerminalCommandSegments(command, { - windowsStyle: isWindowsPlatform(platform) - })) { - for (const candidatePath of collectBoundaryPathTokens(segment.tokens)) { - if (isRelativeEscapeToken(candidatePath)) { - return { ok: false, reason: `relative path escapes workspace boundary: ${candidatePath}` } - } - const expandedCandidatePath = /^~[\\/]/.test(candidatePath) - ? path.join(os.homedir(), candidatePath.slice(2)) - : candidatePath - if ( - (expandedCandidatePath.startsWith('/') || - /^[a-z]:[\\/]/i.test(expandedCandidatePath) || - /^\\\\/.test(expandedCandidatePath)) && - !isPathInsideAnyRoot(expandedCandidatePath, allowedRootPaths) - ) { - return { ok: false, reason: `path out of allowed boundary: ${candidatePath}` } - } - } - } - - return { ok: true } + const base = path.isAbsolute(raw) ? raw : path.resolve(input.cwd, raw) + return path.resolve(base) } const extractRedirectionPaths = (tokens: string[]): string[] => { diff --git a/src/main/services/ai/human/tools/terminal/platform/TerminalPlatformAdapter.ts b/src/main/services/ai/human/tools/terminal/platform/TerminalPlatformAdapter.ts index f2dc6f90..9039ed5c 100644 --- a/src/main/services/ai/human/tools/terminal/platform/TerminalPlatformAdapter.ts +++ b/src/main/services/ai/human/tools/terminal/platform/TerminalPlatformAdapter.ts @@ -11,7 +11,6 @@ export type TerminalShellConfig = { export type ResolveCommandCwdInput = { command: string currentPath: string - allowedRootPaths: readonly string[] } export interface TerminalPlatformAdapter { diff --git a/src/main/services/ai/human/tools/terminal/platform/WindowsTerminalAdapter.ts b/src/main/services/ai/human/tools/terminal/platform/WindowsTerminalAdapter.ts index 776f7da9..67844737 100644 --- a/src/main/services/ai/human/tools/terminal/platform/WindowsTerminalAdapter.ts +++ b/src/main/services/ai/human/tools/terminal/platform/WindowsTerminalAdapter.ts @@ -2,7 +2,6 @@ import path from 'node:path' import { stat } from 'node:fs/promises' import { execFile } from 'node:child_process' import type { IPty } from 'node-pty' -import { isPathInsideAnyRoot } from '../commandPolicy' import type { TerminalProtocol } from '../protocol/TerminalProtocol' import type { ResolveCommandCwdInput, TerminalPlatformAdapter } from './TerminalPlatformAdapter' @@ -73,7 +72,6 @@ export class WindowsTerminalAdapter implements TerminalPlatformAdapter { const requestedPath = match[1] || match[2] || match[3] if (!requestedPath) return undefined const nextPath = path.resolve(input.currentPath, requestedPath) - if (!isPathInsideAnyRoot(nextPath, input.allowedRootPaths)) return undefined try { return (await stat(nextPath)).isDirectory() ? nextPath : undefined } catch { diff --git a/src/main/services/ai/human/tools/textFile/textFileTools.ts b/src/main/services/ai/human/tools/textFile/textFileTools.ts index ee6d6de4..eadf7594 100644 --- a/src/main/services/ai/human/tools/textFile/textFileTools.ts +++ b/src/main/services/ai/human/tools/textFile/textFileTools.ts @@ -31,6 +31,7 @@ import { resolveSafeTextFilePath, withTextFileMutationQueue } from './textFileUtils' +import { isPathInsideRoot } from '../terminal/commandPolicy' type TextFileToolFailure = Error & { resultData?: JsonObject @@ -38,7 +39,7 @@ type TextFileToolFailure = Error & { type AppliedEdit = { message: string - record: AiFileChangeRecord + record?: AiFileChangeRecord } type MatchedReplacement = { @@ -297,6 +298,31 @@ const editTextFile = async ( throw new Error(`Cannot write ${args.sourcePath}: binary text is not supported.`) } + const trackedByWorkspace = isPathInsideRoot(sourcePath, buildInput.workspaceRootPath) + if (!trackedByWorkspace) { + if (args.action === 'create') { + await fs.writeFile(sourcePath, afterBuffer, { flag: 'wx', mode: 0o644 }) + } else { + const currentBuffer = await fs.readFile(sourcePath) + if (calculateBufferChecksum(currentBuffer) !== beforeChecksum) { + throw new Error(`Cannot write ${args.sourcePath}: the file changed during the operation.`) + } + await atomicReplaceFile({ + sourcePath, + content: afterBuffer, + mode: beforeMode, + operationId + }) + } + return { + message: buildEditMessage({ + action: args.action, + sourcePath: args.sourcePath, + changeCount + }) + } + } + let baselineNormalized = beforeNormalized let recordBeforeSnapshotPath = beforeBuffer ? path.join( diff --git a/src/main/services/ai/human/tools/textFile/textFileUtils.ts b/src/main/services/ai/human/tools/textFile/textFileUtils.ts index d4b71b29..62fbfbc6 100644 --- a/src/main/services/ai/human/tools/textFile/textFileUtils.ts +++ b/src/main/services/ai/human/tools/textFile/textFileUtils.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' import fs from 'node:fs/promises' import path from 'node:path' -import { isPathInsideRoot, resolveWorkspacePath } from '../terminal/commandPolicy' +import { resolveToolPath } from '../terminal/commandPolicy' export type DecodedTextFile = { bom: boolean @@ -76,21 +76,16 @@ export const resolveSafeTextFilePath = async (input: { workspaceRootPath: string allowMissing: boolean }): Promise => { - const resolved = resolveWorkspacePath({ + const resolved = resolveToolPath({ rawPath: input.sourcePath, - cwd: input.workspaceRootPath, - rootPath: input.workspaceRootPath + cwd: input.workspaceRootPath }) if (!resolved) throw new Error(`Invalid file path: ${input.sourcePath}.`) - const realRoot = await fs.realpath(input.workspaceRootPath) const existingAncestor = await findExistingAncestor( input.allowMissing ? path.dirname(resolved) : resolved ) - const realAncestor = await fs.realpath(existingAncestor) - if (!isPathInsideRoot(realAncestor, realRoot)) { - throw new Error(`Invalid file path: ${input.sourcePath}.`) - } + await fs.realpath(existingAncestor) if (!input.allowMissing) { const stats = await fs.lstat(resolved) @@ -98,10 +93,7 @@ export const resolveSafeTextFilePath = async (input: { throw new Error(`Cannot access ${input.sourcePath}: symbolic links are not supported.`) if (!stats.isFile()) throw new Error(`Cannot access ${input.sourcePath}: path is not a regular file.`) - const realTarget = await fs.realpath(resolved) - if (!isPathInsideRoot(realTarget, realRoot)) { - throw new Error(`Invalid file path: ${input.sourcePath}.`) - } + await fs.realpath(resolved) } return resolved diff --git a/src/main/services/ai/prompts/referenceScope.ts b/src/main/services/ai/prompts/referenceScope.ts index 4bed043d..69035542 100644 --- a/src/main/services/ai/prompts/referenceScope.ts +++ b/src/main/services/ai/prompts/referenceScope.ts @@ -50,8 +50,7 @@ export const renderReferenceScopeText = (scope: AiReferenceScope): string => { const selectedReferences = scope.selectedReferences || [] const attachments = scope.attachments || [] const workspaceText = `工作区: -- 根目录:${scope.workspace.rootPath} -- 除系统明确提供的用户级 runtime 路径外,所有任务文件和新生成的产物都必须位于这个工作区内。` +- 根目录:${scope.workspace.rootPath}` const fieldGuideText = `字段说明: - 类型:资料来源类型,目前支持本地文件、本地目录、网页资料。 diff --git a/src/main/services/ai/runtime.ts b/src/main/services/ai/runtime.ts index a3a947c5..014813a8 100644 --- a/src/main/services/ai/runtime.ts +++ b/src/main/services/ai/runtime.ts @@ -10,6 +10,7 @@ import { } from './utils/runContext' import type { AiExecutionPermission, AiPreference } from '../../../shared/types' import { saveHumanResumeSnapshot } from './utils/resumeSnapshot' +import { isPathInsideRoot } from './human/tools/terminal/commandPolicy' const normalizeAiPreference = (value: unknown): AiPreference => value === 'local_sources_relaxed' || value === 'local_sources_strict' ? value : 'default' @@ -272,6 +273,12 @@ export const createAiRunContext = (payload: AiRunStartPayload, deps: AiRunDeps): deps.docxApplyService.discardRun(payload.runId) }, saveFileChangeRecord: async (record) => { + if ( + normalizedWorkspace?.rootPath && + !isPathInsideRoot(record.path, normalizedWorkspace.rootPath) + ) { + return + } await deps.saveFileChangeRecord(record, normalizedWorkspace.rootPath) }, listFileChangeRecords: async (input) => { diff --git a/src/main/services/attachments/AttachmentService.ts b/src/main/services/attachments/AttachmentService.ts index 30404768..776eb7f1 100644 --- a/src/main/services/attachments/AttachmentService.ts +++ b/src/main/services/attachments/AttachmentService.ts @@ -137,6 +137,34 @@ export class AttachmentService { return isSupportedAttachment(input) } + public async createSourceAttachmentFromPath(input: { + sourcePath: string + kind?: ChatAttachment['kind'] + mimeType?: string + }): Promise { + const sourcePath = path.resolve(String(input.sourcePath || '').trim()) + if (!sourcePath || !fs.existsSync(sourcePath)) { + throw new Error('附件文件不存在') + } + const stats = await fs.promises.stat(sourcePath) + if (!stats.isFile()) throw new Error('附件必须是文件') + if ( + !this.isSupportedFile({ + filePath: sourcePath, + name: path.basename(sourcePath), + mimeType: input.mimeType, + kind: input.kind + }) + ) { + throw new Error('暂不支持该文件格式') + } + return this.createAttachmentFromPath({ + filePath: sourcePath, + kind: input.kind, + mimeType: input.mimeType + }) + } + public async saveAttachment(input: { workspaceRootPath: string assetBucketId: string @@ -169,40 +197,6 @@ export class AttachmentService { }) } - public async importAttachmentFromPath(input: { - workspaceRootPath: string - assetBucketId: string - sourcePath: string - kind?: ChatAttachment['kind'] - mimeType?: string - }): Promise { - const sourcePath = path.resolve(String(input.sourcePath || '').trim()) - if (!sourcePath || !fs.existsSync(sourcePath)) { - throw new Error('附件文件不存在') - } - const displayName = path.basename(sourcePath) - if ( - !this.isSupportedFile({ - filePath: sourcePath, - name: displayName, - mimeType: input.mimeType, - kind: input.kind - }) - ) { - throw new Error('暂不支持该文件格式') - } - - const dirPath = await ensureWorkspaceAssetBucket(input.workspaceRootPath, input.assetBucketId) - const targetPath = await resolveUniqueTargetPath(dirPath, displayName) - await fs.promises.copyFile(sourcePath, targetPath) - return this.createAttachmentFromPath({ - filePath: targetPath, - kind: input.kind, - mimeType: input.mimeType, - displayName - }) - } - public async getFileSize(filePath: string): Promise { return (await fs.promises.stat(path.resolve(filePath))).size } diff --git a/src/main/types/store.d.ts b/src/main/types/store.d.ts index 3c7e5e80..7b32d63d 100644 --- a/src/main/types/store.d.ts +++ b/src/main/types/store.d.ts @@ -59,6 +59,7 @@ import { ChatAttachment, AttachmentPickRequest, AttachmentPickResponse, + AttachmentCreateFromPathRequest, AttachmentProcessResponse, AttachmentSaveRequest, ChatSessionSnapshotRequest, @@ -198,6 +199,7 @@ export type { ChatAttachment, AttachmentPickRequest, AttachmentPickResponse, + AttachmentCreateFromPathRequest, AttachmentProcessResponse, AttachmentSaveRequest, ChatSessionSnapshotRequest, diff --git a/src/preload/api.ts b/src/preload/api.ts index 296708d7..5f9233db 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -31,6 +31,7 @@ import type { ChatMessage, AttachmentPickRequest, AttachmentPickResponse, + AttachmentCreateFromPathRequest, AttachmentProcessResponse, AttachmentSaveRequest, ChatRunEventsRequest, @@ -188,6 +189,10 @@ export type DesktopApi = { readFile: (filePath: string) => Promise fileExists: (filePath: string) => Promise attachmentPick: (payload: AttachmentPickRequest) => Promise + attachmentGetSourcePath: (file: File) => string + attachmentCreateFromPath: ( + payload: AttachmentCreateFromPathRequest + ) => Promise attachmentSaveFile: (payload: AttachmentSaveRequest) => Promise chatSessionList: () => Promise chatSessionSave: (session: ChatSession) => Promise<{ success: boolean; error?: string }> diff --git a/src/preload/index.ts b/src/preload/index.ts index 835d1625..98c6708f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer, webUtils } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import type { AiRunEvent, @@ -249,6 +249,11 @@ const api: DesktopApi = { emitE2EIpcCall('attachmentPick') return ipcRenderer.invoke('attachment-pick', payload) }, + attachmentGetSourcePath: (file) => webUtils.getPathForFile(file), + attachmentCreateFromPath: (payload) => { + emitE2EIpcCall('attachmentCreateFromPath') + return ipcRenderer.invoke('attachment-create-from-path', payload) + }, attachmentSaveFile: (payload) => { emitE2EIpcCall('attachmentSaveFile') return ipcRenderer.invoke('attachment-save-file', payload) diff --git a/src/renderer/src/components/workbench/WorkbenchComposer.tsx b/src/renderer/src/components/workbench/WorkbenchComposer.tsx index 651af81c..9f6ec9de 100644 --- a/src/renderer/src/components/workbench/WorkbenchComposer.tsx +++ b/src/renderer/src/components/workbench/WorkbenchComposer.tsx @@ -665,7 +665,7 @@ const WorkbenchComposer: React.FC = ({ focusEditorAfterControlChange() }, [appendAttachments, focusEditorAfterControlChange, message, requestAttachmentTarget]) - const createAttachmentFromFile = useCallback( + const createClipboardAttachmentFromFile = useCallback( async (file: File) => { const target = await requestAttachmentTarget() if (!target) { @@ -689,10 +689,34 @@ const WorkbenchComposer: React.FC = ({ [message, requestAttachmentTarget] ) + const createDroppedAttachmentFromFile = useCallback( + async (file: File) => { + const target = await requestAttachmentTarget() + if (!target) return null + const sourcePath = window.api.attachmentGetSourcePath(file) + if (!sourcePath) { + message.warning('无法读取拖入文件的原始路径') + return null + } + const created = await window.api.attachmentCreateFromPath({ + sourcePath, + mimeType: file.type, + kind: file.type.startsWith('image/') ? 'image' : 'file' + }) + if (!created.success && created.error) message.warning(created.error) + return created.attachment + ? { attachment: created.attachment, assetBucketId: target.assetBucketId } + : null + }, + [message, requestAttachmentTarget] + ) + const handleAttachmentFiles = useCallback( - (files: File[]): void => { + (files: File[], source: 'clipboard' | 'drop'): void => { if (files.length === 0) return - void Promise.all(files.map(createAttachmentFromFile)).then((items) => { + const createAttachment = + source === 'drop' ? createDroppedAttachmentFromFile : createClipboardAttachmentFromFile + void Promise.all(files.map(createAttachment)).then((items) => { const validItems = items.filter( (item): item is { attachment: ChatAttachment; assetBucketId: string } => Boolean(item) ) @@ -702,11 +726,11 @@ const WorkbenchComposer: React.FC = ({ ) }) }, - [appendAttachments, createAttachmentFromFile] + [appendAttachments, createClipboardAttachmentFromFile, createDroppedAttachmentFromFile] ) useEffect(() => { - onFileDropHandlerReady?.(handleAttachmentFiles) + onFileDropHandlerReady?.((files) => handleAttachmentFiles(files, 'drop')) return () => onFileDropHandlerReady?.(null) }, [handleAttachmentFiles, onFileDropHandlerReady]) @@ -714,7 +738,7 @@ const WorkbenchComposer: React.FC = ({ const files = Array.from(event.clipboardData.files || []) if (files.length > 0) { event.preventDefault() - handleAttachmentFiles(files) + handleAttachmentFiles(files, 'clipboard') return } const pastedText = event.clipboardData.getData('text/plain') diff --git a/src/shared/types.ts b/src/shared/types.ts index fe801a45..0a8be44b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -598,6 +598,12 @@ export type AttachmentProcessResponse = { error?: string } +export type AttachmentCreateFromPathRequest = { + sourcePath: string + mimeType?: string + kind?: ChatAttachmentKind +} + export type AttachmentSaveRequest = { workspaceRootPath: string assetBucketId: string diff --git a/tests/case/ai/tool/image-generation/image-generate-contract.test.ts b/tests/case/ai/tool/image-generation/image-generate-contract.test.ts index 1f0cab2a..2764c09f 100644 --- a/tests/case/ai/tool/image-generation/image-generate-contract.test.ts +++ b/tests/case/ai/tool/image-generation/image-generate-contract.test.ts @@ -130,7 +130,7 @@ describe('ai tool image-generation backend', () => { domain: 'ai', suite: 'tool', groups: ['image-generation'], - description: '验证危险 SVG、非法格式和工作区越界失败时不写入文件。', + description: '验证危险 SVG、非法格式会失败,工作区外路径可写入但不产生回退记录。', fixtures: [], runMode: 'single', execute: async (runtime) => { @@ -170,7 +170,7 @@ describe('ai tool image-generation backend', () => { const outsidePath = path.join(path.dirname(runtime.workspaceRoot), 'outside.png') const outsideError = await captureError({ outputPath: outsidePath, - svg: ' { result.cssExternalError.includes('external resource') && result.cssImportError.includes('forbidden CSS import') && result.oversizedError.includes('dimensions are too large') && - result.outsideError.includes('Invalid file path') && + result.outsideError.length === 0 && result.formatError.length > 0 && result.malformedError.includes('parsing failed') && result.preservedUnchanged && @@ -231,12 +231,12 @@ describe('ai tool image-generation backend', () => { !result.cssExternalExists && !result.cssImportExists && !result.oversizedExists && - !result.outsideExists && + result.outsideExists && result.recordCount === 0 && result.artifactCount === 0, { name: 'image-generate-rejects-unsafe-input', - message: '危险、越界和不支持的图片生成请求必须失败且不产生文件副作用。', + message: '危险和不支持的图片生成请求必须失败;工作区外路径可写入但不产生回退记录。', data: result } ) diff --git a/tests/case/ai/tool/pdf/pdf-read-contract.test.ts b/tests/case/ai/tool/pdf/pdf-read-contract.test.ts index cfc1b96d..0b3a4650 100644 --- a/tests/case/ai/tool/pdf/pdf-read-contract.test.ts +++ b/tests/case/ai/tool/pdf/pdf-read-contract.test.ts @@ -229,7 +229,7 @@ describe('ai tool pdf backend', () => { domain: 'ai', suite: 'tool', groups: ['pdf'], - description: '验证越界页码、非法 regex、伪 PDF 和逃逸工作区的符号链接会明确失败。', + description: '验证越界页码、非法 regex、伪 PDF 会明确失败,工作区外 PDF 可读取。', fixtures: [PDF_FIXTURE], runMode: 'single', execute: async (runtime) => { @@ -268,7 +268,7 @@ describe('ai tool pdf backend', () => { const corrupt = await harness.invoke('pdf_inspect', { sourcePath: corruptPath }) - const outside = await harness.invoke('pdf_inspect', { + const outside = await harness.invoke>('pdf_inspect', { sourcePath: escapedPdfPath }) return { outOfRange, badRegex, oversizedRange, corrupt, outside } @@ -279,16 +279,13 @@ describe('ai tool pdf backend', () => { }, assert: async (result, runtime) => { runtime.assertThat( - [ - result.outOfRange, - result.badRegex, - result.oversizedRange, - result.corrupt, - result.outside - ].every((failure) => failure.ok === false && failure.error.length > 0), + [result.outOfRange, result.badRegex, result.oversizedRange, result.corrupt].every( + (failure) => failure.ok === false && failure.error.length > 0 + ) && + typeof result.outside.pageCount === 'number' && result.outside.pageCount > 0, { name: 'pdf-invalid-inputs-fail-explicitly', - message: '所有 PDF 边界错误都应返回明确失败信息。', + message: 'PDF 非法输入应明确失败,工作区外 PDF 应可读取。', data: result } ) diff --git a/tests/case/unit/ai/human-decision-hint-contract.test.ts b/tests/case/unit/ai/human-decision-hint-contract.test.ts index 6e7f1198..9e99d48b 100644 --- a/tests/case/unit/ai/human-decision-hint-contract.test.ts +++ b/tests/case/unit/ai/human-decision-hint-contract.test.ts @@ -132,19 +132,6 @@ const HINT_FIXTURES: Record<(typeof HUMAN_DECISION_HINT_CODES)[number], HumanDec ] } }, - hand_workspace_boundary_blocked: { - code: 'hand_workspace_boundary_blocked', - detail: { - blockedCalls: [ - { - callIndex: 1, - toolName: 'terminal_run', - message: - 'workspace boundary check failed: relative path escapes workspace boundary: ../secret.txt' - } - ] - } - }, brain_todolist_incomplete: { code: 'brain_todolist_incomplete', detail: { diff --git a/tests/case/unit/ai/human-hand-decision-hints.test.ts b/tests/case/unit/ai/human-hand-decision-hints.test.ts index 97c8a86a..aac0d0ca 100644 --- a/tests/case/unit/ai/human-hand-decision-hints.test.ts +++ b/tests/case/unit/ai/human-hand-decision-hints.test.ts @@ -5,7 +5,6 @@ import { } from '../../../../src/main/services/ai/human/node/handNode/followupSignals' import { buildHandCallInvalidHint, - buildHandWorkspaceBoundaryBlockedHint, deriveGroupedHandFailureHints } from '../../../../src/main/services/ai/human/node/handNode/decisionHints' @@ -43,7 +42,7 @@ describe('ai unit hand decision hints', () => { }) }) - it('groups workspace boundary failures without repeating generic runtime errors', () => { + it('does not convert external path failures into a workspace boundary hint', () => { const grouped = deriveGroupedHandFailureHints({ calls: [ { @@ -79,15 +78,7 @@ describe('ai unit hand decision hints', () => { ] }) - expect(grouped.workspaceBoundaryHint).toEqual( - buildHandWorkspaceBoundaryBlockedHint([ - { - callIndex: 0, - toolName: 'terminal_run', - message: 'workspace boundary check failed: relative path escapes workspace boundary: ../secret.txt' - } - ]) - ) + expect(grouped).not.toHaveProperty('workspaceBoundaryHint') expect(grouped).not.toHaveProperty('toolRuntimeFailedHint') }) diff --git a/tests/case/unit/ai/selected-context-workspace-path.test.ts b/tests/case/unit/ai/selected-context-workspace-path.test.ts index d81becb8..d119e7f9 100644 --- a/tests/case/unit/ai/selected-context-workspace-path.test.ts +++ b/tests/case/unit/ai/selected-context-workspace-path.test.ts @@ -5,7 +5,7 @@ import { } from '../../../../src/main/services/ai/prompts/referenceScope' describe('ai unit selected context workspace path', () => { - it('renders workspace rootPath and boundary without legacy cwd guidance', () => { + it('renders workspace rootPath without a workspace-only path restriction', () => { const text = renderReferenceScopeText( buildReferenceScope({ workspace: { @@ -19,9 +19,7 @@ describe('ai unit selected context workspace path', () => { expect(text).toContain('工作区:') expect(text).toContain('- 根目录:/tmp/real-workspace-root') - expect(text).toContain( - '除系统明确提供的用户级 runtime 路径外,所有任务文件和新生成的产物都必须位于这个工作区内。' - ) + expect(text).not.toContain('所有任务文件和新生成的产物都必须位于这个工作区内') expect(text).not.toContain('display-name-only') expect(text).not.toContain('cwd') expect(text).not.toContain('显式指定 cwd') diff --git a/tests/case/unit/workbench/attachment-service.test.ts b/tests/case/unit/workbench/attachment-service.test.ts index 2550543c..4c3aae6b 100644 --- a/tests/case/unit/workbench/attachment-service.test.ts +++ b/tests/case/unit/workbench/attachment-service.test.ts @@ -72,25 +72,29 @@ describe('attachment service runtime directories', () => { await expect(fs.stat(path.join(workspaceRootPath, '.ncurator', 'scripts'))).rejects.toThrow() }) - it('imports attachments into the runtime bucket and keeps names unique', async () => { + it('creates source attachments without copying them into the runtime bucket', async () => { const sourcePath = path.join(sourceRootPath, 'reference.json') await fs.writeFile(sourcePath, '{"ready":true}') - const first = await service.importAttachmentFromPath({ - workspaceRootPath, - assetBucketId: 'bucket-2', - sourcePath - }) - const second = await service.importAttachmentFromPath({ - workspaceRootPath, - assetBucketId: 'bucket-2', - sourcePath - }) + const attachment = await service.createSourceAttachmentFromPath({ sourcePath }) - const bucketPath = path.join(workspaceRootPath, '.ncurator', 'runtime', 'assets', 'bucket-2') - expect(first.path).toBe(path.join(bucketPath, 'reference.json')) - expect(second.path).toBe(path.join(bucketPath, 'reference-1.json')) - await expect(fs.readFile(second.path, 'utf8')).resolves.toBe('{"ready":true}') + expect(attachment.path).toBe(sourcePath) + expect(attachment.name).toBe('reference.json') + await expect( + fs.stat(path.join(workspaceRootPath, '.ncurator', 'runtime', 'assets')) + ).rejects.toThrow() + }) + + it('rejects source attachment directories and unsupported files', async () => { + const unsupportedPath = path.join(sourceRootPath, 'archive.zip') + await fs.writeFile(unsupportedPath, 'not supported') + + await expect( + service.createSourceAttachmentFromPath({ sourcePath: sourceRootPath }) + ).rejects.toThrow('附件必须是文件') + await expect( + service.createSourceAttachmentFromPath({ sourcePath: unsupportedPath }) + ).rejects.toThrow('暂不支持该文件格式') }) it('accepts PDF attachments and assigns application/pdf', async () => { diff --git a/tests/case/workbench/conversation/composer/composer-attachments-context.e2e.test.ts b/tests/case/workbench/conversation/composer/composer-attachments-context.e2e.test.ts index 1ebf48ab..d9d92932 100644 --- a/tests/case/workbench/conversation/composer/composer-attachments-context.e2e.test.ts +++ b/tests/case/workbench/conversation/composer/composer-attachments-context.e2e.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import ExcelJS from 'exceljs' @@ -241,7 +241,6 @@ describe('workbench conversation e2e', () => { }, { targetTestId: workbenchSelectors.attachmentDropTarget, fileData: droppedFileData } ) - await waitForAttachmentCardByName(page, 'dropped-context.md') await attachmentDropOverlay.waitFor({ state: 'hidden' }) const attachmentDropOverlayHidden = (await attachmentDropOverlay.count()) === 0 const sessionCountAfterPaste = await countWorkspaceSessions( @@ -339,15 +338,6 @@ describe('workbench conversation e2e', () => { .textContent()) || '' ) ) - const assetsRoot = path.join( - workspaceBinding.workspaceRootPath, - '.ncurator', - 'runtime', - 'assets' - ) - const assetEntries = await readdir(assetsRoot, { recursive: true }) - const importedAttachmentNames = assetEntries.map((entry) => path.basename(String(entry))) - await selectWorkbenchAttachmentThroughNativeDialog(page, attachments.largePath) await waitForAttachmentCardByName(page, 'qa-large.json') const largeFileMessage = await waitForAntMessageText( @@ -418,8 +408,6 @@ describe('workbench conversation e2e', () => { legacyDocCardText, pptxCardText, spreadsheetCardTexts, - importedAttachmentNames, - assetsRoot, submittedAttachmentNames: submittedAttachments.map((attachment) => attachment.name), submittedAttachmentPaths: submittedAttachments.map((attachment) => attachment.path), largeFileMessage, @@ -455,7 +443,6 @@ describe('workbench conversation e2e', () => { result.spreadsheetCardTexts[index]?.includes(label) ) && [ - 'qa-image.png', 'workbench-preview.md', 'legacy-reference.doc', 'slides-reference.pptx', @@ -463,32 +450,24 @@ describe('workbench conversation e2e', () => { 'macro-reference.xlsm', 'template-reference.xltx', 'macro-template-reference.xltm', - 'dropped-context.md' - ].every((name) => result.importedAttachmentNames.includes(name)) && - result.assetsRoot.includes(path.join('.ncurator', 'runtime', 'assets')) && - [ - 'workbench-preview.md', - 'legacy-reference.doc', - 'slides-reference.pptx', - 'sheet-reference.xlsx', - 'macro-reference.xlsm', - 'template-reference.xltx', - 'macro-template-reference.xltm', - 'qa-large.json', - 'dropped-context.md' + 'qa-large.json' ].every((name) => result.submittedAttachmentNames.includes(name)) && - result.submittedAttachmentPaths.length === 9 && - result.submittedAttachmentPaths.every((attachmentPath) => - attachmentPath.startsWith(`${result.assetsRoot}${path.sep}`) + result.submittedAttachmentPaths.length === 8 && + result.submittedAttachmentPaths.every( + (attachmentPath) => + attachmentPath.includes(`${path.sep}ncurator-composer-attachments-`) && + !attachmentPath.includes( + `${path.sep}.ncurator${path.sep}runtime${path.sep}assets${path.sep}` + ) ) && result.largeFileMessage.includes('文件过大,建议放到工作空间索引里使用') && result.inputText.includes('plain paste ok') && result.openPathCallCount >= 1, - result.attachmentSaveFileCallCount >= 1, + result.attachmentSaveFileCallCount === 0, { name: 'workbench-composer-attachments-prd-flow', message: - '附件输入应满足 PRD:文件拖到中间聊天区会显示覆盖层并导入附件;真实选择附件会走系统选择和附件导入,doc/pptx/Excel 可以添加并显示正确类型,添加附件不创建新会话,图片可预览且控件不压图,文件可默认打开,超过 10MB 只提示不拦截,普通文本粘贴仍正常。', + '附件输入应满足 PRD:文件拖到中间聊天区会显示并关闭覆盖层;真实选择附件保留源文件路径,doc/pptx/Excel 可以添加并显示正确类型,添加附件不创建新会话,图片可预览且控件不压图,文件可默认打开,超过 10MB 只提示不拦截,普通文本粘贴仍正常。', data: result } )