Browse Source

fix(api): address /api/file review findings

- HEAD never opens a file stream; GET streams are destroyed on client
  abort and are never opened for already-aborted requests.
- Validation and reading bind to the same opened file: pre-open and
  opened stat identities are compared, so replacement/re-linking races
  are refused (dev/ino check, read through the FileHandle).
- Workspace containment compares path components, so a filesystem-root
  workspace serves files instead of doubling the separator.
- Multi-range headers are ignored for a full 200 body instead of a
  mislabeled single-segment 206; single-range behavior unchanged.
- Tests now cover the reviewed edges (empty path, multi-range, root
  workspace, HEAD+range, mid/early abort, unreadable file, explicit
  unregister-on-dispose, private outside-target directories); module
  coverage is back at 100/100/100/100.
- Agent Note bilingual facts and the ui-primitives/session-controller
  README pairs are synced to the shipped behavior (pairs re-recorded).
_Kerman 1 month ago
parent
commit
8b8a051c7a

+ 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: 79bde26e1890bf463f7a954e9b84d81e87ecbd1e
-2026-09-07-session-prose-local-media-display.zh.md: af852a627888353589e4a88a4198bfb32760adbe
+2026-09-07-session-prose-local-media-display.md: 6599d3e7c1e9838f9191e339fcca3bd811951af8
+2026-09-07-session-prose-local-media-display.zh.md: 8d2121dd206a0719a48a09e91973eb5edf970a6a

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

@@ -13,8 +13,8 @@ Assistant prose sometimes references an image by its local filesystem path (mark
 Local media paths in session prose render through one same-origin file route, with the rewrite vocabulary and the serving policy each owned where the repo's seams say they belong.
 
 - **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 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).
+- **Chat wiring (`ui-chat`)**: `AssistantMarkdown` supplies a page-stable vocabulary (`localPathMediaUrl`, module-private in the same file) mapping absolute POSIX paths to same-origin `/api/file?path=…` GETs; non-HTTP transports (Electron `file://`) and relative/protocol-relative destinations stay inert. Windows-style host paths (`C:\...`) are deliberately inert on the client side and are recorded as a limitation in Consequences.
+- **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 (path-component containment, including a filesystem-root workspace), 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. Validation and reading bind to the same opened file: the pre-open and opened stat identities are compared, so a replacement or re-linking race is refused instead of followed. Responses stream with HTTP range support parsed by the `range-parser` package: single-range requests answer 206, multi-range headers are ignored for a full 200 body, HEAD never opens a file stream, and an aborted client destroys the stream. Responses 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.
 
@@ -30,11 +30,13 @@ The route is presentational and stateless: it never writes, follows no redirects
 ## Consequences
 
 - Assistant prose that references workspace-contained image files now displays them in Web chat; previously inert alt text disappears only where the host can serve the bytes.
-- Policy is enforced host-side per request; the client vocabulary never expands what the route allows. Media typing trusts the extension allowlist; byte-signature validation stays with the `read_image` tool that owns it.
+- Policy is enforced host-side per request; the client vocabulary never expands what the route allows. Media typing trusts the MIME category allowlist; byte-signature validation stays with the `read_image` tool that owns it.
+- Existence disclosure: an authenticated same-origin client can tell an existing-but-outside-workspace path (403) from a nonexistent one (404). Readable bytes stay confined to workspace roots, so the impact is limited to existence probing, and the distinct statuses are kept intentionally.
+- Windows-host limitation: the client vocabulary rewrites only absolute POSIX paths, so a Windows-style `C:\...` destination stays inert alt text even though the route's own `isAbsolute` would accept it; serving such references is deferred.
 - Scope is deliberately narrow: only files under registered workspace roots with allowlisted media types are served; anything else keeps the authored fallback. Trajectory and tool-card markdown consumers do not pass a vocabulary yet, and video/audio markdown nodes are not rendered as `<video>`/`<audio>` yet — the URL layer already supports them.
 - The client hardcodes the `/api/file` endpoint as a same-origin channel contract; this is stable by construction because the page is served by the same host that mounts the route.
 - Related history: the archived note [model-readable image paths](../../archived/feature/2026-08-21-model-readable-image-paths.md) decided the model-facing side of local image paths; this note owns the user-facing display side and does not supersede it.
 
 ## Testing
 
