Просмотр исходного кода

fix(attachment): address dimension-limit review

creatixchu 2 месяцев назад
Родитель
Сommit
d559ba9b2b

+ 2 - 2
.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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-07-22-web-multimodal-image-input-and-durable-attachments.md
-2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 5162e0d6f63715ffd83cff1a622e07c110a7938e
-2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: d3a5cf18a7d308753d5e46741cea2b2993536f70
+2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 8c07b8b786aeeb87a4c2db7c0e6e49928b0ddf2c
+2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 5d2d2963d6bee729362852701ea58f191f9721b2

+ 2 - 2
.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md

@@ -140,9 +140,9 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state
 
 ### Limits and trust boundaries
 
-Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 20 images and 100 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (160 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end.
+Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 20 images and 100 MiB aggregate image bytes per message, 40 million intrinsic pixels per image, and 2000 pixels on either side. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (160 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end.
 
-Malformed base64, unsupported or mismatched media, truncated image payloads, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser.
+Malformed base64, unsupported or mismatched media, truncated image payloads, excess bytes, excess image count, excess pixels, excess per-side dimensions, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser.
 
 ### Package and surface changes
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md

@@ -140,9 +140,9 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme
 
 ### 限制与信任边界
 
-第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 20 张图片和 100 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 160 MiB);如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。
+第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 20 张图片和 100 MiB 图片总字节数、每张图片 4,000 万个固有像素,以及任一边 2,000 像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 160 MiB);如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。
 
-格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。
+格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、超出单边尺寸限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。
 
 ### 包与接口变更
 

+ 3 - 2
apps/web/tests/assembled-boot.ts

@@ -186,9 +186,10 @@ export function installAssembledBootEnv(): void {
 /**
  * Mount the assembled application on the fixture transport; the teardown
  * registered by installAssembledBootEnv disposes it.
+ * @param search - fixture query string used to select deterministic host behavior.
  */
-export function mountAssembledApp(): void {
-  history.replaceState(null, '', '/?fixture')
+export function mountAssembledApp(search = '?fixture'): void {
+  history.replaceState(null, '', `/${search}`)
   const root = document.createElement('div')
   root.id = 'root'
   document.body.appendChild(root)

+ 32 - 0
apps/web/tests/image-display.snapshot.ts

@@ -197,3 +197,35 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov
   const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
   expect([...(rail?.querySelectorAll('img') ?? [])]).toHaveLength(1)
 })
+
+it('renders a host dimension rejection with the projected 2000px limit', async () => {
+  mountAssembledApp('?fixture&fixturePrompt=reject')
+
+  const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+  const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
+  if (start === null) throw new Error('fixture Workspace new-session action missing')
+  fireEvent.click(start)
+
+  const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
+  const image = new File([new Uint8Array([137, 80, 78, 71])], 'too-wide.png', { type: 'image/png' })
+  fireEvent.paste(textarea, {
+    clipboardData: {
+      items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }],
+      getData: () => '',
+    },
+  })
+  await waitFor(() => {
+    expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull()
+  })
+  fireEvent.keyDown(textarea, { key: 'Enter' })
+
+  const message = 'Image sides must be at most 2000px; downscale it and try again'
+  const toast = await screen.findByText(message)
+  expect({ role: toast.closest('[role="alert"]')?.getAttribute('role'), text: toast.textContent }).toMatchInlineSnapshot(`
+    {
+      "role": "alert",
+      "text": "Image sides must be at most 2000px; downscale it and try again",
+    }
+  `)
+  expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull()
+})

+ 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: 2c3577648d71ed38939e56a98c4cf6e9d2974112
-config-catalog.zh.md: b4b6ef27f2657e737b0da115538bdc5356dfa6b4
+config-catalog.md: 469291801851e73837965992555468e659282ab2
+config-catalog.zh.md: 06207bf8a3475c20a14d2fb1509ec5d21f36733b

+ 1 - 1
docs/config-catalog.md

@@ -340,7 +340,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/attachment/attachment-local/src/index.ts:32`](../packages/attachment/attachment-local/src/index.ts)
+Source: [`packages/attachment/attachment-local/src/index.ts:31`](../packages/attachment/attachment-local/src/index.ts)
 
 <a id="deepseek-aidsh-bash-local"></a>
 

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

@@ -342,7 +342,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/attachment/attachment-local/src/index.ts:32`](../packages/attachment/attachment-local/src/index.ts)
+来源:[`packages/attachment/attachment-local/src/index.ts:31`](../packages/attachment/attachment-local/src/index.ts)
 
 <a id="deepseek-aidsh-bash-local"></a>
 

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

@@ -8,7 +8,6 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, St
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { readImageFile, saveImageFile, validateImageFile } from './store.ts'
 
-export { detectImage } from './image.ts'
 export { readImageFile, saveImageFile, validateImageFile } from './store.ts'
 
 /** Default maximum encoded bytes for one image. */

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

@@ -2403,6 +2403,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
           return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } })
         }
         if (options.rejectPrompt) {
+          if (content.some(block => block.type === 'image')) {
+            return err(request, {
+              code: 'attachment-error',
+              message: 'fixture: image side exceeds the deployment limit',
+              details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' },
+            })
+          }
           return err(request, {
             code: 'agent-busy',
             message: 'fixture: prompt rejected before acceptance',

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

@@ -867,6 +867,15 @@ describe('createFixtureApi', () => {
       content: [{ type: 'text' as const, text: 'keep me' }],
     }))
     expect(prompt.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
+    const imagePrompt = await rejecting.sessions.prompt(req({
+      sessionId: real.result.value.sessionId,
+      mode: 'queue' as const,
+      content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: 'iVBORw0KGgo=' }],
+    }))
+    expect(imagePrompt.result).toMatchObject({
+      ok: false,
+      error: { code: 'attachment-error', details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' } },
+    })
   })
 
   it('timing hooks: history delay + one-shot failure, silent append, and breakStreams end open generators', async () => {

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

@@ -395,7 +395,7 @@ describe('image admission failures', () => {
     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 () => {
+  it('surfaces the per-side limit from attachment admission', 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'))
@@ -421,8 +421,8 @@ describe('image admission failures', () => {
         return Promise.resolve()
       }
 
-      saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
-        return Promise.reject(FailingStore.failure)
+      async saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
+        throw FailingStore.failure
       }
 
       readImage(_ref: ImageAttachmentRef): Promise<StoredImageAttachment> {