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.
This commit is contained in:
_Kerman
2026-09-07 16:35:15 +08:00
parent f81c5072bd
commit 4b009e7f94
8 changed files with 223 additions and 270 deletions
@@ -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
@@ -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.
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
@@ -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')
},
}
@@ -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)
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-workspace-file-out-')))
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
View File
@@ -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': {}