fix(desktop): upload Windows attachments to Linux backends

This commit is contained in:
Atakan 2026-07-25 21:42:31 +03:00 committed by Teknium
parent d4221b2736
commit 15317e1fd7
4 changed files with 155 additions and 16 deletions

View file

@ -169,7 +169,12 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
}
if (attachment.kind === 'image' || attachment.kind === 'file') {
const next = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
const next = await uploadComposerAttachment(attachment, {
backendCwd: readState()?.cwd,
remote,
requestGateway,
sessionId
})
if (options.updateComposerAttachments ?? true) {
scope.attachments.update(next)

View file

@ -12,6 +12,7 @@ import { $notifications, clearNotifications } from '@/store/notifications'
import {
$busy,
$connection,
$currentCwd,
$currentUsage,
$messages,
$sessions,
@ -2128,6 +2129,7 @@ describe('usePromptActions file attachment sync', () => {
afterEach(() => {
cleanup()
$connection.set(null)
$currentCwd.set('')
vi.restoreAllMocks()
})
@ -2190,6 +2192,100 @@ describe('usePromptActions file attachment sync', () => {
})
})
it('uploads Windows file bytes when local mode fronts a POSIX WSL/Docker backend', async () => {
$connection.set({ mode: 'local' } as never)
$currentCwd.set('/root')
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,aGVsbG8=')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const attachment: ComposerAttachment = {
...fileAttachment(),
path: 'C:\\Users\\alice\\Downloads\\report.txt',
refText: '@file:`C:\\Users\\alice\\Downloads\\report.txt`'
}
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return {
attached: true,
path: '/root/.hermes/desktop-attachments/report.txt',
ref_text: '@file:.hermes/desktop-attachments/report.txt',
uploaded: true
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
expect(await handle!.submitText('summarize', { attachments: [attachment] })).toBe(true)
expect(readFileDataUrl).toHaveBeenCalledWith('C:\\Users\\alice\\Downloads\\report.txt')
expect(calls[0]).toEqual({
method: 'file.attach',
params: {
data_url: 'data:text/plain;base64,aGVsbG8=',
name: 'report.txt',
path: 'C:\\Users\\alice\\Downloads\\report.txt',
session_id: RUNTIME_SESSION_ID
}
})
expect(calls[1]).toEqual({
method: 'prompt.submit',
params: { session_id: RUNTIME_SESSION_ID, text: '@file:.hermes/desktop-attachments/report.txt\n\nsummarize' }
})
})
it('uses image.attach_bytes for a Windows image when the local backend cwd is POSIX', async () => {
const readFileDataUrl = vi.fn(async () => 'data:image/jpeg;base64,aGVsbG8=')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const requestGateway = vi.fn(async (method: string) => {
if (method === 'image.attach_bytes') {
return { attached: true, path: '/root/tmp/photo.jpg' } as never
}
return {} as never
})
const uploaded = await uploadComposerAttachment(
{
id: 'image:photo.jpg',
kind: 'image',
label: 'photo.jpg',
path: 'C:\\Users\\alice\\Pictures\\photo.jpg'
},
{
backendCwd: '/root',
remote: false,
requestGateway,
sessionId: RUNTIME_SESSION_ID
}
)
expect(readFileDataUrl).toHaveBeenCalledWith('C:\\Users\\alice\\Pictures\\photo.jpg')
expect(requestGateway).toHaveBeenCalledWith('image.attach_bytes', {
content_base64: 'aGVsbG8=',
filename: 'photo.jpg',
session_id: RUNTIME_SESSION_ID
})
expect(requestGateway).not.toHaveBeenCalledWith('image.attach', expect.anything())
expect(uploaded.path).toBe('/root/tmp/photo.jpg')
})
it('passes a path-less @file: ref straight through (no path = nothing to upload)', async () => {
// Submit-layer contract: only attachments that carry a `path` are upload
// candidates. A path-less ref (an @-mention/context ref or pasted text)
@ -2237,8 +2333,20 @@ describe('usePromptActions file attachment sync', () => {
expect(calls[0]?.params?.text).toContain('@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`')
})
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
it('passes a Windows path directly for a native Windows local backend', async () => {
$connection.set({ mode: 'local' } as never)
$currentCwd.set('C:\\Users\\alice\\project')
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,c2hvdWxkLW5vdC1iZS1yZWFk')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const attachment: ComposerAttachment = {
...fileAttachment(),
path: 'C:\\Users\\alice\\Downloads\\report.txt',
refText: '@file:`C:\\Users\\alice\\Downloads\\report.txt`'
}
const calls: { method: string; params?: Record<string, unknown> }[] = []
@ -2257,11 +2365,12 @@ describe('usePromptActions file attachment sync', () => {
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
const ok = await handle!.submitText('summarize', { attachments: [attachment] })
expect(ok).toBe(true)
expect(calls[0]?.method).toBe('file.attach')
// Local mode sends no data_url — the gateway shares this disk.
expect(readFileDataUrl).not.toHaveBeenCalled()
// Native Windows local mode shares the same path namespace.
expect(calls[0]?.params).not.toHaveProperty('data_url')
expect(calls[1]).toEqual({
method: 'prompt.submit',

View file

@ -25,6 +25,7 @@ import { clearAllPrompts } from '@/store/prompts'
import {
$busy,
$connection,
$currentCwd,
$messages,
setAwaitingResponse,
setBusy,
@ -76,26 +77,38 @@ interface HandoffResult {
error?: string
}
const WINDOWS_ABSOLUTE_PATH_RE = /^(?:[A-Za-z]:[\\/]|\\\\)/
const POSIX_ABSOLUTE_PATH_RE = /^\/(?!\/)/
// `mode: local` means the gateway was launched locally, not necessarily that
// Electron and the gateway share a filesystem. Windows Desktop can front a
// WSL/Docker backend whose cwd is POSIX, so a Windows host path must cross the
// boundary as bytes just like a remote attachment.
function attachmentPathNeedsUpload(path: string, backendCwd?: null | string): boolean {
return WINDOWS_ABSOLUTE_PATH_RE.test(path.trim()) && POSIX_ABSOLUTE_PATH_RE.test(backendCwd?.trim() || '')
}
/**
* Stage one file/image attachment into the session workspace and return the
* attachment rewritten with the gateway-side ref. Images upload their bytes in
* remote mode (so vision works) and pass the path locally; non-image files
* upload bytes remotely and pass the path locally. Throws on failure so callers
* can surface an error. Shared by submit-time sync, the eager drop-time upload,
* and the message-edit composer drop keep them in lockstep.
* attachment rewritten with the gateway-side ref. Attachments upload their
* bytes for remote gateways and local cross-filesystem backends; otherwise the
* gateway receives the shared local path. Throws on failure so callers can
* surface an error. Shared by submit-time sync, the eager drop-time upload, and
* the message-edit composer drop keep them in lockstep.
*/
export async function uploadComposerAttachment(
attachment: ComposerAttachment,
opts: { remote: boolean; requestGateway: GatewayRequest; sessionId: string }
opts: { backendCwd?: null | string; remote: boolean; requestGateway: GatewayRequest; sessionId: string }
): Promise<ComposerAttachment> {
const { remote, requestGateway, sessionId } = opts
const { backendCwd, remote, requestGateway, sessionId } = opts
const path = attachment.path ?? ''
const label = attachment.label || pathLabel(path)
const uploadBytes = remote || attachmentPathNeedsUpload(path, backendCwd)
if (attachment.kind === 'image') {
let result: ImageAttachResponse
if (remote) {
if (uploadBytes) {
let payload: Awaited<ReturnType<typeof readImageForRemoteAttach>>
try {
@ -138,7 +151,7 @@ export async function uploadComposerAttachment(
// Non-image file.
let dataUrl: string | null = null
if (remote) {
if (uploadBytes) {
try {
dataUrl = await readFileDataUrlForAttach(path)
} catch (err) {
@ -317,7 +330,12 @@ export function usePromptActions({
}
if (attachment.kind === 'image' || attachment.kind === 'file') {
const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
const nextAttachment = await uploadComposerAttachment(attachment, {
backendCwd: $currentCwd.get(),
remote,
requestGateway,
sessionId
})
// Update-only: never resurrect a chip the user removed mid-upload.
if (updateComposerAttachments) {
@ -356,7 +374,14 @@ export function usePromptActions({
try {
// Update-only: if the user removed the chip while this was uploading,
// don't resurrect it — just drop the staged result on the floor.
updateComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }))
updateComposerAttachment(
await uploadComposerAttachment(attachment, {
backendCwd: $currentCwd.get(),
remote,
requestGateway,
sessionId
})
)
} catch (err) {
// Leave the chip in place so submit-time sync can retry (or the user can
// remove it) and flag the card; also toast so a hard failure (unreadable

View file

@ -415,7 +415,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
try {
const uploaded = await uploadComposerAttachment(
{ detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path },
{ remote, requestGateway, sessionId }
{ backendCwd: cwd, remote, requestGateway, sessionId }
)
const ref = attachmentDisplayText(uploaded)