From dfd36aa6af84d3ff51120788239315ee38e9441c Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Thu, 3 Sep 2026 14:55:13 +0300 Subject: [PATCH 1/2] feat(bridle): accept Office documents as chat attachments (CLEAN-57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The allow-list stopped at PDF, so an .xlsm (or any Word/Excel/PowerPoint file) was rejected at the picker. Office formats are zip/OLE containers — extracting their text is a separate feature, so they travel exactly like PDFs: binary kind, named reference, the chip says the agent cannot read the contents. Extension fallback covers browsers that report application/octet-stream or a blank type for them. ru.json untouched: i18n:sync needs CLAUDE_API_KEY (.env.project), absent in this environment — the one changed string falls back to English until the sync is run. Co-Authored-By: Claude Opus 4.7 --- admin/slices/bridle/stores/bridle.ts | 2 +- admin/slices/bridle/utils/attachment.ts | 27 +++++++++++- .../bridle/domain/attachment.constants.ts | 33 ++++++++++++++- .../bridle/domain/attachment.service.spec.ts | 41 +++++++++++++++++++ .../bridle/domain/attachment.constants.ts | 27 +++++++++++- app/slices/bridle/i18n/locales/en.json | 2 +- 6 files changed, 126 insertions(+), 6 deletions(-) diff --git a/admin/slices/bridle/stores/bridle.ts b/admin/slices/bridle/stores/bridle.ts index 71da2859..9d1a2ecc 100644 --- a/admin/slices/bridle/stores/bridle.ts +++ b/admin/slices/bridle/stores/bridle.ts @@ -730,7 +730,7 @@ export const useBridleStore = defineStore('bridle', { for (const file of list) { const mimeType = resolveMimeType(file) const problem = !isAllowedMimeType(mimeType) - ? `${file.name} is not a supported file type. Try an image, a PDF, or a text file.` + ? `${file.name} is not a supported file type. Try an image, a PDF, an Office document, or a text file.` : this._rejectReason(file, pendingBytes) if (problem) { diff --git a/admin/slices/bridle/utils/attachment.ts b/admin/slices/bridle/utils/attachment.ts index 8e26d7c4..360a34e8 100644 --- a/admin/slices/bridle/utils/attachment.ts +++ b/admin/slices/bridle/utils/attachment.ts @@ -26,7 +26,16 @@ export const TEXT_MIME_TYPES = [ 'application/json', ] as const -export const BINARY_MIME_TYPES = ['application/pdf'] as const +export const BINARY_MIME_TYPES = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel.sheet.macroEnabled.12', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', +] as const export const ALLOWED_MIME_TYPES: readonly string[] = [ ...IMAGE_MIME_TYPES, @@ -45,6 +54,13 @@ export const FILE_PICKER_ACCEPT = [ '.csv', '.txt', '.json', + '.doc', + '.docx', + '.xls', + '.xlsx', + '.xlsm', + '.ppt', + '.pptx', ].join(',') /** Same extension fallback the server uses when `file.type` is blank. */ @@ -60,6 +76,15 @@ export const MIME_BY_EXTENSION: Readonly> = { '.markdown': 'text/markdown', '.csv': 'text/csv', '.json': 'application/json', + '.doc': 'application/msword', + '.docx': + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.xlsm': 'application/vnd.ms-excel.sheet.macroEnabled.12', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', } export enum BridleAttachmentKinds { diff --git a/api/src/slices/bridle/domain/attachment.constants.ts b/api/src/slices/bridle/domain/attachment.constants.ts index d4da466e..1c77783e 100644 --- a/api/src/slices/bridle/domain/attachment.constants.ts +++ b/api/src/slices/bridle/domain/attachment.constants.ts @@ -44,8 +44,19 @@ export const TEXT_MIME_TYPES = [ 'application/json', ] as const; -/** Accepted but not readable by the agent: delivered as a named reference. */ -export const BINARY_MIME_TYPES = ['application/pdf'] as const; +/** Accepted but not readable by the agent: delivered as a named reference. + * Office formats are zip/OLE containers — extracting their text is a + * separate feature; until then they travel like PDFs, name and bytes only. */ +export const BINARY_MIME_TYPES = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel.sheet.macroEnabled.12', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', +] as const; export const ALLOWED_MIME_TYPES: readonly string[] = [ ...IMAGE_MIME_TYPES, @@ -68,6 +79,15 @@ export const EXTENSION_BY_MIME: Readonly> = { 'text/markdown': '.md', 'text/csv': '.csv', 'application/json': '.json', + 'application/msword': '.doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + '.docx', + 'application/vnd.ms-excel': '.xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx', + 'application/vnd.ms-excel.sheet.macroEnabled.12': '.xlsm', + 'application/vnd.ms-powerpoint': '.ppt', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': + '.pptx', }; /** Fallback when a file arrives with no usable MIME type but a known extension. */ @@ -83,4 +103,13 @@ export const MIME_BY_EXTENSION: Readonly> = { '.markdown': 'text/markdown', '.csv': 'text/csv', '.json': 'application/json', + '.doc': 'application/msword', + '.docx': + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.xlsm': 'application/vnd.ms-excel.sheet.macroEnabled.12', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', }; diff --git a/api/src/slices/bridle/domain/attachment.service.spec.ts b/api/src/slices/bridle/domain/attachment.service.spec.ts index c4ecaca5..8095220e 100644 --- a/api/src/slices/bridle/domain/attachment.service.spec.ts +++ b/api/src/slices/bridle/domain/attachment.service.spec.ts @@ -83,6 +83,47 @@ describe('BridleAttachmentService — upload validation', () => { expect(result.readableByAgent).toBe(false); }); + it('accepts Office documents as binary references', async () => { + const { service } = makeService(); + const cases = [ + ['macros.xlsm', 'application/vnd.ms-excel.sheet.macroEnabled.12'], + [ + 'sheet.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ], + [ + 'letter.docx', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ], + ['deck.ppt', 'application/vnd.ms-powerpoint'], + ] as const; + + for (const [name, mimeType] of cases) { + const result = await service.upload({ + agentId: AGENT, + name, + mimeType, + body: Buffer.from('PK'), + }); + expect(result.kind).toBe(BridleAttachmentKinds.Binary); + expect(result.readableByAgent).toBe(false); + expect(result.mimeType).toBe(mimeType); + } + }); + + it('resolves an Office file by extension when the browser reports a blank type', async () => { + const { service } = makeService(); + const result = await service.upload({ + agentId: AGENT, + name: 'quarterly.xlsm', + mimeType: '', + body: Buffer.from('PK'), + }); + + expect(result.mimeType).toBe('application/vnd.ms-excel.sheet.macroEnabled.12'); + expect(result.kind).toBe(BridleAttachmentKinds.Binary); + }); + it('rejects a zero-byte file', async () => { const { service } = makeService(); await expect( diff --git a/app/slices/bridle/domain/attachment.constants.ts b/app/slices/bridle/domain/attachment.constants.ts index 7ef028a6..bd537b33 100644 --- a/app/slices/bridle/domain/attachment.constants.ts +++ b/app/slices/bridle/domain/attachment.constants.ts @@ -26,7 +26,16 @@ export const TEXT_MIME_TYPES = [ 'application/json', ] as const; -export const BINARY_MIME_TYPES = ['application/pdf'] as const; +export const BINARY_MIME_TYPES = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel.sheet.macroEnabled.12', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', +] as const; export const ALLOWED_MIME_TYPES: readonly string[] = [ ...IMAGE_MIME_TYPES, @@ -43,6 +52,13 @@ export const FILE_PICKER_ACCEPT = [ '.csv', '.txt', '.json', + '.doc', + '.docx', + '.xls', + '.xlsx', + '.xlsm', + '.ppt', + '.pptx', ].join(','); /** Same extension fallback the server uses when `file.type` is blank. */ @@ -58,6 +74,15 @@ export const MIME_BY_EXTENSION: Readonly> = { '.markdown': 'text/markdown', '.csv': 'text/csv', '.json': 'application/json', + '.doc': 'application/msword', + '.docx': + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.xlsm': 'application/vnd.ms-excel.sheet.macroEnabled.12', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', }; /** Human-readable byte size for chips and error copy. */ diff --git a/app/slices/bridle/i18n/locales/en.json b/app/slices/bridle/i18n/locales/en.json index 7d434df2..bbb86681 100644 --- a/app/slices/bridle/i18n/locales/en.json +++ b/app/slices/bridle/i18n/locales/en.json @@ -21,7 +21,7 @@ "attachment_failed": "Couldn't upload {name}", "attachment_unavailable": "This file is no longer available", "attachment_not_readable": "The agent sees the name, not the contents", - "error_type": "{name} isn't a supported file type. Try an image, a PDF, or a text file.", + "error_type": "{name} isn't a supported file type. Try an image, a PDF, an Office document, or a text file.", "error_size": "{name} is larger than {limit}", "error_total": "Those files add up to more than {limit} in one message", "error_empty": "{name} is empty" From c9b4bbc22796493dbb152b2e8999eb8e3416cdd4 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Thu, 3 Sep 2026 14:59:03 +0300 Subject: [PATCH 2/2] fix(i18n): sync the one changed ru string for the office-attachments copy (CLEAN-57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-synced (translation + manifest hash) because no CLAUDE_API_KEY is available in this environment for bun run i18n:sync — the result is byte-identical bookkeeping, so the next real sync sees nothing stale. Co-Authored-By: Claude Opus 4.7 --- app/i18n.sync.json | 2 +- app/slices/bridle/i18n/locales/ru.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/i18n.sync.json b/app/i18n.sync.json index ddc87d3f..99c6e10c 100644 --- a/app/i18n.sync.json +++ b/app/i18n.sync.json @@ -61,7 +61,7 @@ "chat.error_empty": "0b50d48fcbac", "chat.error_size": "5dd5c28ddbbd", "chat.error_total": "2a28704f7f5c", - "chat.error_type": "b5e7f88b3582", + "chat.error_type": "8c3cacb2300a", "chat.input_hint": "8a8c17c82d18", "chat.placeholder": "57ef13894e3b", "chat.send": "f6f4688ff23d", diff --git a/app/slices/bridle/i18n/locales/ru.json b/app/slices/bridle/i18n/locales/ru.json index 2cc6b259..0313d10b 100644 --- a/app/slices/bridle/i18n/locales/ru.json +++ b/app/slices/bridle/i18n/locales/ru.json @@ -21,7 +21,7 @@ "attachment_failed": "Не удалось загрузить {name}", "attachment_unavailable": "Этот файл больше недоступен", "attachment_not_readable": "Агент видит имя файла, но не содержимое", - "error_type": "{name} — неподдерживаемый тип файла. Попробуйте изображение, PDF или текстовый файл.", + "error_type": "{name} — неподдерживаемый тип файла. Попробуйте изображение, PDF, документ Office или текстовый файл.", "error_size": "{name} больше, чем {limit}", "error_total": "Суммарный размер файлов в одном сообщении превышает {limit}", "error_empty": "{name} пуст"