Kaynağa Gözat

refactor(api): shrink media-references surface and reuse maintained libs

- /api/file module now exports only the SessionMediaReferences plugin
  contribution; MIME and range policy helpers are module-private and
  exercised entirely through the registered route.
- Replace the hand-rolled media extension table with mime-types (served
  categories image/video/audio, excluding image/svg+xml) and the
  hand-rolled Range parser with range-parser; keep the fail-closed
  workspace containment policy.
- Spec rewritten as route-level behavior tests (12 cases) covering the
  same branches; Agent Note facts updated in the same change.
_Kerman 4 gün önce
ebeveyn
işleme
4b009e7f94

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md
-2026-09-07-session-prose-local-media-display.md: b62b4d1e5c382540150fad3e2840e2f7bbe33dd2
-2026-09-07-session-prose-local-media-display.zh.md: 101f2d234b59e5efebd0f42fa39830747e9181fa
+2026-09-07-session-prose-local-media-display.md: 79bde26e1890bf463f7a954e9b84d81e87ecbd1e
+2026-09-07-session-prose-local-media-display.zh.md: af852a627888353589e4a88a4198bfb32760adbe

+ 1 - 1
.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md

@@ -14,7 +14,7 @@ Local media paths in session prose render through one same-origin file route, wi
 
 - **Renderer seam (`ui-primitives`)**: `MarkdownText` gained a `MarkdownPathImages` vocabulary (`pathImages` prop) with the same settled-only gate as `fileMentions`: while a message streams, frozen cached blocks never bake in a vocabulary handler; the settled pass rewrites image destinations that fail the remote-URL allowlist, and a rewritten destination is emitted only when it is an absolute `http(s)`/`blob`/`data` URL. Without a vocabulary the renderer output is byte-identical to before.
 - **Chat wiring (`ui-chat`)**: `AssistantMarkdown` supplies a page-stable vocabulary mapping absolute POSIX paths to same-origin `/api/file?path=…` GETs (`local-path-media.ts`); non-HTTP transports (Electron `file://`) and relative/protocol-relative destinations stay inert.
