Răsfoiți Sursa

fix(present): follow session filesystem access for deliverables

creatixchu 3 zile în urmă
părinte
comite
00bfdfbce6
29 a modificat fișierele cu 258 adăugiri și 85 ștergeri
  1. 2 2
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
  2. 5 5
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
  3. 4 4
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml
  5. 27 0
      .agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md
  6. 27 0
      .agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md
  7. 1 1
      docs/config-catalog.md
  8. 1 1
      docs/persistence-catalog.md
  9. 2 2
      docs/tool-catalog.md
  10. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  11. 4 4
      packages/client/ui-deliverables/README.md
  12. 4 4
      packages/client/ui-deliverables/README.zh.md
  13. 6 1
      packages/client/ui-deliverables/package.json
  14. 1 1
      packages/client/ui-deliverables/src/client/PresentedFileCard.tsx
  15. 2 0
      packages/client/ui-deliverables/src/client/locales.ts
  16. 3 3
      packages/client/ui-deliverables/src/client/present-open.ts
  17. 3 3
      packages/client/ui-deliverables/src/index.ts
  18. 18 13
      packages/client/ui-deliverables/src/present-open.ts
  19. 9 0
      packages/client/ui-deliverables/tests/present-open.client.spec.ts
  20. 53 15
      packages/client/ui-deliverables/tests/present-open.host.spec.ts
  21. 2 0
      packages/client/ui-deliverables/tests/prompt.host.spec.ts
  22. 9 0
      packages/client/ui-deliverables/tsconfig.host.json
  23. 2 2
      packages/fs/tool-present/README.i18n.yaml
  24. 6 6
      packages/fs/tool-present/README.md
  25. 6 6
      packages/fs/tool-present/README.zh.md
  26. 5 5
      packages/fs/tool-present/src/index.ts
  27. 3 3
      packages/fs/tool-present/src/types.ts
  28. 30 2
      packages/fs/tool-present/tests/present.spec.ts
  29. 15 0
      pnpm-lock.yaml

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
-2026-09-08-present-workspace-source-files.md: b8956b17a20090847da6de3c301254a73701e6f2
-2026-09-08-present-workspace-source-files.zh.md: 2e82f4e27a99f484005243a4769f3aadad290666
+2026-09-08-present-workspace-source-files.md: 650c16fe5a614ce1ccf118744a5f0e8cb5d19f4b
+2026-09-08-present-workspace-source-files.zh.md: 61bca82c83ff42600a7017039a6be4c3a4605769

+ 5 - 5
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md

@@ -10,15 +10,15 @@ Users need to open and edit the files produced in their workspace, including she
 
 ## Decision
 
-The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular files inside the calling Session's workspace. It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them.
+The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular source files under the [Session filesystem access policy](2026-09-09-present-filesystem-access.md). It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them.
 
 The tool description requires `present` after writing a file the user asked to receive and before the final response, including files created through Bash or code execution. A prose path reference does not replace the call. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) uses a user request that does not name `present`, and checks the resulting file, delivery event, and card. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations.
 
 The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results.
 
-An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery.
+An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host verifies regular-file existence and Host-path mapping before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery.
 
-File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and recheck workspace containment; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. Desktop metadata is invalidated with the connection generation so an old Host cannot keep native actions disabled or supply the wrong file-manager labels. Old metadata requests are cancelled and cannot replace the new generation’s response.
+File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and verify the Session filesystem and Host path; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. Desktop metadata is invalidated with the connection generation so an old Host cannot keep native actions disabled or supply the wrong file-manager labels. Old metadata requests are cancelled and cannot replace the new generation’s response.
 
 ## Alternatives considered
 
@@ -30,7 +30,7 @@ File cards use the same split-control pattern as the Session header. The card an
 
 **Tool text as the durable index** cannot survive post-processing or result spill reliably. Execution identity and final successful results retain declaration ownership independently of displayed tool text.
 
-**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks reject ordinary escapes; concurrent swap-and-restore remains outside the path API's guarantees.
+**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks verify file metadata and path mapping; concurrent swap-and-restore remains outside the path API's guarantees.
 
 ## Consequences
 
@@ -38,4 +38,4 @@ The Session log persists declarations but no attachment references or file conte
 
 The removed file-size cap has no role in a metadata-only declaration; the configurable file-count limit still bounds result size. Cards show file names and descriptions, falling back to file types, without stale byte-size metadata. No artifact service or speculative storage fallback is introduced.
 
-Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, workspace escapes, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export.
+Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, external paths and unavailable Host mappings, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export.

+ 4 - 4
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md

@@ -10,15 +10,15 @@ Status: implemented
 
 ## 决策
 
