fix(gui): harden multimodal image attachments
This commit is contained in:
@@ -387,11 +387,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
|
||||
try {
|
||||
if (content.some(part => part.type === 'image')) {
|
||||
const activeModel = (await ctx.llm.listModels(defaults.provider)).find(model => model.id === defaults.model)
|
||||
const routed = agent.session.requestHeader()?.config
|
||||
const provider = routed?.provider ?? agent.options.provider ?? defaults.provider
|
||||
const model = routed?.model ?? agent.options.model ?? defaults.model
|
||||
const activeModel = (await ctx.llm.listModels(provider)).find(candidate => candidate.id === model)
|
||||
if (activeModel?.inputModalities !== undefined && !activeModel.inputModalities.includes('image')) {
|
||||
return err(request, {
|
||||
code: 'attachment-error',
|
||||
message: `Model "${defaults.model}" does not support image input.`,
|
||||
message: `Model "${model}" does not support image input.`,
|
||||
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,13 +18,14 @@ class ScriptedAdapter extends LlmAdapter {
|
||||
constructor(
|
||||
private script: (StreamChunk[] | 'hang')[],
|
||||
private readonly inputModalities: readonly ModelModality[] = ['text', 'image'],
|
||||
private readonly model = 'test-model',
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve([{
|
||||
provider, id: 'test-model', name: 'test-model',
|
||||
provider, id: this.model, name: this.model,
|
||||
inputModalities: this.inputModalities, outputModalities: ['text'],
|
||||
}])
|
||||
}
|
||||
@@ -112,11 +113,38 @@ describe('bootHost / startHost', () => {
|
||||
const response = await running.handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', body }))
|
||||
const parsed = await response.json() as { result: { ok: boolean; value: { provider: string } } }
|
||||
expect(parsed.result.value.provider).toBe('scripted')
|
||||
const attachmentBody = JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: 'r-attachment',
|
||||
method: 'session.attachment',
|
||||
payload: { sessionId: 'session-missing', attachmentId: 'sha256:missing' },
|
||||
})
|
||||
const attachmentResponse = await running.handler.fetch(new Request('http://x/api/session.attachment', {
|
||||
method: 'POST',
|
||||
body: attachmentBody,
|
||||
}))
|
||||
expect((await attachmentResponse.json() as { result: { ok: boolean } }).result.ok).toBe(false)
|
||||
const first = running.dispose()
|
||||
expect(running.dispose()).toBe(first)
|
||||
await first
|
||||
host = undefined
|
||||
})
|
||||
|
||||
it('mounts configured pi-ai providers while accepting an explicit empty list', async () => {
|
||||
const empty = await bootHost({
|
||||
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-pi-empty-')),
|
||||
piAiProviders: [],
|
||||
})
|
||||
expect(empty.ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
await empty.dispose()
|
||||
|
||||
const configured = await bootHost({
|
||||
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-pi-')),
|
||||
piAiProviders: [{ provider: 'openai' }],
|
||||
})
|
||||
expect(configured.ctx.llm.listProviders()).toContainEqual({ id: 'openai', name: 'openai' })
|
||||
await configured.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('host.describe', () => {
|
||||
@@ -125,6 +153,18 @@ describe('host.describe', () => {
|
||||
const value = expectOk(await api.host.describe(request({})))
|
||||
expect(value).toMatchObject({ version: '0.0.1', cwd: process.cwd(), provider: 'scripted', model: 'test-model', attachedSessions: 0 })
|
||||
})
|
||||
|
||||
it('omits activeModel when the configured model is absent from the provider catalog', async () => {
|
||||
host = await startHost({
|
||||
boot: {
|
||||
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-describe-missing-model-')),
|
||||
provider: 'scripted',
|
||||
model: 'missing-model',
|
||||
},
|
||||
})
|
||||
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text'], 'other-model'))
|
||||
expect(expectOk(await host.api.host.describe(request({})))).not.toHaveProperty('activeModel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions.create / list', () => {
|
||||
@@ -239,6 +279,125 @@ describe('sessions.prompt / cancel', () => {
|
||||
expect(denied.result).toMatchObject({
|
||||
ok: false, error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
|
||||
})
|
||||
|
||||
const { sessionId: nestedSession } = expectOk(await host.api.sessions.create(request({})))
|
||||
const nestedAgent = host.ctx.agents.get(nestedSession) as Agent
|
||||
nestedAgent.session.append('context/message', {
|
||||
content: [
|
||||
null,
|
||||
[],
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'nested-text' as never,
|
||||
content: [{ type: 'text', text: 'no image here' }],
|
||||
},
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'nested-image' as never,
|
||||
content: [{ type: 'image', attachment: image.attachment }],
|
||||
},
|
||||
] as never,
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
expectOk(await host.api.sessions.attachment(request({
|
||||
sessionId: nestedSession,
|
||||
attachmentId: image.attachment.attachmentId,
|
||||
})))
|
||||
|
||||
const { sessionId: streamedSession } = expectOk(await host.api.sessions.create(request({})))
|
||||
const streamedAgent = host.ctx.agents.get(streamedSession) as Agent
|
||||
streamedAgent.session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: image.attachment } },
|
||||
})
|
||||
expectOk(await host.api.sessions.attachment(request({
|
||||
sessionId: streamedSession,
|
||||
attachmentId: image.attachment.attachmentId,
|
||||
})))
|
||||
|
||||
const missingRef = {
|
||||
...image.attachment,
|
||||
attachmentId: `sha256:${'b'.repeat(64)}` as never,
|
||||
}
|
||||
streamedAgent.session.append('context/message', {
|
||||
content: [{ type: 'image', attachment: missingRef }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const missing = await host.api.sessions.attachment(request({
|
||||
sessionId: streamedSession,
|
||||
attachmentId: missingRef.attachmentId,
|
||||
}))
|
||||
expect(missing.result).toMatchObject({
|
||||
ok: false, error: { details: { reason: 'ATTACHMENT_NOT_FOUND' } },
|
||||
})
|
||||
|
||||
const read = vi.spyOn(host.ctx.attachments, 'readImage').mockRejectedValueOnce(new Error('read failed'))
|
||||
const internal = await host.api.sessions.attachment(request({
|
||||
sessionId: nestedSession,
|
||||
attachmentId: image.attachment.attachmentId,
|
||||
}))
|
||||
expect(internal.result).toMatchObject({ ok: false, error: { code: 'internal' } })
|
||||
read.mockRestore()
|
||||
|
||||
const ghost = await host.api.sessions.attachment(request({
|
||||
sessionId: 'session-ghost' as SessionId,
|
||||
attachmentId: image.attachment.attachmentId,
|
||||
}))
|
||||
expect(ghost.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
|
||||
it('rejects non-canonical, excessive-count, and excessive-byte image prompts', async () => {
|
||||
const running = await boot()
|
||||
const { sessionId } = expectOk(await running.api.sessions.create(request({})))
|
||||
for (const data of ['', 'AB==']) {
|
||||
const invalid = await running.api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'image' as const, mediaType: 'image/png' as const, data }],
|
||||
}))
|
||||
expect(invalid.result).toMatchObject({
|
||||
ok: false, error: { details: { reason: 'INVALID_IMAGE_BASE64' } },
|
||||
})
|
||||
}
|
||||
|
||||
const attachmentService = running.ctx.attachments as unknown as {
|
||||
imageLimits: typeof running.ctx.attachments.imageLimits
|
||||
}
|
||||
attachmentService.imageLimits = {
|
||||
...running.ctx.attachments.imageLimits,
|
||||
maxImagesPerMessage: 1,
|
||||
}
|
||||
const tooMany = await running.api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: Array.from({ length: 2 }, () => ({
|
||||
type: 'image' as const,
|
||||
mediaType: 'image/png' as const,
|
||||
data: PNG_BASE64,
|
||||
})),
|
||||
}))
|
||||
expect(tooMany.result).toMatchObject({
|
||||
ok: false, error: { details: { reason: 'TOO_MANY_IMAGES' } },
|
||||
})
|
||||
|
||||
attachmentService.imageLimits = {
|
||||
...running.ctx.attachments.imageLimits,
|
||||
maxImagesPerMessage: 10,
|
||||
maxMessageImageBytes: 100,
|
||||
}
|
||||
const excessiveBytes = await running.api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: Array.from({ length: 2 }, () => ({
|
||||
type: 'image' as const,
|
||||
mediaType: 'image/png' as const,
|
||||
data: PNG_BASE64,
|
||||
})),
|
||||
}))
|
||||
expect(excessiveBytes.result).toMatchObject({
|
||||
ok: false, error: { details: { reason: 'IMAGES_TOO_LARGE' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects images for an explicitly text-only model without creating a session event', async () => {
|
||||
@@ -260,6 +419,57 @@ describe('sessions.prompt / cancel', () => {
|
||||
expect(existsSync(join(dshHome, 'attachments'))).toBe(false)
|
||||
})
|
||||
|
||||
it('preflights the session route instead of the host default model', async () => {
|
||||
const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-routed-session-'))
|
||||
const dshHome = mkdtempSync(join(tmpdir(), 'dsh-routed-home-'))
|
||||
host = await startHost({
|
||||
boot: { persistenceRoot, dshHome, provider: 'scripted', model: 'test-model' },
|
||||
})
|
||||
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text']))
|
||||
host.ctx.llm.registerAdapter(
|
||||
['visual'],
|
||||
new ScriptedAdapter([textResponse('seen')], ['text', 'image'], 'visual-model'),
|
||||
)
|
||||
const { sessionId } = expectOk(await host.api.sessions.create(request({})))
|
||||
const agent = host.ctx.agents.get(sessionId) as Agent
|
||||
agent.options.provider = 'visual'
|
||||
agent.options.model = 'visual-model'
|
||||
const idle = waitForIdle(host.ctx, agent)
|
||||
|
||||
expectOk(await host.api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64 }],
|
||||
})))
|
||||
await idle
|
||||
|
||||
expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true)
|
||||
expect(existsSync(join(dshHome, 'attachments'))).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to host routing when a session has no routed or agent model options', async () => {
|
||||
host = await startHost({
|
||||
boot: {
|
||||
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-route-default-')),
|
||||
provider: 'scripted',
|
||||
model: 'test-model',
|
||||
},
|
||||
})
|
||||
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text']))
|
||||
const { sessionId } = expectOk(await host.api.sessions.create(request({})))
|
||||
const agent = host.ctx.agents.get(sessionId) as Agent
|
||||
agent.options.provider = undefined as never
|
||||
agent.options.model = undefined as never
|
||||
const response = await host.api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64 }],
|
||||
}))
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false, error: { details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels an attached agent and rejects an unattached one', async () => {
|
||||
const running = await boot(['hang'])
|
||||
const { api, ctx } = running
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405.
|
||||
|
||||
The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; port `0` requests an OS-assigned port and the running handle reports the assigned value. `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply the bind `host`, `port`, and positive `maxRequestBodyBytes`; port `0` requests an OS-assigned port and the running handle reports the assigned value. The API bridge returns 413 before buffering a declared oversized body and keeps chunked-body buffering within the same cap. `dsh web` derives its default cap from the configured aggregate image limit plus base64/envelope expansion and accepts `--max-request-body-bytes` as an explicit override. It defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
|
||||
Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own.
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface WebServerOptions {
|
||||
distIndex: string
|
||||
/** Fetch-shaped API carrier; /api/*-prefixed requests are bridged to it. */
|
||||
apiHandler: { fetch: typeof fetch }
|
||||
/** Maximum buffered bytes accepted for one `/api/*` request body. */
|
||||
maxRequestBodyBytes: number
|
||||
/**
|
||||
* Web plugin table. When present, every index.html response carries a
|
||||
* `window.__DSH_BOOT__` manifest script and `/plugins/<id>/client.js` serves
|
||||
@@ -66,7 +68,10 @@ export interface RunningWebServer {
|
||||
* @returns the running server handle once listening.
|
||||
*/
|
||||
export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise<RunningWebServer> {
|
||||
const { host, port, distIndex, apiHandler, webPlugins } = options
|
||||
const { host, port, distIndex, apiHandler, maxRequestBodyBytes, webPlugins } = options
|
||||
if (!Number.isInteger(maxRequestBodyBytes) || maxRequestBodyBytes < 1) {
|
||||
throw new RangeError('host webserver: maxRequestBodyBytes must be a positive integer')
|
||||
}
|
||||
const distRoot = dirname(distIndex)
|
||||
const renderIndex = webPlugins === undefined ? undefined : async (): Promise<string> => {
|
||||
const html = await readFile(distIndex, 'utf8')
|
||||
@@ -78,7 +83,7 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error)
|
||||
requests; the field is only optional on the client-side IncomingMessage type */
|
||||
const rawPath = new URL(req.url ?? '/', 'http://x').pathname
|
||||
if (rawPath.startsWith('/api/')) {
|
||||
await bridge(req, res, apiHandler)
|
||||
await bridge(req, res, apiHandler, maxRequestBodyBytes)
|
||||
return
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
@@ -163,8 +168,13 @@ async function servePluginBundle(
|
||||
}
|
||||
}
|
||||
|
||||
/** Bridge one node:http request to the WHATWG fetch handler (client close aborts; SSE bodies stream out chunk by chunk). */
|
||||
async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
|
||||
/** Bridge one bounded node:http request to the WHATWG fetch handler. */
|
||||
async function bridge(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
apiHandler: { fetch: typeof fetch },
|
||||
maxRequestBodyBytes: number,
|
||||
): Promise<void> {
|
||||
const abort = new AbortController()
|
||||
// Client-disconnect detection MUST hang off the response, not the request:
|
||||
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
|
||||
@@ -174,8 +184,31 @@ async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { f
|
||||
res.on('close', () => {
|
||||
if (!res.writableEnded) abort.abort()
|
||||
})
|
||||
const declaredLength = req.headers['content-length']
|
||||
if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
|
||||
res.writeHead(413)
|
||||
res.end()
|
||||
req.resume()
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer)
|
||||
let received = 0
|
||||
let oversized = false
|
||||
for await (const chunk of req) {
|
||||
const buffer = chunk as Buffer
|
||||
received += buffer.byteLength
|
||||
if (received > maxRequestBodyBytes) {
|
||||
oversized = true
|
||||
chunks.length = 0
|
||||
continue
|
||||
}
|
||||
if (!oversized) chunks.push(buffer)
|
||||
}
|
||||
if (oversized) {
|
||||
res.writeHead(413)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
/* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
|
||||
requests; the fields are only optional on the client-side IncomingMessage type */
|
||||
const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { request as httpRequest } from 'node:http'
|
||||
import { createServer as createNetServer, Server as NetServer, type AddressInfo } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startWebServer, type RunningWebServer } from '../src/index.ts'
|
||||
|
||||
const MAX_REQUEST_BODY_BYTES = 64 * 1024
|
||||
|
||||
/** Reserve a loopback port for tests that need to address a second server. */
|
||||
function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -104,17 +107,35 @@ afterEach(async () => {
|
||||
server = undefined
|
||||
})
|
||||
|
||||
async function boot(onError: (err: Error) => void = () => undefined): Promise<string> {
|
||||
async function boot(
|
||||
onError: (err: Error) => void = () => undefined,
|
||||
maxRequestBodyBytes = MAX_REQUEST_BODY_BYTES,
|
||||
): Promise<string> {
|
||||
const { distIndex } = makeDist()
|
||||
const port = await freePort()
|
||||
server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, onError)
|
||||
server = await startWebServer({
|
||||
host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes,
|
||||
}, onError)
|
||||
return `http://127.0.0.1:${String(server.port)}`
|
||||
}
|
||||
|
||||
describe('startWebServer', () => {
|
||||
it('rejects an invalid request-body cap before listening', () => {
|
||||
const { distIndex } = makeDist()
|
||||
expect(() => startWebServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
distIndex,
|
||||
apiHandler: echoingApi,
|
||||
maxRequestBodyBytes: 0,
|
||||
}, () => undefined)).toThrow(/positive integer/)
|
||||
})
|
||||
|
||||
it('reports the listening port and closes idempotently', async () => {
|
||||
const { distIndex } = makeDist()
|
||||
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
server = await startWebServer({
|
||||
host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES,
|
||||
}, () => undefined)
|
||||
expect(server.port).toBeGreaterThan(0)
|
||||
const first = server.close()
|
||||
const second = server.close()
|
||||
@@ -136,7 +157,9 @@ describe('startWebServer', () => {
|
||||
})
|
||||
const address = vi.spyOn(NetServer.prototype, 'address').mockReturnValue({ address: host, family: 'IPv4', port })
|
||||
try {
|
||||
const inertServer = await startWebServer({ host, port, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
const inertServer = await startWebServer({
|
||||
host, port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES,
|
||||
}, () => undefined)
|
||||
expect(listen).toHaveBeenCalledWith(port, host, expect.any(Function))
|
||||
await inertServer.close()
|
||||
} finally {
|
||||
@@ -148,8 +171,12 @@ describe('startWebServer', () => {
|
||||
it('rejects when the port is already taken', async () => {
|
||||
const { distIndex } = makeDist()
|
||||
const port = await freePort()
|
||||
server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined))
|
||||
server = await startWebServer({
|
||||
host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES,
|
||||
}, () => undefined)
|
||||
await expect(startWebServer({
|
||||
host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES,
|
||||
}, () => undefined))
|
||||
.rejects.toMatchObject({ code: 'EADDRINUSE' })
|
||||
})
|
||||
})
|
||||
@@ -207,7 +234,10 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
|
||||
}
|
||||
const port = await freePort()
|
||||
server = await startWebServer(
|
||||
{ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
|
||||
{
|
||||
host: '127.0.0.1', port, distIndex, apiHandler: echoingApi,
|
||||
maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, webPlugins,
|
||||
}, () => undefined,
|
||||
)
|
||||
return `http://127.0.0.1:${String(server.port)}`
|
||||
}
|
||||
@@ -245,7 +275,10 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
|
||||
}
|
||||
const port = await freePort()
|
||||
server = await startWebServer(
|
||||
{ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
|
||||
{
|
||||
host: '127.0.0.1', port, distIndex, apiHandler: echoingApi,
|
||||
maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, webPlugins,
|
||||
}, () => undefined,
|
||||
)
|
||||
const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/@deepseek-ai/dsh-client-connection/client.js`)
|
||||
expect(res.status).toBe(404)
|
||||
@@ -305,6 +338,35 @@ describe('/api bridge', () => {
|
||||
expect(await response.json()).toEqual({ method: 'POST', body: '{"n":1}', header: 'p1' })
|
||||
})
|
||||
|
||||
it('returns 413 before buffering a declared oversized body', async () => {
|
||||
const base = await boot()
|
||||
const response = await fetch(`${base}/api/echo`, {
|
||||
method: 'POST',
|
||||
body: 'x'.repeat(MAX_REQUEST_BODY_BYTES + 1),
|
||||
})
|
||||
expect(response.status).toBe(413)
|
||||
})
|
||||
|
||||
it('bounds chunked request buffering when no content length is declared', async () => {
|
||||
const base = await boot(() => undefined, 8)
|
||||
const target = new URL(`${base}/api/echo`)
|
||||
const status = await new Promise<number | undefined>((resolve, reject) => {
|
||||
const request = httpRequest({
|
||||
hostname: target.hostname,
|
||||
port: target.port,
|
||||
path: target.pathname,
|
||||
method: 'POST',
|
||||
}, (response) => {
|
||||
response.resume()
|
||||
response.on('end', () => { resolve(response.statusCode) })
|
||||
})
|
||||
request.on('error', reject)
|
||||
request.write('12345')
|
||||
request.end('67890')
|
||||
})
|
||||
expect(status).toBe(413)
|
||||
})
|
||||
|
||||
it('relays a bodyless response', async () => {
|
||||
const base = await boot()
|
||||
const response = await fetch(`${base}/api/empty`, { method: 'POST' })
|
||||
|
||||
Reference in New Issue
Block a user