Files
Hermes-ui/packages/server/src/services/hermes/file-provider.ts
T

860 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)
const execOpts = { windowsHide: true }
// 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 normalizePlatformPath(filePath: string, platform = process.platform): string {
if (platform !== 'win32') return filePath
const msysDrivePath = filePath.match(/^\/([a-zA-Z])(?:\/(.*))?$/)
if (!msysDrivePath) return filePath
const [, drive, rest = ''] = msysDrivePath
return `${drive.toUpperCase()}:\\${rest.replace(/\//g, '\\')}`
}
export function validatePath(filePath: string): string {
if (!filePath) throw Object.assign(new Error('Missing file path'), { code: 'missing_path' })
const resolved = resolve(normalizePlatformPath(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, { json: true }) 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 }