Răsfoiți Sursa

fix(attachment): refuse oversized image sides at admission

An image with a side above the deployed routes' 2000px many-image bound
could be durably committed by read_image, ride every later request, and
permanently fail the session with provider 400s. Admission now enforces a
configurable maxImageDimension (default 2000) during the full decode, so
read_image surfaces a recoverable tool error naming the limit instead of
poisoning durable history; the Web composer gets dedicated copy for the
new IMAGE_DIMENSION_TOO_LARGE reason.

Fixes #2626
creatixchu 1 lună în urmă
părinte
comite
0e39055121
47 a modificat fișierele cu 287 adăugiri și 26 ștergeri
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.i18n.yaml
  2. 30 0
      .agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md
  3. 30 0
      .agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md
  4. 2 2
      docs/config-catalog.i18n.yaml
  5. 3 1
      docs/config-catalog.md
  6. 3 1
      docs/config-catalog.zh.md
  7. 2 2
      docs/subsystems/attachment.i18n.yaml
  8. 2 0
      docs/subsystems/attachment.md
  9. 2 0
      docs/subsystems/attachment.zh.md
  10. 11 0
      examples/acp-agent/tests/acp.snapshot.ts
  11. 14 0
      examples/acp-agent/tests/snapshots/read-image-dimension/input.json
  12. 26 0
      examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl
  13. 4 0
      examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl
  14. BIN
      examples/acp-agent/tests/snapshots/read-image-dimension/workspace/wide.png
  15. 1 0
      packages/acp/acp/tests/harness.ts
  16. 2 2
      packages/attachment/attachment-local/README.i18n.yaml
  17. 1 1
      packages/attachment/attachment-local/README.md
  18. 1 1
      packages/attachment/attachment-local/README.zh.md
  19. 14 3
      packages/attachment/attachment-local/src/image.ts
  20. 12 0
      packages/attachment/attachment-local/src/index.ts
  21. 4 4
      packages/attachment/attachment-local/src/store.ts
  22. 8 1
      packages/attachment/attachment-local/tests/image.spec.ts
  23. 2 0
      packages/attachment/attachment-local/tests/index.spec.ts
  24. 4 0
      packages/attachment/attachment-local/tests/store.spec.ts
  25. 1 0
      packages/attachment/attachment/src/error.ts
  26. 2 0
      packages/attachment/attachment/src/types.ts
  27. 1 0
      packages/attachment/attachment/tests/index.spec.ts
  28. 1 0
      packages/client/connection/src/client/fixture.ts
  29. 1 0
      packages/client/connection/tests/fixture.client.spec.ts
  30. 3 0
      packages/client/ui-conversation/src/client/image-labels.ts
  31. 2 0
      packages/client/ui-conversation/src/client/locales.ts
  32. 3 0
      packages/client/ui-conversation/tests/image-labels.client.spec.tsx
  33. 4 0
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  34. 1 1
      packages/extensions/tool-cordis/src/api-catalog.ts
  35. 2 2
      packages/fs/tool-fs/README.i18n.yaml
  36. 1 1
      packages/fs/tool-fs/README.md
  37. 1 1
      packages/fs/tool-fs/README.zh.md
  38. 17 1
      packages/fs/tool-fs/src/read-image.ts
  39. 55 2
      packages/fs/tool-fs/tests/read-image.spec.ts
  40. 1 0
      packages/host/apiproxy/src/api/sessions.schema.ts
  41. 1 0
      packages/host/apiproxy/tests/api-proxy-models.spec.ts
  42. 1 0
      packages/host/apiproxy/tests/api-proxy-projections.spec.ts
  43. 1 0
      packages/llm/llm-pi-ai/tests/adapter.spec.ts
  44. 1 0
      packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts
  45. 1 0
      packages/mcp/mcp-client/tests/mcp-client.spec.ts
  46. 1 0
      scripts/gen-tool-catalog.ts
  47. 1 0
      scripts/test-invariants.ts

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.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/bug-fix/2026-08-17-image-dimension-admission-limit.md
+2026-08-17-image-dimension-admission-limit.md: 027259c0949d142ce8d8af27e7daa2abd54769ab
+2026-08-17-image-dimension-admission-limit.zh.md: 3b66fe9a474f965653f94dacc7e0b8b0d0b9229a

+ 30 - 0
.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md

