Compare commits

...

1 Commits

Author SHA1 Message Date
Brooklyn Nicholson
fb1c886bf9 feat(desktop): browse + upload to the gateway filesystem on remote backends
Desktop file ops assumed the agent shared the client's filesystem, so on a
remote gateway (VPS over tailscale) image uploads, the Files sidebar, context
attachments, and the cwd picker all pointed at the wrong machine.

- gateway: fs.list / fs.read_text / fs.read_data_url / fs.git_root run on the
  agent host (shapes mirror the Electron fs IPC); image.attach_bytes writes
  client-uploaded bytes into $HERMES_HOME/images.
- renderer: desktop-fs facade routes reads + path selection through fs.* when
  $connection.mode === 'remote', else local IPC. Files sidebar, preview, and
  the image/file/folder/cwd pickers flow through it; a RemotePathPicker modal
  browses the gateway when native dialogs can't. Image attach falls back to
  byte upload when a client path can't resolve on the gateway.
2026-06-05 21:23:33 -05:00
13 changed files with 992 additions and 47 deletions

View File

@@ -3,6 +3,7 @@ import { useCallback } from 'react'
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
import { fsReadFileDataUrl, selectPaths } from '@/lib/desktop-fs'
import {
addComposerAttachment,
type ComposerAttachment,
@@ -36,6 +37,27 @@ function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
// Thumbnail source for an attached image. Locally-held paths (drag/paste saves,
// local picks) read off the client; when that fails on a remote backend the
// path lives on the gateway host, so fall back to the gateway data-url read.
async function loadImagePreviewDataUrl(filePath: string): Promise<string | undefined> {
try {
const local = await window.hermesDesktop?.readFileDataUrl(filePath)
if (local) {
return local
}
} catch {
// Path isn't on the client (remote-picked image) — try the gateway below.
}
try {
return await fsReadFileDataUrl(filePath)
} catch {
return undefined
}
}
export interface DroppedFile {
/** Browser-native File handle. Absent for in-app drags (e.g. project tree). */
file?: File
@@ -228,7 +250,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
const pickContextPaths = useCallback(
async (kind: 'file' | 'folder') => {
const paths = await window.hermesDesktop?.selectPaths({
const paths = await selectPaths({
title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
defaultPath: currentCwd || undefined,
directories: kind === 'folder'
@@ -291,19 +313,13 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
attachToMain(baseAttachment)
try {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
const previewUrl = await loadImagePreviewDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
}
return true
} catch (err) {
notifyError(err, 'Image preview failed')
return true
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
}
return true
}, [])
const attachImageBlob = useCallback(
@@ -338,7 +354,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
)
const pickImages = useCallback(async () => {
const paths = await window.hermesDesktop?.selectPaths({
const paths = await selectPaths({
title: 'Attach images',
defaultPath: currentCwd || undefined,
filters: [

View File

@@ -12,6 +12,7 @@ import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { PageLoader } from '@/components/page-loader'
import { fsReadFileDataUrl, fsReadFileText } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
@@ -179,21 +180,19 @@ function looksBinaryBytes(bytes: Uint8Array) {
}
async function readTextPreview(filePath: string) {
if (window.hermesDesktop.readFileText) {
try {
return await window.hermesDesktop.readFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
try {
return await fsReadFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
}
// Back-compat for a running Electron process whose preload hasn't been
// restarted since readFileText was added. readFileDataUrl already existed.
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
const dataUrl = await fsReadFileDataUrl(filePath)
const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || []
const base64 = metadata.includes(';base64')
const mimeType = metadata.replace(/;base64$/, '') || undefined
@@ -441,7 +440,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
try {
if (isImage) {
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
const dataUrl = await fsReadFileDataUrl(filePath)
if (active) {
setState({ dataUrl, loading: false })

View File

@@ -68,6 +68,7 @@ import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { RemotePathPicker } from './remote-path-picker'
import { RightSidebarPane } from './right-sidebar'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
@@ -672,6 +673,7 @@ export function DesktopController() {
<GatewayConnectingOverlay />
<BootFailureOverlay />
<CommandPalette />
<RemotePathPicker />
{settingsOpen && (
<Suspense fallback={null}>

View File

@@ -0,0 +1,234 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Loader } from '@/components/ui/loader'
import type { HermesReadDirEntry } from '@/global'
import { fsReadDir } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
import { $remotePathPicker, resolveRemotePathPicker } from '@/store/remote-path-picker'
function parentDir(path: string): string | null {
const trimmed = path.replace(/[\\/]+$/, '')
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
if (idx <= 0) {
return idx === 0 ? '/' : null
}
return trimmed.slice(0, idx)
}
function baseName(path: string): string {
return (
path
.replace(/[\\/]+$/, '')
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? path
)
}
// Browses the GATEWAY filesystem (via fs.list) so users on a remote backend can
// pick files/folders that exist on the agent host rather than their own machine.
// Mirrors the native selectPaths contract: resolves with absolute gateway paths
// (or [] when cancelled).
export function RemotePathPicker() {
const request = useStore($remotePathPicker)
if (!request) {
return null
}
return <RemotePathPickerDialog key={request.id} />
}
function RemotePathPickerDialog() {
const request = useStore($remotePathPicker)
const options = request?.options ?? {}
const directoriesMode = Boolean(options.directories)
const allowMultiple = options.multiple !== false && !directoriesMode
const [dir, setDir] = useState<string>(options.defaultPath ?? '')
const [entries, setEntries] = useState<HermesReadDirEntry[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const allowedExtensions = useMemo(() => {
const exts = (options.filters ?? []).flatMap(filter => filter.extensions)
return exts.length > 0 ? new Set(exts.map(ext => ext.toLowerCase().replace(/^\./, ''))) : null
}, [options.filters])
const load = useCallback(async (target: string) => {
setLoading(true)
setError(null)
const result = await fsReadDir(target)
setDir(result.path ?? target)
setEntries(result.entries ?? [])
setError(result.error ?? null)
setSelected(new Set())
setLoading(false)
}, [])
// Loads the initial directory. `load` is stable and defaultPath is fixed for
// this keyed instance, so this runs once; navigation calls `load` directly.
useEffect(() => {
void load(options.defaultPath ?? '')
}, [load, options.defaultPath])
const visibleEntries = useMemo(() => {
return entries.filter(entry => {
if (entry.isDirectory) {
return true
}
if (directoriesMode) {
return false
}
if (!allowedExtensions) {
return true
}
const ext = baseName(entry.name).split('.').pop()?.toLowerCase() ?? ''
return allowedExtensions.has(ext)
})
}, [allowedExtensions, directoriesMode, entries])
const cancel = useCallback(() => resolveRemotePathPicker([]), [])
const confirm = useCallback(() => {
if (directoriesMode) {
resolveRemotePathPicker([dir])
return
}
if (selected.size > 0) {
resolveRemotePathPicker([...selected])
}
}, [dir, directoriesMode, selected])
const onEntryClick = useCallback(
(entry: HermesReadDirEntry) => {
if (entry.isDirectory) {
void load(entry.path)
return
}
if (directoriesMode) {
return
}
if (!allowMultiple) {
resolveRemotePathPicker([entry.path])
return
}
setSelected(prev => {
const next = new Set(prev)
if (next.has(entry.path)) {
next.delete(entry.path)
} else {
next.add(entry.path)
}
return next
})
},
[allowMultiple, directoriesMode, load]
)
const parent = parentDir(dir)
const title = options.title || (directoriesMode ? 'Select a folder' : 'Select files')
const confirmLabel = directoriesMode ? 'Use this folder' : `Attach${selected.size > 1 ? ` (${selected.size})` : ''}`
const confirmDisabled = directoriesMode ? !dir : selected.size === 0
return (
<Dialog onOpenChange={value => !value && cancel()} open>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-1.5 text-xs text-(--ui-text-tertiary)">
<Button
aria-label="Up one folder"
disabled={!parent || loading}
onClick={() => parent && void load(parent)}
size="icon-xs"
variant="ghost"
>
<Codicon name="arrow-up" size="0.9rem" />
</Button>
<span className="truncate font-mono" title={dir}>
{dir || '…'}
</span>
</div>
<div className="h-72 overflow-y-auto rounded-md border border-(--ui-stroke-secondary) bg-background/40">
{loading ? (
<div className="flex h-full items-center justify-center">
<Loader />
</div>
) : error ? (
<div className="flex h-full items-center justify-center px-4 text-center text-xs text-destructive">
Could not read this folder ({error}).
</div>
) : visibleEntries.length === 0 ? (
<div className="flex h-full items-center justify-center px-4 text-center text-xs text-(--ui-text-tertiary)">
{directoriesMode ? 'No subfolders here.' : 'No matching files here.'}
</div>
) : (
<ul className="py-1">
{visibleEntries.map(entry => {
const isSelected = selected.has(entry.path)
return (
<li key={entry.path}>
<button
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-(--chrome-action-hover)',
isSelected && 'bg-(--chrome-action-hover)'
)}
onClick={() => onEntryClick(entry)}
type="button"
>
<Codicon
className={entry.isDirectory ? 'text-(--ui-accent)' : 'text-(--ui-text-tertiary)'}
name={entry.isDirectory ? 'folder' : 'file'}
size="0.95rem"
/>
<span className="flex-1 truncate">{entry.name}</span>
{!entry.isDirectory && isSelected && <Codicon name="check" size="0.9rem" />}
{entry.isDirectory && <Codicon name="chevron-right" size="0.85rem" />}
</button>
</li>
)
})}
</ul>
)}
</div>
<DialogFooter>
<Button onClick={cancel} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={confirmDisabled} onClick={confirm} type="button">
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,6 +1,7 @@
import ignore from 'ignore'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
import { fsGitRoot, fsReadDir, fsReadFileDataUrl } from '@/lib/desktop-fs'
export type ProjectTreeEntry = HermesReadDirEntry
@@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) {
}
async function gitRootFor(start: string) {
if (!window.hermesDesktop?.gitRoot) {
return null
}
const key = clean(start)
let cached = gitRootCache.get(key)
if (!cached) {
cached = window.hermesDesktop.gitRoot(key)
cached = fsGitRoot(key)
gitRootCache.set(key, cached)
}
@@ -80,18 +77,14 @@ async function gitRootFor(start: string) {
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
return null
}
try {
const listing = await window.hermesDesktop.readDir(dir)
const listing = await fsReadDir(dir)
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
return null
}
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
const text = decodeDataUrl(await fsReadFileDataUrl(`${dir}/.gitignore`))
return { base: dir, ig: ignore().add(text) }
} catch {
@@ -138,11 +131,7 @@ async function filterIgnored(entries: HermesReadDirEntry[], rootPath: string, di
}
export async function readProjectDir(dirPath: string, rootPath = dirPath): Promise<HermesReadDirResult> {
if (!window.hermesDesktop) {
return { entries: [], error: 'no-bridge' }
}
const result = await window.hermesDesktop.readDir(dirPath)
const result = await fsReadDir(dirPath)
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
}

View File

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { selectPaths } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
@@ -68,7 +69,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
const chooseFolder = async () => {
const selected = await window.hermesDesktop?.selectPaths({
const selected = await selectPaths({
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false,

View File

@@ -177,6 +177,42 @@ export function usePromptActions({
[selectedStoredSessionIdRef, updateSessionState]
)
// Remote gateways (e.g. a VPS over tailscale) cannot see the client's local
// filesystem, so a path-based `image.attach` fails with "image not found".
// Fall back to uploading the bytes the Electron client already holds.
const uploadImageAttachmentBytes = useCallback(
async (sessionId: string, attachment: ComposerAttachment): Promise<ImageAttachResponse | null> => {
const path = attachment.path
if (!path) {
return null
}
let data = attachment.previewUrl
if (!data && window.hermesDesktop?.readFileDataUrl) {
try {
data = await window.hermesDesktop.readFileDataUrl(path)
} catch {
return null
}
}
if (!data) {
return null
}
const result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
filename: pathLabel(path),
data
})
return result.attached ? result : null
},
[requestGateway]
)
const syncImageAttachmentsForSubmit = useCallback(
async (
sessionId: string,
@@ -191,14 +227,28 @@ export function usePromptActions({
continue
}
const result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
let result: ImageAttachResponse | null = null
if (!result.attached) {
try {
const pathResult = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
if (pathResult.attached) {
result = pathResult
}
} catch {
result = null
}
if (!result) {
result = await uploadImageAttachmentBytes(sessionId, attachment)
}
if (!result?.attached) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(result.message || `Could not attach ${label}`)
throw new Error(result?.message || `Could not attach ${label}`)
}
const attachedPath = result.path || attachment.path
@@ -214,7 +264,7 @@ export function usePromptActions({
}
}
},
[requestGateway]
[requestGateway, uploadImageAttachmentBytes]
)
const submitPromptText = useCallback(
@@ -537,6 +587,7 @@ export function usePromptActions({
session_id: sessionId,
title: arg
})
const finalTitle = (result?.title || arg).trim()
const queued = result?.pending === true

View File

@@ -372,6 +372,9 @@ export interface HermesReadDirEntry {
export interface HermesReadDirResult {
entries: HermesReadDirEntry[]
error?: string
// Absolute directory the entries were read from. Set by the gateway `fs.list`
// RPC (remote backends); the local Electron readDir omits it.
path?: string
}
export interface HermesPreviewFileChanged {

View File

@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
import { resolveRemotePathPicker } from '@/store/remote-path-picker'
import { $connection } from '@/store/session'
import { fsGitRoot, fsReadDir, fsReadFileDataUrl, isRemoteBackend, selectPaths } from './desktop-fs'
const request = vi.fn()
const readDir = vi.fn()
const readFileDataUrl = vi.fn()
const gitRoot = vi.fn()
const desktopSelectPaths = vi.fn()
function setRemote(remote: boolean) {
$connection.set(remote ? ({ mode: 'remote' } as never) : null)
}
beforeEach(() => {
request.mockReset()
readDir.mockReset()
readFileDataUrl.mockReset()
gitRoot.mockReset()
desktopSelectPaths.mockReset()
$gateway.set({ request } as unknown as HermesGateway)
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
readDir,
readFileDataUrl,
gitRoot,
selectPaths: desktopSelectPaths
}
})
afterEach(() => {
$connection.set(null)
$gateway.set(null)
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('desktop-fs facade', () => {
it('routes reads to local IPC when not remote', async () => {
setRemote(false)
readDir.mockResolvedValue({ entries: [] })
await fsReadDir('/p')
expect(readDir).toHaveBeenCalledWith('/p')
expect(request).not.toHaveBeenCalled()
expect(isRemoteBackend()).toBe(false)
})
it('routes directory listing to fs.list when remote', async () => {
setRemote(true)
request.mockResolvedValue({ entries: [], path: '/srv' })
const result = await fsReadDir('/srv')
expect(request).toHaveBeenCalledWith('fs.list', { path: '/srv' })
expect(result.path).toBe('/srv')
expect(readDir).not.toHaveBeenCalled()
})
it('unwraps the data url from fs.read_data_url when remote', async () => {
setRemote(true)
request.mockResolvedValue({ dataUrl: 'data:image/png;base64,AAAA' })
expect(await fsReadFileDataUrl('/srv/x.png')).toBe('data:image/png;base64,AAAA')
expect(request).toHaveBeenCalledWith('fs.read_data_url', { path: '/srv/x.png' })
})
it('returns gateway git root when remote', async () => {
setRemote(true)
request.mockResolvedValue({ root: '/srv/repo' })
expect(await fsGitRoot('/srv/repo/a')).toBe('/srv/repo')
})
it('uses the native picker locally and the remote picker when remote', async () => {
setRemote(false)
desktopSelectPaths.mockResolvedValue(['/local/a.png'])
expect(await selectPaths({ title: 'pick' })).toEqual(['/local/a.png'])
setRemote(true)
const pending = selectPaths({ title: 'pick' })
resolveRemotePathPicker(['/srv/a.png'])
expect(await pending).toEqual(['/srv/a.png'])
// Remote selection never touches the native dialog.
expect(desktopSelectPaths).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,64 @@
import type { HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
import { $gateway } from '@/store/gateway'
import { openRemotePathPicker } from '@/store/remote-path-picker'
import { $connection } from '@/store/session'
// On a remote gateway (e.g. a VPS over tailscale) the agent's filesystem lives
// on the server, but the Electron IPC helpers only see the client machine. This
// facade routes reads + path selection through gateway `fs.*` RPCs when remote,
// and falls back to local Electron IPC against a locally-spawned backend.
export const isRemoteBackend = (): boolean => $connection.get()?.mode === 'remote'
function gw<T>(method: string, params: Record<string, unknown>): Promise<T> {
const gateway = $gateway.get()
if (!gateway) {
throw new Error('Hermes gateway unavailable')
}
return gateway.request<T>(method, params)
}
const unavailable = (): never => {
throw new Error('File reading is unavailable')
}
export function fsReadDir(path: string): Promise<HermesReadDirResult> {
if (isRemoteBackend()) {
return gw('fs.list', { path })
}
return window.hermesDesktop?.readDir?.(path) ?? Promise.resolve({ entries: [], error: 'no-bridge' })
}
export function fsReadFileText(path: string): Promise<HermesReadFileTextResult> {
if (isRemoteBackend()) {
return gw('fs.read_text', { path })
}
return window.hermesDesktop?.readFileText?.(path) ?? unavailable()
}
export async function fsReadFileDataUrl(path: string): Promise<string> {
if (isRemoteBackend()) {
return (await gw<{ dataUrl?: string }>('fs.read_data_url', { path })).dataUrl ?? unavailable()
}
return window.hermesDesktop?.readFileDataUrl?.(path) ?? unavailable()
}
export async function fsGitRoot(path: string): Promise<string | null> {
if (isRemoteBackend()) {
return (await gw<{ root?: string | null }>('fs.git_root', { path })).root ?? null
}
return window.hermesDesktop?.gitRoot?.(path) ?? null
}
export async function selectPaths(options: HermesSelectPathsOptions = {}): Promise<string[]> {
if (isRemoteBackend()) {
return openRemotePathPicker(options)
}
return (await window.hermesDesktop?.selectPaths?.(options)) ?? []
}

View File

@@ -0,0 +1,41 @@
import { atom } from 'nanostores'
import type { HermesSelectPathsOptions } from '@/global'
export interface RemotePathPickerRequest {
id: number
options: HermesSelectPathsOptions
resolve: (paths: string[]) => void
}
// Holds the currently open remote path-picker request, if any. The picker
// modal subscribes and resolves the promise when the user confirms or cancels.
// Used only when the desktop is connected to a remote gateway, where the native
// OS dialog (which browses the client machine) is the wrong filesystem.
export const $remotePathPicker = atom<RemotePathPickerRequest | null>(null)
let nextRequestId = 0
export function openRemotePathPicker(options: HermesSelectPathsOptions = {}): Promise<string[]> {
// Only one picker at a time; cancel any prior request.
const previous = $remotePathPicker.get()
if (previous) {
previous.resolve([])
}
return new Promise<string[]>(resolve => {
$remotePathPicker.set({ id: (nextRequestId += 1), options, resolve })
})
}
export function resolveRemotePathPicker(paths: string[]): void {
const request = $remotePathPicker.get()
if (!request) {
return
}
$remotePathPicker.set(null)
request.resolve(paths)
}

View File

@@ -0,0 +1,211 @@
"""Tests for the remote-browsing filesystem RPCs (fs.*) and image.attach_bytes.
These power the desktop app when it talks to a gateway on a remote host (e.g. a
VPS over tailscale): the Files sidebar and path pickers browse the gateway's
filesystem via fs.list / fs.read_text / fs.read_data_url / fs.git_root, and
locally-held images are pushed to the gateway via image.attach_bytes.
"""
from __future__ import annotations
import base64
import importlib
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# 1x1 transparent PNG.
_PNG_1x1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
@pytest.fixture()
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
yield home
@pytest.fixture()
def server(hermes_home):
with patch.dict(
"sys.modules",
{
"hermes_cli.env_loader": MagicMock(),
"hermes_cli.banner": MagicMock(),
},
):
mod = importlib.import_module("tui_gateway.server")
yield mod
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()
mod._methods.clear()
importlib.reload(mod)
def _call(server, method: str, params: dict) -> dict:
return server.handle_request({"id": "1", "method": method, "params": params})
# ── fs.list ──────────────────────────────────────────────────────────
def test_fs_list_returns_sorted_entries(server, tmp_path):
work = tmp_path / "proj"
work.mkdir()
(work / "b_file.txt").write_text("x")
(work / "a_dir").mkdir()
(work / "node_modules").mkdir() # hidden by filter
resp = _call(server, "fs.list", {"path": str(work)})
result = resp["result"]
assert result["path"] == str(work.resolve())
names = [e["name"] for e in result["entries"]]
# Directories first, then files; node_modules filtered out.
assert names == ["a_dir", "b_file.txt"]
assert result["entries"][0]["isDirectory"] is True
assert result["entries"][1]["isDirectory"] is False
def test_fs_list_missing_dir_reports_error(server, tmp_path):
resp = _call(server, "fs.list", {"path": str(tmp_path / "nope")})
assert resp["result"]["entries"] == []
assert resp["result"]["error"] == "ENOENT"
# ── fs.read_text ─────────────────────────────────────────────────────
def test_fs_read_text_reads_file(server, tmp_path):
target = tmp_path / "hello.py"
target.write_text("print('hi')\n")
resp = _call(server, "fs.read_text", {"path": str(target)})
result = resp["result"]
assert result["text"] == "print('hi')\n"
assert result["language"] == "python"
assert result["binary"] is False
assert result["truncated"] is False
def test_fs_read_text_missing_file_errors(server, tmp_path):
resp = _call(server, "fs.read_text", {"path": str(tmp_path / "gone.txt")})
assert resp["error"]["code"] == 4016
def test_fs_read_text_flags_binary(server, tmp_path):
target = tmp_path / "blob.bin"
target.write_bytes(b"\x00\x01\x02\x03")
result = _call(server, "fs.read_text", {"path": str(target)})["result"]
assert result["binary"] is True
# ── fs.read_data_url ─────────────────────────────────────────────────
def test_fs_read_data_url_encodes_file(server, tmp_path):
target = tmp_path / "pixel.png"
target.write_bytes(_PNG_1x1)
result = _call(server, "fs.read_data_url", {"path": str(target)})["result"]
assert result["dataUrl"].startswith("data:image/png;base64,")
encoded = result["dataUrl"].split(",", 1)[1]
assert base64.b64decode(encoded) == _PNG_1x1
def test_fs_read_data_url_rejects_oversized(server, tmp_path):
target = tmp_path / "big.bin"
target.write_bytes(b"x")
# Patch the cap below the file size to exercise the guard deterministically.
with patch.object(server, "_FS_DATA_URL_MAX_BYTES", 0):
resp = _call(server, "fs.read_data_url", {"path": str(target)})
assert resp["error"]["code"] == 4017
# ── fs.git_root ──────────────────────────────────────────────────────
def test_fs_git_root_walks_up(server, tmp_path):
(tmp_path / ".git").mkdir()
nested = tmp_path / "a" / "b"
nested.mkdir(parents=True)
result = _call(server, "fs.git_root", {"path": str(nested)})["result"]
assert result["root"] == str(tmp_path.resolve())
def test_fs_git_root_none_when_absent(server, tmp_path):
result = _call(server, "fs.git_root", {"path": str(tmp_path)})["result"]
assert result["root"] is None
# ── image.attach_bytes ───────────────────────────────────────────────
@pytest.fixture()
def image_session(server):
"""A session that bypasses agent build so _sess() resolves cleanly."""
sid = "sid-img"
# Non-empty so _sess_nowait()'s truthiness check treats it as present.
server._sessions[sid] = {"image_counter": 0}
with patch.object(server, "_start_agent_build", lambda *a, **k: None), patch.object(
server, "_wait_agent", lambda s, rid: None
):
yield sid
def test_image_attach_bytes_writes_and_attaches(server, hermes_home, image_session):
data_url = "data:image/png;base64," + base64.b64encode(_PNG_1x1).decode()
resp = _call(
server,
"image.attach_bytes",
{"session_id": image_session, "data": data_url, "filename": "shot.png"},
)
result = resp["result"]
assert result["attached"] is True
saved = Path(result["path"])
assert saved.exists()
assert saved.read_bytes() == _PNG_1x1
assert saved.suffix == ".png"
# Lands under the gateway's HERMES_HOME, not the client.
assert str(saved).startswith(str(hermes_home / "images"))
assert server._sessions[image_session]["attached_images"] == [str(saved)]
def test_image_attach_bytes_infers_extension_from_mime(server, image_session):
data_url = "data:image/webp;base64," + base64.b64encode(_PNG_1x1).decode()
result = _call(
server,
"image.attach_bytes",
{"session_id": image_session, "data": data_url},
)["result"]
assert Path(result["path"]).suffix == ".webp"
def test_image_attach_bytes_rejects_empty(server, image_session):
resp = _call(
server,
"image.attach_bytes",
{"session_id": image_session, "data": ""},
)
assert resp["error"]["code"] == 4015

View File

@@ -4980,6 +4980,249 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5027, str(e))
_DATA_URL_MIME_EXT = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp",
"image/tiff": ".tiff",
"image/svg+xml": ".svg",
"image/x-icon": ".ico",
"image/vnd.microsoft.icon": ".ico",
}
@method("image.attach_bytes")
def _(rid, params: dict) -> dict:
"""Attach an image uploaded as bytes (base64 / data URL).
Unlike ``image.attach`` (which resolves a path on the gateway host), this
writes the client-supplied bytes into ``$HERMES_HOME/images`` on the
gateway. Used by the desktop app when the gateway is remote (e.g. a VPS)
and the UI file picker yields a path that only exists on the client.
"""
import base64
import re
session, err = _sess(params, rid)
if err:
return err
raw = str(params.get("data", "") or "").strip()
if not raw:
return _err(rid, 4015, "data required")
from cli import _IMAGE_EXTENSIONS
mime = ""
payload = raw
m = re.match(r"^data:([^;,]*)(;base64)?,(.*)$", raw, re.DOTALL)
if m:
mime = (m.group(1) or "").strip().lower()
payload = m.group(3) or ""
try:
blob = base64.b64decode(payload, validate=False)
except Exception:
return _err(rid, 4016, "invalid image data")
if not blob:
return _err(rid, 4016, "empty image data")
ext = Path(str(params.get("filename", "") or "")).suffix.lower()
if ext not in _IMAGE_EXTENSIONS:
ext = _DATA_URL_MIME_EXT.get(mime, "")
if ext not in _IMAGE_EXTENSIONS:
ext = ".png"
session["image_counter"] = session.get("image_counter", 0) + 1
img_dir = _hermes_home / "images"
img_dir.mkdir(parents=True, exist_ok=True)
img_path = (
img_dir
/ f"upload_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{session['image_counter']}{ext}"
)
try:
img_path.write_bytes(blob)
except Exception as e:
session["image_counter"] = max(0, session["image_counter"] - 1)
return _err(rid, 5027, str(e))
session.setdefault("attached_images", []).append(str(img_path))
return _ok(
rid,
{
"attached": True,
"path": str(img_path),
"count": len(session["attached_images"]),
"text": f"[User attached image: {img_path.name}]",
**_image_meta(img_path),
},
)
# Filesystem browsing RPCs (fs.*) run on the GATEWAY host. The desktop app uses
# them when connected to a remote gateway (e.g. a VPS over tailscale) so the
# Files sidebar and path pickers browse the agent's filesystem rather than the
# client's. Shapes mirror the Electron `hermes:fs:*` IPC handlers so the same
# renderer components consume either source unchanged.
_FS_READDIR_HIDDEN = frozenset({
".git", ".hg", ".svn", ".cache", ".next", ".turbo", ".venv",
"__pycache__", "build", "dist", "node_modules", "target", "venv",
})
_FS_TEXT_READ_MAX_BYTES = 512 * 1024
_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024
_FS_LANGUAGE_BY_EXT = {
".c": "c", ".conf": "ini", ".cpp": "cpp", ".css": "css", ".csv": "csv",
".go": "go", ".graphql": "graphql", ".h": "c", ".hpp": "cpp",
".html": "html", ".ini": "ini", ".java": "java", ".js": "javascript",
".json": "json", ".jsx": "jsx", ".kt": "kotlin", ".lua": "lua",
".md": "markdown", ".php": "php", ".py": "python", ".rb": "ruby",
".rs": "rust", ".sh": "shell", ".sql": "sql", ".swift": "swift",
".toml": "toml", ".ts": "typescript", ".tsx": "tsx", ".xml": "xml",
".yaml": "yaml", ".yml": "yaml",
}
def _fs_resolve(params: dict, *, default_to_cwd: bool = False) -> Path:
"""Resolve an fs.* path param on the gateway host.
Relative paths resolve against the session/terminal cwd (same base as path
completions). When ``default_to_cwd`` and no path is given, returns the cwd.
"""
raw = str(params.get("path", "") or "").strip()
base = _completion_cwd(params)
if not raw:
return Path(base)
expanded = os.path.expanduser(os.path.expandvars(raw))
p = Path(expanded)
if not p.is_absolute():
p = Path(base) / p
return p
def _fs_stat_file(rid, params: dict):
"""Resolve + stat a regular file. Returns (resolved, stat, None) or (None, None, err)."""
target = _fs_resolve(params)
try:
resolved = target.resolve()
st = resolved.stat()
except FileNotFoundError:
return None, None, _err(rid, 4016, f"file not found: {target}")
except OSError as e:
return None, None, _err(rid, 5027, str(e))
if not resolved.is_file():
return None, None, _err(rid, 4016, f"not a file: {resolved}")
return resolved, st, None
@method("fs.list")
def _(rid, params: dict) -> dict:
import errno as _errno
target = _fs_resolve(params, default_to_cwd=True)
try:
resolved = target.resolve()
except Exception:
resolved = target
try:
entries = []
with os.scandir(resolved) as it:
for entry in it:
if entry.name in _FS_READDIR_HIDDEN:
continue
try:
is_dir = entry.is_dir()
except OSError:
is_dir = False
entries.append(
{
"name": entry.name,
"path": str(Path(resolved) / entry.name),
"isDirectory": is_dir,
}
)
entries.sort(key=lambda e: (0 if e["isDirectory"] else 1, e["name"].lower()))
return _ok(rid, {"path": str(resolved), "entries": entries})
except OSError as e:
code = _errno.errorcode.get(getattr(e, "errno", None), "read-error")
return _ok(rid, {"path": str(resolved), "entries": [], "error": code})
@method("fs.read_text")
def _(rid, params: dict) -> dict:
import mimetypes
resolved, st, err = _fs_stat_file(rid, params)
if err:
return err
to_read = min(st.st_size, _FS_TEXT_READ_MAX_BYTES)
try:
with open(resolved, "rb") as f:
chunk = f.read(to_read)
except OSError as e:
return _err(rid, 5027, str(e))
binary = b"\x00" in chunk[:4096]
text = "" if binary else chunk.decode("utf-8", errors="replace")
mime = mimetypes.guess_type(str(resolved))[0] or "application/octet-stream"
return _ok(
rid,
{
"binary": binary,
"byteSize": st.st_size,
"language": _FS_LANGUAGE_BY_EXT.get(resolved.suffix.lower(), "text"),
"mimeType": mime,
"path": str(resolved),
"text": text,
"truncated": st.st_size > _FS_TEXT_READ_MAX_BYTES,
},
)
@method("fs.read_data_url")
def _(rid, params: dict) -> dict:
import base64
import mimetypes
resolved, st, err = _fs_stat_file(rid, params)
if err:
return err
if st.st_size > _FS_DATA_URL_MAX_BYTES:
return _err(rid, 4017, f"file too large: {resolved.name}")
try:
data = resolved.read_bytes()
except OSError as e:
return _err(rid, 5027, str(e))
mime = mimetypes.guess_type(str(resolved))[0] or "application/octet-stream"
b64 = base64.b64encode(data).decode("ascii")
return _ok(rid, {"path": str(resolved), "dataUrl": f"data:{mime};base64,{b64}"})
@method("fs.git_root")
def _(rid, params: dict) -> dict:
target = _fs_resolve(params, default_to_cwd=True)
try:
start = target.resolve()
if start.is_file():
start = start.parent
except Exception:
start = target
current = start
for _ in range(50):
try:
if (current / ".git").exists():
return _ok(rid, {"root": str(current)})
except OSError:
return _ok(rid, {"root": None})
parent = current.parent
if parent == current:
break
current = parent
return _ok(rid, {"root": None})
@method("image.detach")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)