From 0cc31ee999a9eb717943c09ec61df10be1eb489b Mon Sep 17 00:00:00 2001 From: ww Date: Thu, 23 Apr 2026 12:09:39 +0800 Subject: [PATCH] feat: add file browser and file download with multi-backend support (#142) * feat: add file browser and file download with multi-backend support Adds a built-in File Browser page and a File Download system to Hermes Web UI, enabling users to browse, edit, preview, upload, and download files from the workspace directly from the web dashboard. File Browser (/hermes/files): - New view FilesView.vue plus components under components/hermes/files/ (FileTree, FileList, FileBreadcrumb, FileToolbar, FileContextMenu, FileEditor, FilePreview, FileRenameModal, FileUploadModal) - New Pinia store stores/hermes/files.ts for directory tree, selection, and editing state - New API module api/hermes/files.ts - New server routes routes/hermes/files.ts with CRUD, rename, upload, and directory listing - New service services/hermes/file-provider.ts with a pluggable provider architecture (local filesystem + multi-terminal backends) File Download: - New server route routes/hermes/download.ts and client API api/hermes/download.ts - Integration in chat messages (MessageItem.vue, MarkdownRenderer.vue) to surface downloadable file references Packaging: - package.json: add a prepare script so the package can be installed directly from a git URL with dist/ built automatically i18n: add files/download translations to en.ts and zh.ts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use clipboard fallback for non-secure HTTP contexts navigator.clipboard is undefined on HTTP intranet deployments (only available in secure contexts). The previous synchronous calls threw silently and the success toast still fired, making 'copy' actions appear broken. - Add packages/client/src/utils/clipboard.ts with execCommand fallback via a hidden textarea - Use the helper in FileContextMenu (copy file path), CodexLoginModal (copy user code), NousLoginModal (copy user code), ChatPanel (copy session id) - Each call now awaits the result and shows success/failure based on the actual outcome Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + README.md | 3 +- README_zh.md | 3 +- package.json | 2 + packages/client/src/api/hermes/download.ts | 36 + packages/client/src/api/hermes/files.ts | 101 +++ .../src/components/hermes/chat/ChatPanel.vue | 8 +- .../hermes/chat/MarkdownRenderer.vue | 30 + .../components/hermes/chat/MessageItem.vue | 43 +- .../hermes/files/FileBreadcrumb.vue | 40 + .../hermes/files/FileContextMenu.vue | 125 +++ .../components/hermes/files/FileEditor.vue | 135 +++ .../src/components/hermes/files/FileList.vue | 212 +++++ .../components/hermes/files/FilePreview.vue | 79 ++ .../hermes/files/FileRenameModal.vue | 98 ++ .../components/hermes/files/FileToolbar.vue | 49 + .../src/components/hermes/files/FileTree.vue | 94 ++ .../hermes/files/FileUploadModal.vue | 84 ++ .../hermes/models/CodexLoginModal.vue | 8 +- .../hermes/models/NousLoginModal.vue | 8 +- .../src/components/layout/AppSidebar.vue | 6 + packages/client/src/i18n/locales/en.ts | 61 ++ packages/client/src/i18n/locales/zh.ts | 61 ++ packages/client/src/router/index.ts | 5 + packages/client/src/stores/hermes/files.ts | 214 +++++ packages/client/src/utils/clipboard.ts | 46 + .../client/src/views/hermes/FilesView.vue | 119 +++ packages/server/src/routes/hermes/download.ts | 114 +++ packages/server/src/routes/hermes/files.ts | 283 ++++++ packages/server/src/routes/index.ts | 4 + .../src/services/hermes/file-provider.ts | 850 ++++++++++++++++++ vite.config.ts | 3 + 32 files changed, 2913 insertions(+), 12 deletions(-) create mode 100644 packages/client/src/api/hermes/download.ts create mode 100644 packages/client/src/api/hermes/files.ts create mode 100644 packages/client/src/components/hermes/files/FileBreadcrumb.vue create mode 100644 packages/client/src/components/hermes/files/FileContextMenu.vue create mode 100644 packages/client/src/components/hermes/files/FileEditor.vue create mode 100644 packages/client/src/components/hermes/files/FileList.vue create mode 100644 packages/client/src/components/hermes/files/FilePreview.vue create mode 100644 packages/client/src/components/hermes/files/FileRenameModal.vue create mode 100644 packages/client/src/components/hermes/files/FileToolbar.vue create mode 100644 packages/client/src/components/hermes/files/FileTree.vue create mode 100644 packages/client/src/components/hermes/files/FileUploadModal.vue create mode 100644 packages/client/src/stores/hermes/files.ts create mode 100644 packages/client/src/utils/clipboard.ts create mode 100644 packages/client/src/views/hermes/FilesView.vue create mode 100644 packages/server/src/routes/hermes/download.ts create mode 100644 packages/server/src/routes/hermes/files.ts create mode 100644 packages/server/src/services/hermes/file-provider.ts diff --git a/.gitignore b/.gitignore index f225f24..dfcca5c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ hermes-dependencies.md *.njsproj *.sln *.sw? +.superpowers/ diff --git a/README.md b/README.md index 29ac1a2..b8e53af 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ - Markdown rendering with syntax highlighting and code copy - Tool call detail expansion (arguments / result) - File upload support +- File download support — download user-uploaded files and agent-generated files across local, Docker, SSH, and Singularity backends - Global model selector — discovers models from `~/.hermes/auth.json` credential pool - Per-session model display badge and context token usage @@ -235,7 +236,7 @@ Browser → BFF (Koa, :8648) → Hermes Gateway (:8642) The frontend is designed with **multi-agent extensibility** — all Hermes-specific code is namespaced under `hermes/` directories (API, components, views, stores), making it straightforward to add new agent integrations alongside. -The BFF layer handles API proxy (with path rewriting), SSE streaming, file upload, session CRUD via CLI, config/credential management, WeChat QR login, model discovery, skills/memory management, log reading, and static file serving. +The BFF layer handles API proxy (with path rewriting), SSE streaming, file upload and download (multi-backend: local/Docker/SSH/Singularity), session CRUD via CLI, config/credential management, WeChat QR login, model discovery, skills/memory management, log reading, and static file serving. ## Tech Stack diff --git a/README_zh.md b/README_zh.md index cd180b9..2c95c6c 100644 --- a/README_zh.md +++ b/README_zh.md @@ -45,6 +45,7 @@ - Markdown 渲染,支持语法高亮和代码复制 - 工具调用详情展开(参数 / 结果) - 文件上传支持 +- 文件下载支持 — 支持下载用户上传的文件和 Agent 生成的文件,兼容 local、Docker、SSH、Singularity 等多种 terminal backend - 全局模型选择器 — 自动从 `~/.hermes/auth.json` 凭证池发现可用模型 - 每个会话显示模型标签和上下文 Token 用量 @@ -236,7 +237,7 @@ npm run build # 构建输出到 dist/ 前端采用 **多 Agent 可扩展架构** — 所有 Hermes 相关代码都按命名空间组织在 `hermes/` 目录下(API、组件、视图、Store),可以方便地并行接入新的 Agent。 -BFF 层负责:API 代理(含路径重写)、SSE 流式推送、文件上传、通过 CLI 管理会话 CRUD、配置/凭证管理、微信扫码登录、模型发现、技能/记忆管理、日志读取和静态文件服务。 +BFF 层负责:API 代理(含路径重写)、SSE 流式推送、文件上传与下载(多 Backend 支持:local/Docker/SSH/Singularity)、通过 CLI 管理会话 CRUD、配置/凭证管理、微信扫码登录、模型发现、技能/记忆管理、日志读取和静态文件服务。 ## 技术栈 diff --git a/package.json b/package.json index 0b600ef..f91b596 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "dev:client": "vite --host", "dev:server": "nodemon --signal SIGTERM --watch packages/server/src -e ts,tsx --exec TS_NODE_PROJECT=packages/server/tsconfig.json node -r ts-node/register packages/server/src/index.ts", "build": "vue-tsc -b && vite build && tsc --noEmit -p packages/server/tsconfig.json && node scripts/build-server.mjs", + "prepare": "[ -d dist ] || npm run build", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", @@ -92,6 +93,7 @@ "koa-send": "^5.0.1", "koa-static": "^5.0.0", "markdown-it": "^14.1.1", + "monaco-editor": "^0.55.1", "naive-ui": "^2.44.1", "nodemon": "^3.1.14", "pinia": "^3.0.4", diff --git a/packages/client/src/api/hermes/download.ts b/packages/client/src/api/hermes/download.ts new file mode 100644 index 0000000..7b6f3a9 --- /dev/null +++ b/packages/client/src/api/hermes/download.ts @@ -0,0 +1,36 @@ +import { getApiKey, getBaseUrlValue } from '../client' + +/** + * Construct a download URL with auth token as query parameter. + * Token is passed via query param because tags cannot set headers. + */ +export function getDownloadUrl(filePath: string, fileName?: string): string { + const base = getBaseUrlValue() + const params = new URLSearchParams({ path: filePath }) + if (fileName) params.set('name', fileName) + const token = getApiKey() + if (token) params.set('token', token) + return `${base}/api/hermes/download?${params.toString()}` +} + +/** + * Download a file. Uses fetch to detect errors, then creates a blob URL + * for the browser download. Throws with error message on failure. + */ +export async function downloadFile(filePath: string, fileName?: string): Promise { + const url = getDownloadUrl(filePath, fileName) + const res = await fetch(url) + if (!res.ok) { + const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) + throw new Error(body.error || `Download failed: ${res.status}`) + } + const blob = await res.blob() + const blobUrl = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = blobUrl + a.download = fileName || filePath.split('/').pop() || 'download' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(blobUrl) +} diff --git a/packages/client/src/api/hermes/files.ts b/packages/client/src/api/hermes/files.ts new file mode 100644 index 0000000..8f58cc9 --- /dev/null +++ b/packages/client/src/api/hermes/files.ts @@ -0,0 +1,101 @@ +import { request, getApiKey, getBaseUrlValue } from '../client' + +export interface FileEntry { + name: string + path: string + isDir: boolean + size: number + modTime: string +} + +export interface FileStat { + name: string + path: string + isDir: boolean + size: number + modTime: string + permissions?: string +} + +export async function listFiles(path: string = ''): Promise<{ entries: FileEntry[]; path: string }> { + const params = new URLSearchParams() + if (path) params.set('path', path) + const query = params.toString() + return request<{ entries: FileEntry[]; path: string }>(`/api/hermes/files/list${query ? `?${query}` : ''}`) +} + +export async function statFile(path: string): Promise { + return request(`/api/hermes/files/stat?path=${encodeURIComponent(path)}`) +} + +export async function readFile(path: string): Promise<{ content: string; path: string; size: number }> { + return request<{ content: string; path: string; size: number }>(`/api/hermes/files/read?path=${encodeURIComponent(path)}`) +} + +export async function writeFile(path: string, content: string): Promise { + await request<{ ok: boolean }>('/api/hermes/files/write', { + method: 'PUT', + body: JSON.stringify({ path, content }), + }) +} + +export async function deleteFile(path: string, recursive: boolean = false): Promise { + await request<{ ok: boolean }>('/api/hermes/files/delete', { + method: 'DELETE', + body: JSON.stringify({ path, recursive }), + }) +} + +export async function renameFile(oldPath: string, newPath: string): Promise { + await request<{ ok: boolean }>('/api/hermes/files/rename', { + method: 'POST', + body: JSON.stringify({ oldPath, newPath }), + }) +} + +export async function mkDir(path: string): Promise { + await request<{ ok: boolean }>('/api/hermes/files/mkdir', { + method: 'POST', + body: JSON.stringify({ path }), + }) +} + +export async function copyFile(srcPath: string, destPath: string): Promise { + await request<{ ok: boolean }>('/api/hermes/files/copy', { + method: 'POST', + body: JSON.stringify({ srcPath, destPath }), + }) +} + +export async function uploadFiles(targetDir: string, files: File[]): Promise<{ name: string; path: string }[]> { + const base = getBaseUrlValue() + const formData = new FormData() + for (const file of files) { + formData.append('file', file) + } + const params = new URLSearchParams() + if (targetDir) params.set('path', targetDir) + const query = params.toString() + const url = `${base}/api/hermes/files/upload${query ? `?${query}` : ''}` + + const headers: Record = {} + const token = getApiKey() + if (token) headers['Authorization'] = `Bearer ${token}` + + const res = await fetch(url, { method: 'POST', headers, body: formData }) + if (!res.ok) { + const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) + throw new Error(body.error || `Upload failed: ${res.status}`) + } + const data = await res.json() + return data.files +} + +export function getFileDownloadUrl(relativePath: string, fileName?: string): string { + const base = getBaseUrlValue() + const params = new URLSearchParams({ path: relativePath }) + if (fileName) params.set('name', fileName) + const token = getApiKey() + if (token) params.set('token', token) + return `${base}/api/hermes/download?${params.toString()}` +} diff --git a/packages/client/src/components/hermes/chat/ChatPanel.vue b/packages/client/src/components/hermes/chat/ChatPanel.vue index 0c8386b..0f92921 100644 --- a/packages/client/src/components/hermes/chat/ChatPanel.vue +++ b/packages/client/src/components/hermes/chat/ChatPanel.vue @@ -6,6 +6,7 @@ import { NButton, NDropdown, NInput, NModal, NTooltip, useMessage } from 'naive- import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { getSourceLabel } from '@/shared/session-display' +import { copyToClipboard } from '@/utils/clipboard' import ChatInput from './ChatInput.vue' import ConversationMonitorPane from './ConversationMonitorPane.vue' import MessageList from './MessageList.vue' @@ -177,11 +178,12 @@ function handleNewChat() { chatStore.newChat() } -function copySessionId(id?: string) { +async function copySessionId(id?: string) { const sessionId = id || chatStore.activeSessionId if (sessionId) { - navigator.clipboard.writeText(sessionId) - message.success(t('common.copied')) + const ok = await copyToClipboard(sessionId) + if (ok) message.success(t('common.copied')) + else message.error(t('common.copied') + ' ✗') } } diff --git a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue index bb2b5cd..a04f441 100644 --- a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue +++ b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue @@ -1,11 +1,14 @@ diff --git a/packages/client/src/components/hermes/chat/MessageItem.vue b/packages/client/src/components/hermes/chat/MessageItem.vue index b23adca..fe4d895 100644 --- a/packages/client/src/components/hermes/chat/MessageItem.vue +++ b/packages/client/src/components/hermes/chat/MessageItem.vue @@ -2,6 +2,8 @@ import type { Message } from "@/stores/hermes/chat"; import { computed, ref } from "vue"; import { useI18n } from "vue-i18n"; +import { useMessage } from "naive-ui"; +import { downloadFile } from "@/api/hermes/download"; import MarkdownRenderer from "./MarkdownRenderer.vue"; import { copyTextToClipboard, @@ -13,6 +15,7 @@ const TOOL_PAYLOAD_DISPLAY_LIMIT = 2000; const props = defineProps<{ message: Message; highlight?: boolean }>(); const { t } = useI18n(); +const toast = useMessage(); const isSystem = computed(() => props.message.role === "system"); const toolExpanded = ref(false); @@ -32,6 +35,39 @@ function formatSize(bytes: number): string { return (bytes / (1024 * 1024)).toFixed(1) + " MB"; } +/** + * Extract the upload file path from message content for a given attachment. + * Upload format in content: [File: name.txt](/tmp/hermes-uploads/abc123.txt) + */ +function getFilePathFromContent(attName: string): string | null { + const content = props.message.content || ""; + const regex = /\[File:\s*([^\]]+)\]\(([^)]+)\)/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(content)) !== null) { + if (match[1].trim() === attName.trim()) return match[2]; + } + return null; +} + +function handleAttachmentDownload(att: { name: string; url: string; type: string }) { + const filePath = getFilePathFromContent(att.name); + if (filePath) { + toast.info(t("download.downloading")); + downloadFile(filePath, att.name).catch((err: Error) => { + toast.error(err.message || t("download.downloadFailed")); + }); + return; + } + if (att.url && att.url.startsWith("blob:")) { + const a = document.createElement("a"); + a.href = att.url; + a.download = att.name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } +} + type ToolPayload = { full: string; display: string; @@ -214,7 +250,7 @@ const renderedToolResult = computed(() => { /> diff --git a/packages/client/src/components/hermes/files/FileBreadcrumb.vue b/packages/client/src/components/hermes/files/FileBreadcrumb.vue new file mode 100644 index 0000000..dacd1b5 --- /dev/null +++ b/packages/client/src/components/hermes/files/FileBreadcrumb.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/packages/client/src/components/hermes/files/FileContextMenu.vue b/packages/client/src/components/hermes/files/FileContextMenu.vue new file mode 100644 index 0000000..f37758f --- /dev/null +++ b/packages/client/src/components/hermes/files/FileContextMenu.vue @@ -0,0 +1,125 @@ + + + diff --git a/packages/client/src/components/hermes/files/FileEditor.vue b/packages/client/src/components/hermes/files/FileEditor.vue new file mode 100644 index 0000000..d6fe73c --- /dev/null +++ b/packages/client/src/components/hermes/files/FileEditor.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/packages/client/src/components/hermes/files/FileList.vue b/packages/client/src/components/hermes/files/FileList.vue new file mode 100644 index 0000000..37adc78 --- /dev/null +++ b/packages/client/src/components/hermes/files/FileList.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/packages/client/src/components/hermes/files/FilePreview.vue b/packages/client/src/components/hermes/files/FilePreview.vue new file mode 100644 index 0000000..3532dc1 --- /dev/null +++ b/packages/client/src/components/hermes/files/FilePreview.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/packages/client/src/components/hermes/files/FileRenameModal.vue b/packages/client/src/components/hermes/files/FileRenameModal.vue new file mode 100644 index 0000000..ece9748 --- /dev/null +++ b/packages/client/src/components/hermes/files/FileRenameModal.vue @@ -0,0 +1,98 @@ + + + diff --git a/packages/client/src/components/hermes/files/FileToolbar.vue b/packages/client/src/components/hermes/files/FileToolbar.vue new file mode 100644 index 0000000..394706d --- /dev/null +++ b/packages/client/src/components/hermes/files/FileToolbar.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/packages/client/src/components/hermes/files/FileTree.vue b/packages/client/src/components/hermes/files/FileTree.vue new file mode 100644 index 0000000..5602f75 --- /dev/null +++ b/packages/client/src/components/hermes/files/FileTree.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/packages/client/src/components/hermes/files/FileUploadModal.vue b/packages/client/src/components/hermes/files/FileUploadModal.vue new file mode 100644 index 0000000..c145d40 --- /dev/null +++ b/packages/client/src/components/hermes/files/FileUploadModal.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/packages/client/src/components/hermes/models/CodexLoginModal.vue b/packages/client/src/components/hermes/models/CodexLoginModal.vue index fede904..19a33c4 100644 --- a/packages/client/src/components/hermes/models/CodexLoginModal.vue +++ b/packages/client/src/components/hermes/models/CodexLoginModal.vue @@ -3,6 +3,7 @@ import { ref, onUnmounted } from 'vue' import { NModal, NButton, useMessage } from 'naive-ui' import { useI18n } from 'vue-i18n' import { startCodexLogin, pollCodexLogin } from '@/api/hermes/codex-auth' +import { copyToClipboard } from '@/utils/clipboard' const { t } = useI18n() const emit = defineEmits<{ close: []; success: [] }>() @@ -85,9 +86,10 @@ function handleClose() { setTimeout(() => emit('close'), 200) } -function copyCode() { - navigator.clipboard.writeText(userCode.value) - message.success(t('models.codexCopyCode')) +async function copyCode() { + const ok = await copyToClipboard(userCode.value) + if (ok) message.success(t('models.codexCopyCode')) + else message.error(t('models.codexCopyCode') + ' ✗') } function openLink() { diff --git a/packages/client/src/components/hermes/models/NousLoginModal.vue b/packages/client/src/components/hermes/models/NousLoginModal.vue index 5f9d006..a594d33 100644 --- a/packages/client/src/components/hermes/models/NousLoginModal.vue +++ b/packages/client/src/components/hermes/models/NousLoginModal.vue @@ -3,6 +3,7 @@ import { ref, onUnmounted } from 'vue' import { NModal, NButton, NSpin, useMessage } from 'naive-ui' import { useI18n } from 'vue-i18n' import { startNousLogin, pollNousLogin } from '@/api/hermes/nous-auth' +import { copyToClipboard } from '@/utils/clipboard' const { t } = useI18n() const emit = defineEmits<{ close: []; success: [] }>() @@ -87,9 +88,10 @@ function handleClose() { setTimeout(() => emit('close'), 200) } -function copyCode() { - navigator.clipboard.writeText(userCode.value) - message.success(t('models.nousCopyCode')) +async function copyCode() { + const ok = await copyToClipboard(userCode.value) + if (ok) message.success(t('models.nousCopyCode')) + else message.error(t('models.nousCopyCode') + ' ✗') } function openLink() { diff --git a/packages/client/src/components/layout/AppSidebar.vue b/packages/client/src/components/layout/AppSidebar.vue index bcf4425..3f3bf60 100644 --- a/packages/client/src/components/layout/AppSidebar.vue +++ b/packages/client/src/components/layout/AppSidebar.vue @@ -190,6 +190,12 @@ function openChangelog() { {{ t("sidebar.terminal") }} + diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index f6a195e..421878c 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -79,6 +79,7 @@ export default { channels: 'Channels', gateways: 'Gateways', terminal: 'Terminal', + files: 'Files', groupConversation: 'Conversation', groupPlatform: 'Platform', groupAgent: 'Agent', @@ -548,6 +549,66 @@ export default { cost: 'Cost', noData: 'No usage data', }, + // Files + files: { + title: 'Files', + tree: 'Directory Tree', + list: 'File List', + breadcrumbRoot: 'Home', + newFile: 'New File', + newFolder: 'New Folder', + upload: 'Upload', + refresh: 'Refresh', + open: 'Open', + edit: 'Edit', + preview: 'Preview', + download: 'Download', + copyPath: 'Copy Path', + rename: 'Rename', + delete: 'Delete', + name: 'Name', + size: 'Size', + modified: 'Modified', + actions: 'Actions', + emptyDir: 'Empty directory', + loading: 'Loading...', + confirmDelete: 'Are you sure you want to delete "{name}"?', + confirmDeleteDir: 'Are you sure you want to delete directory "{name}" and all its contents?', + deleteFailed: 'Delete failed', + deleted: 'Deleted', + renameTo: 'Rename to', + newFileName: 'File name', + newFolderName: 'Folder name', + created: 'Created', + createFailed: 'Create failed', + renamed: 'Renamed', + renameFailed: 'Rename failed', + uploadSuccess: 'Uploaded {count} file(s)', + uploadFailed: 'Upload failed', + saveFailed: 'Save failed', + saved: 'Saved', + unsavedChanges: 'You have unsaved changes. Discard?', + pathCopied: 'Path copied', + fileTooLarge: 'File too large (max 10MB)', + permissionDenied: 'Cannot modify protected file', + notFound: 'File or directory not found', + backendError: 'File operation failed', + dragDropHint: 'Drag files here to upload', + closeEditor: 'Close Editor', + saveFile: 'Save', + }, + // Download + download: { + downloading: 'Downloading...', + downloadFailed: 'Download failed', + fileNotFound: 'File not found or deleted', + fileTooLarge: 'File too large (exceeds limit)', + backendError: 'File read failed, remote environment may be unavailable', + backendTimeout: 'File read timed out', + unsupportedBackend: 'Current terminal backend does not support file download', + invalidPath: 'Invalid file path', + download: 'Download', + }, // Changelog changelog: { diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index 53113e3..e501ead 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -79,6 +79,7 @@ export default { channels: '频道', gateways: '网关', terminal: '终端', + files: '文件', groupConversation: '对话', groupPlatform: '平台', groupAgent: '代理', @@ -550,6 +551,66 @@ export default { cost: '费用', noData: '暂无用量数据', }, + // 文件管理 + files: { + title: '文件', + tree: '目录树', + list: '文件列表', + breadcrumbRoot: '根目录', + newFile: '新建文件', + newFolder: '新建文件夹', + upload: '上传', + refresh: '刷新', + open: '打开', + edit: '编辑', + preview: '预览', + download: '下载', + copyPath: '复制路径', + rename: '重命名', + delete: '删除', + name: '名称', + size: '大小', + modified: '修改时间', + actions: '操作', + emptyDir: '空目录', + loading: '加载中...', + confirmDelete: '确定要删除「{name}」吗?', + confirmDeleteDir: '确定要删除目录「{name}」及其所有内容吗?', + deleteFailed: '删除失败', + deleted: '已删除', + renameTo: '重命名为', + newFileName: '文件名', + newFolderName: '文件夹名', + created: '已创建', + createFailed: '创建失败', + renamed: '已重命名', + renameFailed: '重命名失败', + uploadSuccess: '已上传 {count} 个文件', + uploadFailed: '上传失败', + saveFailed: '保存失败', + saved: '已保存', + unsavedChanges: '有未保存的更改,是否丢弃?', + pathCopied: '路径已复制', + fileTooLarge: '文件过大(最大 10MB)', + permissionDenied: '无法修改受保护的文件', + notFound: '文件或目录不存在', + backendError: '文件操作失败', + dragDropHint: '拖拽文件到此处上传', + closeEditor: '关闭编辑器', + saveFile: '保存', + }, + // 下载 + download: { + downloading: '正在下载...', + downloadFailed: '下载失败', + fileNotFound: '文件不存在或已被删除', + fileTooLarge: '文件过大(超过限制)', + backendError: '文件读取失败,远程环境可能不可用', + backendTimeout: '文件读取超时', + unsupportedBackend: '当前 terminal backend 暂不支持文件下载', + invalidPath: '无效的文件路径', + download: '下载', + }, // 更新日志 changelog: { diff --git a/packages/client/src/router/index.ts b/packages/client/src/router/index.ts index e16231e..bd8b7c5 100644 --- a/packages/client/src/router/index.ts +++ b/packages/client/src/router/index.ts @@ -70,6 +70,11 @@ const router = createRouter({ name: 'hermes.terminal', component: () => import('@/views/hermes/TerminalView.vue'), }, + { + path: '/hermes/files', + name: 'hermes.files', + component: () => import('@/views/hermes/FilesView.vue'), + }, ], }) diff --git a/packages/client/src/stores/hermes/files.ts b/packages/client/src/stores/hermes/files.ts new file mode 100644 index 0000000..61b86cc --- /dev/null +++ b/packages/client/src/stores/hermes/files.ts @@ -0,0 +1,214 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import * as filesApi from '@/api/hermes/files' +import type { FileEntry } from '@/api/hermes/files' + +const EXT_LANG_MAP: Record = { + '.js': 'javascript', '.jsx': 'javascript', + '.ts': 'typescript', '.tsx': 'typescript', + '.json': 'json', '.jsonc': 'json', + '.html': 'html', '.htm': 'html', + '.css': 'css', '.scss': 'scss', '.less': 'less', + '.md': 'markdown', '.markdown': 'markdown', + '.py': 'python', + '.yaml': 'yaml', '.yml': 'yaml', + '.xml': 'xml', + '.sh': 'shell', '.bash': 'shell', '.zsh': 'shell', + '.sql': 'sql', + '.go': 'go', + '.rs': 'rust', + '.java': 'java', + '.c': 'c', '.h': 'c', + '.cpp': 'cpp', '.hpp': 'cpp', + '.toml': 'ini', + '.ini': 'ini', + '.env': 'ini', + '.vue': 'html', + '.dockerfile': 'dockerfile', + '.graphql': 'graphql', + '.lua': 'lua', + '.r': 'r', + '.rb': 'ruby', + '.php': 'php', + '.swift': 'swift', + '.kt': 'kotlin', +} + +function getLanguageFromPath(filePath: string): string { + const name = filePath.split('/').pop() || '' + if (name === 'Dockerfile') return 'dockerfile' + if (name === 'Makefile') return 'makefile' + const ext = '.' + name.split('.').pop()?.toLowerCase() + return EXT_LANG_MAP[ext] || 'plaintext' +} + +const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico']) + +function getFileExt(name: string): string { + const idx = name.lastIndexOf('.') + return idx >= 0 ? name.slice(idx).toLowerCase() : '' +} + +export function isImageFile(name: string): boolean { + return IMAGE_EXTS.has(getFileExt(name)) +} + +export function isMarkdownFile(name: string): boolean { + const ext = getFileExt(name) + return ext === '.md' || ext === '.markdown' +} + +export function isTextFile(name: string): boolean { + const binaryExts = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.zip', '.gz', '.tar', '.7z', '.rar', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.mp3', '.mp4', '.wav', '.webm', '.avi', '.mov', '.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.db', '.sqlite']) + return !binaryExts.has(getFileExt(name)) +} + +export const useFilesStore = defineStore('files', () => { + const currentPath = ref('') + const entries = ref([]) + const loading = ref(false) + const sortBy = ref<'name' | 'size' | 'modTime'>('name') + const sortOrder = ref<'asc' | 'desc'>('asc') + + const editingFile = ref<{ + path: string + content: string + originalContent: string + language: string + } | null>(null) + + const previewFile = ref<{ + path: string + type: 'image' | 'markdown' + content?: string + } | null>(null) + + const pathSegments = computed(() => { + if (!currentPath.value) return [] + return currentPath.value.split('/').filter(Boolean) + }) + + const sortedEntries = computed(() => { + const copy = [...entries.value] + copy.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1 + let cmp = 0 + switch (sortBy.value) { + case 'name': cmp = a.name.localeCompare(b.name); break + case 'size': cmp = a.size - b.size; break + case 'modTime': cmp = a.modTime.localeCompare(b.modTime); break + } + return sortOrder.value === 'asc' ? cmp : -cmp + }) + return copy + }) + + async function fetchEntries(path?: string) { + if (path !== undefined) currentPath.value = path + loading.value = true + try { + const result = await filesApi.listFiles(currentPath.value) + entries.value = result.entries + } catch (err) { + console.error('Failed to fetch files:', err) + throw err + } finally { + loading.value = false + } + } + + function navigateTo(path: string) { return fetchEntries(path) } + function navigateUp() { + const parts = currentPath.value.split('/').filter(Boolean) + parts.pop() + return fetchEntries(parts.join('/')) + } + + async function openEditor(filePath: string) { + const result = await filesApi.readFile(filePath) + editingFile.value = { + path: filePath, + content: result.content, + originalContent: result.content, + language: getLanguageFromPath(filePath), + } + } + + async function saveEditor() { + if (!editingFile.value) return + await filesApi.writeFile(editingFile.value.path, editingFile.value.content) + editingFile.value.originalContent = editingFile.value.content + } + + function closeEditor() { editingFile.value = null } + + async function openPreview(entry: FileEntry) { + if (isImageFile(entry.name)) { + previewFile.value = { path: entry.path, type: 'image' } + } else if (isMarkdownFile(entry.name)) { + const result = await filesApi.readFile(entry.path) + previewFile.value = { path: entry.path, type: 'markdown', content: result.content } + } + } + + function closePreview() { previewFile.value = null } + + async function createDir(name: string) { + const path = currentPath.value ? `${currentPath.value}/${name}` : name + await filesApi.mkDir(path) + await fetchEntries() + } + + async function createFile(name: string) { + const path = currentPath.value ? `${currentPath.value}/${name}` : name + await filesApi.writeFile(path, '') + await fetchEntries() + } + + async function deleteEntry(entry: FileEntry) { + await filesApi.deleteFile(entry.path, entry.isDir) + await fetchEntries() + } + + async function renameEntry(entry: FileEntry, newName: string) { + const parentPath = entry.path.includes('/') ? entry.path.slice(0, entry.path.lastIndexOf('/')) : '' + const newPath = parentPath ? `${parentPath}/${newName}` : newName + await filesApi.renameFile(entry.path, newPath) + await fetchEntries() + } + + async function copyEntry(entry: FileEntry, destPath: string) { + await filesApi.copyFile(entry.path, destPath) + await fetchEntries() + } + + async function uploadFiles(files: File[]) { + await filesApi.uploadFiles(currentPath.value, files) + await fetchEntries() + } + + function setSort(by: 'name' | 'size' | 'modTime') { + if (sortBy.value === by) { + sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc' + } else { + sortBy.value = by + sortOrder.value = 'asc' + } + } + + const hasUnsavedChanges = computed(() => { + if (!editingFile.value) return false + return editingFile.value.content !== editingFile.value.originalContent + }) + + return { + currentPath, entries, loading, sortBy, sortOrder, + editingFile, previewFile, + pathSegments, sortedEntries, hasUnsavedChanges, + fetchEntries, navigateTo, navigateUp, + openEditor, saveEditor, closeEditor, + openPreview, closePreview, + createDir, createFile, deleteEntry, renameEntry, copyEntry, + uploadFiles, setSort, + } +}) diff --git a/packages/client/src/utils/clipboard.ts b/packages/client/src/utils/clipboard.ts new file mode 100644 index 0000000..f559729 --- /dev/null +++ b/packages/client/src/utils/clipboard.ts @@ -0,0 +1,46 @@ +/** + * Copy text to clipboard with a fallback for non-secure contexts (HTTP, non-localhost). + * `navigator.clipboard` is only available in secure contexts; intranet HTTP deployments + * must fall back to the legacy `document.execCommand('copy')` flow via a hidden textarea. + */ +export async function copyToClipboard(text: string): Promise { + if (typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // fall through to legacy fallback + } + } + + if (typeof document === 'undefined') return false + + const textarea = document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', '') + textarea.style.position = 'fixed' + textarea.style.top = '0' + textarea.style.left = '0' + textarea.style.width = '1px' + textarea.style.height = '1px' + textarea.style.padding = '0' + textarea.style.border = 'none' + textarea.style.outline = 'none' + textarea.style.boxShadow = 'none' + textarea.style.background = 'transparent' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + + let ok = false + try { + textarea.focus() + textarea.select() + textarea.setSelectionRange(0, text.length) + ok = document.execCommand('copy') + } catch { + ok = false + } finally { + document.body.removeChild(textarea) + } + return ok +} diff --git a/packages/client/src/views/hermes/FilesView.vue b/packages/client/src/views/hermes/FilesView.vue new file mode 100644 index 0000000..801e4ee --- /dev/null +++ b/packages/client/src/views/hermes/FilesView.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/packages/server/src/routes/hermes/download.ts b/packages/server/src/routes/hermes/download.ts new file mode 100644 index 0000000..b551947 --- /dev/null +++ b/packages/server/src/routes/hermes/download.ts @@ -0,0 +1,114 @@ +import Router from '@koa/router' +import { basename, extname } from 'path' +import { + createFileProvider, + localProvider, + isInUploadDir, + validatePath, + resolveHermesPath, +} from '../../services/hermes/file-provider' + +export const downloadRoutes = new Router() + +// MIME type mapping for common extensions +const MIME_MAP: Record = { + '.txt': 'text/plain', + '.html': 'text/html', + '.htm': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.json': 'application/json', + '.xml': 'application/xml', + '.csv': 'text/csv', + '.md': 'text/markdown', + '.pdf': 'application/pdf', + '.zip': 'application/zip', + '.gz': 'application/gzip', + '.tar': 'application/x-tar', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.py': 'text/x-python', + '.ts': 'text/typescript', + '.tsx': 'text/typescript', + '.rs': 'text/x-rust', + '.go': 'text/x-go', + '.java': 'text/x-java', + '.c': 'text/x-c', + '.cpp': 'text/x-c++', + '.h': 'text/x-c', + '.sh': 'text/x-shellscript', + '.yaml': 'text/yaml', + '.yml': 'text/yaml', + '.toml': 'text/toml', + '.log': 'text/plain', +} + +function getMimeType(fileName: string): string { + const ext = extname(fileName).toLowerCase() + return MIME_MAP[ext] || 'application/octet-stream' +} + +downloadRoutes.get('/api/hermes/download', async (ctx) => { + const filePath = ctx.query.path as string | undefined + const fileName = ctx.query.name as string | undefined + + if (!filePath) { + ctx.status = 400 + ctx.body = { error: 'Missing path parameter', code: 'missing_path' } + return + } + + try { + // Validate the path first + // Support both absolute and relative paths + const validPath = filePath.startsWith('/') ? validatePath(filePath) : resolveHermesPath(filePath) + + // Choose provider: always use local for upload directory files + let data: Buffer + if (isInUploadDir(validPath)) { + data = await localProvider.readFile(validPath) + } else { + const provider = await createFileProvider() + data = await provider.readFile(validPath) + } + + // Determine filename and MIME type + const name = fileName || basename(validPath) + const mime = getMimeType(name) + + // Set response headers + ctx.set('Content-Type', mime) + ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`) + ctx.set('Content-Length', String(data.length)) + ctx.set('Cache-Control', 'no-cache') + ctx.body = data + } catch (err: any) { + const code = err.code || 'unknown' + const statusMap: Record = { + missing_path: 400, + invalid_path: 400, + not_found: 404, + ENOENT: 404, + file_too_large: 413, + unsupported_backend: 501, + backend_error: 502, + backend_timeout: 504, + } + ctx.status = statusMap[code] || 500 + ctx.body = { error: err.message, code } + } +}) diff --git a/packages/server/src/routes/hermes/files.ts b/packages/server/src/routes/hermes/files.ts new file mode 100644 index 0000000..ab18ef6 --- /dev/null +++ b/packages/server/src/routes/hermes/files.ts @@ -0,0 +1,283 @@ +import Router from '@koa/router' +import { + createFileProvider, + resolveHermesPath, + isSensitivePath, + MAX_EDIT_SIZE, +} from '../../services/hermes/file-provider' + +export const fileRoutes = new Router() + +function handleError(ctx: any, err: any) { + const code = err.code || 'unknown' + const statusMap: Record = { + missing_path: 400, + invalid_path: 400, + not_found: 404, + ENOENT: 404, + already_exists: 409, + permission_denied: 403, + file_too_large: 413, + not_a_directory: 400, + not_a_file: 400, + unsupported_backend: 501, + backend_error: 502, + backend_timeout: 504, + } + ctx.status = statusMap[code] || 500 + ctx.body = { error: err.message, code } +} + +// GET /api/hermes/files/list?path= +fileRoutes.get('/api/hermes/files/list', async (ctx) => { + const relativePath = (ctx.query.path as string) || '' + try { + const absPath = resolveHermesPath(relativePath) + const provider = await createFileProvider() + const entries = await provider.listDir(absPath) + entries.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1 + return a.name.localeCompare(b.name) + }) + ctx.body = { entries, path: relativePath } + } catch (err: any) { + handleError(ctx, err) + } +}) + +// GET /api/hermes/files/stat?path= +fileRoutes.get('/api/hermes/files/stat', async (ctx) => { + const relativePath = ctx.query.path as string + if (!relativePath) { + ctx.status = 400 + ctx.body = { error: 'Missing path parameter', code: 'missing_path' } + return + } + try { + const absPath = resolveHermesPath(relativePath) + const provider = await createFileProvider() + const info = await provider.stat(absPath) + ctx.body = info + } catch (err: any) { + handleError(ctx, err) + } +}) + +// GET /api/hermes/files/read?path= +fileRoutes.get('/api/hermes/files/read', async (ctx) => { + const relativePath = ctx.query.path as string + if (!relativePath) { + ctx.status = 400 + ctx.body = { error: 'Missing path parameter', code: 'missing_path' } + return + } + try { + const absPath = resolveHermesPath(relativePath) + const provider = await createFileProvider() + const data = await provider.readFile(absPath) + if (data.length > MAX_EDIT_SIZE) { + ctx.status = 413 + ctx.body = { error: 'File too large to edit', code: 'file_too_large' } + return + } + ctx.body = { content: data.toString('utf-8'), path: relativePath, size: data.length } + } catch (err: any) { + handleError(ctx, err) + } +}) + +// PUT /api/hermes/files/write body: { path, content } +fileRoutes.put('/api/hermes/files/write', async (ctx) => { + const { path: relativePath, content } = ctx.request.body as { path?: string; content?: string } + if (!relativePath) { + ctx.status = 400 + ctx.body = { error: 'Missing path parameter', code: 'missing_path' } + return + } + if (isSensitivePath(relativePath)) { + ctx.status = 403 + ctx.body = { error: 'Cannot modify sensitive file', code: 'permission_denied' } + return + } + try { + const buf = Buffer.from(content || '', 'utf-8') + if (buf.length > MAX_EDIT_SIZE) { + ctx.status = 413 + ctx.body = { error: 'Content too large', code: 'file_too_large' } + return + } + const absPath = resolveHermesPath(relativePath) + const provider = await createFileProvider() + await provider.writeFile(absPath, buf) + ctx.body = { ok: true, path: relativePath } + } catch (err: any) { + handleError(ctx, err) + } +}) + +// DELETE /api/hermes/files/delete body: { path, recursive? } +fileRoutes.delete('/api/hermes/files/delete', async (ctx) => { + const { path: relativePath, recursive } = ctx.request.body as { path?: string; recursive?: boolean } + if (!relativePath) { + ctx.status = 400 + ctx.body = { error: 'Missing path parameter', code: 'missing_path' } + return + } + if (isSensitivePath(relativePath)) { + ctx.status = 403 + ctx.body = { error: 'Cannot delete sensitive file', code: 'permission_denied' } + return + } + try { + const absPath = resolveHermesPath(relativePath) + const provider = await createFileProvider() + if (recursive) { + await provider.deleteDir(absPath) + } else { + await provider.deleteFile(absPath) + } + ctx.body = { ok: true } + } catch (err: any) { + handleError(ctx, err) + } +}) + +// POST /api/hermes/files/rename body: { oldPath, newPath } +fileRoutes.post('/api/hermes/files/rename', async (ctx) => { + const { oldPath, newPath } = ctx.request.body as { oldPath?: string; newPath?: string } + if (!oldPath || !newPath) { + ctx.status = 400 + ctx.body = { error: 'Missing oldPath or newPath', code: 'missing_path' } + return + } + if (isSensitivePath(oldPath)) { + ctx.status = 403 + ctx.body = { error: 'Cannot rename sensitive file', code: 'permission_denied' } + return + } + try { + const absOld = resolveHermesPath(oldPath) + const absNew = resolveHermesPath(newPath) + const provider = await createFileProvider() + await provider.renameFile(absOld, absNew) + ctx.body = { ok: true } + } catch (err: any) { + handleError(ctx, err) + } +}) + +// POST /api/hermes/files/mkdir body: { path } +fileRoutes.post('/api/hermes/files/mkdir', async (ctx) => { + const { path: relativePath } = ctx.request.body as { path?: string } + if (!relativePath) { + ctx.status = 400 + ctx.body = { error: 'Missing path parameter', code: 'missing_path' } + return + } + try { + const absPath = resolveHermesPath(relativePath) + const provider = await createFileProvider() + await provider.mkDir(absPath) + ctx.body = { ok: true } + } catch (err: any) { + handleError(ctx, err) + } +}) + +// POST /api/hermes/files/copy body: { srcPath, destPath } +fileRoutes.post('/api/hermes/files/copy', async (ctx) => { + const { srcPath, destPath } = ctx.request.body as { srcPath?: string; destPath?: string } + if (!srcPath || !destPath) { + ctx.status = 400 + ctx.body = { error: 'Missing srcPath or destPath', code: 'missing_path' } + return + } + try { + const absSrc = resolveHermesPath(srcPath) + const absDest = resolveHermesPath(destPath) + const provider = await createFileProvider() + await provider.copyFile(absSrc, absDest) + ctx.body = { ok: true } + } catch (err: any) { + handleError(ctx, err) + } +}) + +// POST /api/hermes/files/upload?path= (multipart/form-data) +fileRoutes.post('/api/hermes/files/upload', async (ctx) => { + const targetDir = (ctx.query.path as string) || '' + const contentType = ctx.get('content-type') || '' + if (!contentType.startsWith('multipart/form-data')) { + ctx.status = 400 + ctx.body = { error: 'Expected multipart/form-data', code: 'invalid_request' } + return + } + + const boundary = '--' + contentType.split('boundary=')[1] + if (!boundary || boundary === '--undefined') { + ctx.status = 400 + ctx.body = { error: 'Missing boundary', code: 'invalid_request' } + return + } + + const chunks: Buffer[] = [] + for await (const chunk of ctx.req) chunks.push(chunk) + const raw = Buffer.concat(chunks) + + const boundaryBuf = Buffer.from(boundary) + const parts = splitMultipart(raw, boundaryBuf) + const provider = await createFileProvider() + const results: { name: string; path: string }[] = [] + + for (const part of parts) { + const headerEnd = part.indexOf(Buffer.from('\r\n\r\n')) + if (headerEnd === -1) continue + const headerBuf = part.subarray(0, headerEnd) + const header = headerBuf.toString('utf-8') + const data = part.subarray(headerEnd + 4, part.length - 2) + + let filename = '' + const filenameStarMatch = header.match(/filename\*=UTF-8''(.+)/i) + if (filenameStarMatch) { + filename = decodeURIComponent(filenameStarMatch[1]) + } else { + const filenameMatch = header.match(/filename="([^"]+)"/) + if (!filenameMatch) continue + filename = filenameMatch[1] + } + + if (data.length > MAX_EDIT_SIZE) { + ctx.status = 413 + ctx.body = { error: `File ${filename} too large`, code: 'file_too_large' } + return + } + + const filePath = targetDir ? `${targetDir}/${filename}` : filename + if (isSensitivePath(filePath)) { + ctx.status = 403 + ctx.body = { error: `Cannot overwrite sensitive file: ${filename}`, code: 'permission_denied' } + return + } + + const absPath = resolveHermesPath(filePath) + await provider.writeFile(absPath, data) + results.push({ name: filename, path: filePath }) + } + + ctx.body = { files: results } +}) + +function splitMultipart(raw: Buffer, boundary: Buffer): Buffer[] { + const parts: Buffer[] = [] + let start = 0 + while (true) { + const idx = raw.indexOf(boundary, start) + if (idx === -1) break + if (start > 0) { + const partStart = start + 2 + parts.push(raw.subarray(partStart, idx)) + } + start = idx + boundary.length + } + return parts +} diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 418b98d..e1fa598 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -20,6 +20,8 @@ import { codexAuthRoutes } from './hermes/codex-auth' import { nousAuthRoutes } from './hermes/nous-auth' import { gatewayRoutes } from './hermes/gateways' import { weixinRoutes } from './hermes/weixin' +import { fileRoutes } from './hermes/files' +import { downloadRoutes } from './hermes/download' import { proxyRoutes, proxyMiddleware } from './hermes/proxy' /** @@ -52,6 +54,8 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next) app.use(nousAuthRoutes.routes()) app.use(gatewayRoutes.routes()) app.use(weixinRoutes.routes()) + app.use(fileRoutes.routes()) // Must be before proxy (proxy catch-all matches everything) + app.use(downloadRoutes.routes()) // Must be before proxy app.use(proxyRoutes.routes()) // Proxy catch-all middleware (must be last) diff --git a/packages/server/src/services/hermes/file-provider.ts b/packages/server/src/services/hermes/file-provider.ts new file mode 100644 index 0000000..dad3b2d --- /dev/null +++ b/packages/server/src/services/hermes/file-provider.ts @@ -0,0 +1,850 @@ +import { readFile, stat as fsStat, readdir, mkdir, rm, rename, copyFile as fsCopyFile, writeFile as fsWriteFile } from 'fs/promises' +import { resolve, normalize, isAbsolute, basename } from 'path' +import { execFile } from 'child_process' +import { promisify } from 'util' +import { existsSync, readFileSync } from 'fs' +import YAML from 'js-yaml' +import { config } from '../../config' +import { getActiveProfileDir, getActiveEnvPath } from './hermes-profile' + +const execFileAsync = promisify(execFile) + +// Max download file size (default 100MB) +const MAX_DOWNLOAD_SIZE = parseInt(process.env.MAX_DOWNLOAD_SIZE || '', 10) || 100 * 1024 * 1024 +// Backend command timeout (default 30s) +const BACKEND_TIMEOUT = 30_000 + +// Max edit/upload file size (default 10MB) +export const MAX_EDIT_SIZE = parseInt(process.env.MAX_EDIT_SIZE || '', 10) || 10 * 1024 * 1024 + +// Sensitive files that should not be written/deleted/renamed +const SENSITIVE_FILES = new Set(['.env', 'auth.json']) + +export interface FileEntry { + name: string + path: string // relative to hermes home + isDir: boolean + size: number + modTime: string // ISO 8601 +} + +export interface FileStat { + name: string + path: string // relative to hermes home + isDir: boolean + size: number + modTime: string // ISO 8601 + permissions?: string +} + +export type BackendType = 'local' | 'docker' | 'ssh' | 'singularity' | 'modal' | 'daytona' + +export interface FileProvider { + type: BackendType + readFile(filePath: string): Promise + exists(filePath: string): Promise + listDir(dirPath: string): Promise + stat(filePath: string): Promise + writeFile(filePath: string, content: Buffer): Promise + deleteFile(filePath: string): Promise + deleteDir(dirPath: string): Promise + renameFile(oldPath: string, newPath: string): Promise + mkDir(dirPath: string): Promise + copyFile(srcPath: string, destPath: string): Promise +} + +export interface TerminalConfig { + backend: BackendType + docker_image?: string + docker_container_name?: string + cwd?: string + singularity_image?: string +} + +/** + * Validate a file path: must be absolute and not contain '..' traversal. + */ +export function validatePath(filePath: string): string { + if (!filePath) throw Object.assign(new Error('Missing file path'), { code: 'missing_path' }) + const resolved = resolve(filePath) + const normalized = normalize(resolved) + if (normalized.includes('..')) { + throw Object.assign(new Error('Invalid file path'), { code: 'invalid_path' }) + } + if (!isAbsolute(normalized)) { + throw Object.assign(new Error('Path must be absolute'), { code: 'invalid_path' }) + } + return normalized +} + +/** + * Check if a path is inside the upload directory. + */ +export function isInUploadDir(filePath: string): boolean { + const normalized = normalize(resolve(filePath)) + const uploadNormalized = normalize(resolve(config.uploadDir)) + return normalized.startsWith(uploadNormalized + '/') + || normalized.startsWith(uploadNormalized + '\\') + || normalized === uploadNormalized +} + +/** + * Check if a relative path refers to a sensitive file. + */ +export function isSensitivePath(relativePath: string): boolean { + const parts = relativePath.replace(/\\/g, '/').split('/') + const fileName = parts[parts.length - 1] + return SENSITIVE_FILES.has(fileName) +} + +/** + * Resolve a relative path to an absolute path under the hermes home directory. + * Validates path safety (no traversal). + */ +export function resolveHermesPath(relativePath: string): string { + const homeDir = getActiveProfileDir() + if (!relativePath || relativePath === '.' || relativePath === '/') { + return homeDir + } + const normalized = normalize(relativePath).replace(/\\/g, '/') + if (normalized.startsWith('..') || normalized.includes('/../') || normalized.startsWith('/')) { + throw Object.assign(new Error('Invalid file path'), { code: 'invalid_path' }) + } + const resolved = resolve(homeDir, normalized) + if (!resolved.startsWith(homeDir)) { + throw Object.assign(new Error('Path traversal detected'), { code: 'invalid_path' }) + } + return resolved +} + +// --- Local --- + +export class LocalFileProvider implements FileProvider { + type: BackendType = 'local' + + async readFile(filePath: string): Promise { + const p = validatePath(filePath) + const s = await fsStat(p) + if (!s.isFile()) throw Object.assign(new Error('Not a file'), { code: 'not_found' }) + if (s.size > MAX_DOWNLOAD_SIZE) { + throw Object.assign(new Error(`File too large: ${s.size} bytes`), { code: 'file_too_large' }) + } + return readFile(p) + } + + async exists(filePath: string): Promise { + try { + const p = validatePath(filePath) + const s = await fsStat(p) + return s.isFile() + } catch { + return false + } + } + + async listDir(dirPath: string): Promise { + const p = validatePath(dirPath) + const homeDir = getActiveProfileDir() + const entries = await readdir(p, { withFileTypes: true }) + const results: FileEntry[] = [] + for (const entry of entries) { + try { + const fullPath = resolve(p, entry.name) + const s = await fsStat(fullPath) + const relPath = fullPath.startsWith(homeDir) + ? fullPath.slice(homeDir.length + 1) + : entry.name + results.push({ + name: entry.name, + path: relPath, + isDir: s.isDirectory(), + size: s.size, + modTime: s.mtime.toISOString(), + }) + } catch { + // skip entries that fail to stat + } + } + return results + } + + async stat(filePath: string): Promise { + const p = validatePath(filePath) + const homeDir = getActiveProfileDir() + const s = await fsStat(p) + const relPath = p.startsWith(homeDir) + ? p.slice(homeDir.length + 1) + : basename(p) + return { + name: basename(p), + path: relPath || basename(p), + isDir: s.isDirectory(), + size: s.size, + modTime: s.mtime.toISOString(), + } + } + + async writeFile(filePath: string, content: Buffer): Promise { + const p = validatePath(filePath) + await fsWriteFile(p, content) + } + + async deleteFile(filePath: string): Promise { + const p = validatePath(filePath) + const s = await fsStat(p) + if (!s.isFile()) throw Object.assign(new Error('Not a file'), { code: 'not_found' }) + await rm(p) + } + + async deleteDir(dirPath: string): Promise { + const p = validatePath(dirPath) + const s = await fsStat(p) + if (!s.isDirectory()) throw Object.assign(new Error('Not a directory'), { code: 'not_found' }) + await rm(p, { recursive: true }) + } + + async renameFile(oldPath: string, newPath: string): Promise { + const op = validatePath(oldPath) + const np = validatePath(newPath) + await rename(op, np) + } + + async mkDir(dirPath: string): Promise { + const p = validatePath(dirPath) + await mkdir(p, { recursive: true }) + } + + async copyFile(srcPath: string, destPath: string): Promise { + const sp = validatePath(srcPath) + const dp = validatePath(destPath) + await fsCopyFile(sp, dp) + } +} + +/** + * Parse `ls -la --time-style=+%Y-%m-%dT%H:%M:%S` output into FileEntry[]. + * Example line: `drwxr-xr-x 2 user group 4096 2025-07-20T10:30:00 dirname` + * Skips the "total N" line and entries "." and "..". + */ +function parseLsOutput(output: string, parentRelPath: string): FileEntry[] { + const entries: FileEntry[] = [] + for (const line of output.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('total ')) continue + const parts = trimmed.split(/\s+/) + if (parts.length < 7) continue + const permissions = parts[0] + const size = parseInt(parts[4], 10) || 0 + const modTime = parts[5] + const name = parts.slice(6).join(' ') + if (name === '.' || name === '..') continue + const isDir = permissions.startsWith('d') + const relPath = parentRelPath ? `${parentRelPath}/${name}` : name + entries.push({ name, path: relPath, isDir, size, modTime: modTime.includes('T') ? modTime : new Date(modTime).toISOString() }) + } + return entries +} + +/** + * Parse `stat -c '%n|%F|%s|%Y'` output. + * Output: `/path/to/file|regular file|1234|1721500000` + */ +function parseStatOutput(output: string, relativePath: string): FileStat { + const parts = output.trim().split('|') + if (parts.length < 4) throw Object.assign(new Error('Failed to parse stat output'), { code: 'backend_error' }) + const name = basename(parts[0]) + const fileType = parts[1].toLowerCase() + const size = parseInt(parts[2], 10) || 0 + const modEpoch = parseInt(parts[3], 10) || 0 + const isDir = fileType.includes('directory') + return { + name, + path: relativePath, + isDir, + size, + modTime: new Date(modEpoch * 1000).toISOString(), + } +} + +// --- Docker --- + +export class DockerFileProvider implements FileProvider { + type: BackendType = 'docker' + private containerName: string + + constructor(containerName: string) { + this.containerName = containerName + } + + async readFile(filePath: string): Promise { + const p = validatePath(filePath) + try { + // Node.js supports encoding: 'buffer' but @types/node doesn't type it correctly + const { stdout } = await execFileAsync('docker', [ + 'exec', this.containerName, 'cat', p, + ], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any }) + return stdout as unknown as Buffer + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) { + throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + } + if (err.stderr && /no such file/i.test(String(err.stderr))) { + throw Object.assign(new Error('File not found in container'), { code: 'not_found' }) + } + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async exists(filePath: string): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('docker', [ + 'exec', this.containerName, 'test', '-f', p, + ], { timeout: 5000 }) + return true + } catch { + return false + } + } + + async listDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + const { stdout } = await execFileAsync('docker', [ + 'exec', this.containerName, 'ls', '-la', '--time-style=+%Y-%m-%dT%H:%M:%S', p, + ], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT }) + const homeDir = getActiveProfileDir() + const relParent = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : '' + return parseLsOutput(stdout, relParent) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + if (err.stderr && /no such file|not a directory/i.test(String(err.stderr))) + throw Object.assign(new Error('Directory not found'), { code: 'not_found' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async stat(filePath: string): Promise { + const p = validatePath(filePath) + try { + const { stdout } = await execFileAsync('docker', [ + 'exec', this.containerName, 'stat', '-c', '%n|%F|%s|%Y', p, + ], { timeout: BACKEND_TIMEOUT }) + const homeDir = getActiveProfileDir() + const relPath = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : basename(p) + return parseStatOutput(stdout, relPath) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + if (err.stderr && /no such file/i.test(String(err.stderr))) throw Object.assign(new Error('Not found'), { code: 'not_found' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async writeFile(filePath: string, content: Buffer): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('docker', [ + 'exec', '-i', this.containerName, 'sh', '-c', `cat > '${p.replace(/'/g, "'\\''")}'`, + ], { timeout: BACKEND_TIMEOUT, input: content } as any) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async deleteFile(filePath: string): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('docker', ['exec', this.containerName, 'rm', p], { timeout: BACKEND_TIMEOUT }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async deleteDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + await execFileAsync('docker', ['exec', this.containerName, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async renameFile(oldPath: string, newPath: string): Promise { + const op = validatePath(oldPath) + const np = validatePath(newPath) + try { + await execFileAsync('docker', ['exec', this.containerName, 'mv', op, np], { timeout: BACKEND_TIMEOUT }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async mkDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + await execFileAsync('docker', ['exec', this.containerName, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async copyFile(srcPath: string, destPath: string): Promise { + const sp = validatePath(srcPath) + const dp = validatePath(destPath) + try { + await execFileAsync('docker', ['exec', this.containerName, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } +} + +// --- SSH --- + +export class SSHFileProvider implements FileProvider { + type: BackendType = 'ssh' + private host: string + private user: string + private keyPath?: string + + constructor(host: string, user: string, keyPath?: string) { + this.host = host + this.user = user + this.keyPath = keyPath + } + + private sshArgs(): string[] { + // StrictHostKeyChecking disabled for automated tooling with user-configured hosts + const args = ['-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes'] + if (this.keyPath) args.push('-i', this.keyPath) + args.push(`${this.user}@${this.host}`) + return args + } + + /** + * Shell-escape a string for safe use in a remote SSH command. + * Wraps in single quotes and escapes embedded single quotes. + */ + private shellEscape(s: string): string { + return "'" + s.replace(/'/g, "'\\''") + "'" + } + + async readFile(filePath: string): Promise { + const p = validatePath(filePath) + try { + // Node.js supports encoding: 'buffer' but @types/node doesn't type it correctly + // Pass a single quoted command string to prevent shell injection on remote + const { stdout } = await execFileAsync('ssh', [ + ...this.sshArgs(), `cat ${this.shellEscape(p)}`, + ], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any }) + return stdout as unknown as Buffer + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) { + throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + } + if (err.stderr && /no such file/i.test(String(err.stderr))) { + throw Object.assign(new Error('File not found on remote'), { code: 'not_found' }) + } + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async exists(filePath: string): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('ssh', [ + ...this.sshArgs(), `test -f ${this.shellEscape(p)}`, + ], { timeout: 5000 }) + return true + } catch { + return false + } + } + + async listDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + const { stdout } = await execFileAsync('ssh', [ + ...this.sshArgs(), `ls -la --time-style=+%Y-%m-%dT%H:%M:%S ${this.shellEscape(p)}`, + ], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT }) + const homeDir = getActiveProfileDir() + const relParent = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : '' + return parseLsOutput(stdout, relParent) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + if (err.stderr && /no such file|not a directory/i.test(String(err.stderr))) + throw Object.assign(new Error('Directory not found'), { code: 'not_found' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async stat(filePath: string): Promise { + const p = validatePath(filePath) + try { + const { stdout } = await execFileAsync('ssh', [ + ...this.sshArgs(), `stat -c '%n|%F|%s|%Y' ${this.shellEscape(p)}`, + ], { timeout: BACKEND_TIMEOUT }) + const homeDir = getActiveProfileDir() + const relPath = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : basename(p) + return parseStatOutput(stdout, relPath) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + if (err.stderr && /no such file/i.test(String(err.stderr))) throw Object.assign(new Error('Not found'), { code: 'not_found' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async writeFile(filePath: string, content: Buffer): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('ssh', [ + ...this.sshArgs(), `cat > ${this.shellEscape(p)}`, + ], { timeout: BACKEND_TIMEOUT, input: content } as any) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async deleteFile(filePath: string): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('ssh', [...this.sshArgs(), `rm ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async deleteDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + await execFileAsync('ssh', [...this.sshArgs(), `rm -rf ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async renameFile(oldPath: string, newPath: string): Promise { + const op = validatePath(oldPath) + const np = validatePath(newPath) + try { + await execFileAsync('ssh', [...this.sshArgs(), `mv ${this.shellEscape(op)} ${this.shellEscape(np)}`], { timeout: BACKEND_TIMEOUT }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async mkDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + await execFileAsync('ssh', [...this.sshArgs(), `mkdir -p ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async copyFile(srcPath: string, destPath: string): Promise { + const sp = validatePath(srcPath) + const dp = validatePath(destPath) + try { + await execFileAsync('ssh', [...this.sshArgs(), `cp ${this.shellEscape(sp)} ${this.shellEscape(dp)}`], { timeout: BACKEND_TIMEOUT }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } +} + +// --- Singularity --- + +export class SingularityFileProvider implements FileProvider { + type: BackendType = 'singularity' + private imagePath: string + + constructor(imagePath: string) { + this.imagePath = imagePath + } + + async readFile(filePath: string): Promise { + const p = validatePath(filePath) + try { + // Node.js supports encoding: 'buffer' but @types/node doesn't type it correctly + const { stdout } = await execFileAsync('singularity', [ + 'exec', this.imagePath, 'cat', p, + ], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any }) + return stdout as unknown as Buffer + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) { + throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + } + if (err.stderr && /no such file/i.test(String(err.stderr))) { + throw Object.assign(new Error('File not found in container'), { code: 'not_found' }) + } + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async exists(filePath: string): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('singularity', [ + 'exec', this.imagePath, 'test', '-f', p, + ], { timeout: 5000 }) + return true + } catch { + return false + } + } + + async listDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + const { stdout } = await execFileAsync('singularity', [ + 'exec', this.imagePath, 'ls', '-la', '--time-style=+%Y-%m-%dT%H:%M:%S', p, + ], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT }) + const homeDir = getActiveProfileDir() + const relParent = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : '' + return parseLsOutput(stdout, relParent) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + if (err.stderr && /no such file|not a directory/i.test(String(err.stderr))) + throw Object.assign(new Error('Directory not found'), { code: 'not_found' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async stat(filePath: string): Promise { + const p = validatePath(filePath) + try { + const { stdout } = await execFileAsync('singularity', [ + 'exec', this.imagePath, 'stat', '-c', '%n|%F|%s|%Y', p, + ], { timeout: BACKEND_TIMEOUT }) + const homeDir = getActiveProfileDir() + const relPath = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : basename(p) + return parseStatOutput(stdout, relPath) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + if (err.stderr && /no such file/i.test(String(err.stderr))) throw Object.assign(new Error('Not found'), { code: 'not_found' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async writeFile(filePath: string, content: Buffer): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('singularity', [ + 'exec', this.imagePath, 'sh', '-c', `cat > '${p.replace(/'/g, "'\\''")}'`, + ], { timeout: BACKEND_TIMEOUT, input: content } as any) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async deleteFile(filePath: string): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('singularity', ['exec', this.imagePath, 'rm', p], { timeout: BACKEND_TIMEOUT }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async deleteDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + await execFileAsync('singularity', ['exec', this.imagePath, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async renameFile(oldPath: string, newPath: string): Promise { + const op = validatePath(oldPath) + const np = validatePath(newPath) + try { + await execFileAsync('singularity', ['exec', this.imagePath, 'mv', op, np], { timeout: BACKEND_TIMEOUT }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async mkDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + await execFileAsync('singularity', ['exec', this.imagePath, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async copyFile(srcPath: string, destPath: string): Promise { + const sp = validatePath(srcPath) + const dp = validatePath(destPath) + try { + await execFileAsync('singularity', ['exec', this.imagePath, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } +} + +// --- Config helpers --- + +/** + * Read terminal config from hermes config.yaml. + */ +export function getTerminalConfig(): TerminalConfig { + try { + const configPath = `${getActiveProfileDir()}/config.yaml` + if (!existsSync(configPath)) return { backend: 'local' } + const raw = readFileSync(configPath, 'utf-8') + const doc = YAML.load(raw) as any + const t = doc?.terminal || {} + return { + backend: (t.backend as BackendType) || 'local', + docker_image: t.docker_image, + docker_container_name: t.docker_container_name, + cwd: t.cwd, + singularity_image: t.singularity_image, + } + } catch { + return { backend: 'local' } + } +} + +/** + * Read SSH env vars from hermes .env file. + */ +function getSSHEnvVars(): { host?: string; user?: string; key?: string } { + try { + const envPath = getActiveEnvPath() + if (!existsSync(envPath)) return {} + const raw = readFileSync(envPath, 'utf-8') + const vars: Record = {} + for (const line of raw.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eqIdx = trimmed.indexOf('=') + if (eqIdx === -1) continue + let value = trimmed.slice(eqIdx + 1).trim() + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1) + } + vars[trimmed.slice(0, eqIdx).trim()] = value + } + return { + host: vars.TERMINAL_SSH_HOST, + user: vars.TERMINAL_SSH_USER, + key: vars.TERMINAL_SSH_KEY, + } + } catch { + return {} + } +} + +/** + * Resolve Docker container name. If not configured, try to find a running + * container based on the configured image. + */ +async function resolveDockerContainer(cfg: TerminalConfig): Promise { + if (cfg.docker_container_name) return cfg.docker_container_name + if (cfg.docker_image) { + try { + const { stdout } = await execFileAsync('docker', [ + 'ps', '-q', '--filter', `ancestor=${cfg.docker_image}`, '--latest', + ], { timeout: 5000 }) + const id = stdout.trim() + if (id) return id + } catch { } + } + throw Object.assign( + new Error('Cannot determine Docker container. Set terminal.docker_container_name in hermes config.'), + { code: 'backend_error' }, + ) +} + +// --- Factory --- + +// Cache the provider for a short time to avoid re-reading config on every request +let cachedProvider: FileProvider | null = null +let cachedAt = 0 +const CACHE_TTL = 10_000 + +/** @internal — for testing only */ +export function _resetFileProviderCache() { + cachedProvider = null + cachedAt = 0 +} + +/** + * Create a FileProvider based on the active hermes terminal config. + * Defaults to LocalFileProvider if config cannot be read or backend is unknown. + */ +export async function createFileProvider(): Promise { + const now = Date.now() + if (cachedProvider && now - cachedAt < CACHE_TTL) return cachedProvider + + const cfg = getTerminalConfig() + let provider: FileProvider + + switch (cfg.backend) { + case 'docker': { + const container = await resolveDockerContainer(cfg) + provider = new DockerFileProvider(container) + break + } + case 'ssh': { + const ssh = getSSHEnvVars() + if (!ssh.host || !ssh.user) { + throw Object.assign( + new Error('SSH backend requires TERMINAL_SSH_HOST and TERMINAL_SSH_USER in .env'), + { code: 'backend_error' }, + ) + } + provider = new SSHFileProvider(ssh.host, ssh.user, ssh.key) + break + } + case 'singularity': { + if (!cfg.singularity_image) { + throw Object.assign( + new Error('Singularity backend requires terminal.singularity_image in config'), + { code: 'backend_error' }, + ) + } + provider = new SingularityFileProvider(cfg.singularity_image) + break + } + case 'modal': + case 'daytona': + throw Object.assign( + new Error(`File download not yet supported for '${cfg.backend}' backend`), + { code: 'unsupported_backend' }, + ) + default: + provider = new LocalFileProvider() + } + + cachedProvider = provider + cachedAt = now + return provider +} + +// Always-available local provider for upload directory files +const localProvider = new LocalFileProvider() +export { localProvider, MAX_DOWNLOAD_SIZE } diff --git a/vite.config.ts b/vite.config.ts index b6de327..17b14d8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -38,6 +38,9 @@ export default defineConfig({ outDir: '../../dist/client', emptyOutDir: true, }, + optimizeDeps: { + include: ['monaco-editor'], + }, server: { proxy: { '/api': createProxyConfig(),