@@ -0,0 +1,30 @@
+# Agent Note: Per-side image dimension admission limit
+
+Status: implemented
+
+English | [中文](2026-08-17-image-dimension-admission-limit.zh.md)
+
+## Problem
+
+`read_image` durably committed an image and appended its block to session history before any dimension check beyond byte count and total pixels. Deployed model routes reject a request with HTTP 400 when it carries many images and any of them has a side above 2000px. An admitted image rides every later request of its session, so one oversized read poisoned the durable history: the next model request failed, and so did every retry, permanently killing the session. The same gap applied to every other image producer (host uploads, MCP tool images) because admission had no per-side bound at all.
+
+## Decision
+
+`ImageAttachmentLimits` carries `maxImageDimension`, enforced during the admission full decode (`detectImage`) as `IMAGE_DIMENSION_TOO_LARGE`, so every producer that commits through the attachment service refuses an oversized image before anything reaches durable history. `LocalAttachmentStore` exposes it as the `maxImageDimension` config field with default `DEFAULT_MAX_IMAGE_DIMENSION = 2000`, the strictest per-side bound deployed routes enforce; deployments with laxer routes raise it from cordis.yml. `read_image` maps `IMAGE_DIMENSION_TOO_LARGE` and `IMAGE_TOO_MANY_PIXELS` to model-facing errors that name the resolved path and the limit and tell the model to downscale and retry — the turn continues as a recoverable tool error. The Web composer surfaces `IMAGE_DIMENSION_TOO_LARGE` with dedicated copy naming the limit. The `read-image-dimension` snapshot scenario replays the refusal keylessly through the assembled app: a 2001x1 workspace fixture, a recoverable tool error, and a completed turn.
+
+## Alternatives considered
+
+- **Downscale at admission instead of refusing.** Resampling changes the stored bytes away from what the caller supplied, adds a resampling-quality policy, and hides the limit from the model. Refusal keeps admission a pure gate; the model or user can downscale with full knowledge. Worth revisiting only if refusals prove frequent in practice.
+- **Enforce at the provider adapter per route.** Too late: by the time a request is assembled the image is already durable history, so every route and every retry re-fails. Admission is the last point where a provider-rejected image can be kept out.
+- **Repair already-poisoned sessions** (drop or replace the oversized block on later requests). Out of scope for this fix; admission prevents new poisonings, and history rewriting needs its own design against the model-visible ⟺ logged invariant.
+
+## Related
+
+- [Minimal read_image tool](../feature/2026-08-10-minimal-read-image-tool.md) — the tool whose admission gap this closes.
+- [Web image intake and limits alignment](../feature/2026-08-12-web-image-intake-and-limits-alignment.md) — the composer-side surfacing of the same `ImageAttachmentLimits`.
+
+## Consequences
+
+- One oversized `read_image` can no longer break a session; the model sees an actionable error and the turn completes.
+- Images with a side above 2000px are refused even in compositions whose routes would accept them on small requests; such deployments must raise `maxImageDimension` explicitly.
+- Sessions that already carry an oversized image remain broken; this change does not repair existing history.

+ 30 - 0
.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md

@@ -0,0 +1,30 @@
+# Agent Note: 图片单边尺寸准入上限
+
+Status: implemented
+
+[English](2026-08-17-image-dimension-admission-limit.md) | 中文
+
+## Problem
+
+`read_image` 在字节数与总像素之外没有任何尺寸检查,就把图片持久提交并追加进会话历史。已部署的模型路由在请求携带多张图片且其中任何一张单边超过 2000px 时会以 HTTP 400 拒绝整个请求。已接纳的图片会随该会话之后的每次请求发送,因此一次超限读取就毒化了持久历史:下一次模型请求失败,之后的每次重试同样失败,会话被永久杀死。其他图片来源(宿主上传、MCP 工具图片)存在同样的缺口,因为准入完全没有单边上限。
+
+## Decision
+
+`ImageAttachmentLimits` 增加 `maxImageDimension`,在准入完整解码(`detectImage`)中以 `IMAGE_DIMENSION_TOO_LARGE` 强制执行,因此所有经附件服务提交的来源都会在任何内容进入持久历史之前拒绝超限图片。`LocalAttachmentStore` 将其暴露为 `maxImageDimension` 配置项,默认值 `DEFAULT_MAX_IMAGE_DIMENSION = 2000`,即已部署路由强制执行的最严格单边上限;路由更宽松的部署可在 cordis.yml 中调高。`read_image` 把 `IMAGE_DIMENSION_TOO_LARGE` 与 `IMAGE_TOO_MANY_PIXELS` 映射为面向模型的错误,指明解析后的路径与上限并提示缩图重试,本轮以可恢复的工具错误继续。Web 输入框对 `IMAGE_DIMENSION_TOO_LARGE` 给出指明上限的专用文案。`read-image-dimension` 快照场景通过组装后的应用无 key 回放这次拒绝:2001x1 的工作区 fixture、一条可恢复的工具错误、一个正常完成的轮次。
+
+## Alternatives considered
+
+- **准入时缩图而非拒绝。** 重采样会让存储字节偏离调用方提供的内容,引入重采样质量策略,还会对模型隐藏上限。拒绝让准入保持为纯粹的门禁;模型或用户可以在知情的前提下自行缩图。只有当拒绝在实践中频繁出现时才值得重新考虑。
+- **在 provider 适配器按路由强制执行。** 为时已晚:组装请求时图片已是持久历史,每条路由、每次重试都会再次失败。准入是把必然被上游拒绝的图片挡在外面的最后一道关口。
+- **修复已被毒化的会话**(在之后的请求中丢弃或替换超限图片块)。不在本次修复范围内;准入阻止新的毒化,而重写历史需要针对「模型可见 ⟺ 已记录」不变量单独设计。
+
+## Related
+
+- [最小 read_image 工具](../feature/2026-08-10-minimal-read-image-tool.md),本次修复补上的正是该工具的准入缺口。
+- [Web 图片摄入与限制对齐](../feature/2026-08-12-web-image-intake-and-limits-alignment.md),同一组 `ImageAttachmentLimits` 在输入框侧的呈现。
+
+## Consequences
+
+- 一次超限的 `read_image` 不再能弄坏会话;模型看到可操作的错误,轮次正常完成。
+- 单边超过 2000px 的图片即使在其路由本可接受(小请求)的组合中也会被拒绝;这类部署必须显式调高 `maxImageDimension`。
+- 已经携带超限图片的会话仍然是坏的;本次改动不修复既有历史。

+ 2 - 2
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: 4da2774eb94eae3216c2cf89b7864a075872f9ee
-config-catalog.zh.md: 4f9e2cf3ecb454cbc34da565bdc23877c4b54401
+config-catalog.md: 9f8f307b19910f0a0e61296e74f2f9b4d4ed6bec
+config-catalog.zh.md: c7f88416a52f0e106c82fffeceb325b50d9ba068

