fix(gui): address ds-review-bot findings on image attachments
- dsh web gains --provider/--model: a non-deepseek provider mounts the matching pi-ai catalog route (ambient credentials), making image input reachable from the shipped Web assembly; requires an explicit --model - attachment-local syncs the publication directories after the hard-link publish so a reported durable reference survives a crash (POSIX; Windows relies on filesystem metadata journaling) - the attachment seam gains storage-free validateImage; the host validates a complete multi-image prompt before persisting any member, so one malformed image cannot strand valid members as unreferenced objects - startSession sends before navigating: a rejected first send keeps the empty state, its error strip, and the complete draft mounted - the webserver rejects an undeclared-length body the moment it crosses the configured limit instead of draining a potentially endless stream to EOF
This commit is contained in:
@@ -54,6 +54,16 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten
|
||||
if (totalBytes > limits.maxMessageImageBytes) {
|
||||
throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
|
||||
}
|
||||
// Validate the complete batch before persisting any member: the store has no
|
||||
// garbage collection, so one malformed image must not leave the batch's
|
||||
// valid members as published objects no message event will ever reference.
|
||||
for (const image of images) {
|
||||
ctx.attachments.validateImage({
|
||||
data: image.data,
|
||||
mediaType: image.part.mediaType,
|
||||
...image.part.name === undefined ? {} : { name: image.part.name },
|
||||
})
|
||||
}
|
||||
return Promise.all(prepared.map(async (item): Promise<ContentBlock> => {
|
||||
if (!('data' in item)) return { type: 'text', text: item.text }
|
||||
const attachment = await ctx.attachments.saveImage({
|
||||
|
||||
@@ -614,6 +614,31 @@ describe('sessions.prompt / cancel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes nothing when one member of a multi-image prompt is malformed', async () => {
|
||||
const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-batch-session-'))
|
||||
const dshHome = mkdtempSync(join(tmpdir(), 'dsh-batch-home-'))
|
||||
host = await startHost({
|
||||
boot: { persistenceRoot, workspaceContext: false, dshHome, provider: 'scripted', model: 'test-model' },
|
||||
})
|
||||
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('unused')]))
|
||||
const { sessionId } = expectOk(await host.api.sessions.create(request({})))
|
||||
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 },
|
||||
// Canonical base64, but the bytes are not a PNG: the whole batch must
|
||||
// be validated before any member persists, or the valid image above
|
||||
// would become a permanently unreferenced object (this store has no GC).
|
||||
{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AQID' },
|
||||
],
|
||||
}))
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false, error: { details: { reason: 'INVALID_IMAGE' } },
|
||||
})
|
||||
expect(existsSync(join(dshHome, 'attachments'))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects images for an explicitly text-only model without creating a session event', async () => {
|
||||
const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-text-session-'))
|
||||
const dshHome = mkdtempSync(join(tmpdir(), 'dsh-text-home-'))
|
||||
|
||||
@@ -214,21 +214,19 @@ async function bridge(
|
||||
}
|
||||
const chunks: 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
|
||||
// Reject the moment the threshold is crossed: draining a chunked body to
|
||||
// EOF first would let a client without Content-Length stream
|
||||
// indefinitely while holding the socket and this request task.
|
||||
res.writeHead(413, { connection: 'close' })
|
||||
res.end()
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
if (!oversized) chunks.push(buffer)
|
||||
}
|
||||
if (oversized) {
|
||||
res.writeHead(413)
|
||||
res.end()
|
||||
return
|
||||
chunks.push(buffer)
|
||||
}
|
||||
/* 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 */
|
||||
|
||||
@@ -435,6 +435,27 @@ describe('/api bridge', () => {
|
||||
expect(status).toBe(413)
|
||||
})
|
||||
|
||||
it('rejects an unterminated chunked body at the threshold without draining to EOF', async () => {
|
||||
const base = await boot(() => undefined, 8)
|
||||
const target = new URL(`${base}/api/echo`)
|
||||
// The client never calls end(): the 413 must arrive the moment the limit
|
||||
// is crossed, or a hostile stream would hold the socket open forever.
|
||||
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('123456789')
|
||||
})
|
||||
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