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(),