+ 3 - 1
docs/config-catalog.md

@@ -335,10 +335,12 @@ export interface Config {
   maxMessageImageBytes?: number
   /** Maximum intrinsic width multiplied by height accepted for one image. */
   maxImagePixels?: number
+  /** Maximum intrinsic width and maximum intrinsic height accepted for one image. */
+  maxImageDimension?: number
 }
 ```
 
-Source: [`packages/attachment/attachment-local/src/index.ts:24`](../packages/attachment/attachment-local/src/index.ts)
+Source: [`packages/attachment/attachment-local/src/index.ts:32`](../packages/attachment/attachment-local/src/index.ts)
 
 <a id="deepseek-aidsh-bash-local"></a>
 

+ 3 - 1
docs/config-catalog.zh.md

@@ -337,10 +337,12 @@ export interface Config {
   maxMessageImageBytes?: number
   /** Maximum intrinsic width multiplied by height accepted for one image. */
   maxImagePixels?: number
+  /** Maximum intrinsic width and maximum intrinsic height accepted for one image. */
+  maxImageDimension?: number
 }
 ```
 
-来源:[`packages/attachment/attachment-local/src/index.ts:24`](../packages/attachment/attachment-local/src/index.ts)
+来源:[`packages/attachment/attachment-local/src/index.ts:32`](../packages/attachment/attachment-local/src/index.ts)
 
 <a id="deepseek-aidsh-bash-local"></a>
 

+ 2 - 2
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: ff5a802b23b0111dff4481394772438f5d68feab
-attachment.zh.md: e3db8cd58e9bf2eebad66dacb78353cad98d2fd5
+attachment.md: 21e60dbc40504f22229ef98a2dd112eda82fffdd
+attachment.zh.md: 886e569b6db9f2a5b1dca39125785d8286e22c7a

+ 2 - 0
docs/subsystems/attachment.md

@@ -42,6 +42,8 @@ interface ImageAttachmentLimits {
   maxImagesPerMessage: number
   maxMessageImageBytes: number
   maxImagePixels: number
+  /** Maximum intrinsic width and maximum intrinsic height in pixels for one image. */
+  maxImageDimension: number
   mediaTypes: readonly ImageMediaType[]
 }
 ```

+ 2 - 0
docs/subsystems/attachment.zh.md

@@ -42,6 +42,8 @@ interface ImageAttachmentLimits {
   maxImagesPerMessage: number
   maxMessageImageBytes: number
   maxImagePixels: number
+  /** Maximum intrinsic width and maximum intrinsic height in pixels for one image. */
+  maxImageDimension: number
   mediaTypes: readonly ImageMediaType[]
 }
 ```

+ 11 - 0
examples/acp-agent/tests/acp.snapshot.ts

@@ -213,6 +213,17 @@ const SCENARIOS: Scenario[] = [
     headerClass: 'image',
     configPath: IMAGE_TEXT_ROUTE_CONFIG,
   },
+  // Authored keyless replay of the oversized-image refusal: admission rejects
+  // the 2001x1 fixture at the default 2000px per-side limit, the model sees a
+  // recoverable tool error, and the turn still completes — the image never
+  // enters durable history.
+  {
+    name: 'read-image-dimension',
+    hasModelTurn: true,
+    recorded: false,
+    headerClass: 'image',
+    configPath: IMAGE_CONFIG,
+  },
   {
     name: 'inline-image-prompt',
     hasModelTurn: true,

+ 14 - 0
examples/acp-agent/tests/snapshots/read-image-dimension/input.json

@@ -0,0 +1,14 @@
+{
+  "steps": [
+    {
+      "op": "initialize"
+    },
+    {
+      "op": "newSession"
+    },
+    {
+      "op": "prompt",
+      "text": "Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE."
+    }
+  ]
+}

+ 26 - 0
examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl

@@ -0,0 +1,26 @@
+{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0}
+{"type":"agent/inbox/spliced","seq":0,"time":1783951000001,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"}]}}
+{"type":"turn/start","seq":1,"time":1783951000002,"data":{"turn":1}}
+{"type":"agent/inbox/spliced","seq":2,"time":1783951000002,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
+{"type":"step/start","seq":3,"time":1783951000003,"data":{"turn":1,"step":1}}
+{"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"}
+{"type":"user/message","seq":5,"time":1783951000004,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"11a08f07-014a-408b-bfc5-634770ce7179"},"surfaceOp":"append"}
+{"type":"session/title","seq":6,"time":1783951000004,"data":{"title":"Use read_image on wide.png in","messageSeqs":[4],"source":{"kind":"fallback"}}}
+{"type":"request/header","seq":7,"time":1783951000004,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":8,"time":1783951000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":10,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}}}
+{"type":"assistant/chunk","seq":11,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
+{"type":"assistant/chunk","seq":12,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":13,"time":1783951000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9676ac40-f7a8-4a7b-9326-a45fef18f11e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
+{"type":"tool/call","seq":14,"time":1783951000009,"data":{"turn":1,"step":1,"callId":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}
+{"type":"tool/result","seq":15,"time":1783951000014,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-dimension"},"content":[{"type":"tool-result","toolCallId":"read-image-dimension","content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/wide.png\": at least one image side exceeds the 2000px limit; downscale the image and read the smaller copy"}],"isError":true}],"role":"user","id":"ee31751e-df5a-458e-8497-8113cf6107ef"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
+{"type":"step/end","seq":16,"time":1783951000014,"data":{"turn":1,"step":1}}
+{"type":"step/start","seq":17,"time":1783951000015,"data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","seq":18,"time":1783951000016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":19,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"TOOLARGE"}}}}
+{"type":"assistant/chunk","seq":20,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
+{"type":"assistant/chunk","seq":21,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":22,"time":1783951000018,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1c15b391-a95a-4113-9d47-2a1dfc991cf9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"}
+{"type":"step/end","seq":23,"time":1783951000018,"data":{"turn":1,"step":2}}
+{"type":"turn/end","seq":24,"time":1783951000018,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 4 - 0
examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl

@@ -0,0 +1,4 @@
+{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false}},"authMethods":[]}}
+{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"TOOLARGE"}}}}
+{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

BIN
examples/acp-agent/tests/snapshots/read-image-dimension/workspace/wide.png


+ 1 - 0
packages/acp/acp/tests/harness.ts

@@ -82,6 +82,7 @@ const IMAGE_LIMITS: ImageAttachmentLimits = {
   maxImagesPerMessage: 4,
   maxMessageImageBytes: 2048,
   maxImagePixels: 1024,
+  maxImageDimension: 2000,
   mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
 }
 

+ 2 - 2
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: ba0b9efb2cf51bfef671020bed4a2c16f6ee0119
-README.zh.md: 8e2474357a0dbb5e8834a3b25de7a977827a29e3
+README.md: e4f2d5748768a1dc2a6b79c3ed9e364c56a67248
+README.zh.md: 6b548fb993faef996f1508ba9f9efc31b20fea64

+ 1 - 1
packages/attachment/attachment-local/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then 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. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable.
+The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then 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. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte, total-pixel, and per-side dimension limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. The per-side default (2000px) stays below the strictest dimension bound deployed model routes enforce on requests carrying many images: an admitted image rides every later request of its session, so admission is the last point where a provider-rejected image can be kept out of durable history.
 
 `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`.
 

+ 1 - 1
packages/attachment/attachment-local/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。
+这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节、总像素和单边尺寸限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。单边默认值(2000px)低于已部署模型路由对携带多张图片的请求所强制执行的最严格尺寸上限:一张已接纳的图片会随会话之后的每次请求发送,准入是把必然被上游拒绝的图片挡在持久历史之外的最后一道关口。
 
 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。
 

+ 14 - 3
packages/attachment/attachment-local/src/image.ts

@@ -44,19 +44,30 @@ export async function probeImage(data: Uint8Array): Promise<DetectedImage> {
   }
 }
 
