mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-13 04:03:30 +00:00
refactor(api): reuse attachment limits for file responses
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -36,7 +36,7 @@ The Session object also carries local submission echoes: `session.beginSubmissio
|
||||
<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 `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=<absolute 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.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
|
||||
<a id="session-media-references"></a>
|
||||
## 会话媒体引用
|
||||
|
||||
当 `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 的完整来源。
|
||||
|
||||
|
||||
@@ -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<Config> = 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) => {
|
||||
|
||||
@@ -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<Response> {
|
||||
async function serveFile(request: Request, fs: FileSystem, maxBytes: number): Promise<Response> {
|
||||
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<string, string> = {
|
||||
...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')
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<Response>) | 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)
|
||||
|
||||
Reference in New Issue
Block a user