-[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付调用方 Session 工作区中已存在的普通文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。
+[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付[Session 文件系统访问策略](2026-09-09-present-filesystem-access.zh.md)允许的已有普通源文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。
 
 工具说明要求在写好用户要求接收的文件后、最终回复前调用 `present`,包括通过 Bash 或代码执行创建的文件。正文中的路径引用不能替代调用。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)使用未提及 `present` 的用户请求,检查生成文件、交付事件和卡片。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。
 
 工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。
 
-经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。
+经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前检查普通文件是否存在,并验证 Host 路径映射。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。
 
-文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并重新检查工作区包含关系;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 桌面元数据随连接代次失效,避免旧主机信息让原生操作持续禁用或显示错误的文件管理器名称。旧元数据请求会被取消,不能覆盖新代次的响应。
+文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并验证 Session 文件系统与 Host 路径;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 桌面元数据随连接代次失效,避免旧主机信息让原生操作持续禁用或显示错误的文件管理器名称。旧元数据请求会被取消,不能覆盖新代次的响应。
 
 ## 考虑过的替代方案
 
@@ -30,7 +30,7 @@ Status: implemented
 
 **以工具文本作为持久索引**无法可靠应对后处理或结果溢出。执行身份与最终成功结果使声明归属独立于展示的工具文本。
 
-**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查拒绝普通越界;并发替换后复原仍不在路径 API 的保证范围内。
+**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查验证文件元数据和路径映射;并发替换后复原仍不在路径 API 的保证范围内。
 
 ## 影响
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md
+2026-09-09-present-filesystem-access.md: 24b0e767d96261a95e449278d0919c381141f50d
+2026-09-09-present-filesystem-access.zh.md: 2f4dc550823bd6c8de6ad61c113d58e3f238bbb5

+ 27 - 0
.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md

@@ -0,0 +1,27 @@
+# Agent Note: Present follows Session filesystem access
+
+Status: implemented
+
+English | [中文](2026-09-09-present-filesystem-access.zh.md)
+
+## Problem
+
+Generated files commonly live outside the workspace, especially in `/tmp`. Workspace containment rejects files that the Session filesystem and Sidebar already allow. A provider process path may also name a remote file rather than a file on the serving Host.
+
+## Decision
+
+`present` accepts existing regular files accessible through its composed `ctx.fs`, with relative paths resolved against the Session working directory. There is no workspace containment check or special temporary-directory allowlist. Missing files, directories, final symbolic links, and provider failures reject the declaration. A sandbox's private temporary files remain unavailable when the filesystem provider cannot see them.
+
+Native actions resolve or resume the viewed Session's Agent and use its `workspaceFiles.stat`. The resulting canonical process path must map from a Host path back to the same process path through that Agent's filesystem. Absent or different mappings produce 422 and a localized Sidebar-preview suggestion. This conservatively supports Host paths that share their canonical process spelling; providers with only a nonidentity Host mapping can still serve previews. A same-named local file never substitutes for an unmapped provider file.
+
+This replaces the workspace-only access rule in the [source-file delivery decision](2026-09-08-present-workspace-source-files.md). That note continues to own content-free declarations, Session events, and editing current sources. The request still selects only saved Session/event/file coordinates, never an arbitrary browser-supplied path.
+
+## Alternatives considered
+
+A `/tmp` allowlist excludes other readable output locations and duplicates filesystem policy. Treating every provider process path as a Host path can open an unrelated local file. Adding a generic inverse path-mapping API or copying remote files expands provider and retention responsibilities beyond source-file delivery.
+
+## Consequences
+
+Workspace files, accessible temporary files, Downloads, and files in another project use the same declaration rules. Native opening requires both a serving desktop and a verified Host path. Metadata checks do not make a desktop application's later path lookup atomic.
+
+Focused tests cover external absolute and relative paths, final symbolic links, missing files, Session lookup failures, absent and mismatched Host mappings, and localized native-unavailable state. Existing recorded Web scenarios retain declaration, preview, native action, and content-free export coverage.

+ 27 - 0
.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md

@@ -0,0 +1,27 @@
+# Agent Note:Present 遵循 Session 文件系统访问规则
+
+Status: implemented
+
+[English](2026-09-09-present-filesystem-access.md) | 中文
+
+## 问题
+
+生成文件经常位于工作区外,尤其是 `/tmp`。工作区包含检查会拒绝 Session 文件系统和侧栏已经允许访问的文件。提供方的进程路径也可能指向远端文件,而非服务 Host 上的文件。
+
+## 决策
+
+`present` 接受所组合的 `ctx.fs` 可访问的已有普通文件,相对路径按 Session 工作目录解析。不检查工作区包含关系,也不另设临时目录白名单。文件缺失、为目录、最终路径为符号链接或提供方出错时,声明失败。文件系统提供方不可见的沙箱私有临时文件仍不可用。
+
+原生操作查找或恢复当前查看的 Session 的 Agent,并调用其 `workspaceFiles.stat`。得到的规范化进程路径必须能通过该 Agent 的文件系统从 Host 路径映射回同一进程路径。映射缺失或不同会返回 422,并显示使用侧栏预览的本地化提示。这只支持规范化后与进程路径写法相同的 Host 路径;仅支持不同写法的 Host 映射的提供方仍可提供预览。没有映射时,本机同名文件不能替代提供方文件。
+
+本决策替代[源文件交付决策](2026-09-08-present-workspace-source-files.zh.md)中只允许工作区文件的访问规则。原说明继续负责不保存内容的声明、Session 事件和编辑当前源文件。请求仍只按已保存的 Session、事件和文件索引选择文件,不接受浏览器提供的任意路径。
+
+## 考虑过的替代方案
+
+`/tmp` 白名单会排除其他可读的输出位置,并重复文件系统策略。把所有提供方进程路径都视作 Host 路径可能打开不相关的本机文件。增加通用反向路径映射 API 或复制远端文件,会让提供方与文件保留承担源文件交付之外的职责。
+
+## 影响
+
+工作区文件、可访问的临时文件、Downloads 和其他项目中的文件使用相同的声明规则。原生打开同时要求服务主机有可用桌面和经过验证的 Host 路径。元数据检查不能使桌面应用随后按路径打开文件的操作具有原子性。
+
+定向测试覆盖工作区外的绝对和相对路径、最终符号链接、文件缺失、Session 查询失败、Host 映射缺失或不一致,以及本地化的原生操作不可用状态。已有的 Web 录制场景继续覆盖声明、预览、原生操作和不含文件内容的导出。

+ 1 - 1
docs/config-catalog.md

@@ -3459,7 +3459,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts))