+/** Admission limits applied to a decoded raster's intrinsic dimensions. */
+export interface DecodedImageLimits {
+  /** Decoded-pixel (width times height) admission limit. */
+  maxPixels?: number
+  /** Per-side admission limit applied to width and height independently. */
+  maxDimension?: number
+}
+
 /**
  * Fully decode a supported raster and return its intrinsic metadata.
  * @param data - complete encoded image bytes.
- * @param maxPixels - decoded-pixel admission limit.
+ * @param limits - intrinsic-dimension admission limits.
  * @returns verified format and dimensions.
  */
-export async function detectImage(data: Uint8Array, maxPixels?: number): Promise<DetectedImage> {
+export async function detectImage(data: Uint8Array, limits?: DecodedImageLimits): Promise<DetectedImage> {
   try {
     const image = sharp(data, { failOn: 'error', limitInputPixels: false })
     const detected = await imageMetadata(image)
-    if (maxPixels !== undefined && detected.width * detected.height > maxPixels) {
+    if (limits?.maxPixels !== undefined && detected.width * detected.height > limits.maxPixels) {
       throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS')
     }
+    if (limits?.maxDimension !== undefined && Math.max(detected.width, detected.height) > limits.maxDimension) {
+      throw new AttachmentError('Image exceeds the configured per-side pixel limit.', 'IMAGE_DIMENSION_TOO_LARGE')
+    }
     await image.raw().toBuffer()
     return detected
   } catch (error) {

+ 12 - 0
packages/attachment/attachment-local/src/index.ts

@@ -19,6 +19,14 @@ export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 20
 export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 100 * 1024 * 1024
 /** Default maximum intrinsic pixels for one image. */
 export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000
+/**
+ * Default maximum intrinsic width and height for one image. Deployed model
+ * routes reject any request whose history carries an image with a side above
+ * 2000px once the request holds many images, and an admitted image rides
+ * every later request of its session, so admission refuses at the same line
+ * to keep the durable history streamable.
+ */
+export const DEFAULT_MAX_IMAGE_DIMENSION = 2000
 
 /** Local attachment backend configuration. */
 export interface Config {
@@ -32,6 +40,8 @@ export interface Config {
   maxMessageImageBytes?: number
   /** Maximum intrinsic width multiplied by height accepted for one image. */
   maxImagePixels?: number
+  /** Maximum intrinsic width and maximum intrinsic height accepted for one image. */
+  maxImageDimension?: number
 }
 
 /** Persistent content-addressed local attachment store. */
@@ -42,6 +52,7 @@ export class LocalAttachmentStore extends AttachmentStore {
     maxImagesPerMessage: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_MESSAGE),
     maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES),
     maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS),
+    maxImageDimension: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_DIMENSION),
   })
 
   /** Absolute versioned storage root. */
@@ -56,6 +67,7 @@ export class LocalAttachmentStore extends AttachmentStore {
       maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE,
       maxMessageImageBytes: config.maxMessageImageBytes ?? DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
       maxImagePixels: config.maxImagePixels ?? DEFAULT_MAX_IMAGE_PIXELS,
+      maxImageDimension: config.maxImageDimension ?? DEFAULT_MAX_IMAGE_DIMENSION,
       mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const),
     })
   }

