feat(desktop): make attachment data-URL size limit configurable (#73221)

Hard 16 MB cap on readFileDataUrl blocked larger local attaches with no way to raise it. Settings -> Chat now has a free-form MB field. Main process owns the persisted value and clamps only absurd inputs.
This commit is contained in:
Adolanium 2026-07-28 16:10:21 +03:00 committed by GitHub
parent b259668cac
commit b5dc471152
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 344 additions and 12 deletions

View file

@ -7,6 +7,9 @@ import { pathToFileURL } from 'node:url'
import { test } from 'vitest'
import {
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
dataUrlReadMaxBytesFromMb,
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret,
resolveDirectoryForIpc,
@ -24,6 +27,14 @@ async function rejectsWithCode(promise, code: string) {
})
}
test('clampDataUrlReadMaxMb defaults and bounds the attach size preference', () => {
assert.equal(clampDataUrlReadMaxMb(undefined), DATA_URL_READ_DEFAULT_MAX_MB)
assert.equal(clampDataUrlReadMaxMb(0), 1)
assert.equal(clampDataUrlReadMaxMb(256), 256)
assert.equal(clampDataUrlReadMaxMb(99999), 4096)
assert.equal(dataUrlReadMaxBytesFromMb(16), 16 * 1024 * 1024)
})
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)

View file

@ -4,9 +4,30 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024
// Default / floor / ceiling for Desktop's data-URL file load (composer attach,
// image preview, etc.). The whole file is base64-buffered in main, so this is
// a memory guard — not a model limit. Settings → Chat takes a free-form MB
// value; 16 MB ships as default. The ceiling is only a typo guard (very large
// values can OOM / crash the app).
const DATA_URL_READ_DEFAULT_MAX_MB = 16
const DATA_URL_READ_MIN_MAX_MB = 1
const DATA_URL_READ_MAX_MAX_MB = 4096
const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024
function clampDataUrlReadMaxMb(value) {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return DATA_URL_READ_DEFAULT_MAX_MB
}
return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed)))
}
function dataUrlReadMaxBytesFromMb(maxMb) {
return clampDataUrlReadMaxMb(maxMb) * 1024 * 1024
}
const SAFE_ENV_SUFFIXES = new Set(['dist', 'example', 'sample', 'template'])
const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
@ -304,7 +325,11 @@ async function resolveReadableFileForIpc(
}
export {
DATA_URL_READ_MAX_BYTES,
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
DATA_URL_READ_MAX_MAX_MB,
DATA_URL_READ_MIN_MAX_MB,
dataUrlReadMaxBytesFromMb,
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret,
rejectUnsafePathSyntax,

View file

@ -115,7 +115,9 @@ import {
switchBranch
} from './git-worktree-ops'
import {
DATA_URL_READ_MAX_BYTES,
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
dataUrlReadMaxBytesFromMb,
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret as encryptDesktopSecretStrict,
resolveReadableFileForIpc,
@ -960,7 +962,7 @@ app.setAboutPanelOptions({
// Custom scheme for streaming local media (video/audio) into the renderer.
// Reading large media through `readFileDataUrl` failed: it base64-loads the
// whole file into memory and is hard-capped at DATA_URL_READ_MAX_BYTES (16 MB),
// whole file into memory and is hard-capped (default 16 MB, Settings → Chat),
// so any non-trivial video silently refused to load. Streaming via a protocol
// handler removes the size cap and gives the <video> element seekable,
// range-aware playback. Must be registered before the app is ready.
@ -10052,9 +10054,56 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
return true
})
// Data-URL file load cap (composer attach + local previews). Main owns the
// persisted MB value so every IPC read honours Settings → Chat without the
// renderer having to pass maxBytes on each call. Default is 16 MB; clamp
// lives in hardening.ts.
const DATA_URL_READ_MAX_CONFIG_PATH = path.join(app.getPath('userData'), 'data-url-read-max.json')
function readPersistedDataUrlReadMaxMb() {
try {
return clampDataUrlReadMaxMb(JSON.parse(fs.readFileSync(DATA_URL_READ_MAX_CONFIG_PATH, 'utf8')).maxMb)
} catch {
return DATA_URL_READ_DEFAULT_MAX_MB
}
}
let dataUrlReadMaxMb = readPersistedDataUrlReadMaxMb()
function persistDataUrlReadMaxMb(maxMb) {
const next = clampDataUrlReadMaxMb(maxMb)
dataUrlReadMaxMb = next
try {
fs.mkdirSync(path.dirname(DATA_URL_READ_MAX_CONFIG_PATH), { recursive: true })
fs.writeFileSync(DATA_URL_READ_MAX_CONFIG_PATH, JSON.stringify({ maxMb: next }, null, 2), 'utf8')
} catch (error) {
rememberLog(`[data-url-read-max] write failed: ${error.message}`)
}
return next
}
ipcMain.handle('hermes:data-url-read-max:get', () => ({
maxMb: dataUrlReadMaxMb,
// Keep the default bytes constant visible for tests / diagnostics.
defaultMaxMb: DATA_URL_READ_DEFAULT_MAX_MB,
maxBytes: dataUrlReadMaxBytesFromMb(dataUrlReadMaxMb)
}))
ipcMain.handle('hermes:data-url-read-max:set', (_event, maxMb) => {
const next = persistDataUrlReadMaxMb(maxMb)
return {
maxMb: next,
defaultMaxMb: DATA_URL_READ_DEFAULT_MAX_MB,
maxBytes: dataUrlReadMaxBytesFromMb(next)
}
})
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
const { resolvedPath } = await resolveReadableFileForIpc(filePath, {
maxBytes: DATA_URL_READ_MAX_BYTES,
maxBytes: dataUrlReadMaxBytesFromMb(dataUrlReadMaxMb),
purpose: 'File preview'
})

View file

@ -98,6 +98,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath),
dataUrlReadMax: {
get: () => ipcRenderer.invoke('hermes:data-url-read-max:get'),
set: maxMb => ipcRenderer.invoke('hermes:data-url-read-max:set', maxMb)
},
readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath),
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),

