Scope files jobs and plugins to request profile
This commit is contained in:
@@ -46,7 +46,7 @@ export function isStoredSuperAdmin(): boolean {
|
||||
return getStoredUserRole() === 'super_admin'
|
||||
}
|
||||
|
||||
function getActiveProfileName(): string | null {
|
||||
export function getActiveProfileName(): string | null {
|
||||
return localStorage.getItem('hermes_active_profile_name')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getApiKey, getBaseUrlValue } from '../client'
|
||||
import { getActiveProfileName, getApiKey, getBaseUrlValue } from '../client'
|
||||
|
||||
/**
|
||||
* Construct a download URL with auth token as query parameter.
|
||||
@@ -27,6 +27,8 @@ export function getDownloadUrl(filePath: string, fileName?: string): string {
|
||||
const decodedName = decodeURIComponent(fileName)
|
||||
params.set('name', decodedName)
|
||||
}
|
||||
const profileName = getActiveProfileName()
|
||||
if (profileName) params.set('profile', profileName)
|
||||
const token = getApiKey()
|
||||
if (token) params.set('token', token)
|
||||
return `${base}/api/hermes/download?${params.toString()}`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { request, getApiKey, getBaseUrlValue } from '../client'
|
||||
import { request, getActiveProfileName, getApiKey, getBaseUrlValue } from '../client'
|
||||
|
||||
export interface FileEntry {
|
||||
name: string
|
||||
@@ -83,6 +83,8 @@ export async function uploadFiles(targetDir: string, files: File[]): Promise<{ n
|
||||
const headers: Record<string, string> = {}
|
||||
const token = getApiKey()
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
const profileName = getActiveProfileName()
|
||||
if (profileName) headers['X-Hermes-Profile'] = profileName
|
||||
|
||||
const res = await fetch(url, { method: 'POST', headers, body: formData })
|
||||
if (!res.ok) {
|
||||
@@ -97,6 +99,8 @@ export function getFileDownloadUrl(relativePath: string, fileName?: string): str
|
||||
const base = getBaseUrlValue()
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
if (fileName) params.set('name', fileName)
|
||||
const profileName = getActiveProfileName()
|
||||
if (profileName) params.set('profile', profileName)
|
||||
const token = getApiKey()
|
||||
if (token) params.set('token', token)
|
||||
return `${base}/api/hermes/download?${params.toString()}`
|
||||
|
||||
@@ -3,8 +3,7 @@ import type { Message, ContentBlock } from "@/stores/hermes/chat";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watchEffect } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useMessage } from "naive-ui";
|
||||
import { downloadFile } from "@/api/hermes/download";
|
||||
import { getApiKey } from "@/api/client";
|
||||
import { downloadFile, getDownloadUrl } from "@/api/hermes/download";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import MarkdownRenderer from "./MarkdownRenderer.vue";
|
||||
import { parseThinking, countThinkingChars } from "@/utils/thinking-parser";
|
||||
@@ -167,13 +166,6 @@ const contentFiles = computed<DisplayContentFile[] | null>(() => {
|
||||
});
|
||||
});
|
||||
|
||||
// Generate download URL with auth token
|
||||
function getDownloadUrl(path: string, name: string): string {
|
||||
const token = getApiKey();
|
||||
const base = `/api/hermes/download?path=${encodeURIComponent(path)}&name=${encodeURIComponent(name)}`;
|
||||
return token ? `${base}&token=${encodeURIComponent(token)}` : base;
|
||||
}
|
||||
|
||||
function getContentFileUrl(file: DisplayContentFile): string {
|
||||
if (file.url) return file.url
|
||||
return file.path ? getDownloadUrl(file.path, file.name) : ''
|
||||
|
||||
@@ -9,6 +9,7 @@ import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
|
||||
const props = defineProps<{
|
||||
selectedJobId: string | null
|
||||
jobNameMap: Record<string, string>
|
||||
profileKey: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -66,7 +67,7 @@ function getJobName(jobId: string): string {
|
||||
return props.jobNameMap[jobId] || jobId
|
||||
}
|
||||
|
||||
watch(() => props.selectedJobId, () => {
|
||||
watch(() => [props.selectedJobId, props.profileKey], () => {
|
||||
expandedContent.value = {}
|
||||
fetchRuns()
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, onPeerUserMessage, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
|
||||
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, setSessionModel, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
|
||||
import { getApiKey } from '@/api/client'
|
||||
import { getActiveProfileName } from '@/api/client'
|
||||
import { getDownloadUrl } from '@/api/hermes/download'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAppStore } from './app'
|
||||
@@ -101,10 +102,14 @@ async function uploadFiles(attachments: Attachment[]): Promise<{ name: string; p
|
||||
if (att.file) formData.append('file', att.file, att.name)
|
||||
}
|
||||
const token = localStorage.getItem('hermes_api_key') || ''
|
||||
const profileName = getActiveProfileName()
|
||||
const headers: Record<string, string> = {}
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
if (profileName) headers['X-Hermes-Profile'] = profileName
|
||||
const res = await fetch('/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
headers,
|
||||
})
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
|
||||
const data = await res.json() as { files: { name: string; path: string }[] }
|
||||
@@ -1040,10 +1045,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const uploaded = await uploadFiles(attachments)
|
||||
|
||||
// Update attachment URLs on the user message for display
|
||||
const token = getApiKey()
|
||||
const urlMap = new Map(uploaded.map(f => {
|
||||
const base = `/api/hermes/download?path=${encodeURIComponent(f.path)}&name=${encodeURIComponent(f.name)}`
|
||||
return [f.name, token ? `${base}&token=${encodeURIComponent(token)}` : base]
|
||||
return [f.name, getDownloadUrl(f.path, f.name)]
|
||||
}))
|
||||
if (shouldQueue && userMsg.attachments) {
|
||||
userMsg.attachments = userMsg.attachments.map(a => {
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { NButton, NSpin } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import JobsPanel from '@/components/hermes/jobs/JobsPanel.vue'
|
||||
import JobRunHistory from '@/components/hermes/jobs/JobRunHistory.vue'
|
||||
import JobFormModal from '@/components/hermes/jobs/JobFormModal.vue'
|
||||
import { useJobsStore } from '@/stores/hermes/jobs'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
|
||||
const { t } = useI18n()
|
||||
const jobsStore = useJobsStore()
|
||||
const profilesStore = useProfilesStore()
|
||||
const showModal = ref(false)
|
||||
const editingJob = ref<string | null>(null)
|
||||
const selectedJobId = ref<string | null>(null)
|
||||
const activeProfileName = computed(() => profilesStore.activeProfileName || 'default')
|
||||
|
||||
const jobNameMap = computed(() => {
|
||||
const map: Record<string, string> = {}
|
||||
@@ -22,9 +25,11 @@ const jobNameMap = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
jobsStore.fetchJobs()
|
||||
})
|
||||
watch(activeProfileName, () => {
|
||||
selectedJobId.value = null
|
||||
jobsStore.jobs = []
|
||||
void jobsStore.fetchJobs()
|
||||
}, { immediate: true })
|
||||
|
||||
function openCreateModal() {
|
||||
editingJob.value = null
|
||||
@@ -80,6 +85,7 @@ function handleSelectJob(jobId: string | null) {
|
||||
<JobRunHistory
|
||||
:selected-job-id="selectedJobId"
|
||||
:job-name-map="jobNameMap"
|
||||
:profile-key="activeProfileName"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NEmpty, NInput, NSelect, NSpin, NTag, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { fetchPlugins, type HermesPluginInfo, type HermesPluginsMetadata } from '@/api/hermes/plugins'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
const message = useMessage()
|
||||
const profilesStore = useProfilesStore()
|
||||
|
||||
const plugins = ref<HermesPluginInfo[]>([])
|
||||
const warnings = ref<string[]>([])
|
||||
@@ -111,7 +113,12 @@ async function copyCommand(plugin: HermesPluginInfo) {
|
||||
message.success(t('plugins.commandCopied'))
|
||||
}
|
||||
|
||||
onMounted(loadPlugins)
|
||||
watch(() => profilesStore.activeProfileName || 'default', () => {
|
||||
plugins.value = []
|
||||
warnings.value = []
|
||||
metadata.value = null
|
||||
void loadPlugins()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -13,6 +13,8 @@ vi.mock('@/router', () => ({
|
||||
}))
|
||||
|
||||
import { getApiKey, setApiKey, clearApiKey, hasApiKey, getStoredUserRole, isStoredSuperAdmin, request } from '../../packages/client/src/api/client'
|
||||
import { getDownloadUrl } from '../../packages/client/src/api/hermes/download'
|
||||
import { uploadFiles } from '../../packages/client/src/api/hermes/files'
|
||||
import router from '@/router'
|
||||
|
||||
function fakeJwt(payload: Record<string, unknown>) {
|
||||
@@ -153,4 +155,41 @@ describe('API Client', () => {
|
||||
expect(result).toEqual(data)
|
||||
})
|
||||
})
|
||||
|
||||
describe('download URLs', () => {
|
||||
it('adds the active profile selector to direct download URLs', () => {
|
||||
setApiKey('secret-key')
|
||||
localStorage.setItem('hermes_active_profile_name', 'research')
|
||||
|
||||
const url = new URL(getDownloadUrl('/tmp/report.txt', 'report.txt'), 'http://localhost')
|
||||
|
||||
expect(url.pathname).toBe('/api/hermes/download')
|
||||
expect(url.searchParams.get('path')).toBe('/tmp/report.txt')
|
||||
expect(url.searchParams.get('name')).toBe('report.txt')
|
||||
expect(url.searchParams.get('profile')).toBe('research')
|
||||
expect(url.searchParams.get('token')).toBe('secret-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('file upload', () => {
|
||||
it('adds auth and active profile headers to multipart uploads', async () => {
|
||||
setApiKey('secret-key')
|
||||
localStorage.setItem('hermes_active_profile_name', 'research')
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ files: [] }),
|
||||
})
|
||||
|
||||
await uploadFiles('notes', [new File(['hello'], 'hello.txt', { type: 'text/plain' })])
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [url, options] = mockFetch.mock.calls[0]
|
||||
expect(url).toBe('/api/hermes/files/upload?path=notes')
|
||||
expect(options.method).toBe('POST')
|
||||
expect(options.headers.Authorization).toBe('Bearer secret-key')
|
||||
expect(options.headers['X-Hermes-Profile']).toBe('research')
|
||||
expect(options.body).toBeInstanceOf(FormData)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
scheduleToEditableInput,
|
||||
updateJob,
|
||||
} from '../../packages/client/src/api/hermes/jobs'
|
||||
import { listCronRuns } from '../../packages/client/src/api/hermes/cron-history'
|
||||
import type { Job } from '../../packages/client/src/api/hermes/jobs'
|
||||
|
||||
function makeJob(overrides: Partial<Job> = {}): Job {
|
||||
@@ -118,4 +119,20 @@ describe('Hermes jobs edit payloads', () => {
|
||||
schedule: 'every 14400m',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends active profile header when loading job run history', async () => {
|
||||
localStorage.setItem('hermes_active_profile_name', 'research')
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ runs: [] }),
|
||||
})
|
||||
|
||||
await listCronRuns('job-1')
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [url, options] = mockFetch.mock.calls[0]
|
||||
expect(url).toBe('/api/cron-history?jobId=job-1')
|
||||
expect(options.headers['X-Hermes-Profile']).toBe('research')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,8 @@ test('renders authenticated shell and navigates between key product routes', asy
|
||||
const jobsRequest = api.requests.find((request) => request.pathname === '/api/hermes/jobs')
|
||||
expect(jobsRequest?.headers.authorization).toBe(`Bearer ${TEST_ACCESS_KEY}`)
|
||||
expect(jobsRequest?.headers['x-hermes-profile']).toBe('research')
|
||||
const cronHistoryRequest = api.requests.find((request) => request.pathname === '/api/cron-history')
|
||||
expect(cronHistoryRequest?.headers['x-hermes-profile']).toBe('research')
|
||||
|
||||
await page.locator('aside.sidebar').getByRole('button', { name: /^Models$/ }).click()
|
||||
await expect(page).toHaveURL(/#\/hermes\/models$/)
|
||||
|
||||
@@ -3,10 +3,14 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
const profileDirState = vi.hoisted(() => ({ value: '' }))
|
||||
const profileDirState = vi.hoisted(() => ({
|
||||
value: '',
|
||||
dirs: {} as Record<string, string>,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
getActiveProfileDir: () => profileDirState.value,
|
||||
getActiveProfileName: () => 'default',
|
||||
getProfileDir: (profile: string) => profileDirState.dirs[profile] || profileDirState.value,
|
||||
}))
|
||||
|
||||
function createCtx(overrides: Record<string, any> = {}) {
|
||||
@@ -19,8 +23,8 @@ function createCtx(overrides: Record<string, any> = {}) {
|
||||
} as any
|
||||
}
|
||||
|
||||
function writeJobs(jobs: unknown[]) {
|
||||
const cronDir = join(profileDirState.value, 'cron')
|
||||
function writeJobs(jobs: unknown[], profileDir = profileDirState.value) {
|
||||
const cronDir = join(profileDir, 'cron')
|
||||
mkdirSync(cronDir, { recursive: true })
|
||||
writeFileSync(join(cronDir, 'jobs.json'), JSON.stringify({ jobs }))
|
||||
}
|
||||
@@ -29,10 +33,45 @@ describe('Hermes cron history controller', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
profileDirState.value = mkdtempSync(join(tmpdir(), 'hwui-cron-history-'))
|
||||
profileDirState.dirs = { default: profileDirState.value }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (profileDirState.value) rmSync(profileDirState.value, { recursive: true, force: true })
|
||||
for (const dir of Object.values(profileDirState.dirs)) {
|
||||
if (dir !== profileDirState.value) rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reads run history from the request profile directory', async () => {
|
||||
const researchDir = mkdtempSync(join(tmpdir(), 'hwui-cron-history-research-'))
|
||||
profileDirState.dirs.research = researchDir
|
||||
writeJobs([
|
||||
{
|
||||
id: 'default-job',
|
||||
name: 'Default job',
|
||||
last_run_at: '2026-05-05T01:00:00+00:00',
|
||||
},
|
||||
])
|
||||
writeJobs([
|
||||
{
|
||||
id: 'research-job',
|
||||
name: 'Research job',
|
||||
last_run_at: '2026-05-05T02:00:00+00:00',
|
||||
},
|
||||
], researchDir)
|
||||
|
||||
const { listRuns } = await import('../../packages/server/src/controllers/hermes/cron-history')
|
||||
|
||||
const ctx = createCtx({ state: { profile: { name: 'research' } } })
|
||||
await listRuns(ctx)
|
||||
|
||||
expect(ctx.body.runs).toEqual([
|
||||
expect.objectContaining({
|
||||
jobId: 'research-job',
|
||||
runTime: '2026-05-05 02:00:00',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('surfaces scheduler metadata when a job ran without an output artifact', async () => {
|
||||
|
||||
@@ -33,10 +33,12 @@ describe('file routes path metadata', () => {
|
||||
|
||||
const { fileRoutes } = await import('../../packages/server/src/routes/hermes/files')
|
||||
const layer = fileRoutes.stack.find((entry: any) => entry.path === '/api/hermes/files/list')
|
||||
const ctx: any = { query: { path: 'logs' }, body: null }
|
||||
const ctx: any = { query: { path: 'logs' }, state: { profile: { name: 'research' } }, body: null }
|
||||
|
||||
await layer.stack[0](ctx)
|
||||
|
||||
expect(createFileProviderMock).toHaveBeenCalledWith('research')
|
||||
expect(resolveHermesPathMock).toHaveBeenCalledWith('logs', 'research')
|
||||
expect(provider.listDir).toHaveBeenCalledWith('/home/agent/.hermes/logs')
|
||||
expect(ctx.body).toEqual({
|
||||
path: 'logs',
|
||||
@@ -65,10 +67,12 @@ describe('file routes path metadata', () => {
|
||||
|
||||
const { fileRoutes } = await import('../../packages/server/src/routes/hermes/files')
|
||||
const layer = fileRoutes.stack.find((entry: any) => entry.path === '/api/hermes/files/stat')
|
||||
const ctx: any = { query: { path: 'logs/app.log' }, body: null }
|
||||
const ctx: any = { query: { path: 'logs/app.log' }, state: { profile: { name: 'research' } }, body: null }
|
||||
|
||||
await layer.stack[0](ctx)
|
||||
|
||||
expect(createFileProviderMock).toHaveBeenCalledWith('research')
|
||||
expect(resolveHermesPathMock).toHaveBeenCalledWith('logs/app.log', 'research')
|
||||
expect(ctx.body).toEqual({
|
||||
name: 'app.log',
|
||||
path: 'logs/app.log',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Readable } from 'stream'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mkdirMock = vi.hoisted(() => vi.fn())
|
||||
const writeFileMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises')
|
||||
return {
|
||||
...actual,
|
||||
mkdir: mkdirMock,
|
||||
writeFile: writeFileMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
getActiveProfileName: vi.fn(() => 'default'),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/upload-paths', () => ({
|
||||
getProfileUploadDir: vi.fn((profile: string) => `/tmp/hermes-web-ui/upload/${profile}`),
|
||||
}))
|
||||
|
||||
function multipartBody(boundary: string, name: string, content: string): Buffer {
|
||||
return Buffer.from([
|
||||
`--${boundary}`,
|
||||
`Content-Disposition: form-data; name="file"; filename="${name}"`,
|
||||
'Content-Type: text/plain',
|
||||
'',
|
||||
content,
|
||||
`--${boundary}--`,
|
||||
'',
|
||||
].join('\r\n'))
|
||||
}
|
||||
|
||||
describe('upload controller', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mkdirMock.mockResolvedValue(undefined)
|
||||
writeFileMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('stores chat uploads under the request-scoped profile upload directory', async () => {
|
||||
const boundary = 'test-boundary'
|
||||
const { handleUpload } = await import('../../packages/server/src/controllers/upload')
|
||||
const ctx: any = {
|
||||
get: vi.fn((header: string) => header === 'content-type' ? `multipart/form-data; boundary=${boundary}` : ''),
|
||||
req: Readable.from([multipartBody(boundary, 'note.txt', 'hello')]),
|
||||
state: { profile: { name: 'research' } },
|
||||
body: undefined,
|
||||
status: 200,
|
||||
}
|
||||
|
||||
await handleUpload(ctx)
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith('/tmp/hermes-web-ui/upload/research', { recursive: true })
|
||||
expect(writeFileMock).toHaveBeenCalledOnce()
|
||||
const [savedPath, data] = writeFileMock.mock.calls[0]
|
||||
expect(savedPath).toMatch(/^\/tmp\/hermes-web-ui\/upload\/research\/[a-f0-9]+\.txt$/)
|
||||
expect(data.toString('utf-8')).toBe('hello')
|
||||
expect(ctx.body.files[0]).toMatchObject({ name: 'note.txt', path: savedPath })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user