b9f9e62179
* fix: add missing i18n key and unify session data source (#408) - Add `chat.sessionNotFound` translation key to all 8 locales - Fix history page data source inconsistency: - Change `getHermesSession` to prioritize database over CLI - Now consistent with `listHermesSessions` behavior - Prevents "session in list but detail not found" issue - Update CI workflow to trigger on base branch PRs - Remove debug log from sessions-db Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: filter special characters and emoji in speech playback (#409) - Update extractReadableText to filter special characters like *# - Only keep common punctuation marks for speech synthesis - Remove emoji, symbols, and special unicode characters - Improve text-to-speech readability Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add drawer panel with mobile sidebar support and customizable button (#412) * feat: add drawer panel with mobile sidebar support - Add DrawerPanel component with Terminal and Files tabs - Extract TerminalPanel and FilesPanel from existing views - Add mobile sidebar toggle functionality with overlay - Add rainbow breathing light effect to drawer button - Remove Tools section from AppSidebar (Terminal/Files entries) - Add i18n support for drawer and file tree - Optimize mobile button layout and spacing - Fix z-index hierarchy for proper layering - Add responsive sidebar behavior (PC: always visible, Mobile: toggle) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: customize drawer button with arc rainbow border - Change drawer button to semi-circle shape贴着右边 - Add arrow icon pointing left (向左箭头) - Add rainbow border from top to bottom through semi-circle arc - Slow down animation from 4s to 8s for smoother effect - Move drawer button wrapper to messages area only (not贯穿header和input) - Add semi-transparent accent color background to button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve profile switching state sync issue (#414) (#415) * fix: resolve profile switching state sync issue (#414) Fix bug where switching to a different profile would still show the old profile name in the UI and prevent switching back to default. Root cause: - Frontend relied entirely on fetchProfiles() return value to set activeProfileName - Backend Hermes CLI may return stale active flag due to timing issues between profile use and profile list commands - This caused frontend to display wrong profile and prevented switching back to default Solution: - Immediately set activeProfileName when switchProfile API succeeds - Don't rely solely on listProfiles() result which may have stale data - Use activeProfileName instead of activeProfile?.name in ProfileSelector Changes: - profiles store: Set activeProfileName immediately after successful switch - ProfileSelector: Use activeProfileName computed property - Add test to verify activeProfileName updates on switch Fixes #414 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refine: improve error handling for profile switching failures Add proper error handling for edge cases: - If fetchProfiles() fails after successful switch, keep the updated activeProfileName (don't let fetchProfiles failure undo the switch) - Add test cases to verify: 1. API failure doesn't change state 2. fetchProfiles failure doesn't affect successful switch This ensures the UI remains consistent even when profile list refresh fails after a successful profile switch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refine: add rollback mechanism for profile switching verification Add backend verification after profile switch: - Save old activeProfileName before setting new value - After fetchProfiles, verify backend reports expected active profile - If backend reports different profile, rollback frontend state and return false - This handles edge case where API returns 200 but backend didn't actually switch Test cases: - ✅ Normal switch: updates and verifies successfully - ✅ API failure: doesn't change state - ✅ fetchProfiles failure: assumes success (API returned 200) - ✅ Backend verification fails: rolls back to old profile This ensures frontend state always matches backend reality, even in edge cases where hermes profile use succeeded but gateway/cleanup steps failed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refine: add user feedback for profile operations Improve user experience with success/error messages: - ProfileSelector: Add error message when switch fails - ProfileCard: Add success message before reload on switch - ProfileSelector: Use async/await for better error handling - ProfileCard: Add 500ms delay before reload to show success message Before: Silent failures, no feedback After: Clear success/error messages for all operations Example feedback: - Success: "已切换到配置 qinghe" - Failure: "切换配置失败,网关可能需要手动重启" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: update frontend changelog for v0.5.7 (#419) * docs: update frontend changelog for v0.5.7 - Update changelog.ts with v0.5.7 release date and changes - Add i18n translation keys for all languages (en, zh, de, es, fr, ja, ko, pt) - Include v0.5.7 changelog entries: - Optimize context compression and session sync - Add startup delays to prevent database race conditions Changes: - packages/client/src/data/changelog.ts: Update v0.5.7 entry - packages/client/src/i18n/locales/*.ts: Add changelog translation section This enables the changelog modal in the UI to display v0.5.7 release notes. * feat: add v0.5.7 changelog translations to all supported languages Add new_0_5_7_1, new_0_5_7_2, and new_0_5_7_3 changelog entries to all locale files (en, zh, de, es, fr, ja, ko, pt) with proper translations for each language. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove duplicate changelog sections causing syntax errors Remove duplicate changelog object sections that were causing TypeScript syntax errors in all locale files (en, zh, de, es, fr, ja, ko, pt). The actual changelog entries are already correctly placed in the main changelog section of each file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add v0.5.8 changelog and fix profile parsing issue Add v0.5.8 changelog entries based on PRs merged since v0.5.7: - Drawer panel with mobile sidebar support (#412) - Profile switching state sync fix (#414) - Speech playback special character filtering (#409) - Missing i18n key and session data source unification (#408) - Vite build optimization for faster Docker builds (#403) Also fix issue #417: Profile names with long hyphenated names fail to parse in profile list regex. Change \s{2,} to \s+ to handle compressed column spacing when profile names are long. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove enter key submit from profile creation and rename modals Remove @keyup.enter handlers from NInput components in: - ProfileCreateModal: prevent accidental profile creation when pressing enter - ProfileRenameModal: prevent accidental profile rename when pressing enter Users must now explicitly click the confirm button to submit, preventing unintended profile operations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: allow free text input for profile names Remove frontend character filtering from profile creation and rename modals. Users can now input any characters including spaces and uppercase letters to test backend Hermes CLI validation. Changes: - ProfileCreateModal: Remove toLowerCase() and character filtering - ProfileRenameModal: Remove toLowerCase() and character filtering - Use v-model:value binding instead of :value with @input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: improve error handling for profile creation Display backend error messages when profile creation fails instead of generic "failed" message. This helps users understand why their profile name was rejected (e.g., invalid characters). Changes: - API layer: Capture and return error messages from backend - ProfileCreateModal: Display specific error message from backend Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add profile name validation with i18n support Add client-side validation for profile names to prevent invalid input before sending to backend. Only lowercase letters, numbers, underscores, and hyphens are allowed. Changes: - ProfileCreateModal: Add input validation with real-time feedback - ProfileRenameModal: Add input validation with real-time feedback - Add nameValidation i18n key for all 8 languages - Filter invalid characters on input and show warning message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: revert profile parsing regex changes Revert the regex changes in hermes-cli.ts and gateway-manager.ts back to requiring \s{2,} (at least 2 spaces). Since frontend now validates profile names to only allow lowercase letters, numbers, underscores, and hyphens, the relaxed regex is no longer needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: revert profile parsing regex changes Revert the regex changes in gateway-manager.ts and hermes-cli.ts back to requiring \s{2,} (at least 2 spaces). Since frontend now validates profile names to only allow lowercase letters, numbers, underscores, and hyphens, the relaxed regex is no longer needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: remove tooltip from drawer button Remove the NTooltip wrapper from the floating drawer button. The "Terminal & Files" tooltip is no longer shown on hover. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * Update assets images (#421) Updated two asset images in the client package. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: bump version to 0.5.8 Release v0.5.8 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: improve profile list parsing to handle long profile names (#425) Fixed issue #423 where long profile names caused parsing failures. Changes: - gateway-manager.ts: Use `.+?` instead of `\S+` to match profile names, allowing names that overflow table column width - hermes-cli.ts: Use `\s+` instead of `\s{2,}` for first delimiter to handle cases where long profile names reduce spacing to 1 space The regex now correctly parses profile output even when profile names are long enough to compress table formatting, ensuring all profiles appear in the UI regardless of name length. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add GitHub issue templates Add structured issue templates to guide users when submitting issues: - Bug Report template with version info, reproduction steps, and environment details - Feature Request template with problem statement, solution, and priority - General Issue template for questions that don't fit other categories - Config to enable blank issues and provide contact links to documentation and discussions Templates use YAML forms for better structure and validation of required fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: unify profile management across the application (#432) This commit addresses long-standing profile inconsistency issues by establishing `~/.hermes/active_profile` file as the single source of truth for all profile operations throughout the application. ## Changes ### Backend (Server) **1. profiles.ts - Enhanced profile switching** - Switch from CLI polling to direct file verification (Hermes CLI writes synchronously) - Verify `active_profile` file with quick retry (max 2 attempts × 100ms = 300ms) - Update GatewayManager only after file verification succeeds - Add comprehensive logging for debugging **2. profiles.ts - Authoritative API responses** - Override CLI's active flag with `active_profile` file in `list()` endpoint - Add warning when CLI output differs from file (detects inconsistencies) - Ensures API responses always match actual runtime state **3. jobs.ts - Use authoritative profile source** - `resolveProfile()` falls back to `getActiveProfileName()` when no profile in request - Ensures jobs operate on correct profile even if frontend doesn't specify **4. cron-history.ts - Fix run history to respect active profile** - Changed from fixed `~/.hermes/cron/output/` to `getActiveProfileDir()/cron/output/` - Run history now correctly switches with profile (e.g., `~/.hermes/profiles/hermes/cron/output/`) **5. proxy-handler.ts - Add fallback to authoritative source** - If no profile in request headers/query, read from `getActiveProfileName()` - Prevents proxy from using wrong default profile ### Frontend (Client) **1. api/client.ts - Simplified profile resolution** - Prioritize `useProfilesStore().activeProfileName` over localStorage - localStorage fallback only for early initialization **2. api/hermes/chat.ts - Consistent profile resolution** - Same pattern: store first, localStorage fallback only during init **3. stores/session-browser-prefs.ts - Clean up fallback logic** - Prioritize store, remove redundant localStorage read ## Problem Solved Previously, multiple components had different ways of determining the active profile: - CLI output (◆ marker) - could be stale - GatewayManager memory - startup cache only - localStorage - frontend cache - Various fallbacks scattered across codebase This caused inconsistencies where: - Frontend showed one profile but API used another - Jobs ran on wrong profile - Run history displayed wrong data - Profile switches appeared to fail (but actually succeeded) ## Solution All components now derive the active profile from the same authoritative source: - `~/.hermes/active_profile` file (written synchronously by `hermes profile use`) - `getActiveProfileName()` function (reads the file) - Single source of truth = no inconsistencies Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add v0.5.9 changelog entries (#434) - Add unified profile management across the application - Add GitHub issue and pull request templates Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: Enhance Markdown Media Rendering (Image/Video/File Support) (#438) * feat: enhance markdown media rendering with image, video, and file support - Add image display with thumbnail preview (200x160px) and click-to-fullscreen - Add video playback support for .mp4 and .webm formats with HTML5 player - Add file card UI for downloads with icon and filename - Convert local file paths (/tmp/*) to download URLs with auth token - Add AI output format guidelines system prompt (llm-prompt.ts) - Increase max download file size from 100MB to 200MB - Add documentation for AI output format constraints This enables AI agents to return images, videos, and files using standard Markdown syntax, which the frontend renders as interactive media elements instead of plain text links. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve unused parameter TypeScript errors in MarkdownRenderer Use underscore prefix for unused match parameters in replace callbacks --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: optimize group chat prompts and fix media handling (#439) Group Chat Prompt Improvements: - Add AI_OUTPUT_FORMAT_GUIDELINES to group chat system prompts - Fix duplicate member issue in room member list (deduplicate by name) - Handle empty agentDescription with default fallback - Add rule for sending files to users using proper format Chat Run Socket Integration: - Integrate getSystemPrompt() into chat-run-socket.ts - Append media format guidelines to all chat instructions - Ensure consistent format enforcement across chat and group chat Media Format Guidelines: - Simplify "注意事项" section (remove frontend implementation details) - Add "发送文件给用户" section with clear examples - Update video format description to mention embedded player URL Encoding Fix: - Fix double URL encoding in download.ts (decode first, then encode) - Prevent %25E8... double-encoded paths, now correctly %E8... This ensures AI agents in both private chat and group chat follow consistent media formatting rules when returning images, videos, and files. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
851 lines
32 KiB
TypeScript
851 lines
32 KiB
TypeScript
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 200MB)
|
|
const MAX_DOWNLOAD_SIZE = parseInt(process.env.MAX_DOWNLOAD_SIZE || '', 10) || 200 * 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<Buffer>
|
|
exists(filePath: string): Promise<boolean>
|
|
listDir(dirPath: string): Promise<FileEntry[]>
|
|
stat(filePath: string): Promise<FileStat>
|
|
writeFile(filePath: string, content: Buffer): Promise<void>
|
|
deleteFile(filePath: string): Promise<void>
|
|
deleteDir(dirPath: string): Promise<void>
|
|
renameFile(oldPath: string, newPath: string): Promise<void>
|
|
mkDir(dirPath: string): Promise<void>
|
|
copyFile(srcPath: string, destPath: string): Promise<void>
|
|
}
|
|
|
|
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<Buffer> {
|
|
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<boolean> {
|
|
try {
|
|
const p = validatePath(filePath)
|
|
const s = await fsStat(p)
|
|
return s.isFile()
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async listDir(dirPath: string): Promise<FileEntry[]> {
|
|
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<FileStat> {
|
|
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<void> {
|
|
const p = validatePath(filePath)
|
|
await fsWriteFile(p, content)
|
|
}
|
|
|
|
async deleteFile(filePath: string): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
const op = validatePath(oldPath)
|
|
const np = validatePath(newPath)
|
|
await rename(op, np)
|
|
}
|
|
|
|
async mkDir(dirPath: string): Promise<void> {
|
|
const p = validatePath(dirPath)
|
|
await mkdir(p, { recursive: true })
|
|
}
|
|
|
|
async copyFile(srcPath: string, destPath: string): Promise<void> {
|
|
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<Buffer> {
|
|
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<boolean> {
|
|
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<FileEntry[]> {
|
|
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<FileStat> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<Buffer> {
|
|
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<boolean> {
|
|
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<FileEntry[]> {
|
|
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<FileStat> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<Buffer> {
|
|
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<boolean> {
|
|
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<FileEntry[]> {
|
|
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<FileStat> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, string> = {}
|
|
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<string> {
|
|
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<FileProvider> {
|
|
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 }
|