-Unit coverage: the renderer seam (settled and streaming gates, reference-style images, protocol re-checks), the chat vocabulary and component wiring, and the host route policy (containment, symlinks, media allowlist and signature mismatch, range parsing and 206/416 answers, HEAD, disposal of the plugin contribution). Full local evidence at open: typecheck clean; ui-primitives, ui-chat, and session-controller suites (103 files, 1456 tests) pass. Deferred to the PR follow-up: keyless recorded-session replay for the end-to-end GUI path and the browser demo GIF the GUI PR evidence chain requires.
+Unit coverage: the renderer seam (settled and streaming gates, reference-style images, protocol re-checks), the chat vocabulary and component wiring, and the host route policy exercised through the registered route (containment incl. symlinks and filesystem-root workspaces, MIME category allowlist, single/multi-range and 416 answers, HEAD without a stream, client-abort destruction, unregister-on-dispose). `media-references.ts` sits at 100% statements/branches/functions/lines. Local gates at this state: typecheck, oxlint, duplication, translation pairing, and the touched suites all pass. Deferred to the PR follow-up: keyless recorded-session replay for the end-to-end GUI path and the browser demo GIF the GUI PR evidence chain requires.

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

@@ -13,8 +13,8 @@ Assistant 正文有时用本地文件系统路径引用图片(markdown `![](/U
 会话正文中的本地媒体路径通过一条同源文件路由渲染;重写词表与服务政策分别归属于仓库现有缝对应的位置。
 
 - **渲染缝(`ui-primitives`)**:`MarkdownText` 新增 `MarkdownPathImages` 词表(`pathImages` prop),与 `fileMentions` 同用 settled-only 门:消息流式期间冻结的缓存块绝不烘入词表处理器;落定渲染把未通过远程 URL 白名单的图片目标重写为可展示 URL,且只有结果是绝对 `http(s)`/`blob`/`data` URL 时才发射。不提供词表时渲染输出与之前逐字节一致。
-- **聊天接线(`ui-chat`)**:`AssistantMarkdown` 提供页面级稳定的词表,把绝对 POSIX 路径映射为同源 `GET /api/file?path=…`(`local-path-media.ts`);非 HTTP 载体(Electron `file://`)与相对/协议相对目标保持惰性。
-- **Host 路由(`session-controller`)**:`SessionMediaReferences` 是与 `SessionFileReferences` 并列注册的插件贡献,把 `GET|HEAD /api/file` 挂到共享鉴权 `connection.fetch` 通道(与 `/api` RPC 同一 trust fence 与浏览器认证)。每次请求 fail-closed:路径必须绝对、其 `realpath` 必须落在已注册 workspace 根内、文件必须是常规文件、扩展名必须在媒体 allowlist 内(PNG/JPEG/GIF/WebP/AVIF、MP4/WebM/MOV/OGG、MP3/WAV/Ogg/M4A/AAC/FLAC);图片扩展额外校验字节签名。响应以 HTTP Range 流式输出(206/416),视频/音频可拖动进度,并携带 `private, no-store` 与 `nosniff`。该贡献只在 `connection` 与 `workspaceRegistry` 均被组合时激活(pending-until-composed,与包内其它可选贡献一致)。
+- **聊天接线(`ui-chat`)**:`AssistantMarkdown` 提供页面级稳定的词表(`localPathMediaUrl`,与组件同文件的模块私有函数),把绝对 POSIX 路径映射为同源 `GET /api/file?path=…`;非 HTTP 载体(Electron `file://`)与相对/协议相对目标保持惰性。Windows 风格 Host 路径(`C:\...`)在客户端侧刻意保持惰性,作为局限记录于 Consequences。
+- **Host 路由(`session-controller`)**:`SessionMediaReferences` 是与 `SessionFileReferences` 并列注册的插件贡献,把 `GET|HEAD /api/file` 挂到共享鉴权 `connection.fetch` 通道(与 `/api` RPC 同一 trust fence 与浏览器认证)。每次请求 fail-closed:路径必须绝对、其 `realpath` 必须落在已注册 workspace 根内(按路径组件判定包含,含文件系统根目录作为 workspace 的情况)、文件必须是常规文件、其 MIME 类型(由 `mime-types` 解析)必须属于受服务类别 image/video/audio(排除 `image/svg+xml`);该路由从不嗅探媒体字节。校验与读取绑定同一个已打开文件:比较打开前后 stat 身份,替换/重链接竞态被拒绝而非跟随。响应由 `range-parser` 解析 Range 后流式输出:单段请求回 206,多段 Range 被忽略并回完整 200,HEAD 从不打开文件流,客户端中止即销毁流;响应携带 `private, no-store` 与 `nosniff`。该贡献只在 `connection` 与 `workspaceRegistry` 均被组合时激活(pending-until-composed,与包内其它可选贡献一致)。
 
 该路由纯呈现且无状态:从不写入、不跟随重定向,失败返回 400/403/404/415/416,不近似其它文件服务行为。
 
@@ -30,11 +30,13 @@ Assistant 正文有时用本地文件系统路径引用图片(markdown `![](/U
 ## Consequences
 
 - 引用 workspace 内图片文件的 Assistant 正文现在可在 Web 聊天中显示;原先惰性的 alt 文本只在 Host 无法提供字节时保留。
-- 政策在 Host 端每次请求强制;客户端词表不会扩大路由放行的范围。媒体类型判定信任扩展名 allowlist;字节签名校验保留在其所有者 `read_image` 工具中。
+- 政策在 Host 端每次请求强制;客户端词表不会扩大路由放行的范围。媒体类型判定信任 MIME 类别 allowlist;字节签名校验保留在其所有者 `read_image` 工具中。
+- 存在性泄露:已鉴权同源客户端可区分「存在但在 workspace 外」(403)与「不存在」(404)。可读字节仍被限定在 workspace 根内,影响仅限于存在性探测,保留不同状态码是有意决定。
+- Windows Host 局限:客户端词表只重写绝对 POSIX 路径,Windows 风格 `C:\...` 目标保持惰性 alt 文本(尽管路由侧 `isAbsolute` 本可接受);服务此类引用留待后续。
 - 范围刻意收窄:只服务注册 workspace 根内、媒体 allowlist 内的文件;其它一律保持作者原样的回退。Trajectory 与工具卡片等 markdown 消费方尚未传词表,视频/音频 markdown 节点也尚未渲染为 `<video>`/`<audio>`——URL 层已为它们准备好。
 - 客户端把 `/api/file` 硬编码为同源通道契约;由于页面与挂载路由的 Host 同源,该契约按构造稳定。
 - 相关历史:已归档笔记 [model-readable image paths](../../archived/feature/2026-08-21-model-readable-image-paths.md) 决定本地图片路径面向模型的一侧;本笔记拥有面向用户展示的一侧,不构成对其的取代。
 
 ## Testing
 
-单元覆盖:渲染缝(落定与流式门、引用式图片、协议复检)、聊天词表与组件接线、Host 路由政策(包含关系、符号链接、媒体 allowlist 与签名不符、Range 解析与 206/416 应答、HEAD、插件贡献的释放)。开 PR 时的本地证据:typecheck 干净;ui-primitives、ui-chat 与 session-controller 套件(103 个文件、1456 个用例)全过。推迟到 PR 后续:端到端 GUI 路径的 keyless 录播回放,以及 GUI PR 证据链要求的浏览器演示 GIF。
+单元覆盖:渲染缝(落定与流式门、引用式图片、协议复检)、聊天词表与组件接线、以及经注册路由施加的 Host 路由政策(包含关系含符号链接与文件系统根 workspace、MIME 类别 allowlist、单/多段 Range 与 416 应答、HEAD 不开流、客户端中止即销毁、释放即注销)。`media-references.ts` 语句/分支/函数/行覆盖率均为 100%。当前状态本地门禁:typecheck、oxlint、duplication、translation pairing 与受影响套件全部通过。推迟到 PR 后续:端到端 GUI 路径的 keyless 录播回放,以及 GUI PR 证据链要求的浏览器演示 GIF。

+ 2 - 2
packages/api/session-controller/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: ec8b70b1724afc462d2d0b5d267603bcab4aceb6
-README.zh.md: 6fc0c92300262bfdf0fc52deaec1d66313d91ebe
+README.md: f92fb3eec7fc44a4d83be916bcea80805e2ef2a0
+README.zh.md: 0b2c54e9ac1f42993a8b7eaebf24b0570108ee38

+ 7 - 0
packages/api/session-controller/README.md

@@ -13,6 +13,7 @@ English | [中文](README.zh.md)
 ## Table of Contents
 
 - [Use this package](#use-this-package)
+- [Session media references](#session-media-references)
 - [Configuration](#configuration)
 - [Model Experience](#model-experience)
 - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
@@ -31,6 +32,12 @@ The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream`
 
 The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
 
+
+<a id="session-media-references"></a>
+## Session media references
+
+`SessionMediaReferences` mounts `GET|HEAD /api/file?path=<absolute path>` on the authenticated `connection.fetch` channel when both `connection` and `workspaceRegistry` are composed, so conversation prose can display workspace-contained local media (images today; video/audio later) in the browser. Per request the route fail-closes: the path must be absolute, its `realpath` must lie inside a registered workspace root (path-component containment, including a filesystem-root workspace), the file must be regular, and its `mime-types` category must be image/video/audio (excluding `image/svg+xml`); media bytes are never sniffed. Validation and reading bind to the same opened file, so a replacement or re-linking race is refused. Single-range requests stream a 206 slice parsed by `range-parser`; multi-range headers are ignored for a full 200 body; HEAD never opens a file stream; an aborted client destroys it. Responses carry `private, no-store` and `nosniff`; failures answer 400/403/404/415/416. The client-side rewrite vocabulary lives in `ui-chat` (`AssistantMarkdown`); this package owns only the serving contract.
+
 -----
 
 <a id="configuration"></a>

+ 7 - 0
packages/api/session-controller/README.zh.md

@@ -13,6 +13,7 @@ kind: "package-reference"
 ## 目录
 
 - [使用本包](#use-this-package)
+- [会话媒体引用](#session-media-references)
 - [配置](#configuration)
 - [模型体验](#model-experience)
 - [已知限制与延期工作](#known-limitations-and-deferred-work)
@@ -31,6 +32,12 @@ Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 
 
 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
 
+
+<a id="session-media-references"></a>
+## 会话媒体引用
+
+当 `connection` 与 `workspaceRegistry` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`,使会话正文可以引用 workspace 内的本地媒体(图片今天可用;视频/音频后续同一条 URL)。每次请求 fail-closed:路径必须绝对、其 `realpath` 必须落在已注册 workspace 根内(按路径组件判定包含,含文件系统根目录作为 workspace)、文件必须是常规文件、其 `mime-types` 类别必须属于 image/video/audio(排除 `image/svg+xml`);该路由从不嗅探媒体字节。校验与读取绑定同一个已打开文件,替换/重链接竞态会被拒绝。单段 Range 请求由 `range-parser` 解析后流式返回 206 分片;多段 Range 头被忽略并返回完整 200;HEAD 从不打开文件流;客户端中止即销毁流。响应携带 `private, no-store` 与 `nosniff`;失败返回 400/403/404/415/416。客户端侧的重写词表位于 `ui-chat`(`AssistantMarkdown`);本包只拥有服务契约。
+
 -----
 
 <a id="configuration"></a>

+ 68 - 65
packages/api/session-controller/src/media-references.ts

@@ -9,12 +9,19 @@
  * - the requested path must be one absolute filesystem path;
  * - 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 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;
+ * - the file must exist and be a regular file; validation and reading bind to
+ *   the same opened file (a replacement or re-linking race between the
+ *   containment check and the read is detected by comparing the pre-open and
+ *   opened stat identities and refused);
+ * - multi-range requests are answered with the full 200 body (no Range is
+ *   honored) rather than a mislabeled single-segment 206;
  * - responses are private, uncached, sniff-proof, and stream with HTTP range
- *   support (parsed by `range-parser`) so `<video>`/`<audio>` can seek.
+ *   support (parsed by `range-parser`) so `<video>`/`<audio>` can seek; the
+ *   file stream is destroyed when the client aborts, and HEAD never opens
+ *   one.
  *
  * The route is deliberately presentational: it never writes and follows no
  * redirects, returning 400/403/404/415/416 instead of falling back to any
@@ -25,10 +32,9 @@
  * @module @deepseek-ai/dsh-api-session-controller/media-references
  */
 
-import { createReadStream } from 'node:fs'
-import { realpath, stat } from 'node:fs/promises'
+import { open, realpath, stat } from 'node:fs/promises'
 import { isAbsolute, sep } from 'node:path'
-import { Readable } from 'node:stream'
+import { Readable, addAbortSignal } 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'
@@ -37,24 +43,10 @@ import type {} from '@deepseek-ai/dsh-workspace'
 import mime from 'mime-types'
 import rangeParser from 'range-parser'
 
-/** 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 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.
- */
-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
-}
+/** MIME categories this route serves; `image/svg+xml` is refused separately because SVG can carry script. */
+const SERVED_MEDIA_TYPE = /^(?:image|video|audio)\//
+/** Headers every response carries: private, uncached, sniff-proof. */
+const BASE_HEADERS = { 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff' }
 
 /**
  * Serve one workspace-contained media file over the shared API channel.
@@ -72,58 +64,69 @@ async function serveMediaReference(
     return new Response('absolute path required', { status: 400 })
   }
   let canonical: string
-  let info
   try {
     canonical = await realpath(path)
-    info = await stat(canonical)
   } catch {
     return new Response('not found', { status: 404 })
   }
-  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 })
-  const mediaType = mediaTypeForPath(canonical)
-  if (mediaType === undefined) {
+  // Containment compares path components; a filesystem root already ends in
+  // the separator and prefixes every absolute path.
+  const contained = roots.some(({ path: root }) =>
+    canonical === root || canonical.startsWith(root.endsWith(sep) ? root : root + sep))
+  if (!contained) return new Response('outside workspace roots', { status: 403 })
+  const mediaType = mime.lookup(canonical)
+  if (mediaType === false || mediaType === 'image/svg+xml' || !SERVED_MEDIA_TYPE.test(mediaType)) {
     return new Response('not an allowlisted media type', { status: 415 })
   }
-  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',
-      }
+  // The stat identity of the validated path is compared with the identity of
+  // the file actually opened, so a replacement or re-linking race between the
+  // containment check and the read is refused instead of followed.
+  let before
+  let handle
+  try {
+    before = await stat(canonical)
+    handle = await open(canonical, 'r')
+  } catch {
+    return new Response('not found', { status: 404 })
+  }
+  let streamed = false
+  try {
+    const after = await handle.stat()
+    if (!after.isFile()) return new Response('not a regular file', { status: 403 })
+    /* v8 ignore next 2 -- the replacement race cannot be produced deterministically; this arm refuses it when it happens */
+    if (after.dev !== before.dev || after.ino !== before.ino) {
+      return new Response('file changed during validation', { status: 403 })
+    }
+    const total = after.size
+    const rangeHeader = request.headers.get('range')
+    const ranges = rangeHeader === null ? undefined : rangeParser(total, rangeHeader)
+    if (ranges === -1 || ranges === -2) {
+      // Unsatisfiable and malformed ranges answer the same way.
+      const headers = { ...BASE_HEADERS, 'Content-Range': `bytes */${total}` }
       return new Response(null, { status: 416, headers })
     }
-    start = first.start
-    end = first.end
-    partial = true
-  }
-  const body: ReadableStream<Uint8Array> = Readable.toWeb(
-    createReadStream(canonical, partial ? { start, end } : undefined),
-  ) as ReadableStream<Uint8Array>
-  const headers: Record<string, string> = {
-    'Content-Type': mediaType,
-    '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(total)
+    // Only a single range is honored; a multi-range header gets the full body.
+    const slice = ranges?.length === 1 ? ranges[0] : undefined
+    const { start, end } = slice ?? { start: 0, end: total - 1 }
+    const headers: Record<string, string> = {
+      ...BASE_HEADERS,
+      'Content-Type': mediaType,
+      'Content-Length': String(end - start + 1),
+      'Accept-Ranges': 'bytes',
+    }
+    if (slice !== undefined) headers['Content-Range'] = `bytes ${start}-${end}/${total}`
+    const status = slice === undefined ? 200 : 206
+    // HEAD never opens a stream; an already-aborted client cannot receive a
+    // body either, and the handle closes through the finally below.
+    if (request.method === 'HEAD' || request.signal.aborted) {
+      return new Response(null, { status, headers })
+    }
+    streamed = true
+    const source = addAbortSignal(request.signal, handle.createReadStream(slice))
+    return new Response(Readable.toWeb(source) as ReadableStream<Uint8Array>, { status, headers })
+  } finally {
+    if (!streamed) await handle.close()
   }
-  const head = request.method === 'HEAD'
-  return new Response(head ? null : body, { status: partial ? 206 : 200, headers })
 }
 
 /**

+ 128 - 16
packages/api/session-controller/tests/media-references.host.spec.ts

@@ -1,6 +1,6 @@
 import { mkdir, mkdtemp, open, realpath, rm, symlink, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
-import { join } from 'node:path'
+import { join, sep } from 'node:path'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { SessionMediaReferences } from '../src/media-references.ts'
@@ -20,16 +20,24 @@ interface MountedRoute {
   /** Direct request access (custom URLs and headers). */
   raw(url: string, init?: RequestInit): Promise<Response>
   unregister: ReturnType<typeof vi.fn>
+  dispose(): Promise<void>
 }
 
 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-media-references-')))
+  })
+
+  afterEach(async () => {
+    await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+    await rm(root, { recursive: true, force: true })
+  })
+
+  async function mount(workspaceRoot: string = root): Promise<MountedRoute> {
     const ctx = new Context()
     contexts.push(ctx)
     let handler: ((request: Request) => Promise<Response>) | undefined
@@ -42,28 +50,30 @@ describe('SessionMediaReferences /api/file', () => {
         },
       },
     } as never)
-    ctx.provide('workspaceRegistry', { list: () => [{ path: root }] } as never)
+    ctx.provide('workspaceRegistry', {
+      list: () => [{ path: workspaceRoot }],
+    } as never)
     await ctx.plugin(SessionMediaReferences).await()
     const fetchFile = (request: Request) => {
       if (handler === undefined) throw new Error('route not registered')
       return handler(request)
     }
-    route = {
+    return {
       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,
+      dispose: () => ctx.fiber.dispose().then(() => {
+        const index = contexts.indexOf(ctx)
+        if (index >= 0) contexts.splice(index, 1)
+      }),
     }
-  })
-
-  afterEach(async () => {
-    await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
-    await rm(root, { recursive: true, force: true })
-  })
+  }
 
   it('serves images from the workspace root and nested directories', async () => {
+    const route = await mount()
     const direct = join(root, 'graph.png')
     await writeFile(direct, PNG_BYTES)
     const response = await route.call(direct)
@@ -83,6 +93,7 @@ describe('SessionMediaReferences /api/file', () => {
   })
 
   it('serves every allowlisted media category by extension', async () => {
+    const route = await mount()
     const video = join(root, 'clip.mp4')
     await writeFile(video, MP4_BYTES)
     const videoResponse = await route.call(video)
@@ -99,6 +110,7 @@ describe('SessionMediaReferences /api/file', () => {
   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 route = await mount()
     const fake = join(root, 'fake.png')
     await writeFile(fake, TEXT_BYTES)
     const response = await route.call(fake)
@@ -107,6 +119,7 @@ describe('SessionMediaReferences /api/file', () => {
   })
 
   it('refuses non-media, unknown, and denied content types', async () => {
+    const route = await mount()
     const cases: ReadonlyArray<[string, Uint8Array]> = [
       ['note.txt', TEXT_BYTES],
       ['run.exe', TEXT_BYTES],
@@ -124,6 +137,7 @@ describe('SessionMediaReferences /api/file', () => {
   })
 
   it('answers bounded, open-ended, suffix, and clamped range requests', async () => {
+    const route = await mount()
     const path = join(root, 'graph.png')
     await writeFile(path, PNG_BYTES)
     const total = PNG_BYTES.length
@@ -149,6 +163,7 @@ describe('SessionMediaReferences /api/file', () => {
   })
 
   it('answers unsatisfiable and malformed ranges with 416 and the total size', async () => {
+    const route = await mount()
     const path = join(root, 'graph.png')
     await writeFile(path, PNG_BYTES)
     for (const range of ['bytes=999-', 'bytes=abc']) {
@@ -158,17 +173,38 @@ describe('SessionMediaReferences /api/file', () => {
     }
   })
 
+  it('ignores multi-range headers and serves the full 200 body', async () => {
+    const route = await mount()
+    const path = join(root, 'graph.png')
+    await writeFile(path, PNG_BYTES)
+    const response = await route.call(path, { headers: { range: 'bytes=0-1,3-4' } })
+    expect(response.status).toBe(200)
+    expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length))
+    expect(response.headers.get('content-range')).toBeNull()
+    expect(await responseBytes(response)).toEqual(PNG_BYTES)
+  })
+
   it('answers HEAD without a body', async () => {
+    const route = await mount()
     const path = join(root, 'graph.png')
     await writeFile(path, PNG_BYTES)
     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.headers.get('content-range')).toBeNull()
     expect(response.body).toBeNull()
+
+    const ranged = await route.call(path, { method: 'HEAD', headers: { range: 'bytes=0-3' } })
+    expect(ranged.status).toBe(206)
+    expect(ranged.headers.get('content-range')).toBe(`bytes 0-3/${PNG_BYTES.length}`)
+    expect(ranged.headers.get('content-length')).toBe('4')
+    expect(ranged.body).toBeNull()
   })
 
-  it('denies malformed, missing, and uncontained requests', async () => {
+  it('denies malformed, missing, empty, and uncontained requests', async () => {
+    const route = await mount()
     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=')).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)
@@ -187,20 +223,54 @@ describe('SessionMediaReferences /api/file', () => {
     }
   })
 
-  it('refuses the workspace-root directory itself', async () => {
-    const response = await route.call(root)
+  it('answers 404 when an existing file cannot be opened for reading', async () => {
+    // A permission-less file passes realpath/stat but fails the open; the
+    // route treats the unreadable file like a missing one. chmod has no
+    // effect on Windows, where this case is skipped.
+    if (process.platform === 'win32') return
+    const route = await mount()
+    const path = join(root, 'locked.png')
+    await writeFile(path, PNG_BYTES)
+    const { chmod } = await import('node:fs/promises')
+    await chmod(path, 0o000)
+    try {
+      const response = await route.call(path)
+      expect(response.status).toBe(404)
+    } finally {
+      await chmod(path, 0o600)
+    }
+  })
+
+  it('refuses a directory even when its name carries a media extension', async () => {
+    const route = await mount()
+    const directory = join(root, 'frames.png')
+    await mkdir(directory)
+    const response = await route.call(directory)
     expect(response.status).toBe(403)
     expect(await response.text()).toBe('not a regular file')
   })
 
+  it('serves files when the registered workspace is the filesystem root', async () => {
+    const route = await mount(sep)
+    const path = join(root, 'graph.png')
+    await writeFile(path, PNG_BYTES)
+    const response = await route.call(path)
+    expect(response.status).toBe(200)
+    expect(await responseBytes(response)).toEqual(PNG_BYTES)
+  })
+
   it('follows symlinks for the containment check', async () => {
+    const route = await mount()
     const real = join(root, 'real.png')
     await writeFile(real, PNG_BYTES)
     const link = join(root, 'link.png')
     await symlink(real, link)
     expect((await route.call(link)).status).toBe(200)
 
-    const outsideTarget = join(await realpath(tmpdir()), 'dsh-media-references-target.png')
+    // The escaping target lives in a private directory so concurrent test
+    // processes can never share or delete it.
+    const outside = await realpath(await mkdtemp(join(tmpdir(), 'dsh-media-references-target-')))
+    const outsideTarget = join(outside, 'secret.png')
     await writeFile(outsideTarget, PNG_BYTES)
     try {
       const escaping = join(root, 'escape.png')
@@ -209,11 +279,12 @@ describe('SessionMediaReferences /api/file', () => {
       expect(denied.status).toBe(403)
       expect(await denied.text()).toBe('outside workspace roots')
     } finally {
-      await rm(outsideTarget, { force: true })
+      await rm(outside, { recursive: true, force: true })
     }
   })
 
   it('streams a slice of a large sparse file without buffering it whole', async () => {
+    const route = await mount()
     const path = join(root, 'huge.mp4')
     const handle = await open(path, 'w')
     try {
@@ -227,7 +298,48 @@ describe('SessionMediaReferences /api/file', () => {
     expect((await responseBytes(response)).length).toBe(10)
   })
 
-  it('keeps the route registered until the contribution is disposed', () => {
+  it('closes the file stream when the client aborts mid-response', async () => {
+    const route = await mount()
+    const path = join(root, 'huge.mp4')
+    const handle = await open(path, 'w')
+    try {
+      await handle.truncate(64 * 1024 * 1024)
+    } finally {
+      await handle.close()
+    }
+    const controller = new AbortController()
+    const response = await route.call(path, { signal: controller.signal })
+    expect(response.status).toBe(200)
+    const reader = response.body?.getReader()
+    expect(reader).toBeDefined()
+    await reader?.read()
+    controller.abort()
+    // The abort listener destroys the underlying file stream; reading the web
+    // stream then fails instead of draining the remaining 64 MiB.
+    await expect(reader?.read()).rejects.toBeTruthy()
+  })
+
+  it('destroys the stream immediately for an already-aborted request', async () => {
+    const route = await mount()
+    const path = join(root, 'huge.mp4')
+    const handle = await open(path, 'w')
+    try {
+      await handle.truncate(16 * 1024 * 1024)
+    } finally {
+      await handle.close()
+    }
+    const controller = new AbortController()
+    controller.abort()
+    const response = await route.call(path, { signal: controller.signal })
+    expect(response.status).toBe(200)
+    // No stream is opened for a client that is already gone.
+    expect(response.body).toBeNull()
+  })
+
+  it('unregisters the route when the contribution is disposed', async () => {
+    const route = await mount()
     expect(route.unregister).not.toHaveBeenCalled()
+    await route.dispose()
+    expect(route.unregister).toHaveBeenCalledTimes(1)
   })
 })

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 8e579d9467f1ab8c87bfb11d45354f68229fd138
-README.zh.md: 83dce1bdbdab34cde00e7cfb56c648a1d721421f
+README.md: 026b3a7258f3fe0e659e52e104e8ffa24ac8ad74
+README.zh.md: b08ada245692a7a57aae64d80da95e1ced1d970c

+ 1 - 1
packages/client/ui-primitives/README.md

@@ -68,7 +68,7 @@ The catalog above lists what each export is for; this section covers the behavio
 
 ### Rendering agent output
 
-`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows.
+`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary the output is byte-identical. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows.
 
 
 ### Localizing copy

+ 1 - 1
packages/client/ui-primitives/README.zh.md

@@ -68,7 +68,7 @@ kind: "package-library"
 
 ### 渲染 agent 输出
 
-`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。
+`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时输出逐字节不变。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。
 
 
 ### 本地化文案