From b47f082bae9040acf9802272aadcad35a03b2404 Mon Sep 17 00:00:00 2001 From: ashneil12 <49880658+ashneil12@users.noreply.github.com> Date: Sat, 13 Jun 2026 09:03:34 +0100 Subject: [PATCH] fix(desktop): attach non-image web uploads by path instead of failing the read (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosted web SPA (vweb) has no local filesystem. When a .md/text/pdf is dropped it is uploaded to ~/.hermes/uploads via the web shim and attached as a kind:'file' ref pointing at that server path. At submit/eager-upload time uploadComposerAttachment then re-read the file's bytes client-side via window.hermesDesktop.readFileDataUrl(serverPath). The web shim only caches data URLs for IMAGES (uploadBrowserFile gates the cache write on image/* mime), so for any non-image file the read returned '' and the code threw `Could not read `, surfaced as the "Drop files / Could not read …md" toast — even though the upload itself had already succeeded. Images worked only because their bytes are cached. Fix: in the hosted web client, skip the client-side byte read for non-image files and attach by path only. The file already lives on the gateway (~/.hermes/uploads, made agent-readable by the upload endpoint's chown), so file.attach resolves the path directly (server.py _stage_session_file_attachment Case 2 reads the bytes and returns an @file: ref). Native Electron remote mode is unchanged — its paths live on the client disk and still upload bytes. Adds a regression test asserting the web client attaches by path with no read and no data_url. Co-authored-by: Claude Opus 4.8 (1M context) --- .../session/hooks/use-prompt-actions.test.tsx | 56 +++++++++++++++++++ .../app/session/hooks/use-prompt-actions.ts | 21 ++++++- 2 files changed, 76 insertions(+), 1 deletion(-) 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 e7dfe9d7da5b4..90fd85ec33670 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 @@ -859,4 +859,60 @@ describe('uploadComposerAttachment remote read failures', () => { ) ).rejects.toThrow('ENOENT: no such file') }) + + it('hosted web client attaches a non-image file by path only (no client read, no data_url)', async () => { + // The web SPA has no local disk: the .md was already uploaded to + // ~/.hermes/uploads by the web shim, so we attach by PATH and let the gateway + // read it. The shim's readFileDataUrl only caches image previews, so for a + // .md it returns '' — the old code then threw "Could not read " even + // though the upload had succeeded. + const win = window as unknown as { __HERMES_WEB_CLIENT__?: boolean } + win.__HERMES_WEB_CLIENT__ = true + const readFileDataUrl = vi.fn(async () => '') // web-shim cache miss for non-images + + try { + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { readFileDataUrl } + }) + + const calls: { method: string; params?: Record }[] = [] + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + if (method === 'file.attach') { + return { + attached: true, + ref_text: '@file:.hermes/desktop-attachments/notes.md', + uploaded: true + } as never + } + return {} as never + }) + + const result = await uploadComposerAttachment( + { + id: 'file:notes', + kind: 'file', + label: 'notes.md', + path: '/home/hermes/.hermes/uploads/20260613/071447-79fff2b5-notes.md' + }, + { remote: true, requestGateway, sessionId: RUNTIME_SESSION_ID } + ) + + // Attaches by path; never reads bytes client-side; sends NO data_url. + expect(readFileDataUrl).not.toHaveBeenCalled() + expect(calls).toHaveLength(1) + expect(calls[0]?.method).toBe('file.attach') + expect(calls[0]?.params).toMatchObject({ + session_id: RUNTIME_SESSION_ID, + path: '/home/hermes/.hermes/uploads/20260613/071447-79fff2b5-notes.md', + name: 'notes.md' + }) + expect(calls[0]?.params).not.toHaveProperty('data_url') + expect(result.refText).toBe('@file:.hermes/desktop-attachments/notes.md') + expect(result.attachedSessionId).toBe(RUNTIME_SESSION_ID) + } finally { + delete win.__HERMES_WEB_CLIENT__ + } + }) }) 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 b09d86ffd10b3..6779ad2a46752 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -150,6 +150,17 @@ async function readFileDataUrlForAttach(filePath: string): Promise" for every .md/text/pdf even though the upload that + // produced this attachment already succeeded. + if (remote && !isHostedWebClient()) { try { dataUrl = await readFileDataUrlForAttach(path) } catch (err) {