-- **Host route (`session-controller`)**: `SessionMediaReferences`, a plugin contribution registered beside `SessionFileReferences`, mounts `GET|HEAD /api/file` on the shared authenticated `connection.fetch` channel (same trust fence and browser authentication as `/api` RPC). Per request it fail-closes: the path must be absolute, its `realpath` must lie inside a registered workspace root, the file must be regular, and its extension must name an allowlisted media type (PNG/JPEG/GIF/WebP/AVIF, MP4/WebM/MOV/OGG, MP3/WAV/Ogg/M4A/AAC/FLAC); media bytes are never sniffed on this route. Responses stream with HTTP range support (206/416) so video and audio can seek, and carry `private, no-store` and `nosniff`. The contribution activates only where `connection` and `workspaceRegistry` are composed (pending-until-composed, like the package's other optional contributions).
+- **Host route (`session-controller`)**: `SessionMediaReferences`, a plugin contribution registered beside `SessionFileReferences`, mounts `GET|HEAD /api/file` on the shared authenticated `connection.fetch` channel (same trust fence and browser authentication as `/api` RPC). Per request it fail-closes: the path must be absolute, its `realpath` must lie inside a registered workspace root, the file must be regular, and its MIME type must belong to the served categories image/video/audio (resolved by the `mime-types` package, excluding `image/svg+xml`); media bytes are never sniffed on this route. Responses stream with HTTP range support parsed by the `range-parser` package (206/416) so video and audio can seek, and carry `private, no-store` and `nosniff`. The contribution activates only where `connection` and `workspaceRegistry` are composed (pending-until-composed, like the package's other optional contributions).
 
 The route is presentational and stateless: it never writes, follows no redirects, and returns 400/403/404/415/416 instead of approximating another file-serving behavior.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md

@@ -24,7 +24,7 @@ Assistant 正文有时用本地文件系统路径引用图片(markdown `![](/U
 - **注册到 `workspace-controller`**:该包负责 workspace 注册表生命周期(CRUD、排序、feed),与文件呈现只共享 registry 这一政策数据源。否决并完整回滚。
 - **经会话 RPC 取字节后显示 blob/data URL**:附件图片已如此工作,但 markdown 重写需要渲染时确定性同步 URL(流式冻结缓存、memo 化);异步往返不能成为渲染缝。否决。
 - **逐图片或仅图片专用路由**:媒体类型共享同一条「路径 + 包含」政策,视频/音频本就需要 Range 流式;一条 `/api/file` 路由加扩展名 allowlist 即可覆盖现有与后续媒体类型。作为更窄的方案被否决。
-- **图片扩展的字节签名校验**:否决。相同检查已在 `fs/tool-fs` 的 `read_image` 工具内实现,仓库的跨文件克隆门禁止在此复制,而为单个辅助函数加宽 attachment 包公开 API 没有共享归属。扩展名 allowlist 已把非媒体内容挡在门外,损坏的图片载荷失败发生在浏览器侧而不是路由上。
+- **手写 MIME 表、Range 解析与图片签名校验**:否决,改用维护中的包(`mime-types`、`range-parser`)且不做嗅探其中字节签名校验被否决的理由是:相同检查已在 `fs/tool-fs` 的 `read_image` 工具内实现,仓库的跨文件克隆门禁止在此复制,而为单个辅助函数加宽 attachment 包公开 API 没有共享归属。扩展名 allowlist 已把非媒体内容挡在门外,损坏的图片载荷失败发生在浏览器侧而不是路由上。
 - **每请求交互授权、客户端协商端点或任意 Host 路径**:重写只是呈现;路由对每次请求复验,可读字节限制在注册 workspace 根与 allowlist 媒体内;同源端点是固定通道契约而非协商能力。出于安全与确定性否决。
 
 ## Consequences

+ 4 - 0
THIRD_PARTY_NOTICES.md

@@ -89,11 +89,13 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
 | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
 | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
+| [`mime-types`](https://github.com/jshttp/mime-types) | MIT |
 | [`negotiator`](https://github.com/jshttp/negotiator) | MIT |
 | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
 | [`node-pty`](https://github.com/microsoft/node-pty) | MIT |
 | [`open`](https://github.com/sindresorhus/open) | MIT |
 | [`picomatch`](https://github.com/micromatch/picomatch) | MIT |
+| [`range-parser`](https://github.com/jshttp/range-parser) | MIT |
 | [`react`](https://github.com/facebook/react) | MIT |
 | [`react-dom`](https://github.com/facebook/react) | MIT |
 | [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT |
@@ -152,9 +154,11 @@ External packages **directly declared** only by repository tooling, test infrast
 | [`@types/fs-ext`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/range-parser`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/readable-stream`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |

+ 6 - 2
packages/api/session-controller/package.json

@@ -71,6 +71,8 @@
     "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-deque": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^",
+    "mime-types": "^3.0.2",
+    "range-parser": "^1.3.0",
     "zod": "^4.4.3"
   },
   "peerDependencies": {
@@ -122,8 +124,8 @@
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-file-upload": "workspace:^",
-    "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
+    "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-file-reference": "workspace:^",
     "@deepseek-ai/dsh-jobs": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
@@ -146,6 +148,8 @@
     "@deepseek-ai/dsh-util-crypto": "workspace:^",
     "@deepseek-ai/dsh-util-time": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
-    "@deepseek-ai/dsh-workspace": "workspace:^"
+    "@deepseek-ai/dsh-workspace": "workspace:^",
+    "@types/mime-types": "^3.0.1",
+    "@types/range-parser": "^1.2.7"
   }
 }

+ 52 - 94
packages/api/session-controller/src/media-references.ts

@@ -10,121 +10,67 @@
  * - its canonical location must lie inside a registered workspace root
  *   (`ctx.workspaceRegistry`); no other directory is readable;
  * - the file must exist and be a regular file;
- * - its extension must name an allowlisted media type (media bytes are never
- *   sniffed here: the allowlist keeps non-media content out, and browsers
- *   already reject corrupt image payloads);
- * - responses are private, uncached, sniff-proof, and support HTTP range
- *   requests so `<video>`/`<audio>` can seek without buffering the file.
+ * - its MIME type (resolved by `mime-types`) must belong to the served
+ *   categories image/video/audio, excluding `image/svg+xml`; media bytes are
+ *   never sniffed here, and a corrupt payload fails in the browser;
+ * - responses are private, uncached, sniff-proof, and stream with HTTP range
+ *   support (parsed by `range-parser`) so `<video>`/`<audio>` can seek.
  *
  * The route is deliberately presentational: it never writes and follows no
  * redirects, returning 400/403/404/415/416 instead of falling back to any
  * other file-serving behavior.
  *
- * The package entry imports only `SessionMediaReferences`; the remaining
- * module exports exist for same-package unit tests and are not part of the
- * package's public API.
+ * Only the plugin contribution below is exported: package-internal policy
+ * helpers stay module-private and are exercised through the registered route.
  * @module @deepseek-ai/dsh-api-session-controller/media-references
  */
 
 import { createReadStream } from 'node:fs'
 import { realpath, stat } from 'node:fs/promises'
-import { extname, isAbsolute, sep } from 'node:path'
+import { isAbsolute, sep } from 'node:path'
 import { Readable } from 'node:stream'
 import type { Context } from '@deepseek-ai/cordis'
 // Cordis `ctx.connection` typing and the fetch-route contract.
 import type {} from '@deepseek-ai/dsh-client-connection'
 // Cordis `ctx.workspaceRegistry` typing.
 import type {} from '@deepseek-ai/dsh-workspace'
+import mime from 'mime-types'
+import rangeParser from 'range-parser'
 
-/** Registered-workspace view the route reads; see the `workspaceRegistry` service. */
-export interface MediaReferenceRegistry {
-  /** List registered workspaces with their canonical root directories. */
-  list(): readonly { path: string }[]
-}
-
-/** Media extensions the route serves, mapped to their content types. */
-const MEDIA_TYPES: Readonly<Record<string, string>> = {
-  '.png': 'image/png',
-  '.jpg': 'image/jpeg',
-  '.jpeg': 'image/jpeg',
-  '.gif': 'image/gif',
-  '.webp': 'image/webp',
-  '.avif': 'image/avif',
-  '.mp4': 'video/mp4',
-  '.webm': 'video/webm',
-  '.mov': 'video/quicktime',
-  '.ogv': 'video/ogg',
-  '.mp3': 'audio/mpeg',
-  '.wav': 'audio/wav',
-  '.ogg': 'audio/ogg',
-  '.oga': 'audio/ogg',
-  '.m4a': 'audio/mp4',
-  '.aac': 'audio/aac',
-  '.flac': 'audio/flac',
-}
+/** Media categories this route serves. */
+const MEDIA_CATEGORIES: ReadonlySet<string> = new Set(['image', 'video', 'audio'])
+/** Category matches that still must not be served. */
+const DENIED_MEDIA_TYPES: ReadonlySet<string> = new Set(['image/svg+xml'])
 
 /**
- * The content type a file path may be served as, or undefined when the
- * extension is not an allowlisted media type.
+ * The content type a file path may be served as, or undefined when it is not
+ * an allowlisted media type.
  * @param path - Canonical file path (extension only is read).
  * @returns the content type, or undefined to refuse the file.
  */
-export function mediaTypeForPath(path: string): string | undefined {
-  return MEDIA_TYPES[extname(path).toLowerCase()]
-}
-
-/** One resolved byte-range within a file; `full` streams the whole file. */
-type ByteRange =
-  | { readonly kind: 'full' }
-  | { readonly kind: 'partial'; readonly start: number; readonly end: number }
-
-/**
- * Parse one single-range `Range` header value against the file size.
- * @param header - Raw `Range` request header, or null when absent.
- * @param size - Total file size in bytes.
- * @returns the byte range to serve, or undefined when the header names no
- * satisfiable single range (the caller answers 416 with a `bytes *\/size`
- * Content-Range header).
- */
-export function parseByteRange(header: string | null, size: number): ByteRange | undefined {
-  if (header === null) return { kind: 'full' }
-  const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
-  if (match === null) return undefined
-  const startText = match[1]
-  const endText = match[2]
-  if (startText === '' && endText === '') return undefined
-  let start: number
-  let end: number
-  if (startText === '') {
-    // Suffix range: the last N bytes.
-    const length = Number(endText)
-    if (length === 0) return undefined
-    start = Math.max(size - length, 0)
-    end = size - 1
-  } else {
-    start = Number(startText)
-    end = endText === '' ? size - 1 : Number(endText)
-  }
-  if (start > end || start >= size) return undefined
-  return { kind: 'partial', start, end: Math.min(end, size - 1) }
+function mediaTypeForPath(path: string): string | undefined {
+  const looked = mime.lookup(path)
+  if (looked === false) return undefined
+  if (DENIED_MEDIA_TYPES.has(looked)) return undefined
+  const category = looked.slice(0, looked.indexOf('/'))
+  return MEDIA_CATEGORIES.has(category) ? looked : undefined
 }
 
 /**
  * Serve one workspace-contained media file over the shared API channel.
  * @param request - Authenticated fetch-route request (GET or HEAD).
- * @param registry - Workspace registry; absent (or empty) denies everything.
+ * @param roots - Registered workspace root directories.
  * @returns A streaming media response or a fail-closed status.
  */
-export async function serveMediaReference(
+async function serveMediaReference(
   request: Request,
-  registry: MediaReferenceRegistry | undefined,
+  roots: readonly { path: string }[],
 ): Promise<Response> {
   const path = new URL(request.url).searchParams.get('path')
   if (path === null || path.length === 0) return new Response('missing path', { status: 400 })
   if (path.includes('\0') || !isAbsolute(path)) {
     return new Response('absolute path required', { status: 400 })
   }
-  if (registry === undefined) return new Response('file serving is unavailable', { status: 403 })
   let canonical: string
   let info
   try {
@@ -133,7 +79,7 @@ export async function serveMediaReference(
   } catch {
     return new Response('not found', { status: 404 })
   }
-  const insideWorkspace = registry.list().some(root =>
+  const insideWorkspace = roots.some(root =>
     canonical === root.path || canonical.startsWith(root.path + sep))
   if (!insideWorkspace) return new Response('outside workspace roots', { status: 403 })
   if (!info.isFile()) return new Response('not a regular file', { status: 403 })
@@ -141,29 +87,41 @@ export async function serveMediaReference(
   if (mediaType === undefined) {
     return new Response('not an allowlisted media type', { status: 415 })
   }
-  const range = parseByteRange(request.headers.get('range'), info.size)
-  if (range === undefined) {
-    const headers: Record<string, string> = {
-      'Content-Range': 'bytes */' + String(info.size),
-      'Cache-Control': 'private, no-store',
-      'X-Content-Type-Options': 'nosniff',
+  const total = info.size
+  const rangeHeader = request.headers.get('range')
+  let start = 0
+  let end = total - 1
+  let partial = false
+  if (rangeHeader !== null) {
+    const parsed = rangeParser(total, rangeHeader)
+    // -1 (unsatisfiable) and -2 (malformed) both answer the same terminal
+    // way; array results always carry at least one range.
+    const first = Array.isArray(parsed) ? parsed[0] : undefined
+    if (first === undefined) {
+      const headers: Record<string, string> = {
+        'Content-Range': 'bytes */' + String(total),
+        'Cache-Control': 'private, no-store',
+        'X-Content-Type-Options': 'nosniff',
+      }
+      return new Response(null, { status: 416, headers })
     }
-    return new Response(null, { status: 416, headers })
+    start = first.start
+    end = first.end
+    partial = true
   }
-  const partial = range.kind === 'partial'
-  const start = partial ? range.start : 0
-  const end = partial ? range.end : info.size - 1
   const body: ReadableStream<Uint8Array> = Readable.toWeb(
-    createReadStream(canonical, { start, end }),
+    createReadStream(canonical, partial ? { start, end } : undefined),
   ) as ReadableStream<Uint8Array>
   const headers: Record<string, string> = {
     'Content-Type': mediaType,
-    'Content-Length': String(partial ? end - start + 1 : info.size),
+    'Content-Length': String(partial ? end - start + 1 : total),
     'Accept-Ranges': 'bytes',
     'Cache-Control': 'private, no-store',
     'X-Content-Type-Options': 'nosniff',
   }
-  if (partial) headers['Content-Range'] = 'bytes ' + String(start) + '-' + String(end) + '/' + String(info.size)
+  if (partial) {
+    headers['Content-Range'] = 'bytes ' + String(start) + '-' + String(end) + '/' + String(total)
+  }
   const head = request.method === 'HEAD'
   return new Response(head ? null : body, { status: partial ? 206 : 200, headers })
 }
@@ -186,7 +144,7 @@ export const SessionMediaReferences = {
       path: '/api/file',
       methods: ['GET', 'HEAD'],
       requestBody: 'buffered',
-      fetch: request => serveMediaReference(request, ctx.workspaceRegistry),
+      fetch: request => serveMediaReference(request, ctx.workspaceRegistry.list()),
     }), 'session-controller: /api/file')
   },
 }

+ 141 - 171
packages/api/session-controller/tests/media-references.host.spec.ts

@@ -3,83 +3,70 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
-import {
-  SessionMediaReferences,
-  serveMediaReference,
-  mediaTypeForPath,
-  parseByteRange,
-  type MediaReferenceRegistry,
-} from '../src/media-references.ts'
+import { SessionMediaReferences } from '../src/media-references.ts'
 
-const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4, 5, 6])
+const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4, 5, 6, 7, 8])
 const TEXT_BYTES = new Uint8Array([1, 2, 3, 4])
 const MP4_BYTES = new TextEncoder().encode('....ftypmp42....moov....')
 
-function registry(root: string): MediaReferenceRegistry {
-  return { list: () => [{ path: root }] }
-}
-
 async function responseBytes(response: Response): Promise<Uint8Array> {
   return new Uint8Array(await response.arrayBuffer())
 }
 
-function apiRequest(path: string, init?: RequestInit): Request {
-  const url = `http://127.0.0.1:3080/api/file?path=${encodeURIComponent(path)}`
-  return new Request(url, init)
+/** One mounted plugin contribution with its registered `/api/file` handler. */
+interface MountedRoute {
+  /** GET/HEAD helper for one local path. */
+  call(path: string, init?: RequestInit): Promise<Response>
+  /** Direct request access (custom URLs and headers). */
+  raw(url: string, init?: RequestInit): Promise<Response>
+  unregister: ReturnType<typeof vi.fn>
 }
 
-describe('mediaTypeForPath', () => {
-  it('maps allowlisted media extensions', () => {
-    expect(mediaTypeForPath('/w/graph.png')).toBe('image/png')
-    expect(mediaTypeForPath('/w/graph.jpg')).toBe('image/jpeg')
-    expect(mediaTypeForPath('/w/clip.mp4')).toBe('video/mp4')
-    expect(mediaTypeForPath('/w/clip.webm')).toBe('video/webm')
-    expect(mediaTypeForPath('/w/song.mp3')).toBe('audio/mpeg')
-  })
-
-  it('refuses non-media extensions', () => {
-    expect(mediaTypeForPath('/w/note.txt')).toBeUndefined()
-    expect(mediaTypeForPath('/w/app.exe')).toBeUndefined()
-    expect(mediaTypeForPath('/w/shell.svg')).toBeUndefined()
-  })
-})
-
-describe('parseByteRange', () => {
-  it('parses full, bounded, open-ended, and suffix ranges', () => {
-    expect(parseByteRange(null, 100)).toEqual({ kind: 'full' })
-    expect(parseByteRange('bytes=0-3', 100)).toEqual({ kind: 'partial', start: 0, end: 3 })
-    expect(parseByteRange('bytes=10-', 100)).toEqual({ kind: 'partial', start: 10, end: 99 })
-    expect(parseByteRange('bytes=-4', 100)).toEqual({ kind: 'partial', start: 96, end: 99 })
-    expect(parseByteRange('bytes=90-200', 100)).toEqual({ kind: 'partial', start: 90, end: 99 })
-  })
-
-  it('refuses malformed and unsatisfiable ranges', () => {
-    expect(parseByteRange('bytes=abc', 100)).toBeUndefined()
-    expect(parseByteRange('bytes=-', 100)).toBeUndefined()
-    expect(parseByteRange('bytes=-0', 100)).toBeUndefined()
-    expect(parseByteRange('bytes=5-2', 100)).toBeUndefined()
-    expect(parseByteRange('bytes=100-', 100)).toBeUndefined()
-    expect(parseByteRange('bytes=0-', 0)).toBeUndefined()
-  })
-})
-
-describe('serveMediaReference', () => {
+describe('SessionMediaReferences /api/file', () => {
   let root: string
+  let route: MountedRoute
+  const contexts: Context[] = []
 
   beforeEach(async () => {
     // Real workspace roots are canonical; tmpdir may sit behind a symlink.
-    root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-workspace-file-')))
+    root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-media-references-')))
+    const ctx = new Context()
+    contexts.push(ctx)
+    let handler: ((request: Request) => Promise<Response>) | undefined
+    const unregister = vi.fn(() => {})
+    ctx.provide('connection', {
+      fetch: {
+        register: (registered: { fetch: (request: Request) => Promise<Response> }) => {
+          handler = registered.fetch
+          return unregister
+        },
+      },
+    } as never)
+    ctx.provide('workspaceRegistry', { list: () => [{ path: root }] } as never)
+    await ctx.plugin(SessionMediaReferences).await()
+    const fetchFile = (request: Request) => {
+      if (handler === undefined) throw new Error('route not registered')
+      return handler(request)
+    }
+    route = {
+      call: (path, init) => {
+        const url = `http://127.0.0.1:3080/api/file?path=${encodeURIComponent(path)}`
+        return fetchFile(new Request(url, init))
+      },
+      raw: (url, init) => fetchFile(new Request(url, init)),
+      unregister,
+    }
   })
 
   afterEach(async () => {
+    await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
     await rm(root, { recursive: true, force: true })
   })
 
-  it('serves a workspace image with streaming-safe headers', async () => {
-    const path = join(root, 'shots', 'graph.png')
-    await mkdir(join(root, 'shots'), { recursive: true })
-    await writeFile(path, PNG_BYTES)
-    const response = await serveMediaReference(apiRequest(path), registry(root))
+  it('serves images from the workspace root and nested directories', async () => {
+    const direct = join(root, 'graph.png')
+    await writeFile(direct, PNG_BYTES)
+    const response = await route.call(direct)
     expect(response.status).toBe(200)
     expect(response.headers.get('content-type')).toBe('image/png')
     expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length))
@@ -87,83 +74,112 @@ describe('serveMediaReference', () => {
     expect(response.headers.get('cache-control')).toBe('private, no-store')
     expect(response.headers.get('x-content-type-options')).toBe('nosniff')
     expect(await responseBytes(response)).toEqual(PNG_BYTES)
+
+    const nestedDir = join(root, 'shots', 'deep')
+    await mkdir(nestedDir, { recursive: true })
+    const nested = join(nestedDir, 'x.png')
+    await writeFile(nested, PNG_BYTES)
+    expect((await route.call(nested)).status).toBe(200)
   })
 
-  it('serves media without image sniffing from the extension allowlist', async () => {
-    const path = join(root, 'clip.mp4')
-    await writeFile(path, MP4_BYTES)
-    const response = await serveMediaReference(apiRequest(path), registry(root))
+  it('serves every allowlisted media category by extension', async () => {
+    const video = join(root, 'clip.mp4')
+    await writeFile(video, MP4_BYTES)
+    const videoResponse = await route.call(video)
+    expect(videoResponse.status).toBe(200)
+    expect(videoResponse.headers.get('content-type')).toBe('video/mp4')
+
+    const audio = join(root, 'song.mp3')
+    await writeFile(audio, TEXT_BYTES)
+    const audioResponse = await route.call(audio)
+    expect(audioResponse.status).toBe(200)
+    expect(audioResponse.headers.get('content-type')).toBe('audio/mpeg')
+  })
+
+  it('serves allowlisted image extensions regardless of payload bytes', async () => {
+    // Media bytes are never sniffed on this route: a corrupt payload fails
+    // in the browser, not here.
+    const fake = join(root, 'fake.png')
+    await writeFile(fake, TEXT_BYTES)
+    const response = await route.call(fake)
     expect(response.status).toBe(200)
-    expect(response.headers.get('content-type')).toBe('video/mp4')
-    expect(await responseBytes(response)).toEqual(MP4_BYTES)
+    expect(response.headers.get('content-type')).toBe('image/png')
   })
 
-  it('answers bounded and suffix range requests with 206 slices', async () => {
+  it('refuses non-media, unknown, and denied content types', async () => {
+    const cases: ReadonlyArray<[string, Uint8Array]> = [
+      ['note.txt', TEXT_BYTES],
+      ['run.exe', TEXT_BYTES],
+      ['logo.svg', TEXT_BYTES],
+      ['data.bin', TEXT_BYTES],
+      ['README', TEXT_BYTES],
+    ]
+    for (const [name, bytes] of cases) {
+      const path = join(root, name)
+      await writeFile(path, bytes)
+      const response = await route.call(path)
+      expect(response.status, name).toBe(415)
+      expect(await response.text()).toBe('not an allowlisted media type')
+    }
+  })
+
+  it('answers bounded, open-ended, suffix, and clamped range requests', async () => {
     const path = join(root, 'graph.png')
     await writeFile(path, PNG_BYTES)
-    const bounded = await serveMediaReference(
-      apiRequest(path, { headers: { range: 'bytes=0-3' } }),
-      registry(root),
-    )
+    const total = PNG_BYTES.length
+
+    const bounded = await route.call(path, { headers: { range: 'bytes=0-3' } })
     expect(bounded.status).toBe(206)
-    expect(bounded.headers.get('content-range')).toBe(`bytes 0-3/${PNG_BYTES.length}`)
+    expect(bounded.headers.get('content-range')).toBe(`bytes 0-3/${total}`)
     expect(bounded.headers.get('content-length')).toBe('4')
     expect(await responseBytes(bounded)).toEqual(PNG_BYTES.slice(0, 4))
 
-    const suffix = await serveMediaReference(
-      apiRequest(path, { headers: { range: 'bytes=-4' } }),
-      registry(root),
-    )
+    const suffix = await route.call(path, { headers: { range: 'bytes=-4' } })
     expect(suffix.status).toBe(206)
     expect(await responseBytes(suffix)).toEqual(PNG_BYTES.slice(-4))
+
+    const openEnded = await route.call(path, { headers: { range: 'bytes=4-' } })
+    expect(openEnded.status).toBe(206)
+    expect(await responseBytes(openEnded)).toEqual(PNG_BYTES.slice(4))
+
+    const clamped = await route.call(path, { headers: { range: 'bytes=6-999' } })
+    expect(clamped.status).toBe(206)
+    expect(clamped.headers.get('content-range')).toBe(`bytes 6-${total - 1}/${total}`)
+    expect(await responseBytes(clamped)).toEqual(PNG_BYTES.slice(6))
   })
 
-  it('answers unsatisfiable ranges with 416 and the total size', async () => {
+  it('answers unsatisfiable and malformed ranges with 416 and the total size', async () => {
     const path = join(root, 'graph.png')
     await writeFile(path, PNG_BYTES)
-    const response = await serveMediaReference(
-      apiRequest(path, { headers: { range: 'bytes=999-' } }),
-      registry(root),
-    )
-    expect(response.status).toBe(416)
-    expect(response.headers.get('content-range')).toBe(`bytes */${PNG_BYTES.length}`)
+    for (const range of ['bytes=999-', 'bytes=abc']) {
+      const response = await route.call(path, { headers: { range } })
+      expect(response.status, range).toBe(416)
+      expect(response.headers.get('content-range')).toBe(`bytes */${PNG_BYTES.length}`)
+    }
   })
 
   it('answers HEAD without a body', async () => {
     const path = join(root, 'graph.png')
     await writeFile(path, PNG_BYTES)
-    const response = await serveMediaReference(
-      apiRequest(path, { method: 'HEAD' }),
-      registry(root),
-    )
+    const response = await route.call(path, { method: 'HEAD' })
     expect(response.status).toBe(200)
     expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length))
     expect(response.body).toBeNull()
   })
 
   it('denies malformed, missing, and uncontained requests', async () => {
-    const missing = await serveMediaReference(
-      new Request('http://127.0.0.1:3080/api/file'),
-      registry(root),
-    )
-    expect(missing.status).toBe(400)
-    const relative = await serveMediaReference(
-      new Request(`http://127.0.0.1:3080/api/file?path=${encodeURIComponent('x.png')}`),
-      registry(root),
-    )
-    expect(relative.status).toBe(400)
-    const gone = await serveMediaReference(
-      apiRequest(join(root, 'missing.png')),
-      registry(root),
-    )
-    expect(gone.status).toBe(404)
-    expect((await serveMediaReference(apiRequest(join(root, 'x.png')), undefined)).status).toBe(403)
-
-    const outside = await realpath(await mkdtemp(join(tmpdir(), 'dsh-workspace-file-out-')))
+    expect((await route.raw('http://127.0.0.1:3080/api/file')).status).toBe(400)
+    expect((await route.raw(`http://127.0.0.1:3080/api/file?path=${encodeURIComponent('x.png')}`)).status)
+      .toBe(400)
+    expect((await route.raw(`http://127.0.0.1:3080/api/file?path=${encodeURIComponent('/a\0b.png')}`)).status)
+      .toBe(400)
+    expect((await route.call(join(root, 'missing.png'))).status).toBe(404)
+
+    const outside = await realpath(await mkdtemp(join(tmpdir(), 'dsh-media-references-out-')))
     const path = join(outside, 'x.png')
     await writeFile(path, PNG_BYTES)
     try {
-      const denied = await serveMediaReference(apiRequest(path), registry(root))
+      const denied = await route.call(path)
       expect(denied.status).toBe(403)
       expect(await denied.text()).toBe('outside workspace roots')
     } finally {
@@ -171,36 +187,33 @@ describe('serveMediaReference', () => {
     }
   })
 
-  it('refuses directories and non-allowlisted extensions', async () => {
-    const directory = await serveMediaReference(apiRequest(root), registry(root))
-    expect(directory.status).toBe(403)
-
-    const text = join(root, 'note.txt')
-    await writeFile(text, TEXT_BYTES)
-    expect((await serveMediaReference(apiRequest(text), registry(root))).status).toBe(415)
+  it('refuses the workspace-root directory itself', async () => {
+    const response = await route.call(root)
+    expect(response.status).toBe(403)
+    expect(await response.text()).toBe('not a regular file')
   })
 
-  it('serves allowlisted extensions regardless of payload bytes', async () => {
-    // Media bytes are not sniffed here: the extension allowlist decides what
-    // is served, and a corrupt image payload stays a browser-side failure.
-    const fake = join(root, 'fake.png')
-    await writeFile(fake, TEXT_BYTES)
-    const response = await serveMediaReference(apiRequest(fake), registry(root))
-    expect(response.status).toBe(200)
-    expect(response.headers.get('content-type')).toBe('image/png')
-  })
-
-  it('follows a symlink into the workspace for the containment check', async () => {
+  it('follows symlinks for the containment check', async () => {
     const real = join(root, 'real.png')
     await writeFile(real, PNG_BYTES)
     const link = join(root, 'link.png')
     await symlink(real, link)
-    const response = await serveMediaReference(apiRequest(link), registry(root))
-    expect(response.status).toBe(200)
-    expect(await responseBytes(response)).toEqual(PNG_BYTES)
+    expect((await route.call(link)).status).toBe(200)
+
+    const outsideTarget = join(await realpath(tmpdir()), 'dsh-media-references-target.png')
+    await writeFile(outsideTarget, PNG_BYTES)
+    try {
+      const escaping = join(root, 'escape.png')
+      await symlink(outsideTarget, escaping)
+      const denied = await route.call(escaping)
+      expect(denied.status).toBe(403)
+      expect(await denied.text()).toBe('outside workspace roots')
+    } finally {
+      await rm(outsideTarget, { force: true })
+    }
   })
 
-  it('survives a large sparse file check without buffering it whole', async () => {
+  it('streams a slice of a large sparse file without buffering it whole', async () => {
     const path = join(root, 'huge.mp4')
     const handle = await open(path, 'w')
     try {
@@ -208,56 +221,13 @@ describe('serveMediaReference', () => {
     } finally {
       await handle.close()
     }
-    const response = await serveMediaReference(
-      apiRequest(path, { headers: { range: 'bytes=0-9' } }),
-      registry(root),
-    )
+    const response = await route.call(path, { headers: { range: 'bytes=0-9' } })
     expect(response.status).toBe(206)
     expect(response.headers.get('content-length')).toBe('10')
     expect((await responseBytes(response)).length).toBe(10)
   })
-})
-
-describe('SessionMediaReferences plugin contribution', () => {
-  const roots: Context[] = []
-
-  afterEach(async () => {
-    await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
-  })
-
-  it('registers the /api/file route when connection and registry are composed', async () => {
-    const unregister = vi.fn(() => {})
-    let registered: {
-      path: string
-      methods: readonly string[]
-      requestBody: 'buffered'
-      fetch: (request: Request) => Promise<Response>
-    } | undefined
-    const register = vi.fn((route: typeof registered) => {
-      registered = route
-      return unregister
-    })
-    const ctx = new Context()
-    roots.push(ctx)
-    ctx.provide('connection', { fetch: { register } } as never)
-    ctx.provide('workspaceRegistry', { list: () => [] } as never)
-    await ctx.plugin(SessionMediaReferences).await()
-
-    expect(register).toHaveBeenCalledTimes(1)
-    expect(registered?.path).toBe('/api/file')
-    expect(registered?.methods).toEqual(['GET', 'HEAD'])
-    expect(registered?.requestBody).toBe('buffered')
-
-    // The composed route consults the composed registry: an empty registry
-    // refuses an existing absolute path with the containment verdict.
-    const existing = await realpath(tmpdir())
-    const denied = await registered?.fetch(
-      new Request(`http://127.0.0.1:3080/api/file?path=${encodeURIComponent(existing)}`),
-    )
-    expect(denied?.status).toBe(403)
-    expect(await denied?.text()).toBe('outside workspace roots')
 
-    await ctx.fiber.dispose()
-    expect(unregister).toHaveBeenCalledTimes(1)
+  it('keeps the route registered until the contribution is disposed', () => {
+    expect(route.unregister).not.toHaveBeenCalled()
   })
 })

+ 17 - 0
pnpm-lock.yaml

@@ -849,6 +849,12 @@ importers:
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
+      mime-types:
+        specifier: ^3.0.2
+        version: 3.0.2
+      range-parser:
+        specifier: ^1.3.0
+        version: 1.3.0
       zod:
         specifier: ^4.4.3
         version: 4.4.3
@@ -952,6 +958,12 @@ importers:
       '@deepseek-ai/dsh-workspace':
         specifier: workspace:^
         version: link:../../workspace/workspace
+      '@types/mime-types':
+        specifier: ^3.0.1
+        version: 3.0.1
+      '@types/range-parser':
+        specifier: ^1.2.7
+        version: 1.2.7
 
   packages/api/settings-controller:
     dependencies:
@@ -13104,6 +13116,9 @@ packages:
   '@types/mdurl@2.0.0':
     resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
 
+  '@types/mime-types@3.0.1':
+    resolution: {integrity: sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==}
+
   '@types/ms@2.1.0':
     resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
 
@@ -18781,6 +18796,8 @@ snapshots:
 
   '@types/mdurl@2.0.0': {}
 
+  '@types/mime-types@3.0.1': {}
+
   '@types/ms@2.1.0': {}
 
   '@types/negotiator@0.6.5': {}