diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml
index 16831b0ca3..314dabaa74 100644
--- a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.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-08-21-model-readable-image-paths.md
-2026-08-21-model-readable-image-paths.md: 4c2610ad48e47d2642e32ae53048d0897abd64e9
-2026-08-21-model-readable-image-paths.zh.md: 24335f1da99c382291988d5862bdd52e8d5d961c
+2026-08-21-model-readable-image-paths.md: 729e6347e8623694e1cc4f4779bf71447006cb08
+2026-08-21-model-readable-image-paths.zh.md: d67817cf699afb6b094524388dd6d5a48a653e90
diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md
index 4c2610ad48..729e6347e8 100644
--- a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md
+++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md
@@ -10,7 +10,7 @@ An uploaded image becomes an opaque durable `ImageAttachmentRef`. Image-capable
## Decision
-`ImageAttachmentRef` remains portable session data and contains no host path. `AttachmentStore.imageAccess(ref)` resolves optional access facts from the current provider. The local provider derives an absolute immutable-object path from the resolved `DSH_HOME`, attachment storage version, and validated digest. A request version carries these facts transiently for serialization. A provider without model-readable local storage returns no access facts.
+`ImageAttachmentRef` remains portable session data and contains no host path. `AttachmentStore.imageAccess(ref)` resolves optional access facts from the current provider. The local provider derives an absolute object path from the resolved `DSH_HOME`, attachment storage version, and validated digest, then asks the mounted `ctx.fs` to map that host file into its execution world. `FileSystem.processPathFromHostPath(hostPath)` returns no mapping by default; the host-backed provider returns the absolute process path, while E2B and other remote providers return no access facts. A request version carries available facts transiently for serialization.
The shared LLM image descriptor names the display name or full attachment id, the exact request-preview dimensions, and the provider-resolved normalized path when available. Local access text includes normalized dimensions and media type, identifies the object as read-only, directs the model to copy it to a writable path with the matching extension before editing, and states that normalization or request projection may have resized or re-encoded the upload. DeepSeek Files and pi-ai inline requests use the same descriptor.
@@ -18,7 +18,7 @@ Request-size offload requires a per-image placeholder function; the previous sha
Descriptor identity comes from each occurrence's own durable reference, not from the prepared request version: versions are deduplicated per attachment id, so two uploads of the same content under different names share one version while each occurrence keeps its own display name. Access resolution validates the logged attachment id; a malformed reference in durable history fails the request at assembly, the earliest point that resolves it.
-Absolute paths stay out of session events. Model-visible path text is reconstructed from the logged attachment reference and the provider mounted for the current process. Restoring the same session with a different `DSH_HOME` therefore produces the path that is valid on that host. The attachment object remains immutable; model instructions require a writable copy for modifications.
+Absolute paths stay out of session events. Model-visible path text is reconstructed from the logged attachment reference and the providers mounted for the current process. Restoring the same session with a different `DSH_HOME` produces the path valid on that host; restoring it with a remote execution world that has no shared mount produces no path. Published attachment objects use owner-read-only mode, including deduplicated objects, and model instructions require a writable copy for modifications.
## Alternatives considered
@@ -26,12 +26,14 @@ Absolute paths stay out of session events. Model-visible path text is reconstruc
**Teach each LLM adapter the `~/.dsh` layout.** Explicit `dshHome` and `$DSH_HOME` can select another root, and non-local providers may expose no path. The attachment provider owns this fact.
+**Infer path sharing from the filesystem provider's package or class name.** Provider identity does not establish that a host file is mounted into its execution world. The filesystem provider instead answers the exact mapping question and can support an explicit shared mount without changing the attachment provider.
+
**Add a dedicated crop or recovery tool.** Standard filesystem and image tools can operate after copying the normalized object. A new tool adds a model schema and access-policy surface without being necessary for path discovery.
## Verification
-Package tests cover provider access defaults, local digest-to-path resolution, request-version access propagation, retained-image descriptions, per-image nested offload placeholders, source-property warnings, and matching extensions. A keyless assembled ACP snapshot checks the exact local object path in both a retained DeepSeek Files image handle and an offloaded image placeholder.
+Package tests cover provider access defaults, host-backed path mapping, absence without a mapped filesystem, digest-to-path resolution, owner-read-only publication and deduplication, request-version access propagation, retained-image descriptions, per-image nested offload placeholders, source-property warnings, and matching extensions. A keyless assembled ACP snapshot checks the exact local object path in both a retained DeepSeek Files image handle and an offloaded image placeholder.
## Consequences
-The selected model provider receives a host path that was previously local-only. This disclosure is required for the model to operate on the stored image and is limited to normalized attachment objects already in that request's authorized history. Descriptor text adds tokens for every retained or offloaded image. Paths change when the provider root changes, while deterministic image bytes and session references remain unchanged. A missing local object still fails when a model tool attempts to read it.
+When the execution world maps the attachment object, the selected model provider receives its path. This disclosure lets the model operate on the stored image and is limited to normalized attachment objects already in that request's authorized history. Descriptor text adds tokens for every retained or offloaded image. Paths change when the provider root changes, while deterministic image bytes and session references remain unchanged. Remote execution worlds without a shared mount receive the existing no-path recovery text. A missing local object still fails when a model tool attempts to read it.
diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md
index 24335f1da9..d67817cf69 100644
--- a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md
@@ -10,7 +10,7 @@
## 决策
-`ImageAttachmentRef` 继续作为可移植的会话数据,不包含宿主路径。`AttachmentStore.imageAccess(ref)` 从当前提供方解析可选访问事实。本地提供方根据已解析的 `DSH_HOME`、附件存储版本和经过校验的摘要派生不可变对象的绝对路径。请求版本只在序列化期间携带这些事实。没有模型可读本地存储的提供方不返回访问事实。
+`ImageAttachmentRef` 继续作为可移植的会话数据,不包含宿主路径。`AttachmentStore.imageAccess(ref)` 从当前提供方解析可选访问事实。本地提供方根据已解析的 `DSH_HOME`、附件存储版本和经过校验的摘要派生对象的绝对路径,再要求当前 `ctx.fs` 把这个宿主文件映射到其执行世界。`FileSystem.processPathFromHostPath(hostPath)` 默认不返回映射。宿主后端返回绝对进程路径,E2B 和其他远程后端不返回访问事实。请求版本只在序列化期间携带当前可用的事实。
共用 LLM 图片描述会写明显示名称或完整附件 ID、确切请求预览尺寸,以及当前可用的提供方规范化路径。本地访问文本还包含规范化尺寸和媒体类型,说明对象只供读取,要求模型在编辑前按匹配扩展名复制到可写路径,并指出规范化或请求投影可能缩小或重新编码上传图片。DeepSeek Files 与 pi-ai 内联请求使用同一描述。
@@ -18,7 +18,7 @@
描述文本的身份来自每个出现位置自己的持久引用,而不是准备好的请求版本:请求版本按附件 ID 去重,同一内容以不同文件名上传两次会共享一个版本,但每个出现位置保留自己的显示名称。访问解析会校验已记录的附件 ID;持久历史中的畸形引用会在请求组装时失败,这是能解析它的最早时点。
-绝对路径不会进入会话事件。模型可见路径根据已记录附件引用和当前进程挂载的提供方重建。因此,同一会话在不同 `DSH_HOME` 下恢复时会得到该宿主上的有效路径。附件对象保持不可变;模型指令要求先复制到可写位置再修改。
+绝对路径不会进入会话事件。模型可见路径根据已记录附件引用和当前进程挂载的提供方重建。同一会话在不同 `DSH_HOME` 下恢复时会得到该宿主上的有效路径;在没有共享挂载的远程执行世界中恢复时不会得到路径。发布的附件对象采用仅所有者可读的权限,去重对象也执行该权限。模型指令要求先复制到可写位置再修改。
## 考虑过的替代方案
@@ -26,12 +26,14 @@
**让每个 LLM 适配器了解 `~/.dsh` 布局。** 显式 `dshHome` 和 `$DSH_HOME` 可以选择其他根目录,非本地提供方也可能没有路径。该事实属于附件提供方。
+**根据文件系统提供方的包名或类名判断是否共享路径。** 提供方身份不能证明宿主文件已挂载到其执行世界。文件系统提供方直接回答具体路径能否映射,也允许未来的显式共享挂载在不修改附件提供方的情况下返回映射。
+
**增加专用裁剪或恢复工具。** 把规范化对象复制出来后,标准文件系统与图片工具已经可以处理它。新增工具会增加模型 schema 和访问策略范围,但路径发现不需要它。
## 验证
-包测试覆盖提供方访问默认值、本地摘要到路径的解析、请求版本访问事实传播、保留图片描述、逐图嵌套 offload 占位文本、源属性提醒和匹配扩展名。Keyless ACP 组装快照会检查保留的 DeepSeek Files 图片句柄和被 offload 图片占位文本中的确切本地对象路径。
+包测试覆盖提供方访问默认值、宿主后端路径映射、没有文件系统映射时省略路径、本地摘要到路径的解析、仅所有者可读的发布与去重、请求版本访问事实传播、保留图片描述、逐图嵌套 offload 占位文本、源属性提醒和匹配扩展名。Keyless ACP 组装快照会检查保留的 DeepSeek Files 图片句柄和被 offload 图片占位文本中的确切本地对象路径。
## 后果
-所选模型提供方会收到此前只在本地存在的宿主路径。模型需要该信息才能操作存储图片,并且路径只指向该请求已授权历史中的规范化附件对象。每张保留或被 offload 的图片都会增加描述文本 token。提供方根目录变化时路径会变化,确定性图片字节和会话引用保持不变。本地对象缺失时,模型工具读取它仍会失败。
+执行世界能够映射附件对象时,所选模型提供方会收到该路径。模型可借此操作存储图片,路径只指向该请求已授权历史中的规范化附件对象。每张保留或被 offload 的图片都会增加描述文本 token。提供方根目录变化时路径会变化,确定性图片字节和会话引用保持不变。没有共享挂载的远程执行世界会收到原有的无路径恢复文本。本地对象缺失时,模型工具读取它仍会失败。
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index ea79995c64..c0ac258326 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: ffea1ee18359fd6f7cc9171d103e143b8bcd9e5b
-config-catalog.zh.md: c447104fcb00d0df3625f6cae89ca128bfa44b57
+config-catalog.md: 7af4d4e47866736ca5f100d07dbd04f8341ae4a0
+config-catalog.zh.md: 9e1d3739d25b309172f2a9261cb10e8c5775af12
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index ffea1ee183..7af4d4e478 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -317,7 +317,7 @@ export interface Config {
}
```
-Source: [`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts)
+Source: [`packages/attachment/attachment-local/src/index.ts:53`](../packages/attachment/attachment-local/src/index.ts)
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index c447104fcb..9e1d3739d2 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -319,7 +319,7 @@ export interface Config {
}
```
-来源:[`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts)
+来源:[`packages/attachment/attachment-local/src/index.ts:53`](../packages/attachment/attachment-local/src/index.ts)
diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml
index 17c9d1bf47..fc14d236c4 100644
--- a/docs/subsystems/attachment.i18n.yaml
+++ b/docs/subsystems/attachment.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 docs/subsystems/attachment.md
-attachment.md: fef6b3ad40f424a82049cddc761efa483fdda6f3
-attachment.zh.md: def50f79f33cec9b9262823d481a2d03429f0845
+attachment.md: d855a31c4b463098e30652a30e807d648298c952
+attachment.zh.md: 314a6486266fe27d5e5fda17ae1b0c09ee65c5f3
diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md
index fef6b3ad40..d855a31c4b 100644
--- a/docs/subsystems/attachment.md
+++ b/docs/subsystems/attachment.md
@@ -190,6 +190,7 @@ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise
@@ -313,6 +313,16 @@ abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): P
*/
abstract processPath(target: FsTarget): string
+/**
+ * Map an absolute path from the harness host into this filesystem's
+ * execution world when both paths identify the same file. The base provider
+ * exposes no mapping; host-backed or explicitly shared backends override it.
+ * @param hostPath - absolute path in the harness host filesystem.
+ * @returns the process path for the same file, or undefined when this
+ * execution world cannot read that host file.
+ */
+processPathFromHostPath(hostPath: string): string | undefined
+
/**
* Return the canonical `file:` URI for a target in this filesystem's
* execution world. Backends own URI encoding because the host platform may
diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md
index ecbb9e83cf..ea5b6f7a0d 100644
--- a/docs/subsystems/filesystem.zh.md
+++ b/docs/subsystems/filesystem.zh.md
@@ -12,7 +12,7 @@
每个操作首先将用户提供的路径解析为不透明的后端目标。消费方可以显示 `displayPath`,但禁止解析 `targetKey`(一个品牌化的不透明 id),也不得假设它是本地绝对路径。
-与文件系统共享执行世界的消费方通过提供方获取跨能力坐标,而不是解释该身份:`processPath(target)` 返回子进程可以打开的规范化绝对路径,`fileUrl(target)` 返回采用提供方平台语法的 `file:` URI,`contains(parent, child)` 则检查规范化身份相等或后代包含关系。
+与文件系统共享执行世界的消费方通过提供方获取跨能力坐标,而不是解释该身份:`processPath(target)` 返回子进程可以打开的规范化绝对路径;`processPathFromHostPath(hostPath)` 只在该执行世界共享相应宿主文件时映射其绝对路径;`fileUrl(target)` 返回采用提供方平台语法的 `file:` URI;`contains(parent, child)` 检查规范化身份相等或后代包含关系。
```ts type-equiv
/**
@@ -275,7 +275,7 @@ type FsErrorCode =
## 服务与插件
-`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`readBytes`、`listDir`、`writeText` 与 `editText`。`dsh-fs-observation-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:根据未见/缺失/存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。
+`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`processPathFromHostPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`readBytes`、`listDir`、`writeText` 与 `editText`。`dsh-fs-observation-policy` **不注册服务**。它通过 `fs/*` 事件门禁添加策略,根据未见、缺失或存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取、写入或编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。
@@ -313,6 +313,16 @@ abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): P
*/
abstract processPath(target: FsTarget): string
+/**
+ * Map an absolute path from the harness host into this filesystem's
+ * execution world when both paths identify the same file. The base provider
+ * exposes no mapping; host-backed or explicitly shared backends override it.
+ * @param hostPath - absolute path in the harness host filesystem.
+ * @returns the process path for the same file, or undefined when this
+ * execution world cannot read that host file.
+ */
+processPathFromHostPath(hostPath: string): string | undefined
+
/**
* Return the canonical `file:` URI for a target in this filesystem's
* execution world. Backends own URI encoding because the host platform may
diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml
index a82a48a906..dd994eee03 100644
--- a/packages/attachment/attachment-local/README.i18n.yaml
+++ b/packages/attachment/attachment-local/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/attachment/attachment-local/README.md
-README.md: e23fe75a32a873f3f3762c14060c92c66be3a12a
-README.zh.md: 646d1d8f26df382be7b0c2c18f2b6ed72661f3a4
+README.md: c93f32ede4599d8e07536062dd7d44134bea16b0
+README.zh.md: f06c3aeec937e0eb09ea0aa2ef25d41119e54aa8
diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md
index e23fe75a32..c93f32ede4 100644
--- a/packages/attachment/attachment-local/README.md
+++ b/packages/attachment/attachment-local/README.md
@@ -2,17 +2,17 @@
English | [中文](README.zh.md)
-The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash.
+The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, a synced temporary file, an atomic exclusive hard-link publish, owner-read-only object permissions, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash.
Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `normalizedImageMaxDimension` (2048px by default). The normalized attachment has its own `normalizedImageMaxBytes` safety cap (4MiB by default). Transparent pixels are retained; Sharp/libvips may omit an alpha plane whose samples are all opaque. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color images try PNG, using a palette only when the input has no alpha channel, then WebP at qualities 85, 80, and 75. Other alpha images try WebP at those qualities; other opaque images try JPEG. Each candidate runs only after the preceding candidate exceeds the cap. Dimensions shrink only after every candidate at one size exceeds the cap. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding.
Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation.
-`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. At request assembly, `imageAccess` derives the absolute normalized-object path from that reference and the current provider root. The path is host-specific, read-only, and absent from durable history. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`.
+`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. At request assembly, `imageAccess` derives the normalized object's absolute host path and asks the mounted `ctx.fs` to map that file into its execution world. Host-backed filesystems return a read-only process path; remote filesystems without a shared mount return no path. The path is absent from durable history. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`.
## Model Experience
-Indirectly, through request descriptors that give the model each retained or offloaded image's identity, dimensions, media type, current read-only normalized-object path, matching extension for a writable copy, and a warning that normalization may have resized or re-encoded the upload.
+Indirectly, through request descriptors that give the model each retained or offloaded image's identity, dimensions, media type, available read-only normalized-object path, matching extension for a writable copy, and a warning that normalization may have resized or re-encoded the upload.
#### KV Cache effect
@@ -21,6 +21,5 @@ Normalization and request projection are deterministic. An unchanged attachment
## Known Limitations and Deferred Work
- Objects are retained indefinitely; reference-aware garbage collection is deferred.
-- The local backend assumes the host and provider adapter share this filesystem service.
- Animated GIF sources keep only their first frame; animation is outside the version-one image contract.
- The normalization and request encoders are pinned by the installed sharp/libvips build; an encoder or transform-version upgrade re-addresses future normalized attachments or request variants while existing objects stay valid.
diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md
index 646d1d8f26..f06c3aeec9 100644
--- a/packages/attachment/attachment-local/README.zh.md
+++ b/packages/attachment/attachment-local/README.zh.md
@@ -2,17 +2,17 @@
[English](README.md) | 中文
-这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。
+这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、经过同步的临时文件、原子且排他的硬链接发布、仅所有者可读的对象权限,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。
每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`(默认 2048px)。规范化附件有独立的 `normalizedImageMaxBytes` 安全上限(默认 4MiB)。透明像素会保留;当所有 alpha 样本均为不透明时,Sharp/libvips 可能省略没有实际作用的 alpha 平面。系统用 nearest-neighbour 对有界样本分类,不会通过像素平均把高频图片误判为低色数。确认的低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明图片依次尝试这些质量的 WebP;其他非透明图片依次尝试这些质量的 JPEG。只有前一个候选超限时才会执行下一个候选;同一尺寸的候选全部超限后才缩小尺寸。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。
请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。
-`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。组装请求时,`imageAccess` 根据该引用和当前提供方根目录派生规范化对象的绝对路径。该路径属于当前宿主,只供读取,不进入持久历史。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。
+`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。组装请求时,`imageAccess` 先派生规范化对象的绝对宿主路径,再要求当前 `ctx.fs` 把该文件映射到其执行世界。宿主文件系统返回只读进程路径;没有共享挂载的远程文件系统不返回路径。该路径不进入持久历史。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。
## 模型体验
-该包通过请求描述间接影响模型,描述会给出每张保留或被 offload 图片的身份、尺寸、媒体类型、当前只读规范化对象路径、复制到可写位置时使用的匹配扩展名,以及规范化过程可能缩小或重新编码上传图片的提醒。
+该包通过请求描述间接影响模型,描述会给出每张保留或被 offload 图片的身份、尺寸、媒体类型、当前可用的只读规范化对象路径、复制到可写位置时使用的匹配扩展名,以及规范化过程可能缩小或重新编码上传图片的提醒。
#### KV 缓存影响
@@ -21,6 +21,5 @@
## 已知限制与待完成工作
- 对象会无限期保留;基于引用的垃圾回收尚未实现。
-- 本地后端假定宿主与提供方适配器共享同一个文件系统服务。
- 动态 GIF 源图只保留首帧;动画在版本一图片契约之外。
- 规范化和请求版本编码器由安装的 sharp/libvips 构建钉定;编码器或变换策略版本升级会让未来的规范化附件或请求变体产生新地址,已有对象保持有效。
diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json
index 194112be29..cb2472f62a 100644
--- a/packages/attachment/attachment-local/package.json
+++ b/packages/attachment/attachment-local/package.json
@@ -23,6 +23,7 @@
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
+ "@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
@@ -33,6 +34,8 @@
},
"devDependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
+ "@deepseek-ai/dsh-fs": "workspace:^",
+ "@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts
index 31d9f38014..6a22038154 100644
--- a/packages/attachment/attachment-local/src/index.ts
+++ b/packages/attachment/attachment-local/src/index.ts
@@ -13,6 +13,7 @@ import type {
SaveImageAttachment,
StoredImageAttachment,
} from '@deepseek-ai/dsh-attachment'
+import type {} from '@deepseek-ai/dsh-fs'
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import type { NormalizationPolicy } from './normalization.ts'
import { CompressionLimiter } from './compression-limiter.ts'
@@ -208,8 +209,10 @@ export class LocalAttachmentStore extends AttachmentStore {
return readImageFile(this.root, ref, signal)
}
- override imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess {
- return { readonlyPath: normalizedImagePath(this.root, ref) }
+ override imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined {
+ const hostPath = normalizedImagePath(this.root, ref)
+ const readonlyPath = this.ctx.get('fs')?.processPathFromHostPath(hostPath)
+ return readonlyPath === undefined ? undefined : { readonlyPath }
}
override async readImageRequest(
@@ -235,15 +238,16 @@ export class LocalAttachmentStore extends AttachmentStore {
operation = undefined
}
if (operation === undefined) {
- const shared = new SharedRequest(sharedSignal => this.compression.run(async () => ({
- ...await readRequestImageFile(
+ const shared = new SharedRequest(sharedSignal => this.compression.run(async () => {
+ const request = await readRequestImageFile(
this.root,
stored ?? await this.readImage(ref, sharedSignal),
policy,
sharedSignal,
- ),
- access: this.imageAccess(ref),
- })))
+ )
+ const access = this.imageAccess(ref)
+ return { ...request, ...(access === undefined ? {} : { access }) }
+ }))
operation = shared
this.requestInflight.set(key, shared)
void shared.promise.finally(() => {
diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts
index 266d4f8e34..f5463a478f 100644
--- a/packages/attachment/attachment-local/src/store.ts
+++ b/packages/attachment/attachment-local/src/store.ts
@@ -222,6 +222,9 @@ export async function commitPreparedImageFile(
const existing = new Uint8Array(await readFile(target))
if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
}
+ // The hard link and staging entry share one inode, so this applies to both
+ // names until cleanup and also enforces read-only mode on deduplicated objects.
+ await chmod(target, 0o400)
// Persist the target entry and close a concurrent bucket-creation window
// before the reference can reach a session checkpoint. The dedup path
// repeats both syncs because it may observe another writer's link before
diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts
index e5c409b1d6..ba18777c75 100644
--- a/packages/attachment/attachment-local/tests/index.spec.ts
+++ b/packages/attachment/attachment-local/tests/index.spec.ts
@@ -1,5 +1,6 @@
import { Context } from '@deepseek-ai/cordis'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
+import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -45,12 +46,7 @@ describe('local attachment service', () => {
width: 1,
height: 1,
}
- expect(service.imageAccess(ref).readonlyPath).toBe(join(
- service.root,
- 'objects',
- 'aa',
- 'a'.repeat(64),
- ))
+ expect(service.imageAccess(ref)).toBeUndefined()
expect(() => service.imageAccess({ ...ref, attachmentId: AttachmentId('invalid') }))
.toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' }))
})
@@ -65,8 +61,10 @@ describe('local attachment service', () => {
it('saves and reads through the service boundary', async () => {
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-service-'))
+ const ctx = new Context()
+ new LocalFileSystem(ctx, { cwd: dshHome, diffBasisMaxBytes: 10 * 1024 * 1024 })
try {
- const service = new LocalAttachmentStore(new Context(), { dshHome })
+ const service = new LocalAttachmentStore(ctx, { dshHome })
const data = Uint8Array.from(Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC',
'base64',
@@ -74,6 +72,8 @@ describe('local attachment service', () => {
const ref = await service.saveImage({ data, mediaType: 'image/png' })
await expect(service.readImage(ref)).resolves.toEqual({ ref, data })
const access = service.imageAccess(ref)
+ expect(access).toBeDefined()
+ if (access === undefined) throw new Error('expected a host-path mapping from fs-local')
expect(access.readonlyPath).toBe(join(
dshHome,
'attachments',
diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts
index ad29f856ec..64cca74b7e 100644
--- a/packages/attachment/attachment-local/tests/store.spec.ts
+++ b/packages/attachment/attachment-local/tests/store.spec.ts
@@ -132,9 +132,12 @@ describe('local attachment store', () => {
expect(second.attachmentId).toBe(first.attachmentId)
expect(new Uint8Array(await readFile(object))).toEqual(PNG)
if (process.platform !== 'win32') {
- expect((await stat(object)).mode & 0o777).toBe(0o600)
+ expect((await stat(object)).mode & 0o777).toBe(0o400)
expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700)
}
+ await chmod(object, 0o600)
+ await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY)
+ if (process.platform !== 'win32') expect((await stat(object)).mode & 0o777).toBe(0o400)
await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG })
})
diff --git a/packages/attachment/attachment-local/tsconfig.json b/packages/attachment/attachment-local/tsconfig.json
index 3ac8b3fcff..a5fff7a98a 100644
--- a/packages/attachment/attachment-local/tsconfig.json
+++ b/packages/attachment/attachment-local/tsconfig.json
@@ -6,6 +6,7 @@
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../attachment" },
+ { "path": "../../fs/fs" },
{ "path": "../../util/home-paths" },
{ "path": "../../runtime-diagnostics/invariants" }
]
diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts
index e10ce2d214..f72b0b2052 100644
--- a/packages/attachment/attachment/src/index.ts
+++ b/packages/attachment/attachment/src/index.ts
@@ -113,6 +113,7 @@ export abstract class AttachmentStore extends Service {
* Resolve provider-specific model-tool access without adding host facts to session history.
* @param ref - durable normalized attachment reference.
* @returns current-provider access facts, or undefined when this backend exposes no local path.
+ * @throws an AttachmentError when the durable reference is invalid.
*/
imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined {
void ref
diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts
index fa75709ec2..97e549e84b 100644
--- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts
+++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts
@@ -360,6 +360,7 @@ describe('E2BFileSystem identity, metadata, and reads', () => {
const outside = await fs.resolve('/outside.ts')
expect(fs.processPath(nested)).toBe('/workspace/nested/multibyte # file.ts')
+ expect(fs.processPathFromHostPath('/Users/alice/.dsh/attachments/object')).toBeUndefined()
expect(fs.fileUrl(nested)).toBe('file:///workspace/nested/multibyte%20%23%20file.ts')
expect(fs.contains(workspace, workspace)).toBe(true)
expect(fs.contains(workspace, nested)).toBe(true)
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 197ff6fadd..57d9ab16d6 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -454,6 +454,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
description: 'Resolve provider-specific model-tool access without adding host facts to session history.',
parameters: [{ name: 'ref', description: 'durable normalized attachment reference.' }],
returns: 'current-provider access facts, or undefined when this backend exposes no local path.',
+ throws: ['an AttachmentError when the durable reference is invalid.'],
},
{
signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise',
@@ -769,6 +770,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'target', description: 'the resolved target whose process path is required.' }],
returns: 'an absolute path in the backend\'s execution world.',
},
+ {
+ signature: 'processPathFromHostPath(hostPath: string): string | undefined',
+ description: 'Map an absolute path from the harness host into this filesystem\'s execution world when both paths identify the same file. The base provider exposes no mapping; host-backed or explicitly shared backends override it.',
+ parameters: [{ name: 'hostPath', description: 'absolute path in the harness host filesystem.' }],
+ returns: 'the process path for the same file, or undefined when this execution world cannot read that host file.',
+ },
{
signature: 'abstract fileUrl(target: FsTarget): string',
description: 'Return the canonical `file:` URI for a target in this filesystem\'s execution world. Backends own URI encoding because the host platform may differ from the execution platform.',
diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml
index 68d3340327..02d7e4b704 100644
--- a/packages/fs/fs-local/README.i18n.yaml
+++ b/packages/fs/fs-local/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/fs-local/README.md
-README.md: 4d5c42945b86ccdc8b041d9d7f99a067ab9a37f5
-README.zh.md: 51f417f73294b7497563fece168e5a5d44b73421
+README.md: ae6d2f582abfdbdfcf922899ffe485691167c97e
+README.zh.md: 161e4ee9e5b3dbd0f7efc64692e10dc522538034
diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md
index 4d5c42945b..ae6d2f582a 100644
--- a/packages/fs/fs-local/README.md
+++ b/packages/fs/fs-local/README.md
@@ -15,7 +15,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
## Behavior
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. `opts.signal` is checked before and after local resolution, while a remote sibling backend may use it to abort its round-trip. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
-- **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `fileUrl` encodes that path through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`.
+- **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `processPathFromHostPath` accepts absolute host paths because this backend shares the host filesystem, `fileUrl` encodes target paths through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`.
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing.
- **`readBytes`** — raw whole-file bytes with no decoding or binary rejection (the `read_image` tool validates content through the attachment service). The required byte cap short-circuits on the stat size before any content I/O; the subsequent stream reads at most one byte beyond the cap, so a file growing after stat still fails `FS_TOO_LARGE` without unbounded buffering.
diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md
index 51f417f732..161e4ee9e5 100644
--- a/packages/fs/fs-local/README.zh.md
+++ b/packages/fs/fs-local/README.zh.md
@@ -15,7 +15,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
## 行为
- **`resolve(path, opts?)`**:相对 `path` 在调用方提供 `opts.cwd` 时以该值为基准解析(面向模型的工具会传入调用 agent(智能体)的会话 cwd;见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md)),否则以 `config.cwd` 为基准(默认 `process.cwd()`);绝对 `path` 会忽略两者。`opts.signal` 会在本地解析前后检查,远程同级后端则可以用它中止往返。`targetKey` 是文件的 `realpath`,因此经符号链接到达同一文件的两个输入路径会共享一个身份,写入/编辑落在链接目标上,同时保留链接。尚不存在的路径在父目录存在时使用 realpath 后的父目录加 basename;只有父目录无法解析时才回退到绝对路径。`displayPath` 是绝对但未经解析的路径。
-- **执行世界坐标**:`processPath` 公开目标的规范化宿主路径,`fileUrl` 通过 Node 的平台感知 URL 转换对该路径编码,`contains` 则使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`。
+- **执行世界坐标**:`processPath` 公开目标的规范化宿主路径。由于该后端共享宿主文件系统,`processPathFromHostPath` 接受绝对宿主路径。`fileUrl` 通过 Node 的平台感知 URL 转换对目标路径编码。`contains` 使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`。
- **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此异步探测进行期间发生的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。
- **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以自行限制保留量。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。
- **`readBytes`**:按原始字节读取整个文件,不做解码或二进制拒绝(`read_image` 工具通过附件服务校验内容)。必填的字节上限在任何内容 I/O 之前先按 stat 大小短路;随后的流最多多读一个字节,因此 stat 之后增长的文件仍会以 `FS_TOO_LARGE` 失败,不会无界缓冲。
diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts
index 661ef236b8..7c50532a0e 100644
--- a/packages/fs/fs-local/src/index.ts
+++ b/packages/fs/fs-local/src/index.ts
@@ -114,6 +114,10 @@ export class LocalFileSystem extends FileSystem {
return String(target.targetKey)
}
+ override processPathFromHostPath(hostPath: string): string | undefined {
+ return isAbsolute(hostPath) ? resolve(hostPath) : undefined
+ }
+
override fileUrl(target: FsTarget): string {
return pathToFileURL(this.processPath(target)).href
}
diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts
index 1977f438b9..b791b424fb 100644
--- a/packages/fs/fs-local/tests/filesystem.spec.ts
+++ b/packages/fs/fs-local/tests/filesystem.spec.ts
@@ -59,6 +59,12 @@ describe('registration', () => {
await bareFiber.dispose()
})
+ it('maps only absolute host paths into its process path namespace', () => {
+ const path = join(dir, 'image.png')
+ expect(fs.processPathFromHostPath(path)).toBe(path)
+ expect(fs.processPathFromHostPath('image.png')).toBeUndefined()
+ })
+
it('rejects non-positive, fractional, unsafe, or unallocatable diff-basis limits', async () => {
const maxDiffBasisBytes = Math.min(
bufferConstants.MAX_LENGTH,
diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml
index 5736cd6c81..c001d6a107 100644
--- a/packages/fs/fs/README.i18n.yaml
+++ b/packages/fs/fs/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/fs/README.md
-README.md: 7bde7d4d64005a6bdd0f0ac974bf45e5450e4207
-README.zh.md: c4366dc1805938a7020310f4cf627b9072a75cd5
+README.md: b6255a385daf9185ccbfb45e4d1896ad2e8a2c9e
+README.zh.md: e932b3144430618e2ec7458491b26d43c517024f
diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md
index 7bde7d4d64..b6255a385d 100644
--- a/packages/fs/fs/README.md
+++ b/packages/fs/fs/README.md
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
-The **`FileSystem`** (`ctx.fs`) defines the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read whole or streaming text, read bounded raw bytes, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
+The **`FileSystem`** (`ctx.fs`) defines the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, map shared host files, test containment, read whole or streaming text, read bounded raw bytes, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
This package owns the Service Definition and provider contract layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
@@ -17,12 +17,13 @@ This package owns the Service Definition and provider contract layer of the four
## Service API (`ctx.fs`)
-A backend subclasses `FileSystem` and implements twelve primitives.
+A backend subclasses `FileSystem` and exposes thirteen primitives.
| Member | Semantics |
|---|---|
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default), while `opts.signal` aborts a backend round-trip. Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
| `processPath(target)` | Return the canonical absolute path that a subprocess in this provider's execution world can open. This is intentionally distinct from opaque `targetKey`. |
+| `processPathFromHostPath(hostPath)` | Return this execution world's process path for the same absolute host file when the backend shares it. The base implementation returns `undefined`; host-backed or explicitly mapped backends override it. |
| `fileUrl(target)` | Return the canonical `file:` URI in the execution world's platform syntax. The backend, not the host process, owns encoding. |
| `contains(parent, child)` | Test canonical identity/descendant containment without exposing or parsing target keys. Both targets come from this provider. |
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
@@ -61,6 +62,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Text-only mutations by contract** — text reads and both mutations reject binary/non-UTF-8 content with `FS_NOT_TEXT`; `readBytes` is the one raw-byte primitive, and binary-safe mutations remain a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md).
-- **Twelve primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md).
+- **Thirteen primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md).
- **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)).
- **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend.
diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md
index c4366dc180..e932b31444 100644
--- a/packages/fs/fs/README.zh.md
+++ b/packages/fs/fs/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-**`FileSystem`**(`ctx.fs`)定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、完整或流式读取文本、有界读取原始字节、检查/列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。
+**`FileSystem`**(`ctx.fs`)定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、映射共享的宿主文件、检查包含关系、完整或流式读取文本、有界读取原始字节、检查或列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选**接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。
本包拥有四层文件系统栈中的 Service Definition 和提供方约定层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md)和[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md)):
@@ -17,12 +17,13 @@
## 服务 API(`ctx.fs`)
-后端继承 `FileSystem` 并实现十二个原语。
+后端继承 `FileSystem` 并公开十三个原语。
| 成员 | 语义 |
|---|---|
| `resolve(path, opts?)` | 把路径解析为稳定的 `FsTarget`(不透明 `targetKey`、`displayPath`)。`opts.cwd` 是相对 `path` 解析所依据的基准(调用方提供其会话工作区;绝对路径忽略该值;省略时使用后端默认值),`opts.signal` 则中止后端往返。该方法是异步的,因为远程后端可能需要 I/O。经不同路径到达的同一文件必须产生相同 `targetKey`。 |
| `processPath(target)` | 返回该提供方执行世界中的子进程可以打开的规范化绝对路径。该路径有意与不透明的 `targetKey` 分离。 |
+| `processPathFromHostPath(hostPath)` | 当后端共享同一个宿主文件时,返回该文件在当前执行世界中的进程路径。基类返回 `undefined`,宿主后端或显式映射宿主文件的后端负责覆盖。 |
| `fileUrl(target)` | 返回采用执行世界平台语法的规范化 `file:` URI。编码由后端而非宿主进程负责。 |
| `contains(parent, child)` | 在不公开或解析目标 key 的情况下,检查规范化身份相等或后代包含关系。两个目标都来自该提供方。 |
| `stat(target, signal?)` | 返回 `FsInfo` 元数据(`version`、`type`、可选 `size`);目标不存在时返回 `undefined`。绝不返回内容。 |
@@ -61,6 +62,6 @@
## 已知限制与延期工作
- **变更操作约定只支持文本**:文本读取和两个变更操作都以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;`readBytes` 是唯一的原始字节原语,二进制安全的变更操作仍是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md)有意延期的工作。
-- **只有十二个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。
+- **只有十三个原语**:没有删除、重命名或移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。
- **没有 I/O deadline**:该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见有意采用的 [fs 能力族立场](../README.zh.md))。
- **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。
diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts
index 8ecd03c714..e32890d732 100644
--- a/packages/fs/fs/src/index.ts
+++ b/packages/fs/fs/src/index.ts
@@ -125,6 +125,19 @@ export abstract class FileSystem extends Service {
*/
abstract processPath(target: FsTarget): string
+ /**
+ * Map an absolute path from the harness host into this filesystem's
+ * execution world when both paths identify the same file. The base provider
+ * exposes no mapping; host-backed or explicitly shared backends override it.
+ * @param hostPath - absolute path in the harness host filesystem.
+ * @returns the process path for the same file, or undefined when this
+ * execution world cannot read that host file.
+ */
+ processPathFromHostPath(hostPath: string): string | undefined {
+ void hostPath
+ return undefined
+ }
+
/**
* Return the canonical `file:` URI for a target in this filesystem's
* execution world. Backends own URI encoding because the host platform may
diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts
index 121901dc95..6fbe1268af 100644
--- a/packages/fs/fs/tests/service.spec.ts
+++ b/packages/fs/fs/tests/service.spec.ts
@@ -88,6 +88,7 @@ describe('FileSystem provider seam', () => {
await ctx.plugin(FakeFileSystem)
const fs = ctx.fs as FakeFileSystem
expect(fs.sandboxMode).toBeUndefined()
+ expect(fs.processPathFromHostPath('/host/file')).toBeUndefined()
fs.files.set('a.txt', 'hi')
const target = await fs.resolve('a.txt')
expect((await fs.stat(target))?.type).toBe('file')
diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts
index 747e37ca4c..4f5864aa6d 100644
--- a/packages/llm/llm-deepseek/src/index.ts
+++ b/packages/llm/llm-deepseek/src/index.ts
@@ -195,6 +195,9 @@ export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions
function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] {
const seen = new Set()
return (models ?? DEFAULT_MODELS).map((model) => {
+ if (Object.hasOwn(model, 'imageDetail')) {
+ throw new Error('llm-deepseek: catalog model imageDetail is no longer supported; use imagePixelBudget')
+ }
if (model.id.length === 0) throw new Error('llm-deepseek: catalog model ids must be non-empty')
if (model.name !== undefined && model.name.length === 0) {
throw new Error(`llm-deepseek: catalog model "${model.id}" has an empty name`)
diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts
index 9408f1595b..362a90dadb 100644
--- a/packages/llm/llm-deepseek/tests/adapter.spec.ts
+++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts
@@ -1895,6 +1895,20 @@ describe('plugin registration and config', () => {
expect(() => resolveAdapterOptions({ models: [...models] })).toThrow(message)
})
+ it('rejects the removed imageDetail model setting through schema and direct construction', async () => {
+ const legacyModel = { id: 'vision', inputModalities: ['image'], imageDetail: 'low' } as unknown as
+ LlmDeepSeek.DeepSeekCatalogModel
+ expect(() => resolveAdapterOptions({ models: [legacyModel] })).toThrow(/imageDetail is no longer supported/)
+
+ const ctx = new Context()
+ await ctx.plugin(LlmRuntime)
+ await expect(ctx.plugin(LlmDeepSeek, {
+ baseURL: 'http://127.0.0.1:1',
+ models: [legacyModel],
+ })).rejects.toThrow(/imageDetail is no longer supported/)
+ expect(ctx.llm.listProviders()).toEqual([])
+ })
+
it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => {
expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] }))
.toThrow(/maxTokens must be a positive integer/)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e763188fd0..eebeb12111 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1136,6 +1136,12 @@ importers:
'@deepseek-ai/dsh-attachment':
specifier: workspace:^
version: link:../attachment
+ '@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-home-paths':
specifier: workspace:^
version: link:../../util/home-paths