+ 4 - 4
packages/attachment/attachment-local/src/store.ts

@@ -46,10 +46,10 @@ function ensureReference(ref: ImageAttachmentRef): string {
 async function inspectMetadata(
   data: Uint8Array,
   declaredMediaType: ImageAttachmentRef['mediaType'],
-  maxPixels?: number,
+  limits: ImageAttachmentLimits,
 ): Promise<Omit<ImageAttachmentRef, 'attachmentId' | 'name'>> {
   if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE')
-  const detected = await detectImage(data, maxPixels)
+  const detected = await detectImage(data, { maxPixels: limits.maxImagePixels, maxDimension: limits.maxImageDimension })
   if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH')
   return { ...detected, bytes: data.byteLength }
 }
@@ -64,7 +64,7 @@ export async function validateImageFile(input: SaveImageAttachment, limits: Imag
   if (input.data.byteLength > limits.maxImageBytes) {
     throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
   }
-  await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels)
+  await inspectMetadata(input.data, input.mediaType, limits)
 }
 
 /**
@@ -135,7 +135,7 @@ async function ensureDurableHome(path: string): Promise<string> {
  */
 export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise<ImageAttachmentRef> {
   if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
-  const metadata = await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels)
+  const metadata = await inspectMetadata(input.data, input.mediaType, limits)
   const sha256 = digest(input.data)
   const bucket = join(root, 'objects', sha256.slice(0, 2))
   const staging = join(root, 'tmp')

+ 8 - 1
packages/attachment/attachment-local/tests/image.spec.ts

@@ -23,10 +23,17 @@ describe('raster decoding', () => {
   })
 
   it('rejects excess decoded pixels before decoding', async () => {
-    await expect(detectImage(await raster('png'), 5))
+    await expect(detectImage(await raster('png'), { maxPixels: 5 }))
       .rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
   })
 
