Scope files jobs and plugins to request profile

This commit is contained in:
ekko
2026-05-24 09:25:52 +08:00
committed by ekko
parent 289a958684
commit 9708a6a521
23 changed files with 353 additions and 117 deletions
@@ -2,18 +2,21 @@ import type { Context } from 'koa'
import { readdir, stat, readFile } from 'fs/promises'
import { join } from 'path'
import { existsSync } from 'fs'
import { getActiveProfileDir } from '../../services/hermes/hermes-profile'
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
const SYNTHETIC_RUN_FILE = '__scheduler_metadata__.md'
function getCronOutputDir(): string {
// Use the active profile's directory, so cron history follows profile switches
const profileDir = getActiveProfileDir()
function requestedProfile(ctx: Context): string {
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
}
function getCronOutputDir(profile: string): string {
const profileDir = getProfileDir(profile)
return join(profileDir, 'cron', 'output')
}
function getCronJobsFile(): string {
const profileDir = getActiveProfileDir()
function getCronJobsFile(profile: string): string {
const profileDir = getProfileDir(profile)
return join(profileDir, 'cron', 'jobs.json')
}
@@ -69,8 +72,8 @@ function normaliseJobsPayload(payload: unknown): CronJobMetadata[] {
return []
}
async function readCronJobs(): Promise<CronJobMetadata[]> {
const jobsFile = getCronJobsFile()
async function readCronJobs(profile: string): Promise<CronJobMetadata[]> {
const jobsFile = getCronJobsFile(profile)
if (!existsSync(jobsFile)) return []
try {
@@ -182,7 +185,8 @@ function buildSyntheticContent(job: CronJobMetadata, runTime: string): string {
/** List all run output files, optionally filtered by job ID */
export async function listRuns(ctx: Context) {
const jobId = ctx.query.jobId as string | undefined
const cronOutput = getCronOutputDir()
const profile = requestedProfile(ctx)
const cronOutput = getCronOutputDir(profile)
try {
const runs: RunEntry[] = []
@@ -220,7 +224,7 @@ export async function listRuns(ctx: Context) {
}
}
const jobs = await readCronJobs()
const jobs = await readCronJobs(profile)
const targetJobs = jobId ? jobs.filter(job => getJobId(job) === jobId) : jobs
for (const job of targetJobs) {
const id = getJobId(job)
@@ -242,6 +246,7 @@ export async function listRuns(ctx: Context) {
/** Read a specific run output file */
export async function readRun(ctx: Context) {
const { jobId, fileName } = ctx.params
const profile = requestedProfile(ctx)
if (!jobId || !fileName) {
ctx.status = 400
@@ -264,7 +269,7 @@ export async function readRun(ctx: Context) {
}
if (fileName === SYNTHETIC_RUN_FILE) {
const jobs = await readCronJobs()
const jobs = await readCronJobs(profile)
const job = jobs.find(candidate => getJobId(candidate) === jobId)
const synthetic = job ? syntheticRunEntry(job) : null
if (!job || !synthetic) {
@@ -282,7 +287,7 @@ export async function readRun(ctx: Context) {
return
}
const cronOutput = getCronOutputDir()
const cronOutput = getCronOutputDir(profile)
const filePath = join(cronOutput, jobId, fileName)
if (!existsSync(filePath)) {
@@ -12,7 +12,7 @@ const TIMEOUT_MS = 60_000
type JobRecord = Record<string, any>
function resolveProfile(ctx: Context): string {
const requestedProfile = ctx.get('x-hermes-profile') || (ctx.query.profile as string)
const requestedProfile = ctx.state?.profile?.name
return requestedProfile || getActiveProfileName()
}
@@ -2,7 +2,7 @@ import { listHermesPlugins } from '../../services/hermes/plugins'
export async function list(ctx: any) {
try {
ctx.body = await listHermesPlugins()
ctx.body = await listHermesPlugins(ctx.state?.profile?.name)
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message || 'Failed to discover Hermes plugins' }
+10 -3
View File
@@ -1,10 +1,15 @@
import { randomBytes } from 'crypto'
import { writeFile } from 'fs/promises'
import { mkdir, writeFile } from 'fs/promises'
import { join } from 'path'
import { config } from '../config'
import { getActiveProfileName } from '../services/hermes/hermes-profile'
import { getProfileUploadDir } from '../services/hermes/upload-paths'
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024 // 50MB
function requestedProfile(ctx: any): string {
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
}
export async function handleUpload(ctx: any) {
const contentType = ctx.get('content-type') || ''
if (!contentType.startsWith('multipart/form-data')) {
@@ -27,6 +32,8 @@ export async function handleUpload(ctx: any) {
const boundaryBuf = Buffer.from(boundary)
const parts = splitMultipart(raw, boundaryBuf)
const results: { name: string; path: string }[] = []
const uploadDir = getProfileUploadDir(requestedProfile(ctx))
await mkdir(uploadDir, { recursive: true })
for (const part of parts) {
const headerEnd = part.indexOf(Buffer.from('\r\n\r\n'))
if (headerEnd === -1) continue
@@ -43,7 +50,7 @@ export async function handleUpload(ctx: any) {
}
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
const savedName = randomBytes(8).toString('hex') + ext
const savedPath = join(config.uploadDir, savedName)
const savedPath = join(uploadDir, savedName)
await writeFile(savedPath, data)
results.push({ name: filename, path: savedPath })
}
@@ -7,6 +7,7 @@ import {
validatePath,
resolveHermesPath,
} from '../../services/hermes/file-provider'
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
export const downloadRoutes = new Router()
@@ -62,6 +63,10 @@ function getMimeType(fileName: string): string {
return MIME_MAP[ext] || 'application/octet-stream'
}
function requestedProfile(ctx: any): string {
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
}
downloadRoutes.get('/api/hermes/download', async (ctx) => {
const filePath = ctx.query.path as string | undefined
const fileName = ctx.query.name as string | undefined
@@ -73,16 +78,17 @@ downloadRoutes.get('/api/hermes/download', async (ctx) => {
}
try {
const profile = requestedProfile(ctx)
// Validate the path first
// Support both absolute and relative paths
const validPath = isAbsolute(filePath) ? validatePath(filePath) : resolveHermesPath(filePath)
const validPath = isAbsolute(filePath) ? validatePath(filePath) : resolveHermesPath(filePath, profile)
// 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()
const provider = await createFileProvider(profile)
data = await provider.readFile(validPath)
}
+36 -24
View File
@@ -6,8 +6,20 @@ import {
MAX_EDIT_SIZE,
} from '../../services/hermes/file-provider'
function withAbsolutePath<T extends { path: string }>(entry: T): T & { absolutePath: string } {
return { ...entry, absolutePath: resolveHermesPath(entry.path) }
function requestedProfile(ctx: any): string | undefined {
return ctx.state?.profile?.name
}
function resolveRequestPath(ctx: any, relativePath: string): string {
return resolveHermesPath(relativePath, requestedProfile(ctx))
}
async function createRequestFileProvider(ctx: any) {
return createFileProvider(requestedProfile(ctx))
}
function withAbsolutePath<T extends { path: string }>(ctx: any, entry: T): T & { absolutePath: string } {
return { ...entry, absolutePath: resolveRequestPath(ctx, entry.path) }
}
export const fileRoutes = new Router()
@@ -36,14 +48,14 @@ function handleError(ctx: any, err: any) {
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 absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
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: entries.map(withAbsolutePath), path: relativePath, absolutePath: absPath }
ctx.body = { entries: entries.map(entry => withAbsolutePath(ctx, entry)), path: relativePath, absolutePath: absPath }
} catch (err: any) {
handleError(ctx, err)
}
@@ -58,10 +70,10 @@ fileRoutes.get('/api/hermes/files/stat', async (ctx) => {
return
}
try {
const absPath = resolveHermesPath(relativePath)
const provider = await createFileProvider()
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
const info = await provider.stat(absPath)
ctx.body = withAbsolutePath(info)
ctx.body = withAbsolutePath(ctx, info)
} catch (err: any) {
handleError(ctx, err)
}
@@ -76,8 +88,8 @@ fileRoutes.get('/api/hermes/files/read', async (ctx) => {
return
}
try {
const absPath = resolveHermesPath(relativePath)
const provider = await createFileProvider()
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
const data = await provider.readFile(absPath)
if (data.length > MAX_EDIT_SIZE) {
ctx.status = 413
@@ -110,8 +122,8 @@ fileRoutes.put('/api/hermes/files/write', async (ctx) => {
ctx.body = { error: 'Content too large', code: 'file_too_large' }
return
}
const absPath = resolveHermesPath(relativePath)
const provider = await createFileProvider()
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
await provider.writeFile(absPath, buf)
ctx.body = { ok: true, path: relativePath }
} catch (err: any) {
@@ -133,8 +145,8 @@ fileRoutes.delete('/api/hermes/files/delete', async (ctx) => {
return
}
try {
const absPath = resolveHermesPath(relativePath)
const provider = await createFileProvider()
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
if (recursive) {
await provider.deleteDir(absPath)
} else {
@@ -160,9 +172,9 @@ fileRoutes.post('/api/hermes/files/rename', async (ctx) => {
return
}
try {
const absOld = resolveHermesPath(oldPath)
const absNew = resolveHermesPath(newPath)
const provider = await createFileProvider()
const absOld = resolveRequestPath(ctx, oldPath)
const absNew = resolveRequestPath(ctx, newPath)
const provider = await createRequestFileProvider(ctx)
await provider.renameFile(absOld, absNew)
ctx.body = { ok: true }
} catch (err: any) {
@@ -179,8 +191,8 @@ fileRoutes.post('/api/hermes/files/mkdir', async (ctx) => {
return
}
try {
const absPath = resolveHermesPath(relativePath)
const provider = await createFileProvider()
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
await provider.mkDir(absPath)
ctx.body = { ok: true }
} catch (err: any) {
@@ -197,9 +209,9 @@ fileRoutes.post('/api/hermes/files/copy', async (ctx) => {
return
}
try {
const absSrc = resolveHermesPath(srcPath)
const absDest = resolveHermesPath(destPath)
const provider = await createFileProvider()
const absSrc = resolveRequestPath(ctx, srcPath)
const absDest = resolveRequestPath(ctx, destPath)
const provider = await createRequestFileProvider(ctx)
await provider.copyFile(absSrc, absDest)
ctx.body = { ok: true }
} catch (err: any) {
@@ -230,7 +242,7 @@ fileRoutes.post('/api/hermes/files/upload', async (ctx) => {
const boundaryBuf = Buffer.from(boundary)
const parts = splitMultipart(raw, boundaryBuf)
const provider = await createFileProvider()
const provider = await createRequestFileProvider(ctx)
const results: { name: string; path: string }[] = []
for (const part of parts) {
@@ -263,7 +275,7 @@ fileRoutes.post('/api/hermes/files/upload', async (ctx) => {
return
}
const absPath = resolveHermesPath(filePath)
const absPath = resolveRequestPath(ctx, filePath)
await provider.writeFile(absPath, data)
results.push({ name: filename, path: filePath })
}
@@ -5,7 +5,7 @@ import { promisify } from 'util'
import { existsSync, readFileSync } from 'fs'
import YAML from 'js-yaml'
import { config } from '../../config'
import { getActiveProfileDir, getActiveEnvPath } from './hermes-profile'
import { getActiveProfileDir, getActiveEnvPath, getProfileDir } from './hermes-profile'
import { isPathWithin, relativePathFromBase } from './hermes-path'
const execFileAsync = promisify(execFile)
@@ -94,6 +94,14 @@ export function isInUploadDir(filePath: string): boolean {
return isPathWithin(filePath, config.uploadDir)
}
function homeDirForProfile(profile?: string): string {
return profile ? getProfileDir(profile) : getActiveProfileDir()
}
function envPathForProfile(profile?: string): string {
return profile ? join(getProfileDir(profile), '.env') : getActiveEnvPath()
}
/**
* Check if a relative path refers to a sensitive file.
*/
@@ -107,8 +115,8 @@ export function isSensitivePath(relativePath: string): boolean {
* 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()
export function resolveHermesPath(relativePath: string, profile?: string): string {
const homeDir = homeDirForProfile(profile)
if (!relativePath || relativePath === '.' || relativePath === '/') {
return homeDir
}
@@ -127,6 +135,7 @@ export function resolveHermesPath(relativePath: string): string {
export class LocalFileProvider implements FileProvider {
type: BackendType = 'local'
constructor(private homeDir = getActiveProfileDir()) {}
async readFile(filePath: string): Promise<Buffer> {
const p = validatePath(filePath)
@@ -150,14 +159,13 @@ export class LocalFileProvider implements FileProvider {
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 = relativePathFromBase(fullPath, homeDir) ?? entry.name
const relPath = relativePathFromBase(fullPath, this.homeDir) ?? entry.name
results.push({
name: entry.name,
path: relPath,
@@ -174,9 +182,8 @@ export class LocalFileProvider implements FileProvider {
async stat(filePath: string): Promise<FileStat> {
const p = validatePath(filePath)
const homeDir = getActiveProfileDir()
const s = await fsStat(p)
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
const relPath = relativePathFromBase(p, this.homeDir) ?? basename(p)
return {
name: basename(p),
path: relPath || basename(p),
@@ -273,9 +280,11 @@ function parseStatOutput(output: string, relativePath: string): FileStat {
export class DockerFileProvider implements FileProvider {
type: BackendType = 'docker'
private containerName: string
private homeDir: string
constructor(containerName: string) {
constructor(containerName: string, homeDir = getActiveProfileDir()) {
this.containerName = containerName
this.homeDir = homeDir
}
async readFile(filePath: string): Promise<Buffer> {
@@ -315,8 +324,7 @@ export class DockerFileProvider implements FileProvider {
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, ...execOpts })
const homeDir = getActiveProfileDir()
const relParent = relativePathFromBase(p, homeDir) ?? ''
const relParent = relativePathFromBase(p, this.homeDir) ?? ''
return parseLsOutput(stdout, relParent)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -332,8 +340,7 @@ export class DockerFileProvider implements FileProvider {
const { stdout } = await execFileAsync('docker', [
'exec', this.containerName, 'stat', '-c', '%n|%F|%s|%Y', p,
], { timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
const relPath = relativePathFromBase(p, this.homeDir) ?? 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' })
@@ -414,11 +421,13 @@ export class SSHFileProvider implements FileProvider {
private host: string
private user: string
private keyPath?: string
private homeDir: string
constructor(host: string, user: string, keyPath?: string) {
constructor(host: string, user: string, keyPath?: string, homeDir = getActiveProfileDir()) {
this.host = host
this.user = user
this.keyPath = keyPath
this.homeDir = homeDir
}
private sshArgs(): string[] {
@@ -475,8 +484,7 @@ export class SSHFileProvider implements FileProvider {
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, ...execOpts })
const homeDir = getActiveProfileDir()
const relParent = relativePathFromBase(p, homeDir) ?? ''
const relParent = relativePathFromBase(p, this.homeDir) ?? ''
return parseLsOutput(stdout, relParent)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -492,8 +500,7 @@ export class SSHFileProvider implements FileProvider {
const { stdout } = await execFileAsync('ssh', [
...this.sshArgs(), `stat -c '%n|%F|%s|%Y' ${this.shellEscape(p)}`,
], { timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
const relPath = relativePathFromBase(p, this.homeDir) ?? 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' })
@@ -572,9 +579,11 @@ export class SSHFileProvider implements FileProvider {
export class SingularityFileProvider implements FileProvider {
type: BackendType = 'singularity'
private imagePath: string
private homeDir: string
constructor(imagePath: string) {
constructor(imagePath: string, homeDir = getActiveProfileDir()) {
this.imagePath = imagePath
this.homeDir = homeDir
}
async readFile(filePath: string): Promise<Buffer> {
@@ -614,8 +623,7 @@ export class SingularityFileProvider implements FileProvider {
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, ...execOpts })
const homeDir = getActiveProfileDir()
const relParent = relativePathFromBase(p, homeDir) ?? ''
const relParent = relativePathFromBase(p, this.homeDir) ?? ''
return parseLsOutput(stdout, relParent)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -631,8 +639,7 @@ export class SingularityFileProvider implements FileProvider {
const { stdout } = await execFileAsync('singularity', [
'exec', this.imagePath, 'stat', '-c', '%n|%F|%s|%Y', p,
], { timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
const relPath = relativePathFromBase(p, this.homeDir) ?? 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' })
@@ -711,9 +718,9 @@ export class SingularityFileProvider implements FileProvider {
/**
* Read terminal config from hermes config.yaml.
*/
export function getTerminalConfig(): TerminalConfig {
export function getTerminalConfig(profile?: string): TerminalConfig {
try {
const configPath = join(getActiveProfileDir(), 'config.yaml')
const configPath = join(homeDirForProfile(profile), 'config.yaml')
if (!existsSync(configPath)) return { backend: 'local' }
const raw = readFileSync(configPath, 'utf-8')
const doc = YAML.load(raw, { json: true }) as any
@@ -733,9 +740,9 @@ export function getTerminalConfig(): TerminalConfig {
/**
* Read SSH env vars from hermes .env file.
*/
function getSSHEnvVars(): { host?: string; user?: string; key?: string } {
function getSSHEnvVars(profile?: string): { host?: string; user?: string; key?: string } {
try {
const envPath = getActiveEnvPath()
const envPath = envPathForProfile(profile)
if (!existsSync(envPath)) return {}
const raw = readFileSync(envPath, 'utf-8')
const vars: Record<string, string> = {}
@@ -783,43 +790,44 @@ async function resolveDockerContainer(cfg: TerminalConfig): Promise<string> {
// --- Factory ---
// Cache the provider for a short time to avoid re-reading config on every request
let cachedProvider: FileProvider | null = null
let cachedAt = 0
// Cache providers for a short time to avoid re-reading config on every request
const providerCache = new Map<string, { provider: FileProvider; cachedAt: number }>()
const CACHE_TTL = 10_000
/** @internal — for testing only */
export function _resetFileProviderCache() {
cachedProvider = null
cachedAt = 0
providerCache.clear()
}
/**
* 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> {
export async function createFileProvider(profile?: string): Promise<FileProvider> {
const now = Date.now()
if (cachedProvider && now - cachedAt < CACHE_TTL) return cachedProvider
const homeDir = homeDirForProfile(profile)
const cacheKey = profile || homeDir
const cached = providerCache.get(cacheKey)
if (cached && now - cached.cachedAt < CACHE_TTL) return cached.provider
const cfg = getTerminalConfig()
const cfg = getTerminalConfig(profile)
let provider: FileProvider
switch (cfg.backend) {
case 'docker': {
const container = await resolveDockerContainer(cfg)
provider = new DockerFileProvider(container)
provider = new DockerFileProvider(container, homeDir)
break
}
case 'ssh': {
const ssh = getSSHEnvVars()
const ssh = getSSHEnvVars(profile)
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)
provider = new SSHFileProvider(ssh.host, ssh.user, ssh.key, homeDir)
break
}
case 'singularity': {
@@ -829,7 +837,7 @@ export async function createFileProvider(): Promise<FileProvider> {
{ code: 'backend_error' },
)
}
provider = new SingularityFileProvider(cfg.singularity_image)
provider = new SingularityFileProvider(cfg.singularity_image, homeDir)
break
}
case 'modal':
@@ -839,11 +847,10 @@ export async function createFileProvider(): Promise<FileProvider> {
{ code: 'unsupported_backend' },
)
default:
provider = new LocalFileProvider()
provider = new LocalFileProvider(homeDir)
}
cachedProvider = provider
cachedAt = now
providerCache.set(cacheKey, { provider, cachedAt: now })
return provider
}
@@ -1,6 +1,6 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import { getActiveProfileDir } from './hermes-profile'
import { getActiveProfileDir, getProfileDir } from './hermes-profile'
import { resolveAgentBridgeCommand } from './agent-bridge/manager'
const execFileAsync = promisify(execFile)
@@ -219,13 +219,14 @@ function extractError(err: any): string {
return [err?.message, stdout, stderr].filter(Boolean).join('\n')
}
export async function listHermesPlugins(): Promise<HermesPluginsResponse> {
export async function listHermesPlugins(profile?: string): Promise<HermesPluginsResponse> {
const command = resolveAgentBridgeCommand()
const agentRoot = command.agentRoot || ''
const hermesHome = profile ? getProfileDir(profile) : getActiveProfileDir()
const env: NodeJS.ProcessEnv = {
...process.env,
HERMES_AGENT_ROOT_RESOLVED: agentRoot,
HERMES_HOME: getActiveProfileDir(),
HERMES_HOME: hermesHome,
}
if (!agentRoot) {
delete env.PYTHONHOME
@@ -0,0 +1,19 @@
import { join, resolve } from 'path'
import { config } from '../../config'
import { isPathWithin } from './hermes-path'
function safeProfileSegment(profile: string): string {
const name = (profile || 'default').trim() || 'default'
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
throw Object.assign(new Error('Invalid profile name'), { code: 'invalid_profile' })
}
return name
}
export function getProfileUploadDir(profile: string): string {
return resolve(join(config.uploadDir, safeProfileSegment(profile)))
}
export function isInProfileUploadDir(filePath: string, profile: string): boolean {
return isPathWithin(filePath, getProfileUploadDir(profile))
}