From 585dedf503f7c53d30bdd1d8396ae47cdf2abf88 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Aug 2026 17:04:21 -0700 Subject: [PATCH] fix(agiloft): resolve attachment MIME type instead of trusting the header Agiloft labels most attachments application/octet-stream whatever they actually are, so a downstream consumer keyed on the header mis-handles them. Resolve the type through resolveEffectiveMimeType, the shared helper the other tool routes already use, which prefers a header that names a real format and otherwise falls back to the filename Agiloft sends in Content-Disposition. --- .../api/tools/agiloft/retrieve/route.test.ts | 29 +++++++++++++++++++ .../app/api/tools/agiloft/retrieve/route.ts | 12 ++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/tools/agiloft/retrieve/route.test.ts b/apps/sim/app/api/tools/agiloft/retrieve/route.test.ts index 1756eeb0897..c738a1e952b 100644 --- a/apps/sim/app/api/tools/agiloft/retrieve/route.test.ts +++ b/apps/sim/app/api/tools/agiloft/retrieve/route.test.ts @@ -140,6 +140,35 @@ describe('POST /api/tools/agiloft/retrieve', () => { expect(inputValidationMockFns.mockValidateUrlWithDNS).toHaveBeenCalledTimes(1) }) + it('resolves the real type when Agiloft labels an attachment octet-stream', async () => { + const fileBytes = Buffer.from('PKdocx-bytes', 'utf-8') + + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + mockSecureFetchResponse({ + arrayBuffer: fileBytes.buffer.slice( + fileBytes.byteOffset, + fileBytes.byteOffset + fileBytes.byteLength + ) as ArrayBuffer, + headers: new Headers({ + 'content-type': 'application/octet-stream', + 'content-disposition': 'attachment; filename="Master Agreement.docx"', + }), + }) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + const data = (await response.json()) as { output: { file: { mimeType: string } } } + + /** + * Agiloft labels most attachments octet-stream whatever they are. The + * filename disambiguates what the leading bytes cannot: a ZIP header is + * equally a .docx, .xlsx or a plain archive. + */ + expect(data.output.file.mimeType).toBe( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) + }) + it('propagates upstream errors', async () => { inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce( mockSecureFetchResponse({ ok: false, status: 404, text: 'Record not found' }) diff --git a/apps/sim/app/api/tools/agiloft/retrieve/route.ts b/apps/sim/app/api/tools/agiloft/retrieve/route.ts index 1b09ce16944..f839a0a23fb 100644 --- a/apps/sim/app/api/tools/agiloft/retrieve/route.ts +++ b/apps/sim/app/api/tools/agiloft/retrieve/route.ts @@ -7,6 +7,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithPinnedIP } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveEffectiveMimeType } from '@/lib/uploads/utils/file-utils' import { isEwRestBody } from '@/tools/agiloft/ewrest' import { AGILOFT_MAX_ATTACHMENT_BYTES, @@ -128,10 +129,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + /** + * Agiloft labels most attachments application/octet-stream whatever they + * are, so downstream consumers keyed on the header mis-handle them. The + * filename Agiloft sends in Content-Disposition carries the real type. + */ + const mimeType = resolveEffectiveMimeType(contentType, fileName) + logger.info(`[${requestId}] Attachment downloaded successfully`, { name: fileName, size: fileBuffer.length, - mimeType: contentType, + mimeType, }) const base64Data = fileBuffer.toString('base64') @@ -141,7 +149,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { file: { name: fileName, - mimeType: contentType, + mimeType, data: base64Data, size: fileBuffer.length, },