+ 1 - 1
docs/persistence-catalog.md

@@ -403,7 +403,7 @@ Source: [`packages/compaction/compaction/src/types.ts:34`](../packages/compactio
 #### `deliverables/presented` — log-only
 
 ```ts persistence-catalog
-/** Declared workspace files from a successful final present result, including nested calls. */
+/** Declared filesystem files from a successful final present result, including nested calls. */
 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] }
 ```
 

+ 2 - 2
docs/tool-catalog.md

@@ -225,7 +225,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_
 
 ### `present`
 
-Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.
+Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.
 
 ```json
 {
@@ -239,7 +239,7 @@ Declare existing workspace files as final deliverables. When a file you create o
         "properties": {
           "path": {
             "type": "string",
-            "description": "Path of an existing file inside the workspace."
+            "description": "Path of an existing regular file. Relative paths use the Session working directory."
           },
           "description": {
             "type": "string",

+ 2 - 2
packages/client/ui-deliverables/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
-README.md: 9faea88696f3d6a7d39429e82449978647330a5f
-README.zh.md: 8b2c846dffe674d2112f9b7680040a762d8b0c60
+README.md: 50677e0cfb260c7b9bd31ed9599a2d0214dda201
+README.zh.md: 2196be4fed14cb269056ce4fd83960ca4024f63a

+ 4 - 4
packages/client/ui-deliverables/README.md

@@ -30,7 +30,7 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th
 <a id="explicit-deliveries"></a>
 ### Explicit deliveries
 
-The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply.
+The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply.
 
 The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device.
 
@@ -52,7 +52,7 @@ The closing prose carries the same vocabulary: an inline-code token resolves by
 
 The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost.
 
-Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching either native action. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
+Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves or resumes that Session’s Agent and checks the declaration through its `workspaceFiles.stat`, using the same filesystem as Sidebar previews. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
 
 </details>
 
@@ -96,7 +96,7 @@ These limits define the current deliverables vocabulary. They are current packag
 
 - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it.
 - **Terminal-created files require explicit delivery** — call `present` to declare them for native opening.
-- **Declarations do not preserve file contents** — reopening or transferring a Session requires the source files in the viewed Session’s workspace. Missing files return 404; paths resolving outside the workspace return 403.
+- **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404.
 - **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced.
 
 <a id="dev-note"></a>
@@ -109,4 +109,4 @@ None.
 
 </details>
 
-**Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the workspace owns file contents.
+**Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the filesystem owns file contents.

+ 4 - 4
packages/client/ui-deliverables/README.zh.md

@@ -30,7 +30,7 @@ kind: "package-reference"
 <a id="explicit-deliveries"></a>
 ### 显式交付
 
-Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。
+Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付Session 文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。
 
 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。
 
@@ -52,7 +52,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
 
 Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。
 
-原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 按该 Session 的工作区解析路径,检查当前文件存在且位于工作区内,再启动所选原生操作。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
+原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 查找或恢复该 Session 的 Agent,通过其 `workspaceFiles.stat` 检查声明,与侧栏预览使用同一文件系统。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
 
 </details>
 
@@ -96,7 +96,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 
 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。
 - **终端创建的文件需要显式交付**——调用 `present` 声明文件,以便原生打开。
-- **声明不保存文件内容**——重新打开或转移 Session 后,需要当前查看的 Session 工作区中仍有源文件。文件缺失返回 404;解析到工作区外的路径返回 403
+- **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404
 - **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。
 
 <a id="dev-note"></a>
@@ -109,4 +109,4 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 
 </details>
 
-**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,工作区拥有文件内容。
+**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,文件系统拥有文件内容。

+ 6 - 1
packages/client/ui-deliverables/package.json

@@ -68,7 +68,12 @@
     "@deepseek-ai/dsh-client-ui-tool": "workspace:^",
     "@deepseek-ai/dsh-tool-present": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
-    "@deepseek-ai/dsh-util-workspace-path": "workspace:^"
+    "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
+    "@deepseek-ai/dsh-api-workspace-files": "workspace:^",
+    "@deepseek-ai/dsh-fs": "workspace:^",
+    "@deepseek-ai/dsh-fs-local": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 1 - 1
packages/client/ui-deliverables/src/client/PresentedFileCard.tsx

@@ -56,7 +56,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction,
       <div className={css.details}>
         <span className={css.fileName}>{name}</span>
         <span className={css.description} role={phase === undefined ? undefined : 'status'}
-          data-error={phase === 'error' || phase === 'revealError' ? true : undefined}>
+          data-error={phase === 'error' || phase === 'revealError' || phase === 'nativeUnavailable' ? true : undefined}>
           <span className={css.secondaryText}>{status}</span>
           <span className={css.previewHint}>{t('presented.preview')}</span>
         </span>

+ 2 - 0
packages/client/ui-deliverables/src/client/locales.ts

@@ -5,6 +5,7 @@ export const NS = 'deliverables'
 
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
+  'presented.nativeUnavailable': '此文件没有可用的主机路径,请在侧边栏预览',
   'presented.revealError': '无法在文件管理器中显示,请重试',
   'presented.directoryError': '无法打开所在文件夹,请重试',
   'presented.directoryOpening': '正在打开所在文件夹…',
@@ -46,6 +47,7 @@ export const zh = {
 
 /** English dictionary (same key set). */
 export const en: Record<DeliverablesKey, string> = {
+  'presented.nativeUnavailable': 'This file has no available Host path. Preview it in the sidebar.',
   'presented.revealError': 'Could not show in file manager. Try again.',
   'presented.directoryError': 'Could not open containing folder. Try again.',
   'presented.directoryOpening': 'Opening containing folder…',

+ 3 - 3
packages/client/ui-deliverables/src/client/present-open.ts

@@ -4,7 +4,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { presentedFileUrl, PRESENT_HOST_PATH, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts'
 
 /** State of the latest explicit open gesture for one saved file. */
-export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError'
+export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' | 'nativeUnavailable'
 
 /** One browser plugin's file-open requests, cancelled when that plugin is disposed. */
 export class PresentedOpenController {
@@ -18,7 +18,7 @@ export class PresentedOpenController {
   private readonly pending = new Set<Promise<void>>()
 
   /**
-   * Open a declared workspace file once while a request for the same coordinates is pending.
+   * Open a declared file once while a request for the same coordinates is pending.
    * Failures remain visible on the card and a later gesture retries them.
    * @param sessionId - viewed Session, including a fork's own identity.
    * @param seq - durable delivery event sequence.
@@ -93,7 +93,7 @@ export class PresentedOpenController {
     let phase: PresentedOpenPhase = action === 'open' ? 'opened' : 'revealed'
     try {
       const response = await fetch(action === 'open' ? url : `${url}&action=reveal`, { method: 'POST', signal: this.lifetime.signal })
-      if (!response.ok) phase = failure
+      if (!response.ok) phase = response.status === 422 ? 'nativeUnavailable' : failure
     } catch {
       // Transport failures share the retryable card state with Host open failures.
       phase = failure

+ 3 - 3
packages/client/ui-deliverables/src/index.ts

@@ -1,7 +1,7 @@
 /**
  * Deliverables plugin, node half. Registers the response-format guidance that
  * lets the browser half recognize final-response file references and serves
- * authenticated native opens of workspace files. The browser
+ * authenticated native opens of declared files. The browser
  * half ships via exports["./client"], discovered through the package.json
  * dsh.client declaration.
  */
@@ -10,8 +10,8 @@ import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 import { registerPresentOpen } from './present-open.ts'
 
-/** Services required for file-reference guidance and authenticated native opens of workspace files. */
-export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController']
+/** Services required for file-reference guidance and authenticated native opens of declared files. */
+export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs']
 
 /** Stable final-response guidance owned by the matching renderer. */
 const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. '

+ 18 - 13
packages/client/ui-deliverables/src/present-open.ts

@@ -1,8 +1,9 @@
-/** Open declared source files inside the viewed Session's workspace. */
-import { realpath, stat } from 'node:fs/promises'
-import { isAbsolute, relative, resolve, sep } from 'node:path'
+/** Open declared source files verified by the viewed Session's filesystem. */
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-api-session-controller'
+import type {} from '@deepseek-ai/dsh-api-workspace-files'
+import type {} from '@deepseek-ai/dsh-fs'
+import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
 import type {} from '@deepseek-ai/dsh-client-connection'
 import type {} from '@deepseek-ai/dsh-session-query'
 import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
@@ -53,27 +54,31 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise<Respon
   try {
     request.signal.throwIfAborted()
     if (!ctx.sessionController.workspaceDesktop().available) return new Response('Host desktop unavailable.', { status: 409 })
-    const { session, target } = await ctx.sessionQuery.readEvent({
+    const { target } = await ctx.sessionQuery.readEvent({
       sessionId: id as SessionId, seq: Number(seq) as SessionSeq, before: 0, after: 0,
     }, request.signal)
     const file = target.type === 'deliverables/presented' && isPresentedData(target.data) ? target.data.files[Number(index)] : undefined
     if (!isPresentedFile(file)) return new Response('Presented file not found in this Session result.', { status: 404 })
-    if (session.cwd === undefined) return new Response('Session workspace unavailable.', { status: 404 })
-    const root = await realpath(session.cwd)
-    const path = await realpath(resolve(root, file.path))
-    const within = relative(root, path)
-    if (isAbsolute(within) || within === '..' || within.startsWith(`..${sep}`)) {
-      return new Response('Presented file is outside the workspace.', { status: 403 })
+    const found = await ctx.sessionController.resolveAgent(id as SessionId)
+    if ('error' in found) throw found.error
+    request.signal.throwIfAborted()
+    const { agent } = found
+    const { absolutePath: path } = await agent.ctx.workspaceFiles.stat(agent, file.path, request.signal)
+    const fs = agent.ctx.fs
+    const mapped = fs.processPathFromHostPath(path)
+    if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal: request.signal })) !== path) {
+      return new Response('Presented file has no verified Host path.', { status: 422 })
     }
-    if (!(await stat(path)).isFile()) return new Response('Presented path is not a file.', { status: 404 })
     request.signal.throwIfAborted()
     await ctx.sessionController.openWorkspacePath({ path, ...(action === 'reveal' ? { action } : {}) }, request.signal)
     return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } })
   } catch (error: unknown) {
     request.signal.throwIfAborted()
-    const missing = error instanceof Error && 'code' in error
+    const remote = remoteErrorOf(error)
+    const missing = remote?.code === 'session/not-found' || remote?.code === 'workspace-file/not-found'
+      || remote?.code === 'workspace-file/not-regular-file' || error instanceof Error && 'code' in error
       && (error.code === 'SESSION_QUERY_SESSION_NOT_FOUND' || error.code === 'SESSION_QUERY_EVENT_NOT_FOUND'
         || error.code === 'ENOENT' || error.code === 'ENOTDIR')
-    return new Response('Presented workspace file unavailable.', { status: missing ? 404 : 500 })
+    return new Response('Presented file unavailable.', { status: missing ? 404 : 500 })
   }
 }

+ 9 - 0
packages/client/ui-deliverables/tests/present-open.client.spec.ts

@@ -152,3 +152,12 @@ it('discards a replaced Host response and keeps the new metadata request coalesc
   expect(controller.host.getSnapshot()).toMatchObject({ name: 'new' })
   await controller.dispose()
 })
+
+
+it.each(['open', 'reveal'] as const)('reports an unavailable Host path for %s while retaining the declaration', async (action) => {
+  vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 422 })))
+  const controller = new PresentedOpenController()
+  await controller.open(id, 2, 1, action)
+  expect(controller.state.getSnapshot()[url]).toBe('nativeUnavailable')
+  await controller.dispose()
+})

+ 53 - 15
packages/client/ui-deliverables/tests/present-open.host.spec.ts

@@ -2,6 +2,10 @@
 import { mkdtemp, rm, readFile, writeFile, mkdir, realpath, symlink, unlink } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
+import { WorkspaceFiles } from '@deepseek-ai/dsh-api-workspace-files'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { Context } from '@deepseek-ai/cordis'
 import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
 import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
@@ -30,6 +34,11 @@ async function fixture() {
   const ctx = new Context()
   cleanups.push(() => ctx.fiber.dispose())
   const session: { cwd?: string } = { cwd }
+  await ctx.plugin(LocalFileSystem, { cwd })
+  ctx.provide('sandboxPolicy', { resolve: () => ({ workspaceRoot: session.cwd ?? cwd }) } as never)
+  new WorkspaceFiles(ctx, { maxBytes: 1024, maxFileBytes: 1024, maxLines: 100, maxEntries: 100 })
+  const agent = { ctx, session: { header: session } } as unknown as Agent
+  const resolveAgent = vi.fn<Context['sessionController']['resolveAgent']>(async () => ({ agent }))
   const readEvent = vi.fn(async (request: SessionEventReadRequest) => {
     if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND')
     if (request.seq !== 7) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND')
@@ -37,15 +46,15 @@ async function fixture() {
   })
   ctx.provide('sessionQuery', { readEvent } as never)
   const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const }))
-  ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
+  ctx.provide('sessionController', { resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
   const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
-  const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController'], apply: registerPresentOpen })
+  const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs'], apply: registerPresentOpen })
   await fiber
   const handler = connection.createSharedFetchHandler('/api')
   const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request(
     `http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null },
   ))