+  it('rejects a side above the per-side limit and accepts a side exactly at it', async () => {
+    await expect(detectImage(await raster('png'), { maxDimension: 2 }))
+      .rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' })
+    await expect(detectImage(await raster('png'), { maxDimension: 3 }))
+      .resolves.toEqual({ mediaType: 'image/png', width: 3, height: 2 })
+  })
+
   it('rejects malformed bytes and truncated payloads with readable headers', async () => {
     await expect(detectImage(Uint8Array.of(1, 2, 3)))
       .rejects.toMatchObject({ code: 'INVALID_IMAGE' })

+ 2 - 0
packages/attachment/attachment-local/tests/index.spec.ts

@@ -6,6 +6,7 @@ import { join } from 'node:path'
 import { describe, expect, it } from 'vitest'
 import LocalAttachmentStore, {
   DEFAULT_MAX_IMAGE_BYTES,
+  DEFAULT_MAX_IMAGE_DIMENSION,
   DEFAULT_MAX_IMAGE_PIXELS,
   DEFAULT_MAX_IMAGES_PER_MESSAGE,
   DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
@@ -20,6 +21,7 @@ describe('local attachment service', () => {
       maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE,
       maxMessageImageBytes: DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
       maxImagePixels: DEFAULT_MAX_IMAGE_PIXELS,
+      maxImageDimension: DEFAULT_MAX_IMAGE_DIMENSION,
       mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
     })
   })

+ 4 - 0
packages/attachment/attachment-local/tests/store.spec.ts

@@ -43,6 +43,7 @@ const LIMITS: ImageAttachmentLimits = {
   maxImagesPerMessage: 2,
   maxMessageImageBytes: 2048,
   maxImagePixels: 16,
+  maxImageDimension: 2000,
   mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
 }
 
@@ -176,6 +177,9 @@ describe('local attachment store', () => {
     await expect(saveImageFile(storageRoot, {
       data: wide, mediaType: 'image/png',
     }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
+    await expect(saveImageFile(storageRoot, {
+      data: wide, mediaType: 'image/png',
+    }, { ...LIMITS, maxImagePixels: 25, maxImageDimension: 4 })).rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' })
     const unnamed = await saveImageFile(storageRoot, {
       data: PNG, mediaType: 'image/png', name: '\u0000',
     }, LIMITS)

+ 1 - 0
packages/attachment/attachment/src/error.ts

@@ -9,6 +9,7 @@ const IMAGE_ADMISSION_ERROR_CODES = [
   'IMAGE_TYPE_MISMATCH',
   'IMAGE_TOO_LARGE',
   'IMAGE_TOO_MANY_PIXELS',
+  'IMAGE_DIMENSION_TOO_LARGE',
 ] as const
 
 /** Caller-correctable attachment failure codes raised while admitting image input. */

+ 2 - 0
packages/attachment/attachment/src/types.ts

@@ -29,6 +29,8 @@ export interface ImageAttachmentLimits {
   maxImagesPerMessage: number
   maxMessageImageBytes: number
   maxImagePixels: number
+  /** Maximum intrinsic width and maximum intrinsic height in pixels for one image. */
+  maxImageDimension: number
   mediaTypes: readonly ImageMediaType[]
 }
 

+ 1 - 0
packages/attachment/attachment/tests/index.spec.ts

@@ -15,6 +15,7 @@ const LIMITS = {
   maxImagesPerMessage: 2,
   maxMessageImageBytes: 5,
   maxImagePixels: 4,
+  maxImageDimension: 2000,
   mediaTypes: ['image/png'] as const,
 }
 

+ 1 - 0
packages/client/connection/src/client/fixture.ts

@@ -1077,6 +1077,7 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
     maxImagesPerMessage: 20,
     maxMessageImageBytes: 100 * 1024 * 1024,
     maxImagePixels: 40_000_000,
+    maxImageDimension: 2000,
     mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
   }
   return values

+ 1 - 0
packages/client/connection/tests/fixture.client.spec.ts

@@ -173,6 +173,7 @@ describe('createFixtureApi', () => {
           maxImagesPerMessage: 20,
           maxMessageImageBytes: 100 * 1024 * 1024,
           maxImagePixels: 40_000_000,
+          maxImageDimension: 2000,
           mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
         },
       } },

+ 3 - 0
packages/client/ui-conversation/src/client/image-labels.ts

@@ -38,6 +38,9 @@ export function attachmentErrorText(
     case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported')
     case 'SUBAGENT_IMAGE_UNSUPPORTED': return t('image.subagentUnsupported')
     case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels')
+    case 'IMAGE_DIMENSION_TOO_LARGE':
+      if (limits !== undefined) return t('image.dimensionTooLarge', { size: limits.maxImageDimension })
+      break
     // Undecodable bytes or a declared type its bytes contradict: solvable by
     // replacing or re-exporting the file, so it reads as a format problem.
     case 'INVALID_IMAGE':

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

@@ -46,6 +46,7 @@ export const zh = {
   'image.fileTooLarge': '单张图片不能超过 {size}',
   'image.totalTooLarge': '图片总大小超过 {size},请移除部分图片',
   'image.tooManyPixels': '图片分辨率过大,请压缩后重试',
+  'image.dimensionTooLarge': '图片宽高不能超过 {size}px,请缩小后重试',
   'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型',
   'image.subagentUnsupported': '子智能体会话暂不支持图片',
   'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试',
@@ -215,6 +216,7 @@ export const en = {
   'image.fileTooLarge': 'Each image must be smaller than {size}',
   'image.totalTooLarge': 'Images exceed {size} in total; remove some and try again',
   'image.tooManyPixels': 'Image resolution is too high; compress it and try again',
+  'image.dimensionTooLarge': 'Image sides must be at most {size}px; downscale it and try again',
   'image.modelUnsupported': 'The current model does not support images; switch to a model that does',
   'image.subagentUnsupported': 'Subagent sessions do not support images yet',
   'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again',

+ 3 - 0
packages/client/ui-conversation/tests/image-labels.client.spec.tsx

@@ -32,6 +32,7 @@ describe('attachment rejection copy', () => {
     maxImagesPerMessage: 20,
     maxMessageImageBytes: 100 * 1024 * 1024,
     maxImagePixels: 40_000_000,
+    maxImageDimension: 2000,
     mediaTypes: ['image/png'] as const,
   }
 
@@ -49,6 +50,7 @@ describe('attachment rejection copy', () => {
     expect(attachmentErrorText(t, 'TOO_MANY_IMAGES', limits)).toBe('一条消息最多添加 20 张图片')
     expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE', limits)).toBe('单张图片不能超过 5MB')
     expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE', limits)).toBe('图片总大小超过 100MB,请移除部分图片')
+    expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE', limits)).toBe('图片宽高不能超过 2000px,请缩小后重试')
     expect(attachmentErrorText(enT, 'TOO_MANY_IMAGES', limits)).toBe('A message can include up to 20 images')
   })
 
@@ -57,6 +59,7 @@ describe('attachment rejection copy', () => {
     expect(attachmentErrorText(t, 'TOO_MANY_IMAGES')).toBe('图片发送失败(TOO_MANY_IMAGES),请重新添加图片后再试')
     expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE')).toBe('图片发送失败(IMAGE_TOO_LARGE),请重新添加图片后再试')
     expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE')).toBe('图片发送失败(IMAGES_TOO_LARGE),请重新添加图片后再试')
+    expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE')).toBe('图片发送失败(IMAGE_DIMENSION_TOO_LARGE),请重新添加图片后再试')
   })
 })
 

+ 4 - 0
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -62,6 +62,7 @@ interface BenchOptions {
     maxImagesPerMessage: number
     maxMessageImageBytes: number
     maxImagePixels: number
+    maxImageDimension: number
     mediaTypes: readonly ('image/png' | 'image/jpeg' | 'image/webp' | 'image/gif')[]
   }
   draft?: string
@@ -263,6 +264,7 @@ describe('image draft rail', () => {
       maxImagesPerMessage: 2,
       maxMessageImageBytes: 2 * 1024 * 1024,
       maxImagePixels: 40_000_000,
+      maxImageDimension: 2000,
       mediaTypes: ['image/png'] as const,
     }
     const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' })
@@ -306,6 +308,7 @@ describe('image draft rail', () => {
         maxImagesPerMessage: 1,
         maxMessageImageBytes: 8,
         maxImagePixels: 40_000_000,
+        maxImageDimension: 2000,
         mediaTypes: ['image/png'] as const,
       },
     })
@@ -327,6 +330,7 @@ describe('image draft rail', () => {
         maxImagesPerMessage: 20,
         maxMessageImageBytes: 100 * 1024 * 1024,
         maxImagePixels: 40_000_000,
+        maxImageDimension: 2000,
         mediaTypes: ['image/png'] as const,
       },
     })

+ 1 - 1
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -3137,7 +3137,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'ImageAttachmentLimits',
-    declaration: 'export interface ImageAttachmentLimits {\n    maxImageBytes: number;\n    maxImagesPerMessage: number;\n    maxMessageImageBytes: number;\n    maxImagePixels: number;\n    mediaTypes: readonly ImageMediaType[];\n}',
+    declaration: 'export interface ImageAttachmentLimits {\n    maxImageBytes: number;\n    maxImagesPerMessage: number;\n    maxMessageImageBytes: number;\n    maxImagePixels: number;\n    maxImageDimension: number;\n    mediaTypes: readonly ImageMediaType[];\n}',
   },
   {
     name: 'ImageAttachmentRef',

+ 2 - 2
packages/fs/tool-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/tool-fs/README.md
-README.md: 71dc4134feee6bf1b5d17eef5f62e587653adf6d
-README.zh.md: dff4b838aea4c6fa50a92ce2102bfdd2a96150a5
+README.md: ce7c0ea9070e30c1e6b538933ff5c4605b8d59cc
+README.zh.md: 28360fbe6d0ab0f1a2b5ff11e44155ad6468274c

+ 1 - 1
packages/fs/tool-fs/README.md

@@ -131,7 +131,7 @@ A successful `read_image` returns `<path><displayPath></path>`, `<type>image</ty
 
 #### Token effect
 
-The image is billed on every later request until compaction. Each call is independently bounded by the attachment store's `maxImageBytes`/`maxImagePixels`; repeated successful calls accumulate history, and content addressing deduplicates only the stored bytes, not the per-request token cost.
+The image is billed on every later request until compaction. Each call is independently bounded by the attachment store's `maxImageBytes`/`maxImagePixels`/`maxImageDimension`; repeated successful calls accumulate history, and content addressing deduplicates only the stored bytes, not the per-request token cost.
 
 #### KV Cache effect
 

+ 1 - 1
packages/fs/tool-fs/README.zh.md

@@ -131,7 +131,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
 
 #### Token 影响
 
-图像在之后每次请求中都会计费,直到压缩。每次调用都独立受附件存储的 `maxImageBytes`/`maxImagePixels` 约束;重复成功调用会在历史中累积,内容寻址只去重存储的字节,不去重每次请求的 token 成本。
+图像在之后每次请求中都会计费,直到压缩。每次调用都独立受附件存储的 `maxImageBytes`/`maxImagePixels`/`maxImageDimension` 约束;重复成功调用会在历史中累积,内容寻址只去重存储的字节,不去重每次请求的 token 成本。
 
 #### KV Cache 影响
 

+ 17 - 1
packages/fs/tool-fs/src/read-image.ts

@@ -189,7 +189,23 @@ export function applyReadImageTool(ctx: Context): void {
       try {
         ref = await attachments.saveImage({ data, mediaType, name: basename(target.displayPath) })
       } catch (error: unknown) {
-        if (!(error instanceof AttachmentError) || error.code !== 'IMAGE_TYPE_MISMATCH') throw error
+        if (!(error instanceof AttachmentError)) throw error
+        // Dimension refusals stay recoverable tool errors: an oversized image
+        // must never enter durable history, where it would ride every later
+        // model request past provider-side dimension rejections.
+        if (error.code === 'IMAGE_DIMENSION_TOO_LARGE') {
+          throw new Error(
+            `cannot read "${target.displayPath}": at least one image side exceeds the ${attachments.imageLimits.maxImageDimension}px limit; downscale the image and read the smaller copy`,
+            { cause: error },
+          )
+        }
+        if (error.code === 'IMAGE_TOO_MANY_PIXELS') {
+          throw new Error(
+            `cannot read "${target.displayPath}": the image exceeds the ${attachments.imageLimits.maxImagePixels}-pixel decoded-size limit; downscale the image and read the smaller copy`,
+            { cause: error },
+          )
+        }
+        if (error.code !== 'IMAGE_TYPE_MISMATCH') throw error
         const extension = extname(target.displayPath).toLowerCase()
         throw new Error(
           `cannot read "${target.displayPath}": the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`,

+ 55 - 2
packages/fs/tool-fs/tests/read-image.spec.ts

@@ -20,7 +20,7 @@ import type { Config as ToolConfig } from '@deepseek-ai/dsh-tools'
 import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
 import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy'
 import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local'
-import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment'
+import { AttachmentError, AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment'
 import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment'
 import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
 import {
@@ -93,7 +93,7 @@ interface SetupOptions {
   resolvedModels?: LlmModelInfo[]
   attachments?: boolean
   llm?: boolean
-  storeConfig?: { maxImageBytes?: number; maxImagePixels?: number; maxMessageImageBytes?: number }
+  storeConfig?: { maxImageBytes?: number; maxImagePixels?: number; maxImageDimension?: number; maxMessageImageBytes?: number }
   toolMode?: ToolConfig['mode']
 }
 
@@ -336,6 +336,7 @@ describe('argument and service preconditions', () => {
         maxImagesPerMessage: 1,
         maxMessageImageBytes: 1024,
         maxImagePixels: 100,
+        maxImageDimension: 2000,
         mediaTypes: Object.freeze(['image/jpeg'] as const),
       })
 
@@ -390,6 +391,57 @@ describe('image admission failures', () => {
     const ctx = await setup({ storeConfig: { maxImagePixels: 4 } })
     const result = await readImage(ctx, { file_path: 'big.png' }, agentOn('vision-model'))
     expect(result.isError).toBe(true)
+    expect(text(result)).toContain('exceeds the 4-pixel decoded-size limit')
+    expect(text(result)).toContain('downscale the image and read the smaller copy')
+  })
+
+  it('refuses a side above the per-side limit before anything enters durable history', async () => {
+    await writeFile(join(dir, 'wide.png'), PNG_3X3)
+    const ctx = await setup({ storeConfig: { maxImageDimension: 2 } })
+    const result = await readImage(ctx, { file_path: 'wide.png' }, agentOn('vision-model'))
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('at least one image side exceeds the 2px limit')
+    expect(text(result)).toContain('downscale the image and read the smaller copy')
+  })
+
+  it('passes storage faults and non-attachment failures through unchanged', async () => {
+    /** Store whose commit fails with a configurable error; admission itself passes. */
+    class FailingStore extends AttachmentStore {
+      static failure: unknown
+      readonly imageLimits: ImageAttachmentLimits = Object.freeze({
+        maxImageBytes: 1024,
+        maxImagesPerMessage: 1,
+        maxMessageImageBytes: 1024,
+        maxImagePixels: 100,
+        maxImageDimension: 2000,
+        mediaTypes: Object.freeze(['image/png'] as const),
+      })
+
+      validateImage(_input: SaveImageAttachment): Promise<void> {
+        return Promise.resolve()
+      }
+
+      saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
+        return Promise.reject(FailingStore.failure)
+      }
+
+      readImage(_ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
+        throw new Error('unreachable in this test')
+      }
+    }
+    await writeFile(join(dir, 'red.png'), PNG_1X1)
+    const ctx = await setup({ attachments: false })
+    await ctx.plugin(FailingStore)
+
+    FailingStore.failure = new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED')
+    const storageFault = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model'))
+    expect(storageFault.isError).toBe(true)
+    expect(text(storageFault)).toContain('Unable to persist image attachment.')
+
+    FailingStore.failure = new Error('unrelated infrastructure failure')
+    const unrelated = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model'))
+    expect(unrelated.isError).toBe(true)
+    expect(text(unrelated)).toContain('unrelated infrastructure failure')
   })
 
   it('reports a missing image file and a directory target through the fs vocabulary', async () => {
@@ -415,6 +467,7 @@ describe('image admission failures', () => {
         maxImagesPerMessage: 1,
         maxMessageImageBytes: 1024,
         maxImagePixels: 100,
+        maxImageDimension: 2000,
         mediaTypes: Object.freeze(['image/png'] as const),
       })
 

+ 1 - 0
packages/host/apiproxy/src/api/sessions.schema.ts

@@ -231,6 +231,7 @@ export const imageLimitsProjectionSchema = z.object({
   maxImagesPerMessage: z.number().int().positive(),
   maxMessageImageBytes: z.number().int().positive(),
   maxImagePixels: z.number().int().positive(),
+  maxImageDimension: z.number().int().positive(),
   mediaTypes: z.array(z.string()),
 }) as unknown as z.ZodType<ImageAttachmentLimits>
 

+ 1 - 0
packages/host/apiproxy/tests/api-proxy-models.spec.ts

@@ -147,6 +147,7 @@ describe('Web session model selection', () => {
         maxImagesPerMessage: 2,
         maxMessageImageBytes: 4,
         maxImagePixels: 4,
+        maxImageDimension: 2000,
         mediaTypes: ['image/png'],
       },
       validateImage,

+ 1 - 0
packages/host/apiproxy/tests/api-proxy-projections.spec.ts

@@ -94,6 +94,7 @@ describe('session.history projections block', () => {
       maxImagesPerMessage: 20,
       maxMessageImageBytes: 100 * 1024 * 1024,
       maxImagePixels: 40_000_000,
+      maxImageDimension: 2000,
       mediaTypes: ['image/png'] as const,
     }
     await ctx.plugin(class extends AttachmentStore {

+ 1 - 0
packages/llm/llm-pi-ai/tests/adapter.spec.ts

@@ -217,6 +217,7 @@ describe('PiAiAdapter provider routing', () => {
         maxImagesPerMessage: 1,
         maxMessageImageBytes: 1,
         maxImagePixels: 1,
+        maxImageDimension: 2000,
         mediaTypes: ['image/png'],
       }
 

+ 1 - 0
packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts

@@ -70,6 +70,7 @@ async function harness(image?: StoredImageAttachment): Promise<Context> {
         maxImagesPerMessage: 1,
         maxMessageImageBytes: fixture.data.byteLength,
         maxImagePixels: fixture.ref.width * fixture.ref.height,
+        maxImageDimension: Math.max(fixture.ref.width, fixture.ref.height),
         mediaTypes: [fixture.ref.mediaType],
       }
 

+ 1 - 0
packages/mcp/mcp-client/tests/mcp-client.spec.ts

@@ -73,6 +73,7 @@ const IMAGE_LIMITS: ImageAttachmentLimits = {
   maxImagesPerMessage: 4,
   maxMessageImageBytes: 2048,
   maxImagePixels: 1024,
+  maxImageDimension: 2000,
   mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
 }
 

+ 1 - 0
scripts/gen-tool-catalog.ts

@@ -72,6 +72,7 @@ class CatalogAttachmentStore extends AttachmentStore {
     maxImagesPerMessage: 1,
     maxMessageImageBytes: 1,
     maxImagePixels: 1,
+    maxImageDimension: 1,
     mediaTypes: Object.freeze(['image/png'] as const),
   })
 

+ 1 - 0
scripts/test-invariants.ts

@@ -117,6 +117,7 @@ class TestAttachmentStore extends AttachmentStore {
     maxImagesPerMessage: 1,
     maxMessageImageBytes: 1,
     maxImagePixels: 1,
+    maxImageDimension: 1,
     mediaTypes: ['image/png'],
   }