View file

@ -3844,7 +3844,7 @@ describe('uploadComposerAttachment remote read failures', () => {
it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => {
// electron/hardening.ts rejects the readFileDataUrl IPC with this exact
// shape when a file exceeds DATA_URL_READ_MAX_BYTES.
// shape when a file exceeds the configured data-URL read cap.
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {

View file

@ -168,7 +168,7 @@ export async function readFileDataUrlForAttach(filePath: string): Promise<string
}
// The readFileDataUrl IPC base64-loads the whole file into memory and is
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.ts, which
// hard-capped in the main process (default 16 MB; Settings → Chat), 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

View file

@ -5,8 +5,19 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import {
$dataUrlReadMaxMb,
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
DATA_URL_READ_MAX_MAX_MB,
DATA_URL_READ_MIN_MAX_MB,
refreshDataUrlReadMaxMb,
setDataUrlReadMaxMb
} from '@/store/data-url-read-max'
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
import { notify, notifyError } from '@/store/notifications'
import { repoDiscoveryPolicyFromConfig, repoDiscoveryPolicySignature, scanAndRecordRepos } from '@/store/projects'
@ -21,7 +32,7 @@ import { enumOptionsFor, getNested, isExternalMemoryProvider, sectionFieldEntrie
import { MemoryConnect } from './memory/connect'
import { ProviderConfigPanel } from './memory/provider-config-panel'
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
import { EmptyState, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
import { EmptyState, ListRow, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
import { QuickEntrySettings } from './quick-entry-settings'
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
@ -305,9 +316,13 @@ export function ConfigSettings({
<QuickEntrySettings />
</>
)}
{visibleFields.length === 0 ? (
{/* Device-local attach/preview byte cap (main-process IPC guard). Chat is
where image-attachment behavior already lives, so this sits above the
schema fields for that section. */}
{activeSectionId === 'chat' ? <AttachmentSizeSetting /> : null}
{visibleFields.length === 0 && activeSectionId !== 'chat' ? (
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
) : (
) : visibleFields.length === 0 ? null : (
<div className="grid gap-1">
{visibleFields.map(([key, field]) => (
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
@ -345,3 +360,73 @@ export function ConfigSettings({
</SettingsContent>
)
}
/** Free-form MB cap for Desktop's data-URL attach/preview path (main-process). */
function AttachmentSizeSetting() {
const { t } = useI18n()
const c = t.settings.config
const stored = useStore($dataUrlReadMaxMb)
const [draft, setDraft] = useState(String(stored))
useEffect(() => {
void refreshDataUrlReadMaxMb()
}, [])
useEffect(() => {
setDraft(String(stored))
}, [stored])
const commit = () => {
// An empty draft means "reset to the default", not the 1 MB floor
// (Number('') === 0 would otherwise clamp down to the floor).
const applied = draft.trim() === '' ? DATA_URL_READ_DEFAULT_MAX_MB : clampDataUrlReadMaxMb(draft)
// Unchanged: snap the draft back to the stored value and skip the
// pointless IPC write + haptic.
if (applied === stored) {
setDraft(String(stored))
return
}
void setDataUrlReadMaxMb(applied).then(next => {
setDraft(String(next))
// On a bridge write failure the store keeps the old value; only
// celebrate when the new cap actually landed.
if (next === applied) {
triggerHaptic('selection')
}
})
}
return (
<ListRow
action={
<div className="flex items-center gap-2">
<Input
aria-label={c.attachmentSizeLabel}
className="w-20"
inputMode="numeric"
max={DATA_URL_READ_MAX_MAX_MB}
min={DATA_URL_READ_MIN_MAX_MB}
onBlur={commit}
onChange={event => setDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.currentTarget.blur()
}
}}
type="number"
value={draft}
/>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{c.attachmentSizeUnit}
</span>
</div>
}
description={c.attachmentSizeDesc}
title={c.attachmentSizeTitle}
/>
)
}

View file

@ -114,6 +114,11 @@ declare global {
notify: (payload: HermesNotification) => Promise<boolean>
requestMicrophoneAccess: () => Promise<boolean>
readFileDataUrl: (filePath: string) => Promise<string>
/** Settings → Chat: max size for local files loaded as data URLs (attach/preview). */
dataUrlReadMax?: {
get: () => Promise<{ defaultMaxMb: number; maxBytes: number; maxMb: number }>
set: (maxMb: number) => Promise<{ defaultMaxMb: number; maxBytes: number; maxMb: number }>
}
readFileText: (filePath: string) => Promise<HermesReadFileTextResult>
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
writeClipboard: (text: string) => Promise<boolean>

View file

@ -546,7 +546,12 @@ export const en: Translations = {
imported: 'Config imported',
invalidJson: 'Invalid config JSON',
keepAwakeTitle: 'Keep computer awake',
keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.'
keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.',
attachmentSizeTitle: 'Max attachment size',
attachmentSizeDesc:
'How big a local file Desktop will load for attach and previews, in MB. Default is 16. This is only a limit on this computer. Setting it very high loads the whole file into memory and can freeze or crash the app.',
attachmentSizeUnit: 'MB',
attachmentSizeLabel: 'Max attachment size in megabytes'
},
quickEntry: {
enabledTitle: 'Quick Entry',

View file

@ -454,6 +454,10 @@ export interface Translations {
invalidJson: string
keepAwakeTitle: string
keepAwakeDesc: string
attachmentSizeTitle: string
attachmentSizeDesc: string
attachmentSizeUnit: string
attachmentSizeLabel: string
}
quickEntry: {
enabledTitle: string

View file

@ -756,7 +756,12 @@ export const zh: Translations = {
imported: '配置已导入',
invalidJson: '配置 JSON 无效',
keepAwakeTitle: '保持电脑唤醒',
keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。'
keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。',
attachmentSizeTitle: '附件大小上限',
attachmentSizeDesc:
'桌面端为附件和预览加载本地文件的大小上限MB。默认为 16。此限制仅作用于本机。设置过大会将整个文件读入内存可能导致应用卡死或崩溃。',
attachmentSizeUnit: 'MB',
attachmentSizeLabel: '附件大小上限MB'
},
quickEntry: {
enabledTitle: '快速输入',

View file

@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
$dataUrlReadMaxMb,
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
refreshDataUrlReadMaxMb,
setDataUrlReadMaxMb
} from './data-url-read-max'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
const get = vi.fn(async () => ({ defaultMaxMb: 16, maxBytes: 16 * 1024 * 1024, maxMb: 16 }))
const set = vi.fn(async (maxMb: number) => ({
defaultMaxMb: 16,
maxBytes: maxMb * 1024 * 1024,
maxMb
}))
beforeEach(() => {
desktopWindow.hermesDesktop = { dataUrlReadMax: { get, set } } as unknown as Window['hermesDesktop']
$dataUrlReadMaxMb.set(DATA_URL_READ_DEFAULT_MAX_MB)
get.mockClear()
set.mockClear()
})
afterEach(() => {
desktopWindow.hermesDesktop = initialHermesDesktop
})
describe('clampDataUrlReadMaxMb', () => {
it('defaults invalid input and clamps only the safety range', () => {
expect(clampDataUrlReadMaxMb(undefined)).toBe(16)
expect(clampDataUrlReadMaxMb(0)).toBe(1)
expect(clampDataUrlReadMaxMb(100)).toBe(100)
expect(clampDataUrlReadMaxMb(99999)).toBe(4096)
expect(clampDataUrlReadMaxMb(32.4)).toBe(32)
})
})
describe('data-url-read-max store', () => {
it('loads the main-process value into the atom', async () => {
get.mockResolvedValueOnce({ defaultMaxMb: 16, maxBytes: 32 * 1024 * 1024, maxMb: 32 })
await expect(refreshDataUrlReadMaxMb()).resolves.toBe(32)
expect($dataUrlReadMaxMb.get()).toBe(32)
})
it('writes through the bridge and updates the atom', async () => {
await expect(setDataUrlReadMaxMb(48)).resolves.toBe(48)
expect(set).toHaveBeenCalledWith(48)
expect($dataUrlReadMaxMb.get()).toBe(48)
})
it('keeps the last known-good value when the bridge write fails', async () => {
set.mockRejectedValueOnce(new Error('boom'))
await expect(setDataUrlReadMaxMb(48)).resolves.toBe(16)
expect($dataUrlReadMaxMb.get()).toBe(16)
})
})

View file

@ -0,0 +1,76 @@
/**
* Max size for local files Desktop loads as data URLs (composer attach, image
* previews, etc.). Main owns the real cap + its JSON under userData this
* atom only mirrors it for Settings Chat. See electron/main.ts
* (`hermes:data-url-read-max:*`) and the default/clamp in electron/hardening.ts.
*/
import { atom } from 'nanostores'
import { notifyError } from '@/store/notifications'
/** Ship default; must match DATA_URL_READ_DEFAULT_MAX_MB in hardening.ts. */
export const DATA_URL_READ_DEFAULT_MAX_MB = 16
export const DATA_URL_READ_MIN_MAX_MB = 1
/** Typo / absurd-value guard only — large values can still OOM the app. */
export const DATA_URL_READ_MAX_MAX_MB = 4096
export function clampDataUrlReadMaxMb(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return DATA_URL_READ_DEFAULT_MAX_MB
}
return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed)))
}
export const $dataUrlReadMaxMb = atom<number>(DATA_URL_READ_DEFAULT_MAX_MB)
export async function refreshDataUrlReadMaxMb(): Promise<number> {
const api = window.hermesDesktop?.dataUrlReadMax
if (!api) {
return $dataUrlReadMaxMb.get()
}
try {
const result = await api.get()
const maxMb = clampDataUrlReadMaxMb(result.maxMb)
$dataUrlReadMaxMb.set(maxMb)
return maxMb
} catch {
return $dataUrlReadMaxMb.get()
}
}
export async function setDataUrlReadMaxMb(maxMb: number): Promise<number> {
const next = clampDataUrlReadMaxMb(maxMb)
const api = window.hermesDesktop?.dataUrlReadMax
if (!api) {
$dataUrlReadMaxMb.set(next)
return next
}
try {
const result = await api.set(next)
const applied = clampDataUrlReadMaxMb(result.maxMb)
$dataUrlReadMaxMb.set(applied)
return applied
} catch (error) {
// Leave the atom at the last known-good value and surface the failure —
// an optimistic set here would show a cap that was never persisted and
// silently reverts on restart.
notifyError(error, 'Could not save the max attachment size')
return $dataUrlReadMaxMb.get()
}
}
if (typeof window !== 'undefined' && window.hermesDesktop?.dataUrlReadMax) {
void refreshDataUrlReadMaxMb()
}