From 5fc7dce9c86daecd2a221e62122d2b85aa6b6d8f Mon Sep 17 00:00:00 2001 From: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com> Date: Wed, 20 May 2026 04:36:49 +0200 Subject: [PATCH] Fix file browser absolute path copy (#860) --- packages/client/src/api/hermes/files.ts | 4 +- .../hermes/files/FileContextMenu.vue | 3 +- packages/client/src/utils/file-path.ts | 5 ++ packages/server/src/routes/hermes/files.ts | 8 +- packages/server/src/routes/hermes/terminal.ts | 22 ++++- tests/client/file-path.test.ts | 23 ++++++ tests/server/files-routes.test.ts | 81 +++++++++++++++++++ tests/server/terminal-cwd.test.ts | 41 ++++++++++ 8 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 packages/client/src/utils/file-path.ts create mode 100644 tests/client/file-path.test.ts create mode 100644 tests/server/files-routes.test.ts create mode 100644 tests/server/terminal-cwd.test.ts diff --git a/packages/client/src/api/hermes/files.ts b/packages/client/src/api/hermes/files.ts index 8f58cc90..4e76da7d 100644 --- a/packages/client/src/api/hermes/files.ts +++ b/packages/client/src/api/hermes/files.ts @@ -3,6 +3,7 @@ import { request, getApiKey, getBaseUrlValue } from '../client' export interface FileEntry { name: string path: string + absolutePath?: string isDir: boolean size: number modTime: string @@ -11,13 +12,14 @@ export interface FileEntry { export interface FileStat { name: string path: string + absolutePath?: string isDir: boolean size: number modTime: string permissions?: string } -export async function listFiles(path: string = ''): Promise<{ entries: FileEntry[]; path: string }> { +export async function listFiles(path: string = ''): Promise<{ entries: FileEntry[]; path: string; absolutePath?: string }> { const params = new URLSearchParams() if (path) params.set('path', path) const query = params.toString() diff --git a/packages/client/src/components/hermes/files/FileContextMenu.vue b/packages/client/src/components/hermes/files/FileContextMenu.vue index f37758f7..f36834fa 100644 --- a/packages/client/src/components/hermes/files/FileContextMenu.vue +++ b/packages/client/src/components/hermes/files/FileContextMenu.vue @@ -6,6 +6,7 @@ import { useFilesStore, isTextFile, isImageFile, isMarkdownFile } from '@/stores import { downloadFile } from '@/api/hermes/download' import type { FileEntry } from '@/api/hermes/files' import { copyToClipboard } from '@/utils/clipboard' +import { getClipboardPathForEntry } from '@/utils/file-path' const { t } = useI18n() const message = useMessage() @@ -74,7 +75,7 @@ async function handleSelect(key: string) { try { await downloadFile(entry.path, entry.name) } catch (err: any) { message.error(err.message) } break case 'copyPath': { - const ok = await copyToClipboard(entry.path) + const ok = await copyToClipboard(getClipboardPathForEntry(entry)) if (ok) { message.success(t('files.pathCopied')) } else { diff --git a/packages/client/src/utils/file-path.ts b/packages/client/src/utils/file-path.ts new file mode 100644 index 00000000..e9bee149 --- /dev/null +++ b/packages/client/src/utils/file-path.ts @@ -0,0 +1,5 @@ +import type { FileEntry } from '@/api/hermes/files' + +export function getClipboardPathForEntry(entry: FileEntry): string { + return entry.absolutePath || entry.path +} diff --git a/packages/server/src/routes/hermes/files.ts b/packages/server/src/routes/hermes/files.ts index ab18ef65..d777b6aa 100644 --- a/packages/server/src/routes/hermes/files.ts +++ b/packages/server/src/routes/hermes/files.ts @@ -6,6 +6,10 @@ import { MAX_EDIT_SIZE, } from '../../services/hermes/file-provider' +function withAbsolutePath(entry: T): T & { absolutePath: string } { + return { ...entry, absolutePath: resolveHermesPath(entry.path) } +} + export const fileRoutes = new Router() function handleError(ctx: any, err: any) { @@ -39,7 +43,7 @@ fileRoutes.get('/api/hermes/files/list', async (ctx) => { if (a.isDir !== b.isDir) return a.isDir ? -1 : 1 return a.name.localeCompare(b.name) }) - ctx.body = { entries, path: relativePath } + ctx.body = { entries: entries.map(withAbsolutePath), path: relativePath, absolutePath: absPath } } catch (err: any) { handleError(ctx, err) } @@ -57,7 +61,7 @@ fileRoutes.get('/api/hermes/files/stat', async (ctx) => { const absPath = resolveHermesPath(relativePath) const provider = await createFileProvider() const info = await provider.stat(absPath) - ctx.body = info + ctx.body = withAbsolutePath(info) } catch (err: any) { handleError(ctx, err) } diff --git a/packages/server/src/routes/hermes/terminal.ts b/packages/server/src/routes/hermes/terminal.ts index f45178d4..038279f3 100644 --- a/packages/server/src/routes/hermes/terminal.ts +++ b/packages/server/src/routes/hermes/terminal.ts @@ -1,8 +1,10 @@ import { WebSocketServer } from 'ws' import type { Server as HttpServer } from 'http' import { accessSync, chmodSync, constants as fsConstants, existsSync } from 'fs' -import { dirname, join } from 'path' +import { dirname, join, isAbsolute, resolve as resolvePath } from 'path' import { homedir } from 'os' +import { getActiveProfileDir } from '../../services/hermes/hermes-profile' +import { getTerminalConfig, type TerminalConfig } from '../../services/hermes/file-provider' import { getToken } from '../../services/auth' import { logger } from '../../services/logger' @@ -66,6 +68,22 @@ function shellName(shell: string): string { return shell.split('/').pop() || 'shell' } +export function resolveTerminalCwd( + cfg: Pick = getTerminalConfig(), + profileDir = getActiveProfileDir(), +): string { + const configured = cfg.cwd?.trim() + const fallback = existsSync(profileDir) ? profileDir : homedir() + if (!configured) return fallback + + const cwd = isAbsolute(configured) ? configured : resolvePath(profileDir, configured) + if (!existsSync(cwd)) { + logger.warn({ cwd }, 'Configured terminal cwd does not exist; falling back to Hermes profile directory') + return fallback + } + return cwd +} + // ─── Session types ────────────────────────────────────────────── interface PtySession { @@ -96,7 +114,7 @@ function createSession(shell: string): PtySession { name: 'xterm-color', cols: 80, rows: 24, - cwd: homedir(), + cwd: resolveTerminalCwd(), }) } catch (err: any) { throw new Error(`Failed to spawn shell "${shell}": ${err.message}`) diff --git a/tests/client/file-path.test.ts b/tests/client/file-path.test.ts new file mode 100644 index 00000000..b9545ffb --- /dev/null +++ b/tests/client/file-path.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { getClipboardPathForEntry } from '@/utils/file-path' + +const baseEntry = { + name: 'app.log', + path: 'logs/app.log', + isDir: false, + size: 12, + modTime: '2026-05-20T00:00:00.000Z', +} + +describe('file path clipboard helpers', () => { + it('prefers absolute path metadata when available', () => { + expect(getClipboardPathForEntry({ + ...baseEntry, + absolutePath: '/home/agent/.hermes/logs/app.log', + })).toBe('/home/agent/.hermes/logs/app.log') + }) + + it('falls back to the relative operation path for older API responses', () => { + expect(getClipboardPathForEntry(baseEntry)).toBe('logs/app.log') + }) +}) diff --git a/tests/server/files-routes.test.ts b/tests/server/files-routes.test.ts new file mode 100644 index 00000000..36f6e0ff --- /dev/null +++ b/tests/server/files-routes.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const provider = { + listDir: vi.fn(), + stat: vi.fn(), +} +const createFileProviderMock = vi.fn(async () => provider) +const resolveHermesPathMock = vi.fn((relativePath: string) => { + const normalized = relativePath.replace(/^\/+/, '') + return normalized ? `/home/agent/.hermes/${normalized}` : '/home/agent/.hermes' +}) + +vi.mock('../../packages/server/src/services/hermes/file-provider', () => ({ + createFileProvider: createFileProviderMock, + resolveHermesPath: resolveHermesPathMock, + isSensitivePath: vi.fn(() => false), + MAX_EDIT_SIZE: 10 * 1024 * 1024, +})) + +describe('file routes path metadata', () => { + beforeEach(() => { + vi.resetModules() + createFileProviderMock.mockClear() + resolveHermesPathMock.mockClear() + provider.listDir.mockReset() + provider.stat.mockReset() + }) + + it('returns absolute paths for listed entries while preserving relative operation paths', async () => { + provider.listDir.mockResolvedValue([ + { name: 'app.log', path: 'logs/app.log', isDir: false, size: 12, modTime: '2026-05-20T00:00:00.000Z' }, + ]) + + const { fileRoutes } = await import('../../packages/server/src/routes/hermes/files') + const layer = fileRoutes.stack.find((entry: any) => entry.path === '/api/hermes/files/list') + const ctx: any = { query: { path: 'logs' }, body: null } + + await layer.stack[0](ctx) + + expect(provider.listDir).toHaveBeenCalledWith('/home/agent/.hermes/logs') + expect(ctx.body).toEqual({ + path: 'logs', + absolutePath: '/home/agent/.hermes/logs', + entries: [ + { + name: 'app.log', + path: 'logs/app.log', + absolutePath: '/home/agent/.hermes/logs/app.log', + isDir: false, + size: 12, + modTime: '2026-05-20T00:00:00.000Z', + }, + ], + }) + }) + + it('returns an absolute path in stat responses', async () => { + provider.stat.mockResolvedValue({ + name: 'app.log', + path: 'logs/app.log', + isDir: false, + size: 12, + modTime: '2026-05-20T00:00:00.000Z', + }) + + const { fileRoutes } = await import('../../packages/server/src/routes/hermes/files') + const layer = fileRoutes.stack.find((entry: any) => entry.path === '/api/hermes/files/stat') + const ctx: any = { query: { path: 'logs/app.log' }, body: null } + + await layer.stack[0](ctx) + + expect(ctx.body).toEqual({ + name: 'app.log', + path: 'logs/app.log', + absolutePath: '/home/agent/.hermes/logs/app.log', + isDir: false, + size: 12, + modTime: '2026-05-20T00:00:00.000Z', + }) + }) +}) diff --git a/tests/server/terminal-cwd.test.ts b/tests/server/terminal-cwd.test.ts new file mode 100644 index 00000000..fd9d02c4 --- /dev/null +++ b/tests/server/terminal-cwd.test.ts @@ -0,0 +1,41 @@ +import { mkdtempSync, mkdirSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveTerminalCwd } from '../../packages/server/src/routes/hermes/terminal' + +const tmpRoots: string[] = [] + +function makeTmpRoot() { + const root = mkdtempSync(join(tmpdir(), 'wui-terminal-cwd-')) + tmpRoots.push(root) + return root +} + +afterEach(() => { + for (const root of tmpRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('terminal cwd resolution', () => { + it('defaults terminal sessions to the active Hermes profile directory', () => { + const profileDir = makeTmpRoot() + expect(resolveTerminalCwd({}, profileDir)).toBe(profileDir) + }) + + it('resolves relative configured cwd from the Hermes profile directory', () => { + const profileDir = makeTmpRoot() + mkdirSync(join(profileDir, 'workspace')) + expect(resolveTerminalCwd({ cwd: 'workspace' }, profileDir)).toBe(join(profileDir, 'workspace')) + }) + + it('uses absolute configured cwd when it exists', () => { + const profileDir = makeTmpRoot() + const cwd = makeTmpRoot() + expect(resolveTerminalCwd({ cwd }, profileDir)).toBe(cwd) + }) + + it('falls back to the profile directory when configured cwd is missing', () => { + const profileDir = makeTmpRoot() + expect(resolveTerminalCwd({ cwd: 'missing' }, profileDir)).toBe(profileDir) + }) +})