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:
1 parent
237e4ade04
commit
4886be6376
39 files changed
+294
-446
No files matched your search
+4
-4
@@ -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 打开对应会话。
|
||||
@@ -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. 非目标
|
||||
|
||||
|
||||
@@ -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 标签。
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 = `字段说明:
|
||||
- 类型:资料来源类型,目前支持本地文件、本地目录、网页资料。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -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,
|
||||
|
||||
@@ -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 }>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+11
-32
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user