Refactor terminal command policy and attachment handling

- Replaced `resolveWorkspacePath` with `resolveToolPath` in various services to streamline path resolution.
- Updated error messages for invalid paths and workspace boundary checks.
- Enhanced attachment service to create source attachments directly from file paths without copying to runtime bucket.
- Removed unnecessary checks and legacy code related to workspace boundary enforcement.
- Improved handling of dropped and clipboard attachments in the workbench composer.
- Adjusted tests to reflect changes in attachment handling and workspace boundary logic.
This commit is contained in:
hy committed 2026-08-13 10:12:16 +08:00
1 parent 237e4ade04
commit 4886be6376
39 files changed
+294 -446

No files matched your search

+4 -4
View File
@@ -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 打开对应会话。
+8 -8
View File
@@ -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 附件保存与传递规则
- 系统行为:用户通过 `+`、粘贴或拖入中间聊天区添加附件时,附件统一保存到当前工作区的 `<workspace>/.ncurator/runtime/assets/<assetBucketId>/`;`assetBucketId` 是当前输入草稿生成的 UUID,和会话 ID 解耦,添加附件不会提前创建新会话。文件拖入中间聊天区时显示透明覆盖层“松开以添加文件”,拖离或放下后消失;覆盖层不覆盖左侧栏、顶部栏或右侧资源面板。发送给 AI 的附件上下文只包含顺序、名称和路径。
- 系统行为:用户通过 `+` 或拖入中间聊天区添加本地文件时,附件记录其源文件绝对路径,不复制文件;粘贴文件因不保证有稳定源路径,保存到当前工作区的 `<workspace>/.ncurator/runtime/assets/<assetBucketId>/`。`assetBucketId` 是当前输入草稿生成的 UUID,和会话 ID 解耦,添加附件不会提前创建新会话。文件拖入中间聊天区时显示透明覆盖层“松开以添加文件”,拖离或放下后消失;覆盖层不覆盖左侧栏、顶部栏或右侧资源面板。发送给 AI 的附件上下文只包含顺序、名称和路径。
- 验收:
- `WB-COMPOSER-010-AC1` 未绑定可用工作区时,不允许添加附件,并提示先选择工作区或完成目录绑定。
- `WB-COMPOSER-010-AC2` 已绑定工作区后,`+`、粘贴和拖放添加的附件都会保存到 `<workspace>/.ncurator/runtime/assets/<assetBucketId>/`,且不会提前创建真实会话。
- `WB-COMPOSER-010-AC2` 已绑定工作区后,`+` 和拖放文件保留源文件绝对路径;粘贴文件保存到 `<workspace>/.ncurator/runtime/assets/<assetBucketId>/`;三种方式均不会提前创建真实会话。
- `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. 非目标
+6 -6
View File
@@ -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 标签。
+5 -5
View File
@@ -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`,统一保存到 `<workspace>/.ncurator/runtime/assets/<assetBucketId>/`;`assetBucketId` 为输入草稿 UUID,发送给 AI 时只传附件名称与路径,不再做发送前识图或文件解析。
- Workbench 附件链路当前支持图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`、`xlsx`、`xlsm`、`xltx`、`xltm`;`+` 和拖放保留源文件绝对路径,粘贴文件保存到 `<workspace>/.ncurator/runtime/assets/<assetBucketId>/`。`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/<assetBucketId>/`,不会提前创建真实会话;图片、`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`
+19 -6
View File
@@ -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<AttachmentProcessResponse> => {
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<AttachmentProcessResponse> => {
+1 -2
View File
@@ -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
@@ -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<HumanD
}
})
export const buildHandWorkspaceBoundaryBlockedHint = (
items: IndexedResult[]
): Extract<HumanDecisionHint, { code: 'hand_workspace_boundary_blocked' }> => ({
code: 'hand_workspace_boundary_blocked',
detail: {
blockedCalls: items
}
})
export const deriveGroupedHandFailureHints = (input: {
calls: HandCallList
results: readonly ToolCallResult[]
}): {
callInvalidHint?: Extract<HumanDecisionHint, { code: 'hand_call_invalid' }>
workspaceBoundaryHint?: Extract<HumanDecisionHint, { code: 'hand_workspace_boundary_blocked' }>
} => {
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
}
}
@@ -116,9 +116,7 @@ export const deriveHandFollowupSignals = (input: {
}
return {
decisionHint:
groupedFailureHints.workspaceBoundaryHint
|| groupedFailureHints.callInvalidHint
decisionHint: groupedFailureHints.callInvalidHint
}
}
@@ -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':
-14
View File
@@ -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)
})
})
])
@@ -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 []
@@ -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'
})
}
}
@@ -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!
@@ -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,
@@ -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',
@@ -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,
@@ -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<string> => {
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)
@@ -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'
)
@@ -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)
}
}
@@ -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<Record<string, PathArgumentStrategy>> = {
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[] => {
@@ -11,7 +11,6 @@ export type TerminalShellConfig = {
export type ResolveCommandCwdInput = {
command: string
currentPath: string
allowedRootPaths: readonly string[]
}
export interface TerminalPlatformAdapter {
@@ -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 {
@@ -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(
@@ -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<string> => {
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
@@ -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 = `字段说明:
- 类型:资料来源类型,目前支持本地文件、本地目录、网页资料。
+7
View File
@@ -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) => {
@@ -137,6 +137,34 @@ export class AttachmentService {
return isSupportedAttachment(input)
}
public async createSourceAttachmentFromPath(input: {
sourcePath: string
kind?: ChatAttachment['kind']
mimeType?: string
}): Promise<ChatAttachment> {
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<ChatAttachment> {
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<number> {
return (await fs.promises.stat(path.resolve(filePath))).size
}
+2
View File
@@ -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,
+5
View File
@@ -31,6 +31,7 @@ import type {
ChatMessage,
AttachmentPickRequest,
AttachmentPickResponse,
AttachmentCreateFromPathRequest,
AttachmentProcessResponse,
AttachmentSaveRequest,
ChatRunEventsRequest,
@@ -188,6 +189,10 @@ export type DesktopApi = {
readFile: (filePath: string) => Promise<Uint8Array>
fileExists: (filePath: string) => Promise<boolean>
attachmentPick: (payload: AttachmentPickRequest) => Promise<AttachmentPickResponse>
attachmentGetSourcePath: (file: File) => string
attachmentCreateFromPath: (
payload: AttachmentCreateFromPathRequest
) => Promise<AttachmentProcessResponse>
attachmentSaveFile: (payload: AttachmentSaveRequest) => Promise<AttachmentProcessResponse>
chatSessionList: () => Promise<ChatSessionListItem[]>
chatSessionSave: (session: ChatSession) => Promise<{ success: boolean; error?: string }>
+6 -1
View File
@@ -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)
@@ -665,7 +665,7 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
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<WorkbenchComposerProps> = ({
[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<WorkbenchComposerProps> = ({
)
})
},
[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<WorkbenchComposerProps> = ({
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')
+6
View File
@@ -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
@@ -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: '<svg malformed'
svg: createSvg('#123456')
})
const formatError = await captureError({
outputPath: 'images/generated.gif',
@@ -222,7 +222,7 @@ describe('ai tool image-generation backend', () => {
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
}
)
@@ -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<PdfFailure>('pdf_inspect', {
sourcePath: corruptPath
})
const outside = await harness.invoke<PdfFailure>('pdf_inspect', {
const outside = await harness.invoke<Record<string, unknown>>('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
}
)
@@ -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: {
@@ -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')
})
@@ -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')
@@ -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 () => {
@@ -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
}
)