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:
creatixchu
2026-07-24 20:55:25 +08:00
parent b868355f01
commit f57a4a044a
23 changed files with 217 additions and 41 deletions

View File

@@ -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({

View File

@@ -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-'))