-  return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler }
+  return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent }
 }
 
 describe('Presented workspace file native open route', () => {
@@ -75,7 +84,8 @@ describe('Presented workspace file native open route', () => {
     const fork = join(root, 'fork')
     await mkdir(fork)
     await writeFile(join(fork, file.path), 'child source')
-    readEvent.mockResolvedValueOnce({ session: { ...session, cwd: fork }, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'inherited', files: [file] } } as SessionEvent })
+    session.cwd = fork
+    readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'inherited', files: [file] } } as SessionEvent })
     expect((await open('?sessionId=fork&seq=7&index=0')).status).toBe(204)
     expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(fork, file.path)))
   })
@@ -109,30 +119,29 @@ describe('Presented workspace file native open route', () => {
     expect(opener).not.toHaveBeenCalled()
   })
 
-  it('reports removed files, directories, and absent workspaces without launching', async () => {
-    const { cwd, file, session, open, opener } = await fixture()
+  it('reports removed files and directories without launching', async () => {
+    const { cwd, file, open, opener } = await fixture()
     await unlink(join(cwd, file.path))
     expect((await open()).status).toBe(404)
     file.path = '.'
     expect((await open()).status).toBe(404)
-    delete session.cwd
-    expect((await open()).status).toBe(404)
     expect(opener).not.toHaveBeenCalled()
   })
 
-  it('refuses traversal and a source replaced by a symlink outside the workspace', async () => {
+  it('opens external regular files through absolute and relative paths but refuses final symlinks', async () => {
     const { root, cwd, file, open, opener } = await fixture()
     const outside = join(root, 'outside.txt')
     await writeFile(outside, 'outside')
     const source = join(cwd, file.path)
     await unlink(source)
     await symlink(outside, source)
-    expect((await open()).status).toBe(403)
-    file.path = '../outside.txt'
-    expect((await open()).status).toBe(403)
-    file.path = outside
-    expect((await open()).status).toBe(403)
+    expect((await open()).status).toBe(404)
     expect(opener).not.toHaveBeenCalled()
+    for (const path of ['../outside.txt', outside]) {
+      file.path = path
+      expect((await open()).status).toBe(204)
+      expect(opener.mock.lastCall?.[0].path).toBe(await realpath(outside))
+    }
   })
 
   it('reports query and launcher failures without leaking Host paths and allows retry', async () => {
@@ -186,7 +195,7 @@ it('reports the serving desktop and reveals only an authorized declared source',
   expect(opener).toHaveBeenCalledWith({ path: await realpath(join(cwd, file.path)), action: 'reveal' }, expect.any(AbortSignal))
   expect((await open('?sessionId=owner&seq=7&index=0&action=delete')).status).toBe(400)
   file.path = '..'
-  expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(403)
+  expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(404)
   expect(opener).toHaveBeenCalledOnce()
 })
 
@@ -199,3 +208,32 @@ it('refuses native actions when the configured Host desktop is unavailable', asy
   }
   expect(opener).not.toHaveBeenCalled()
 })
+
+
+it('refuses native opening without a matching Host mapping even when a same-name Host file exists', async () => {
+  const { ctx, open, opener } = await fixture()
+  const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
+  expect((await open()).status).toBe(422)
+  mapping.mockReturnValue('/another-filesystem/file')
+  expect((await open()).status).toBe(422)
+  expect(opener).not.toHaveBeenCalled()
+})
+
+it('reports Session resolution failures before checking files', async () => {
+  const { resolveAgent, open, opener } = await fixture()
+  resolveAgent.mockResolvedValueOnce({ error: new RemoteError('session/not-found', 'missing', { sessionId: SessionId('owner') }) })
+  expect((await open()).status).toBe(404)
+  expect(opener).not.toHaveBeenCalled()
+})
+
+
+it('uses the resolved Agent filesystem instead of the Host filesystem', async () => {
+  const { ctx, cwd, open, resolveAgent, opener } = await fixture()
+  const scoped = ctx.isolate('fs')
+  await scoped.plugin(LocalFileSystem, { cwd })
+  vi.spyOn(scoped.fs, 'processPathFromHostPath').mockReturnValue(undefined)
+  resolveAgent.mockResolvedValueOnce({ agent: { ctx: scoped, session: { header: { cwd } } } as unknown as Agent })
+  expect((await open()).status).toBe(422)
+  expect(opener).not.toHaveBeenCalled()
+  expect((await open()).status).toBe(204)
+})

+ 2 - 0
packages/client/ui-deliverables/tests/prompt.host.spec.ts

@@ -19,6 +19,8 @@ describe('ui-deliverables node plugin', () => {
     ctx.provide('connection', { fetch: { register: () => () => {} } } as never)
     ctx.provide('sessionQuery', {} as never)
     ctx.provide('sessionController', {} as never)
+    ctx.provide('workspaceFiles', {} as never)
+    ctx.provide('fs', {} as never)
     const mounted = ctx.plugin({ apply, inject })
     await mounted.await()
 

+ 9 - 0
packages/client/ui-deliverables/tsconfig.host.json

@@ -34,6 +34,15 @@
     },
     {
       "path": "../../api/session-controller/tsconfig.host.json"
+    },
+    {
+      "path": "../../api/workspace-files/tsconfig.host.json"
+    },
+    {
+      "path": "../../fs/fs"
+    },
+    {
+      "path": "../../typert/protocol"
     }
   ]
 }

+ 2 - 2
packages/fs/tool-present/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/fs/tool-present/README.md
-README.md: 77c1a83a30bdbe58b3be35d8bc0d879f1669b5a5
-README.zh.md: cec2d64b4e6f95a4e8210e7e24a9c796ed95d749
+README.md: 93003711545f678336a02c9bf8589176f844af65
+README.zh.md: 152ba39be7184ca536a56ed9b4cfb5dfa4a5fe82

+ 6 - 6
packages/fs/tool-present/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Declare workspace files as deliverables with present; configuration, Session ownership, and source-file opening."
+description: "Declare accessible files as deliverables with present; configuration, Session ownership, and source-file opening."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Use `present` to declare final workspace files, including files created through shell commands. Users open the current source files in their default application. The tool records paths and optional descriptions without copying file contents.
+Use `present` to declare final files accessible through the Session filesystem, including files created through shell commands. Users open the current source files in their default application. The tool records paths and optional descriptions without copying file contents.
 
 ## Table of Contents
 
@@ -25,7 +25,7 @@ Use `present` to declare final workspace files, including files created through
 <a id="use-this-package"></a>
 ## Use this package
 
-The `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must exist inside the Session workspace and be regular files. Missing files, directories, and paths outside the workspace fail the call.
+The `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must be regular files accessible through the Session filesystem. Relative paths resolve against the Session working directory; absolute paths may name files outside it, including `/tmp` or Downloads. Missing files, directories, final symbolic links, and provider-denied paths fail the call. Files in a shell sandbox’s private `/tmp` must first be written somewhere the Session filesystem can access.
 
 Mount it in an agent's Cordis composition with `tools`, `fs`, and the `turnBoundary` Session projection available:
 
@@ -49,7 +49,7 @@ The file-count limit is validated at mount. The tool requires an agent Session w
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The tool resolves paths through the configured filesystem provider and checks workspace containment and regular-file metadata without reading contents. Successful final `tools/result` notifications append `deliverables/presented`, including nested calls. A later enclosing program failure does not revoke an already completed declaration. Blocked results publish none. Each plugin instance records only calls it executed; scoped tools with the same name cannot publish through another instance.
+The tool resolves paths through the configured filesystem provider and checks regular-file metadata without reading contents. Successful final `tools/result` notifications append `deliverables/presented`, including nested calls. A later enclosing program failure does not revoke an already completed declaration. Blocked results publish none. Each plugin instance records only calls it executed; scoped tools with the same name cannot publish through another instance.
 
 The pure `./types` entry declares `PresentedFile` and the Session event without importing Host runtime code. The Web consumer validates persisted declarations before displaying or opening them. The event stores no Session ID, so forked history resolves relative paths against the viewed Session's workspace.
 
@@ -73,7 +73,7 @@ The pure `./types` entry declares `PresentedFile` and the Session event without
 
 #### What the model sees
 
-The [present schema](../../../docs/tool-catalog.md#present) asks for existing workspace files: “Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” Results report `Presented <path>` for each file; the program result and durable event contain paths and optional descriptions.
+The [present schema](../../../docs/tool-catalog.md#present) asks for existing accessible files: “Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” Results report `Presented <path>` for each file; the program result and durable event contain paths and optional descriptions.
 
 #### Token effect
 
@@ -87,7 +87,7 @@ The tool schema is static for the mount lifetime. Delivery result text extends t
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- Path containment checks are best effort; they cannot atomically defend against a concurrent symlink replacement before the desktop application opens the file.
+- Metadata and Host-path checks cannot atomically prevent replacement before a desktop application opens a file.
 - Edits change what opens. Deleted or moved source files cannot be opened from their declarations.
 - Session ZIP exports contain declarations, not file contents. Persistent delivery versions and copy-on-write storage are deferred.
 

+ 6 - 6
packages/fs/tool-present/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "通过 present 声明交付工作区文件;配置、Session 归属与源文件打开。"
+description: "通过 present 声明交付可访问的文件;配置、Session 归属与源文件打开。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-使用 `present` 声明交付最终工作区文件,包括通过 shell 命令创建的文件。用户使用默认应用打开当前源文件。工具记录路径和可选说明,不复制文件内容。
+使用 `present` 声明交付Session 文件系统可访问的最终文件,包括通过 shell 命令创建的文件。用户使用默认应用打开当前源文件。工具记录路径和可选说明,不复制文件内容。
 
 ## 目录
 
@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须存在于 Session 工作区内,且为普通文件。文件缺失、为目录或路径位于工作区外时,调用失败
+`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须是 Session 文件系统可访问的普通文件。相对路径按 Session 工作目录解析;绝对路径可以指向工作区外的文件,包括 `/tmp` 或 Downloads。文件缺失、为目录、最终路径为符号链接或提供方拒绝访问时,调用失败。Shell 沙箱私有 `/tmp` 中的文件需要先写入 Session 文件系统可访问的位置
 
 在 Agent 的 Cordis 组合中挂载,并提供 `tools`、`fs` 和 `turnBoundary` Session 投影:
 
@@ -49,7 +49,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-工具通过配置的文件系统提供方解析路径,检查工作区包含关系和普通文件元数据,不读取内容。成功的最终 `tools/result` 通知追加 `deliverables/presented`,嵌套调用也适用。外层程序随后失败不会撤销已完成的声明。被阻止的结果不发布声明。每个插件实例只记录其实际执行的调用;同名作用域工具不能通过其他实例发布交付。
+工具通过配置的文件系统提供方解析路径,检查普通文件元数据,不读取内容。成功的最终 `tools/result` 通知追加 `deliverables/presented`,嵌套调用也适用。外层程序随后失败不会撤销已完成的声明。被阻止的结果不发布声明。每个插件实例只记录其实际执行的调用;同名作用域工具不能通过其他实例发布交付。
 
 纯 `./types` 入口声明 `PresentedFile` 与 Session 事件,不导入 Host 运行时代码。Web 消费方在展示或打开文件前校验持久声明。事件不保存 Session ID,因此 fork 历史中的相对路径按当前查看的 Session 工作区解析。
 
@@ -73,7 +73,7 @@ kind: "package-reference"
 
 #### 模型看到的内容
 
-[present schema](../../../docs/tool-catalog.zh.md#present)要求已有的工作区文件:“Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” 每个文件的结果为 `Presented <path>`;程序结果和持久事件包含路径及可选说明。
+[present schema](../../../docs/tool-catalog.zh.md#present)要求已有且可访问的文件:“Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” 每个文件的结果为 `Presented <path>`;程序结果和持久事件包含路径及可选说明。
 
 #### Token 影响
 
@@ -87,7 +87,7 @@ kind: "package-reference"
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- 路径包含关系检查是尽力而为的;无法原子防御桌面应用打开文件前发生的并发符号链接替换。
+- 元数据和 Host 路径检查无法原子性地阻止桌面应用打开文件前发生的路径替换。
 - 编辑会改变打开的内容。源文件删除或移动后,无法通过原声明打开。
 - Session ZIP 导出包含声明,不包含文件内容。交付版本持久化和写时复制存储延期实现。
 

+ 5 - 5
packages/fs/tool-present/src/index.ts

@@ -1,4 +1,4 @@
-/** Scoped tool that declares workspace file deliveries in their owning Session. */
+/** Scoped tool that declares filesystem deliveries in their owning Session. */
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { FsError } from '@deepseek-ai/dsh-fs'
@@ -37,7 +37,7 @@ export function apply(ctx: Context, config: Config): void {
   const pending = new WeakMap<ToolExecution, { session: Session; turn: number; files: PresentedFile[] }>()
   ctx.tools.register(defineTool({
     name: 'present',
-    description: 'Declare existing workspace files as final deliverables. '
+    description: 'Declare existing files accessible through the Session filesystem as final deliverables. '
       + 'When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. '
       + 'Mentioning its path in your reply does not replace this call. The files must already exist. '
       + 'The user opens the current source files; their contents are not copied or preserved.',
@@ -47,7 +47,7 @@ export function apply(ctx: Context, config: Config): void {
         items: {
           type: 'object', additionalProperties: false,
           properties: {
-            path: { type: 'string', required: true, description: 'Path of an existing file inside the workspace.' },
+            path: { type: 'string', required: true, description: 'Path of an existing regular file. Relative paths use the Session working directory.' },
             description: { type: 'string', description: 'Brief description for the user.' },
           },
         },
@@ -80,12 +80,12 @@ export function apply(ctx: Context, config: Config): void {
       const cwd = exec.agent.session.header.cwd
       if (cwd === undefined) throw new Error('present requires a workspace')
       const options = { cwd, signal: exec.signal }
-      const root = await ctx.fs.resolve('.', options)
       const files: PresentedFile[] = []
       for (const file of args.files) {
         if (file.path.trim().length === 0) throw new Error('present requires a non-empty file path')
+        const entry = await ctx.fs.lstat(file.path, { cwd }, exec.signal)
+        if (entry !== undefined && entry.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`)
         const target = await ctx.fs.resolve(file.path, options)
-        if (!ctx.fs.contains(root, target)) throw new Error(`Cannot present ${file.path}: outside the workspace`)
         const info = await ctx.fs.stat(target, exec.signal)
         if (info === undefined) throw new FsError(`Cannot present ${file.path}: file not found. Check the path, create the file if needed, and retry.`, 'FS_NOT_FOUND')
         if (info.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`)

+ 3 - 3
packages/fs/tool-present/src/types.ts

@@ -1,9 +1,9 @@
 /** Durable file deliveries produced by the present tool. */
 import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
 
-/** A declared workspace file whose current contents remain at its source path. */
+/** A declared filesystem file whose current contents remain at its source path. */
 export interface PresentedFile {
-  /** Original workspace path. */
+  /** Original absolute path or path relative to the Session working directory. */
   path: string
   /** Optional description supplied by the model. */
   description?: string
@@ -11,7 +11,7 @@ export interface PresentedFile {
 
 declare module '@deepseek-ai/dsh-session/types' {
   interface SessionEventMap {
-    /** Declared workspace files from a successful final present result, including nested calls. */
+    /** Declared filesystem files from a successful final present result, including nested calls. */
     'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] }
   }
 }

+ 30 - 2
packages/fs/tool-present/tests/present.spec.ts

@@ -1,7 +1,7 @@
 /** Explicit deliveries commit only after a successful final tool result. */
 import { mkdtemp, rm, writeFile, symlink } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
-import { join } from 'node:path'
+import { join, relative } from 'node:path'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
@@ -133,7 +133,7 @@ describe('present', () => {
     expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false)
   })
 
-  it('rejects missing, non-file, outside-workspace, empty, and excessive inputs', async () => {
+  it('rejects missing, non-file, empty, and excessive inputs', async () => {
     const { root, owner, execute } = await setup()
     await writeFile(join(root, 'large'), 'four')
     await symlink(tmpdir(), join(root, 'outside'))
@@ -165,3 +165,31 @@ it('requires an agent, an open turn, and a workspace', async () => {
   const absent = await ctx.tools.execute({ signal: new AbortController().signal, callId: ToolCallId('no-workspace'), name: 'present', arguments: { files: [{ path: 'a' }] }, agent: noWorkspace })
   expect(absent.isError).toBe(true)
 })
+
+
+it('declares readable files outside the Session directory using absolute and relative paths', async () => {
+  const { root, execute, owner } = await setup()
+  const outside = await mkdtemp(join(tmpdir(), 'dsh-present-external-'))
+  cleanups.push(() => rm(outside, { recursive: true, force: true }))
+  const file = join(outside, 'report.txt')
+  await writeFile(file, 'external report')
+  const files = [{ path: file }, { path: relative(root, file) }]
+  expect((await execute(files)).isError).toBe(false)
+  expect(owner.session.snapshotEvents().find(event => event.type === 'deliverables/presented')?.data.files).toEqual(files)
+})
+
+it('refuses a final symlink to an ordinary file', async () => {
+  const { root, execute } = await setup()
+  await writeFile(join(root, 'source'), 'source')
+  await symlink(join(root, 'source'), join(root, 'link'))
+  expect((await execute([{ path: 'link' }])).isError).toBe(true)
+})
+
+
+it('refuses a file replaced by a directory after inspecting its final component', async () => {
+  const { ctx, root, execute } = await setup()
+  await writeFile(join(root, 'source'), 'source')
+  const directory = await ctx.fs.stat(await ctx.fs.resolve(root))
+  vi.spyOn(ctx.fs, 'stat').mockResolvedValueOnce(directory)
+  expect((await execute([{ path: 'source' }])).isError).toBe(true)
+})

+ 15 - 0
pnpm-lock.yaml

@@ -2687,12 +2687,18 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
       '@deepseek-ai/dsh-api-remotes':
         specifier: workspace:^
         version: link:../../api/remotes
       '@deepseek-ai/dsh-api-session-controller':
         specifier: workspace:^
         version: link:../../api/session-controller
+      '@deepseek-ai/dsh-api-workspace-files':
+        specifier: workspace:^
+        version: link:../../api/workspace-files
       '@deepseek-ai/dsh-client-connection':
         specifier: workspace:^
         version: link:../connection
@@ -2723,6 +2729,12 @@ importers:
       '@deepseek-ai/dsh-client-ui-tool':
         specifier: workspace:^
         version: link:../ui-tool
+      '@deepseek-ai/dsh-fs':
+        specifier: workspace:^
+        version: link:../../fs/fs
+      '@deepseek-ai/dsh-fs-local':
+        specifier: workspace:^
+        version: link:../../fs/fs-local
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
@@ -2738,6 +2750,9 @@ importers:
       '@deepseek-ai/dsh-tool-present':
         specifier: workspace:^
         version: link:../../fs/tool-present
+      '@deepseek-ai/dsh-typert-protocol':
+        specifier: workspace:^
+        version: link:../../typert/protocol
       '@deepseek-ai/dsh-util-workspace-path':
         specifier: workspace:^
         version: link:../../util/workspace-path