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 "<file> 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.
This commit is contained in:
Brooklyn Nicholson 2026-06-09 18:31:09 -05:00
parent b021497bc8
commit 29147afd63
2 changed files with 89 additions and 5 deletions

View file

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

View file

@ -120,6 +120,27 @@ async function readFileDataUrlForAttach(filePath: string): Promise<string | null
return dataUrl || null
}
// The readFileDataUrl IPC base64-loads the whole file into memory and is
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which
// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In
// remote mode every attachment's bytes go through that read, so a big file
// surfaces that internal message verbatim in the failure toast. Translate it
// into a friendly "too large to upload to the remote gateway" line, parsing the
// limit out of the message so it tracks the real cap. Non-cap errors pass
// through unchanged.
function friendlyRemoteAttachError(err: unknown, label: string): Error {
const message = err instanceof Error ? err.message : String(err)
if (!/too large/i.test(message)) {
return err instanceof Error ? err : new Error(message)
}
const limitBytes = Number(message.match(/limit (\d+) bytes/)?.[1])
const cap = Number.isFinite(limitBytes) && limitBytes > 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : ''
return new Error(`${label} is too large to upload to the remote gateway${cap}.`)
}
type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
/**
@ -142,7 +163,13 @@ export async function uploadComposerAttachment(
let result: ImageAttachResponse
if (remote) {
const payload = await readImageForRemoteAttach(path)
let payload: Awaited<ReturnType<typeof readImageForRemoteAttach>>
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<FileAttachResponse>('file.attach', {