From 29147afd637d3d19bca0cd1844c674054df5b286 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 9 Jun 2026 18:31:09 -0500 Subject: [PATCH] fix(desktop): friendlier toast when a remote attachment exceeds the 16MB cap Remote attachments read their bytes through the readFileDataUrl IPC, which is hard-capped at 16MB and rejects with a raw "file is too large (N bytes; limit M bytes)" string straight into the failure toast (helix4u review note on #43109). Translate that into " is too large to upload to the remote gateway (max 16 MB)", parsing the limit out of the message so it tracks the real cap. Applies to both the image and non-image remote read paths; non-cap errors pass through unchanged. Adds unit coverage for both. --- .../session/hooks/use-prompt-actions.test.tsx | 51 ++++++++++++++++++- .../app/session/hooks/use-prompt-actions.ts | 43 ++++++++++++++-- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx index 145a397d87f..96af1e8400e 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx @@ -7,7 +7,7 @@ import { $composerAttachments, type ComposerAttachment } from '@/store/composer' import { $connection, $sessions, setSessions } from '@/store/session' import type { SessionInfo } from '@/types/hermes' -import { usePromptActions } from './use-prompt-actions' +import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions' vi.mock('@/hermes', () => ({ getProfiles: vi.fn(async () => ({ profiles: [] })), @@ -703,3 +703,52 @@ describe('usePromptActions eager attachment upload (drop-time)', () => { }) }) +describe('uploadComposerAttachment remote read failures', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => { + // electron/hardening.cjs rejects the readFileDataUrl IPC with this exact + // shape when a file exceeds DATA_URL_READ_MAX_BYTES. + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + readFileDataUrl: vi.fn(async () => { + throw new Error('File preview failed: file is too large (20971520 bytes; limit 16777216 bytes).') + }) + } + }) + + const requestGateway = vi.fn(async () => ({}) as never) + + await expect( + uploadComposerAttachment( + { id: 'file:big', kind: 'file', label: 'huge.csv', path: '/abs/huge.csv' }, + { remote: true, requestGateway, sessionId: RUNTIME_SESSION_ID } + ) + ).rejects.toThrow('huge.csv is too large to upload to the remote gateway (max 16 MB).') + + // The cap is hit before any gateway round-trip. + expect(requestGateway).not.toHaveBeenCalled() + }) + + it('passes non-cap read errors through unchanged', async () => { + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + readFileDataUrl: vi.fn(async () => { + throw new Error('ENOENT: no such file') + }) + } + }) + + await expect( + uploadComposerAttachment( + { id: 'file:gone', kind: 'file', label: 'gone.csv', path: '/abs/gone.csv' }, + { remote: true, requestGateway: vi.fn(async () => ({}) as never), sessionId: RUNTIME_SESSION_ID } + ) + ).rejects.toThrow('ENOENT: no such file') + }) +}) + diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 63e15f05185..167f0d3224f 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -120,6 +120,27 @@ async function readFileDataUrlForAttach(filePath: string): Promise 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : '' + + return new Error(`${label} is too large to upload to the remote gateway${cap}.`) +} + type GatewayRequest = (method: string, params?: Record) => Promise /** @@ -142,7 +163,13 @@ export async function uploadComposerAttachment( let result: ImageAttachResponse if (remote) { - const payload = await readImageForRemoteAttach(path) + let payload: Awaited> + + try { + payload = await readImageForRemoteAttach(path) + } catch (err) { + throw friendlyRemoteAttachError(err, label) + } if (!payload) { throw new Error(`Could not read ${label}`) @@ -176,10 +203,18 @@ export async function uploadComposerAttachment( } // Non-image file. - const dataUrl = remote ? await readFileDataUrlForAttach(path) : null + let dataUrl: string | null = null - if (remote && !dataUrl) { - throw new Error(`Could not read ${label}`) + if (remote) { + try { + dataUrl = await readFileDataUrlForAttach(path) + } catch (err) { + throw friendlyRemoteAttachError(err, label) + } + + if (!dataUrl) { + throw new Error(`Could not read ${label}`) + } } const result = await requestGateway('file.attach', {