From a27a62ff9902b952430299f2fa07ce7eec3fe9d9 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 15 Sep 2026 13:10:47 -0700 Subject: [PATCH 1/4] fix(chat): download generated files without stale storage URLs --- .../message/components/file-download.test.tsx | 138 +++++++++++++++++- .../message/components/file-download.tsx | 62 +++++--- apps/sim/app/api/files/authorization.test.ts | 45 ++++++ .../api/files/serve/[...path]/route.test.ts | 20 +++ 4 files changed, 239 insertions(+), 26 deletions(-) diff --git a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx index b386babcad2..dde617bb2c1 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx @@ -1,10 +1,14 @@ /** * @vitest-environment jsdom */ +import { Blob as NodeBlob } from 'node:buffer' import { act } from 'react' import { createRoot } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { ChatFileDownload } from '@/app/(interfaces)/chat/components/message/components/file-download' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + ChatFileDownload, + ChatFileDownloadAll, +} from '@/app/(interfaces)/chat/components/message/components/file-download' import type { ChatFile } from '@/app/(interfaces)/chat/components/message/message' const imageFile: ChatFile = { @@ -17,13 +21,39 @@ const imageFile: ChatFile = { base64: 'YWJj', } +const fetchMock = vi.fn() +const createObjectURL = vi.fn((_blob: Blob) => 'blob:download') +const downloadedNames: string[] = [] + +beforeEach(() => { + vi.clearAllMocks() + downloadedNames.length = 0 + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('Blob', NodeBlob) + vi.stubGlobal( + 'URL', + class extends URL { + static createObjectURL = createObjectURL + static revokeObjectURL = vi.fn() + } + ) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function () { + downloadedNames.push(this.download) + }) + vi.spyOn(window, 'open').mockImplementation(() => null) +}) + const mounts: Array<() => void> = [] -function renderFile(file: ChatFile): HTMLDivElement { +function renderFile(file: ChatFile | ChatFile[]): HTMLDivElement { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const container = document.createElement('div') const root = createRoot(container) - act(() => root.render()) + act(() => + root.render( + Array.isArray(file) ? : + ) + ) mounts.push(() => act(() => root.unmount())) return container } @@ -31,6 +61,7 @@ function renderFile(file: ChatFile): HTMLDivElement { afterEach(() => { while (mounts.length) mounts.pop()?.() vi.restoreAllMocks() + vi.unstubAllGlobals() }) describe('ChatFileDownload', () => { @@ -67,3 +98,102 @@ describe('ChatFileDownload', () => { expect(container.querySelector('button')?.textContent).toContain('report.pdf') }) }) + +async function clickDownload(container: HTMLDivElement): Promise { + await act(async () => container.querySelector('button')!.click()) +} + +describe('chat file downloads', () => { + it('downloads exact inline bytes without fetching a data URL or needing a session', async () => { + fetchMock.mockRejectedValue(new TypeError('Blocked by connect-src')) + const container = renderFile({ ...imageFile, base64: 'AP9/gAE=' }) + await clickDownload(container) + expect(fetchMock).not.toHaveBeenCalled() + const blob = createObjectURL.mock.calls[0]![0] + expect([...new Uint8Array(await blob.arrayBuffer())]).toEqual([0, 255, 127, 128, 1]) + expect(blob.type).toBe('image/png') + expect(downloadedNames).toEqual(['generated.png']) + expect(window.open).not.toHaveBeenCalled() + }) + + it.each(['s3', 'blob', 'gcs', 'local'])( + 'downloads stored %s files through the logs serve route instead of stale URLs', + async (provider) => { + fetchMock.mockResolvedValue(new Response('current stored bytes')) + const file = { + ...imageFile, + base64: undefined, + key: `execution/workspace/workflow/run/${provider}.png`, + url: 'https://files.example.com/expired?X-Amz-Expires=300', + } + await clickDownload(renderFile(file)) + expect(fetchMock).toHaveBeenCalledExactlyOnceWith( + `/api/files/serve/${encodeURIComponent(file.key)}?context=execution`, + { cache: 'no-store' } + ) + expect(await createObjectURL.mock.calls[0]![0].text()).toBe('current stored bytes') + expect(downloadedNames).toEqual(['generated.png']) + } + ) + + it('keeps external URL files on their existing URL path', async () => { + fetchMock.mockResolvedValue(new Response('external bytes')) + await clickDownload(renderFile({ ...imageFile, base64: undefined, key: 'url/external' })) + expect(fetchMock).toHaveBeenCalledExactlyOnceWith(imageFile.url, { cache: 'no-store' }) + }) + + it('preserves delivered signed access for public visitors without a workspace session', async () => { + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 401 })) + .mockResolvedValueOnce(new Response('publicly delivered bytes')) + await clickDownload(renderFile({ ...imageFile, base64: undefined })) + expect(fetchMock).toHaveBeenNthCalledWith(2, imageFile.url, { cache: 'no-store' }) + expect(downloadedNames).toEqual(['generated.png']) + }) + + it('shows download errors without opening an expired storage error page', async () => { + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 401 })) + .mockResolvedValueOnce(new Response('Request has expired', { status: 403 })) + const container = renderFile({ ...imageFile, base64: undefined }) + await clickDownload(container) + expect(container.querySelector('[role="alert"]')?.textContent).toContain('Unable to download') + expect(downloadedNames).toEqual([]) + expect(window.open).not.toHaveBeenCalled() + expect(container.querySelector('button')?.disabled).toBe(false) + }) + + it.each([403, 404])( + 'does not retry denied or deleted stored files through their old URLs (%s)', + async (status) => { + fetchMock.mockResolvedValue(new Response(null, { status })) + await clickDownload(renderFile({ ...imageFile, base64: undefined })) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(downloadedNames).toEqual([]) + } + ) + + it('uses the same inline and storage handling for download all', async () => { + fetchMock.mockResolvedValue(new Response('stored bytes')) + const stored = { ...imageFile, id: 'stored', name: 'stored.png', base64: undefined } + const container = renderFile([imageFile, stored]) + await act(async () => { + container.querySelector('button')!.click() + await vi.waitFor(() => expect(downloadedNames).toEqual(['generated.png', 'stored.png'])) + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) + +it('refuses unsafe external file URLs', async () => { + const container = renderFile({ + ...imageFile, + base64: undefined, + key: 'url/external', + url: 'javascript:alert(1)', + }) + await clickDownload(container) + expect(fetchMock).not.toHaveBeenCalled() + expect(window.open).not.toHaveBeenCalled() + expect(downloadedNames).toEqual([]) +}) diff --git a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx index f9005b8b7e5..5560a42526f 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx @@ -7,6 +7,7 @@ import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons' import { isSafeHttpUrl } from '@/lib/core/utils/urls' +import { saveBlob } from '@/lib/uploads/client/download' import type { ChatFile } from '@/app/(interfaces)/chat/components/message/message' const logger = createLogger('ChatFileDownload') @@ -56,28 +57,43 @@ function getFileUrl(file: ChatFile): string { return `/api/files/serve/${encodeURIComponent(file.key)}?context=${file.context || 'execution'}` } -async function triggerDownload(url: string, filename: string): Promise { - const response = await fetch(url) - if (!response.ok) { - throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`) +async function triggerDownload(file: ChatFile): Promise { + if (file.base64) { + /** Decoding locally avoids a data-URL fetch, which connect-src does not allow. */ + const decoded = atob(file.base64) + const bytes = new Uint8Array(decoded.length) + for (let index = 0; index < decoded.length; index++) { + bytes[index] = decoded.charCodeAt(index) + } + saveBlob(new Blob([bytes], { type: file.type }), file.name) + return } - const blob = await response.blob() - const blobUrl = URL.createObjectURL(blob) - - const link = document.createElement('a') - link.href = blobUrl - link.download = filename - document.body.appendChild(link) - link.click() - document.body.removeChild(link) + const hasStorageKey = Boolean(file.key && !file.key.startsWith('url/')) + const url = hasStorageKey + ? `/api/files/serve/${encodeURIComponent(file.key)}?context=${encodeURIComponent(file.context || 'execution')}` + : isSafeHttpUrl(file.url) + ? file.url + : null + if (!url) throw new Error('File has no download URL') + + /** The same serve route as execution logs resolves current storage access on each click. */ + // boundary-raw-fetch: binary file download, including externally hosted file URLs + let response = await fetch(url, { cache: 'no-store' }) + if (hasStorageKey && response.status === 401 && isSafeHttpUrl(file.url)) { + /** Public chat visitors may only have the file access already delivered in the response. */ + response = await fetch(file.url, { cache: 'no-store' }) + } + if (!response.ok) { + throw new Error('Unable to download this file. Please try again or request a new copy.') + } - URL.revokeObjectURL(blobUrl) - logger.info(`Downloaded: ${filename}`) + saveBlob(await response.blob(), file.name) } export function ChatFileDownload({ file }: ChatFileDownloadProps) { const [isDownloading, setIsDownloading] = useState(false) + const [downloadFailed, setDownloadFailed] = useState(false) const [failedPreviewUrl, setFailedPreviewUrl] = useState(null) const fileUrl = getFileUrl(file) @@ -85,16 +101,14 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) { if (isDownloading) return setIsDownloading(true) + setDownloadFailed(false) try { logger.info(`Initiating download for file: ${file.name}`) - const url = getFileUrl(file) - await triggerDownload(url, file.name) + await triggerDownload(file) } catch (error) { logger.error(`Failed to download file ${file.name}:`, error) - if (file.url && isSafeHttpUrl(file.url)) { - window.open(file.url, '_blank', 'noopener,noreferrer') - } + setDownloadFailed(true) } finally { setIsDownloading(false) } @@ -142,6 +156,11 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) { )} + {downloadFailed && ( +

+ Unable to download this file. Please try again or request a new copy. +

+ )} ) } @@ -162,8 +181,7 @@ export function ChatFileDownloadAll({ files }: ChatFileDownloadAllProps) { for (let i = 0; i < files.length; i++) { const file = files[i] try { - const url = getFileUrl(file) - await triggerDownload(url, file.name) + await triggerDownload(file) logger.info(`Downloaded file ${i + 1}/${files.length}: ${file.name}`) if (i < files.length - 1) { diff --git a/apps/sim/app/api/files/authorization.test.ts b/apps/sim/app/api/files/authorization.test.ts index a8738342a8d..e9771609833 100644 --- a/apps/sim/app/api/files/authorization.test.ts +++ b/apps/sim/app/api/files/authorization.test.ts @@ -491,3 +491,48 @@ describe('KB file live source authorization', () => { expect(get).not.toHaveBeenCalled() }) }) + +/** Execution downloads share the logs endpoint's current workspace permission check. */ +describe('execution file download authorization', () => { + const executionKey = 'execution/owner-workspace/workflow/run/image.png' + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('allows a current reader of the workspace named by the storage key', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') + await expect(verifyFileAccess(executionKey, USER_ID, undefined, 'execution')).resolves.toBe( + true + ) + expect(mockGetUserEntityPermissions).toHaveBeenCalledExactlyOnceWith( + USER_ID, + 'workspace', + 'owner-workspace' + ) + }) + + it('denies a caller without access to the file workspace', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + await expect(verifyFileAccess(executionKey, USER_ID, undefined, 'execution')).resolves.toBe( + false + ) + }) + + it('rechecks access after membership is revoked', async () => { + mockGetUserEntityPermissions.mockResolvedValueOnce('read').mockResolvedValueOnce(null) + await expect(verifyFileAccess(executionKey, USER_ID, undefined, 'execution')).resolves.toBe( + true + ) + await expect(verifyFileAccess(executionKey, USER_ID, undefined, 'execution')).resolves.toBe( + false + ) + }) + + it('denies a malformed execution key before looking up workspace access', async () => { + await expect( + verifyFileAccess('execution/image.png', USER_ID, undefined, 'execution') + ).resolves.toBe(false) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index e3937b9e78d..38c3cfcf73d 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -200,6 +200,26 @@ describe('File Serve API Route', () => { }) }) + it('requires authentication for execution downloads before reading bytes', async () => { + mockResolveStoredFileContext.mockResolvedValue('execution') + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: false, + error: 'Unauthorized', + }) + const response = await GET( + new NextRequest( + 'http://localhost/api/files/serve/execution%2Fworkspace%2Fworkflow%2Frun%2Fimage.png?context=execution' + ), + { + params: Promise.resolve({ path: ['execution/workspace/workflow/run/image.png'] }), + } + ) + expect(response.status).toBe(401) + expect(mockVerifyFileAccess).not.toHaveBeenCalled() + expect(mockReadFile).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled() + }) + it('bounds every buffered read at the shared transfer ceiling', async () => { mockIsUsingCloudStorage.mockReturnValue(true) mockResolveStoredFileContext.mockResolvedValue('copilot') From 5a09f8da5494a8e826c4adb45b61fcbe7eaaa44f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 15 Sep 2026 13:28:49 -0700 Subject: [PATCH 2/4] fix(chat): preserve external downloads and report bulk failures --- .../message/components/file-download.test.tsx | 43 ++++++++++++++++--- .../message/components/file-download.tsx | 41 ++++++++++++------ 2 files changed, 66 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx index dde617bb2c1..6bbb81bff72 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx @@ -136,11 +136,14 @@ describe('chat file downloads', () => { } ) - it('keeps external URL files on their existing URL path', async () => { - fetchMock.mockResolvedValue(new Response('external bytes')) - await clickDownload(renderFile({ ...imageFile, base64: undefined, key: 'url/external' })) - expect(fetchMock).toHaveBeenCalledExactlyOnceWith(imageFile.url, { cache: 'no-store' }) - }) + it.each(['url/external', 'result-123', ''])( + 'keeps external URL files with key "%s" on their existing URL path', + async (key) => { + fetchMock.mockResolvedValue(new Response('external bytes')) + await clickDownload(renderFile({ ...imageFile, base64: undefined, key })) + expect(fetchMock).toHaveBeenCalledExactlyOnceWith(imageFile.url, { cache: 'no-store' }) + } + ) it('preserves delivered signed access for public visitors without a workspace session', async () => { fetchMock @@ -183,6 +186,36 @@ describe('chat file downloads', () => { }) expect(fetchMock).toHaveBeenCalledTimes(1) }) + + it('reports partial bulk failures, continues the batch, and clears the alert after a successful retry', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 403 })) + const stored = { ...imageFile, id: 'stored', name: 'stored.png', base64: undefined } + const container = renderFile([stored, imageFile]) + await clickDownload(container) + expect(downloadedNames).toEqual(['generated.png']) + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + 'Unable to download 1 file' + ) + fetchMock.mockResolvedValue(new Response('stored bytes')) + await act(async () => { + container.querySelector('button')!.click() + await vi.waitFor(() => + expect(downloadedNames).toEqual(['generated.png', 'stored.png', 'generated.png']) + ) + }) + expect(container.querySelector('[role="alert"]')).toBeNull() + }) + + it('uses the recognized key context when metadata omits it', async () => { + fetchMock.mockResolvedValue(new Response('workspace bytes')) + await clickDownload( + renderFile({ ...imageFile, base64: undefined, key: 'workspace/id/file.png' }) + ) + expect(fetchMock).toHaveBeenCalledExactlyOnceWith( + '/api/files/serve/workspace%2Fid%2Ffile.png?context=workspace', + { cache: 'no-store' } + ) + }) }) it('refuses unsafe external file URLs', async () => { diff --git a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx index 5560a42526f..1953c2ceb21 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx @@ -8,6 +8,7 @@ import { sleep } from '@sim/utils/helpers' import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons' import { isSafeHttpUrl } from '@/lib/core/utils/urls' import { saveBlob } from '@/lib/uploads/client/download' +import { tryInferContextFromKey } from '@/lib/uploads/utils/file-utils' import type { ChatFile } from '@/app/(interfaces)/chat/components/message/message' const logger = createLogger('ChatFileDownload') @@ -69,9 +70,10 @@ async function triggerDownload(file: ChatFile): Promise { return } - const hasStorageKey = Boolean(file.key && !file.key.startsWith('url/')) + const storageContext = tryInferContextFromKey(file.key) + const hasStorageKey = storageContext !== null const url = hasStorageKey - ? `/api/files/serve/${encodeURIComponent(file.key)}?context=${encodeURIComponent(file.context || 'execution')}` + ? `/api/files/serve/${encodeURIComponent(file.key)}?context=${encodeURIComponent(storageContext)}` : isSafeHttpUrl(file.url) ? file.url : null @@ -167,6 +169,7 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) { export function ChatFileDownloadAll({ files }: ChatFileDownloadAllProps) { const [isDownloading, setIsDownloading] = useState(false) + const [failedCount, setFailedCount] = useState(0) if (!files || files.length === 0) return null @@ -174,6 +177,8 @@ export function ChatFileDownloadAll({ files }: ChatFileDownloadAllProps) { if (isDownloading) return setIsDownloading(true) + setFailedCount(0) + let failures = 0 try { logger.info(`Initiating download for ${files.length} files`) @@ -189,25 +194,35 @@ export function ChatFileDownloadAll({ files }: ChatFileDownloadAllProps) { } } catch (error) { logger.error(`Failed to download file ${file.name}:`, error) + failures++ } } } finally { + setFailedCount(failures) setIsDownloading(false) } } return ( - + {failedCount > 0 && ( +

+ Unable to download {failedCount} {failedCount === 1 ? 'file' : 'files'}. Please try + downloading them individually. +

)} - + ) } From a49b4f94ce7bcebd13f4cd70f7d140c6f7dd6dec Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 15 Sep 2026 13:46:39 -0700 Subject: [PATCH 3/4] fix(chat): preserve direct downloads for CORS-restricted files --- .../message/components/file-download.test.tsx | 40 ++++++++++++++- .../message/components/file-download.tsx | 50 ++++++++++++++++--- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx index 6bbb81bff72..5928480b4b8 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx @@ -154,6 +154,42 @@ describe('chat file downloads', () => { expect(downloadedNames).toEqual(['generated.png']) }) + it.each([false, true])( + 'offers a safe browser download when an external host blocks CORS (stored=%s)', + async (stored) => { + if (stored) fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 })) + fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')) + const container = renderFile({ + ...imageFile, + base64: undefined, + key: stored ? imageFile.key : 'result-123', + }) + await clickDownload(container) + const link = container.querySelector('a')! + expect(link.href).toBe(imageFile.url) + expect(link.download).toBe(imageFile.name) + expect(link.rel).toBe('noopener noreferrer') + expect(link.target).toBe('_blank') + expect(window.open).not.toHaveBeenCalled() + } + ) + + it('cancels both discarded authentication and failed download response bodies', async () => { + const cancelAuthentication = vi.fn() + const cancelDownload = vi.fn() + fetchMock.mockResolvedValueOnce( + new Response(new ReadableStream({ cancel: cancelAuthentication }), { status: 401 }) + ) + fetchMock.mockResolvedValueOnce( + new Response(new ReadableStream({ cancel: cancelDownload }), { status: 403 }) + ) + const container = renderFile({ ...imageFile, base64: undefined }) + await clickDownload(container) + expect(cancelAuthentication).toHaveBeenCalledTimes(1) + expect(cancelDownload).toHaveBeenCalledTimes(1) + expect(container.querySelector('a')).toBeNull() + }) + it('shows download errors without opening an expired storage error page', async () => { fetchMock .mockResolvedValueOnce(new Response(null, { status: 401 })) @@ -170,9 +206,11 @@ describe('chat file downloads', () => { 'does not retry denied or deleted stored files through their old URLs (%s)', async (status) => { fetchMock.mockResolvedValue(new Response(null, { status })) - await clickDownload(renderFile({ ...imageFile, base64: undefined })) + const container = renderFile({ ...imageFile, base64: undefined }) + await clickDownload(container) expect(fetchMock).toHaveBeenCalledTimes(1) expect(downloadedNames).toEqual([]) + expect(container.querySelector('a')).toBeNull() } ) diff --git a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx index 1953c2ceb21..859eda04e94 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx @@ -21,6 +21,21 @@ interface ChatFileDownloadAllProps { files: ChatFile[] } +class DirectDownloadRequiredError extends Error { + constructor(readonly url: string) { + super('This file must be downloaded directly in the browser.') + } +} + +async function fetchExternalFile(url: string): Promise { + try { + return await fetch(url, { cache: 'no-store' }) + } catch { + /** A navigation can download external files whose hosts do not allow CORS reads. */ + throw new DirectDownloadRequiredError(url) + } +} + function formatFileSize(bytes: number): string { if (bytes === 0) return '0 B' const k = 1024 @@ -80,13 +95,17 @@ async function triggerDownload(file: ChatFile): Promise { if (!url) throw new Error('File has no download URL') /** The same serve route as execution logs resolves current storage access on each click. */ - // boundary-raw-fetch: binary file download, including externally hosted file URLs - let response = await fetch(url, { cache: 'no-store' }) + let response = hasStorageKey + ? // boundary-raw-fetch: binary file download through the authorized serve route + await fetch(url, { cache: 'no-store' }) + : await fetchExternalFile(url) if (hasStorageKey && response.status === 401 && isSafeHttpUrl(file.url)) { + await response.body?.cancel() /** Public chat visitors may only have the file access already delivered in the response. */ - response = await fetch(file.url, { cache: 'no-store' }) + response = await fetchExternalFile(file.url) } if (!response.ok) { + await response.body?.cancel() throw new Error('Unable to download this file. Please try again or request a new copy.') } @@ -95,7 +114,7 @@ async function triggerDownload(file: ChatFile): Promise { export function ChatFileDownload({ file }: ChatFileDownloadProps) { const [isDownloading, setIsDownloading] = useState(false) - const [downloadFailed, setDownloadFailed] = useState(false) + const [downloadError, setDownloadError] = useState<{ directUrl?: string } | null>(null) const [failedPreviewUrl, setFailedPreviewUrl] = useState(null) const fileUrl = getFileUrl(file) @@ -103,14 +122,14 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) { if (isDownloading) return setIsDownloading(true) - setDownloadFailed(false) + setDownloadError(null) try { logger.info(`Initiating download for file: ${file.name}`) await triggerDownload(file) } catch (error) { logger.error(`Failed to download file ${file.name}:`, error) - setDownloadFailed(true) + setDownloadError(error instanceof DirectDownloadRequiredError ? { directUrl: error.url } : {}) } finally { setIsDownloading(false) } @@ -158,9 +177,24 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) { )} - {downloadFailed && ( + {downloadError && (

- Unable to download this file. Please try again or request a new copy. + {downloadError.directUrl ? ( + <> + Unable to download automatically.{' '} + + Download directly + + + ) : ( + 'Unable to download this file. Please try again or request a new copy.' + )}

)} From 58cdddc09c2f64c97fa037c5fe502110463eb4fc Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 15 Sep 2026 13:58:45 -0700 Subject: [PATCH 4/4] chore(chat): annotate binary download fetch boundaries --- .../components/message/components/file-download.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx index 859eda04e94..1da92cb8959 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx @@ -29,6 +29,7 @@ class DirectDownloadRequiredError extends Error { async function fetchExternalFile(url: string): Promise { try { + // boundary-raw-fetch: external binary download from an already validated HTTP URL return await fetch(url, { cache: 'no-store' }) } catch { /** A navigation can download external files whose hosts do not allow CORS reads. */ @@ -95,10 +96,13 @@ async function triggerDownload(file: ChatFile): Promise { if (!url) throw new Error('File has no download URL') /** The same serve route as execution logs resolves current storage access on each click. */ - let response = hasStorageKey - ? // boundary-raw-fetch: binary file download through the authorized serve route - await fetch(url, { cache: 'no-store' }) - : await fetchExternalFile(url) + let response: Response + if (hasStorageKey) { + // boundary-raw-fetch: binary file download through the authorized serve route + response = await fetch(url, { cache: 'no-store' }) + } else { + response = await fetchExternalFile(url) + } if (hasStorageKey && response.status === 401 && isSafeHttpUrl(file.url)) { await response.body?.cancel() /** Public chat visitors may only have the file access already delivered in the response. */