mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-13 04:03:30 +00:00
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:
+2
-2
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ Assistant 正文有时用本地文件系统路径引用图片(markdown `,与文件呈现只共享 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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Generated
+17
@@ -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': {}
|
||||
|
||||
Reference in New Issue
Block a user