diff --git a/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.i18n.yaml index e88fd1e0e8..0817dcd4f1 100644 --- a/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.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-08-file-display-through-filesystem.md -2026-09-08-file-display-through-filesystem.md: 6ff0c855ff0286c7ca7602ed2442a0fae6245c92 -2026-09-08-file-display-through-filesystem.zh.md: 5f8754ce91b53d964bdde03fb067dca858d3551a +2026-09-08-file-display-through-filesystem.md: b1f93f8c9fbd245ad69bc732b2a599c6e0abeb0d +2026-09-08-file-display-through-filesystem.zh.md: faa3fdb757c9a3fc60cae37692a559158e02ceee diff --git a/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md index 6ff0c855ff..b1f93f8c9f 100644 --- a/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md +++ b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md @@ -14,7 +14,7 @@ The authenticated `/api/file` route reads ordinary files through `ctx.fs`. Authe GET calls the existing `readBytes(target, signal, maxBytes)`: providers reject known oversized files before content I/O and enforce the limit while reading. HEAD uses metadata without reading content. `FS_TOO_LARGE` becomes 413. MIME lookup supplies response metadata without sniffing file contents; unknown extensions use `application/octet-stream`. A sandbox CSP prevents directly opened HTML/SVG from executing with the authenticated API origin. -`maxImageBytes` bounds images; `maxFileBytes` independently bounds audio, video, and other files. Both configuration overrides default to the resolved attachment image limit, normally 20 MiB. Separate overrides let deployments raise the non-image limit without weakening image admission. All responses contain complete files; Range is ignored and no range support is advertised. +All files use the resolved `ctx.attachments.imageLimits.maxImageBytes` limit, normally 20 MiB. The attachment service owns this deployment setting. All responses contain complete files; Range is ignored and no range support is advertised. ## Alternatives considered @@ -32,4 +32,4 @@ Each GET buffers the complete file in Host memory. Audio/video work as complete ## Testing -Route tests cover sparse 1 GiB rejection before content I/O, post-stat growth, separate byte limits, ordinary MIME types, temporary paths and symlinks, opaque remote targets, provider failures, metadata-only HEAD, ignored Range, and disposal. Browser expectations cover rendered images, 413/404 and corrupt-image fallbacks, and an image outside the workspace. Remote byte transfer remains owned by the existing filesystem provider tests. +Route tests cover sparse 1 GiB rejection before content I/O, post-stat growth, the shared attachment byte limit, ordinary MIME types, temporary paths and symlinks, opaque remote targets, provider failures, metadata-only HEAD, ignored Range, and disposal. Browser expectations cover rendered images, 413/404 and corrupt-image fallbacks, and an image outside the workspace. Remote byte transfer remains owned by the existing filesystem provider tests. diff --git a/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md index 5f8754ce91..faa3fdb757 100644 --- a/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md @@ -14,7 +14,7 @@ Status: implemented GET 调用现有 `readBytes(target, signal, maxBytes)`:提供方在内容 I/O 前拒绝已知超限文件,并在读取过程中执行上限。HEAD 使用元数据,不读取内容。`FS_TOO_LARGE` 转换为 413。MIME 查询提供响应元数据,不嗅探文件内容;未知扩展名使用 `application/octet-stream`。sandbox CSP 阻止直接打开的 HTML/SVG 以鉴权 API 源身份执行脚本。 -`maxImageBytes` 限制图片;`maxFileBytes` 独立限制音视频及其他文件。两个配置覆盖项均默认使用已解析的附件图片上限,通常为 20 MiB。独立覆盖项让部署可以提高非图片上限而不削弱图片准入。所有响应均包含完整文件;忽略 Range,也不声明支持 Range。 +所有文件均使用已解析的 `ctx.attachments.imageLimits.maxImageBytes` 上限,通常为 20 MiB。附件服务拥有此部署配置。所有响应均包含完整文件;忽略 Range,也不声明支持 Range。 ## Alternatives considered @@ -32,4 +32,4 @@ GET 调用现有 `readBytes(target, signal, maxBytes)`:提供方在内容 I/O ## Testing -路由测试覆盖内容 I/O 前拒绝稀疏 1 GiB 文件、stat 后增长、独立字节上限、普通 MIME 类型、临时路径与符号链接、不透明远程目标、提供方失败、仅元数据 HEAD、忽略 Range 和释放。浏览器期望覆盖图片渲染、413/404 及损坏图片回退,以及工作区之外的图片。远程字节传输仍由现有文件系统提供方测试负责。 +路由测试覆盖内容 I/O 前拒绝稀疏 1 GiB 文件、stat 后增长、共用附件字节上限、普通 MIME 类型、临时路径与符号链接、不透明远程目标、提供方失败、仅元数据 HEAD、忽略 Range 和释放。浏览器期望覆盖图片渲染、413/404 及损坏图片回退,以及工作区之外的图片。远程字节传输仍由现有文件系统提供方测试负责。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 995a4d8fb0..54a5407c82 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 200c474cc5538151eacfe1c8765d08f2beb0e4e3 -config-catalog.zh.md: 679dab21acf005e3cdffce46305b172c9c69707a +config-catalog.md: 058f3880c33097015d4b3472a1f94af38a965b2f +config-catalog.zh.md: 0f63a6edfeaf9bd1d08cdb6615238519a0909a66 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 200c474cc5..058f3880c3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -210,10 +210,6 @@ Requires: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · ` export interface Config { /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean - /** Inclusive byte limit for images served by `/api/file`. Defaults to `ctx.attachments.imageLimits.maxImageBytes`. */ - readonly maxImageBytes?: number - /** Inclusive byte limit for other files served by `/api/file`. Defaults to `ctx.attachments.imageLimits.maxImageBytes`. */ - readonly maxFileBytes?: number } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 679dab21ac..0f63a6edfe 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -212,10 +212,6 @@ export interface Config { export interface Config { /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean - /** Inclusive byte limit for images served by `/api/file`. Defaults to `ctx.attachments.imageLimits.maxImageBytes`. */ - readonly maxImageBytes?: number - /** Inclusive byte limit for other files served by `/api/file`. Defaults to `ctx.attachments.imageLimits.maxImageBytes`. */ - readonly maxFileBytes?: number } ``` diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index bf13fd18a2..d7e2e717c7 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/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: 319e9a31b32e1a7421ade6a644fb0affd2757c64 -README.zh.md: 481f7311600fd3acd88aa83b5a776f5135867326 +README.md: 1c199007ad92751240102ffcb168de93e95dca50 +README.zh.md: 5ce39d5d73e43953971a000881acec81378793f9 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 319e9a31b3..1c199007ad 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -36,7 +36,7 @@ The Session object also carries local submission echoes: `session.beginSubmissio ## Session media references -`SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. Images use `maxImageBytes`, while audio, video, and other files use `maxFileBytes`; exceeding the applicable limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. +`SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. ----- @@ -46,8 +46,6 @@ The Session object also carries local submission echoes: `session.beginSubmissio | Field | Default | Meaning | |---|---:|---| | `nativeOpen` | platform-detected | Whether Session workspace paths can be handed to a native desktop opener | -| `maxImageBytes` | attachment image limit (normally 20 MiB) | Inclusive byte cap for image responses; positive safe integer | -| `maxFileBytes` | attachment image limit (normally 20 MiB) | Independent inclusive byte cap for audio, video, and other complete-file responses; positive safe integer | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-session-controller) is the exhaustive source for accepted fields and their JSDoc. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 481f731160..5ce39d5d73 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -36,7 +36,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 ## 会话媒体引用 -当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。图片使用 `maxImageBytes`,音视频及其他文件使用 `maxFileBytes`;超过对应上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与 sandbox CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 +当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与 sandbox CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 ----- @@ -46,8 +46,6 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 | 字段 | 默认值 | 含义 | |---|---:|---| | `nativeOpen` | 平台探测 | 是否能把 Session 工作区路径交给原生桌面打开器 | -| `maxImageBytes` | 附件图片上限(通常 20 MiB) | 图片响应的字节上限(含等值);正安全整数 | -| `maxFileBytes` | 附件图片上限(通常 20 MiB) | 音视频及其他完整文件响应的独立字节上限(含等值);正安全整数 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-session-controller)是所有受支持字段及其 JSDoc 的完整来源。 diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 0e24b15f10..9cc93be179 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -70,10 +70,6 @@ declare module '@deepseek-ai/cordis' { export interface Config { /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean - /** Inclusive byte limit for images served by `/api/file`. Defaults to `ctx.attachments.imageLimits.maxImageBytes`. */ - readonly maxImageBytes?: number - /** Inclusive byte limit for other files served by `/api/file`. Defaults to `ctx.attachments.imageLimits.maxImageBytes`. */ - readonly maxFileBytes?: number } /** Host integrations replaceable by direct unit tests. */ @@ -101,8 +97,6 @@ export class SessionController extends TypertRemoteService { static Config: z = z.object({ nativeOpen: z.boolean(), - maxImageBytes: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER), - maxFileBytes: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER), }) private readonly agents: ApiSessionAgentController @@ -116,7 +110,7 @@ export class SessionController extends TypertRemoteService { /** * @param ctx - Host context containing the Session capability assembly. - * @param config - native-opener and media-read deployment policy. + * @param config - native-opener deployment policy. * @param internals - host integrations replaceable by direct unit tests. */ constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) { @@ -141,7 +135,7 @@ export class SessionController extends TypertRemoteService { this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) ctx.plugin(SessionFileReferences) - ctx.plugin(SessionMediaReferences, config) + ctx.plugin(SessionMediaReferences) ctx.plugin(SessionSkillCatalog) ctx.on('session/created', (session) => { diff --git a/packages/api/session-controller/src/media-references.ts b/packages/api/session-controller/src/media-references.ts index bacc0a8b71..9da80029f5 100644 --- a/packages/api/session-controller/src/media-references.ts +++ b/packages/api/session-controller/src/media-references.ts @@ -19,7 +19,7 @@ const BASE_HEADERS = { 'Content-Security-Policy': "sandbox; default-src 'none'", } -async function serveFile(request: Request, fs: FileSystem, limits: { image: number; file: number }): Promise { +async function serveFile(request: Request, fs: FileSystem, maxBytes: number): Promise { const fail = (status: number, text: string): Response => new Response(request.method === 'HEAD' ? null : text, { status, headers: BASE_HEADERS }) const path = new URL(request.url).searchParams.get('path') @@ -28,7 +28,6 @@ async function serveFile(request: Request, fs: FileSystem, limits: { image: numb try { const target = await fs.resolve(path, { signal: request.signal }) const mediaType = mime.lookup(target.displayPath) || 'application/octet-stream' - const maxBytes = mediaType.startsWith('image/') ? limits.image : limits.file const headers: Record = { ...BASE_HEADERS, 'Content-Type': mediaType, @@ -66,14 +65,13 @@ async function serveFile(request: Request, fs: FileSystem, limits: { image: numb */ export const SessionMediaReferences = { inject: ['connection', 'fs', 'attachments'], - apply(ctx: Context, config: { maxImageBytes?: number; maxFileBytes?: number }): void { - const defaults = ctx.attachments.imageLimits.maxImageBytes - const limits = { image: config.maxImageBytes ?? defaults, file: config.maxFileBytes ?? defaults } + apply(ctx: Context): void { + const maxBytes = ctx.attachments.imageLimits.maxImageBytes ctx.effect(() => ctx.connection.fetch.register({ path: '/api/file', methods: ['GET', 'HEAD'], requestBody: 'buffered', - fetch: request => serveFile(request, ctx.fs, limits), + fetch: request => serveFile(request, ctx.fs, maxBytes), }), 'session-controller: /api/file') }, } diff --git a/packages/api/session-controller/tests/media-references.host.spec.ts b/packages/api/session-controller/tests/media-references.host.spec.ts index 187b802efe..61572ab0be 100644 --- a/packages/api/session-controller/tests/media-references.host.spec.ts +++ b/packages/api/session-controller/tests/media-references.host.spec.ts @@ -6,7 +6,6 @@ import { Context } from '@deepseek-ai/cordis' import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' import { SessionMediaReferences } from '../src/media-references.ts' -import { SessionController } from '../src/index.ts' const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]) const DEFAULT_LIMIT = 20 * 1024 * 1024 @@ -28,7 +27,7 @@ describe('SessionMediaReferences /api/file', () => { await rm(root, { recursive: true, force: true }) }) - async function mount(config: { maxImageBytes?: number; maxFileBytes?: number } = {}, defaultLimit = DEFAULT_LIMIT) { + async function mount(maxBytes = DEFAULT_LIMIT) { const ctx = new Context() contexts.push(ctx) let handler: ((request: Request) => Promise) | undefined @@ -41,9 +40,9 @@ describe('SessionMediaReferences /api/file', () => { }, }, } as never) - ctx.provide('attachments', { imageLimits: { maxImageBytes: defaultLimit } } as never) + ctx.provide('attachments', { imageLimits: { maxImageBytes: maxBytes } } as never) await ctx.plugin(LocalFileSystem, { cwd: root }).await() - await ctx.plugin(SessionMediaReferences, config).await() + await ctx.plugin(SessionMediaReferences).await() const raw = (url: string, init?: RequestInit) => { if (handler === undefined) throw new Error('route not registered') return handler(new Request(url, init)) @@ -57,23 +56,8 @@ describe('SessionMediaReferences /api/file', () => { } } - it('inherits attachment limits unless configured and rejects invalid overrides', async () => { - expect(SessionController.Config({}).maxImageBytes).toBeUndefined() - expect(SessionController.Config({}).maxFileBytes).toBeUndefined() - for (const field of ['maxImageBytes', 'maxFileBytes']) { - expect(SessionController.Config({ [field]: 1 })).toMatchObject({ [field]: 1 }) - for (const value of [0, -1, 1.5, Infinity, NaN, Number.MAX_SAFE_INTEGER + 1]) { - expect(() => SessionController.Config({ [field]: value })).toThrow() - } - } - const route = await mount({}, PNG_BYTES.length - 1) - const path = join(root, 'image.png') - await writeFile(path, PNG_BYTES) - expect((await route.call(path)).status).toBe(413) - }) - it('serves the inclusive image cap and refuses larger images for GET, HEAD and Range', async () => { - const route = await mount({ maxImageBytes: PNG_BYTES.length }) + const route = await mount(PNG_BYTES.length) const path = join(root, 'bounded.png') await writeFile(path, PNG_BYTES) expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES) @@ -101,7 +85,7 @@ describe('SessionMediaReferences /api/file', () => { }) it('uses the filesystem byte reader to reject post-stat image growth', async () => { - const route = await mount({ maxImageBytes: PNG_BYTES.length }) + const route = await mount(PNG_BYTES.length) const path = join(root, 'growing.png') await writeFile(path, PNG_BYTES) route.fs.internals.inspectReadBytesAfterStat = async () => { @@ -127,8 +111,8 @@ describe('SessionMediaReferences /api/file', () => { expect(await responseBytes(response)).toEqual(PNG_BYTES) }) - it.each(['mp4', 'mp3', 'bin'])('allows a separate byte cap for .%s files', async (extension) => { - const route = await mount({ maxImageBytes: 1, maxFileBytes: PNG_BYTES.length }) + it.each(['mp4', 'mp3', 'bin'])('applies the attachment byte cap to .%s files', async (extension) => { + const route = await mount(PNG_BYTES.length) const path = join(root, `file.${extension}`) await writeFile(path, PNG_BYTES) expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES)