Add user-scoped Hermes profile access

This commit is contained in:
ekko
2026-05-23 18:44:53 +08:00
committed by ekko
parent 56e7716302
commit 3f6a25d8f1
54 changed files with 2656 additions and 592 deletions
@@ -1,5 +1,5 @@
import * as hermesCli from '../../services/hermes/hermes-cli'
import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb, getExactSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db'
import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb, getSessionDetailFromDbWithProfile, getExactSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db'
import {
listSessions as localListSessions,
searchSessions as localSearchSessions,
@@ -17,6 +17,7 @@ import { isPathWithin } from '../../services/hermes/hermes-path'
import { getGroupChatServer } from '../../routes/hermes/group-chat'
import { logger } from '../../services/logger'
import type { ConversationSummary } from '../../services/hermes/conversations'
import { listUserProfiles } from '../../db/hermes/users-store'
function getPendingDeletedSessionIds(): Set<string> {
return getGroupChatServer()?.getStorage().getPendingDeletedSessionIds() || new Set<string>()
@@ -32,6 +33,35 @@ function filterPendingDeletedConversationSummaries(items: ConversationSummary[])
return filterPendingDeletedSessions(items)
}
function requestedProfile(ctx: any): string | undefined {
const value = ctx.state?.profile?.name || (typeof ctx.query?.profile === 'string' ? ctx.query.profile.trim() : '')
return value || undefined
}
function allowedProfileSet(ctx: any): Set<string> | null {
const user = ctx.state?.user
if (!user || user.role === 'super_admin') return null
return new Set(listUserProfiles(user.id).map(profile => profile.profile_name))
}
function canAccessProfile(ctx: any, profile: string | null | undefined): boolean {
const allowed = allowedProfileSet(ctx)
return !allowed || allowed.has(profile || 'default')
}
function filterByAllowedProfiles<T>(ctx: any, items: T[]): T[] {
const allowed = allowedProfileSet(ctx)
if (!allowed) return items
return items.filter(item => allowed.has(((item as any).profile as string | null | undefined) || 'default'))
}
function denySessionAccess(ctx: any, session: any | null | undefined): boolean {
if (!session || canAccessProfile(ctx, session.profile)) return false
ctx.status = 403
ctx.body = { error: `Profile "${session.profile || 'default'}" is not available for this user` }
return true
}
interface HermesDeleteResult {
attempted: boolean
deleted: boolean
@@ -73,10 +103,11 @@ export async function listConversations(ctx: any) {
const source = (ctx.query.source as string) || undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
const profile = getActiveProfileName()
const profile = requestedProfile(ctx)
const sessions = localListSessions(profile, source, limit && limit > 0 ? limit : 200)
const summaries: ConversationSummary[] = sessions.map(s => ({
id: s.id,
profile: s.profile || null,
source: s.source,
model: s.model,
provider: s.provider,
@@ -100,7 +131,7 @@ export async function listConversations(ctx: any) {
is_active: s.ended_at == null && (Date.now() / 1000 - s.last_active) <= 300,
thread_session_count: 1,
}))
ctx.body = { sessions: filterPendingDeletedConversationSummaries(summaries) }
ctx.body = { sessions: filterPendingDeletedConversationSummaries(filterByAllowedProfiles(ctx, summaries)) }
}
export async function getConversationMessages(ctx: any) {
@@ -112,6 +143,7 @@ export async function getConversationMessages(ctx: any) {
ctx.body = { error: 'Conversation not found' }
return
}
if (denySessionAccess(ctx, detail)) return
const messages = detail.messages
.filter(m => {
if (humanOnly && m.role !== 'user' && m.role !== 'assistant') return false
@@ -136,15 +168,13 @@ export async function getConversationMessages(ctx: any) {
export async function list(ctx: any) {
const source = (ctx.query.source as string) || undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
const profile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim()
? ctx.query.profile.trim()
: undefined
const profile = requestedProfile(ctx)
const effectiveLimit = limit && limit > 0 ? limit : 2000
const allSessions = localListSessions(profile, source, effectiveLimit)
const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk())
ctx.body = {
sessions: filterPendingDeletedSessions(allSessions.filter(s =>
sessions: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, allSessions).filter(s =>
(s.source === 'api_server' || s.source === 'cli') &&
(!knownProfiles || knownProfiles.has(s.profile || 'default')),
)),
@@ -158,23 +188,22 @@ export async function list(ctx: any) {
export async function listHermesSessions(ctx: any) {
const source = (ctx.query.source as string) || undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
const profile = getActiveProfileName()
const profile = requestedProfile(ctx)
const effectiveLimit = limit && limit > 0 ? limit : 2000
const allSessions = await listSessionSummaries(source, effectiveLimit, profile)
ctx.body = { sessions: filterPendingDeletedSessions(allSessions.filter(s => s.source !== 'api_server')) }
const allSessions = (await listSessionSummaries(source, effectiveLimit, profile))
.map(session => profile ? { ...session, profile } : session)
ctx.body = { sessions: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, allSessions).filter(s => s.source !== 'api_server')) }
}
export async function search(ctx: any) {
const q = typeof ctx.query.q === 'string' ? ctx.query.q : ''
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
const profile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim()
? ctx.query.profile.trim()
: undefined
const profile = requestedProfile(ctx)
const results = localSearchSessions(profile, q, limit && limit > 0 ? limit : 20)
const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk())
ctx.body = {
results: filterPendingDeletedSessions(results.filter(s =>
results: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, results).filter(s =>
!knownProfiles || knownProfiles.has(s.profile || 'default'),
)),
}
@@ -187,6 +216,7 @@ export async function get(ctx: any) {
ctx.body = { error: 'Session not found' }
return
}
if (denySessionAccess(ctx, session)) return
ctx.body = { session }
}
@@ -195,20 +225,28 @@ export async function get(ctx: any) {
* GET /api/hermes/sessions/hermes/:id
*/
export async function getHermesSession(ctx: any) {
const profile = requestedProfile(ctx)
// Prefer the Web UI local session store. Hermes state.db can lag behind or
// miss messages for Bridge-backed runs, while the local store is the source
// used by chat rendering and compression.
const localSession = localGetSessionDetail(ctx.params.id)
if (localSession && localSession.source !== 'api_server') {
const localSessionProfile = (localSession?.profile || 'default') as string
if (localSession && localSession.source !== 'api_server' && (!profile || localSessionProfile === profile)) {
if (denySessionAccess(ctx, localSession)) return
ctx.body = { session: localSession }
return
}
// Try Hermes state.db next (consistent with listHermesSessions)
try {
const session = await getSessionDetailFromDb(ctx.params.id)
const session = profile
? await getSessionDetailFromDbWithProfile(ctx.params.id, profile)
: await getSessionDetailFromDb(ctx.params.id)
if (session && session.source !== 'api_server') {
ctx.body = { session }
const sessionWithProfile = profile ? { ...session, profile } : session
if (denySessionAccess(ctx, sessionWithProfile)) return
ctx.body = { session: sessionWithProfile }
return
}
} catch (err) {
@@ -228,13 +266,15 @@ export async function getHermesSession(ctx: any) {
ctx.body = { error: 'Session not found' }
return
}
if (denySessionAccess(ctx, session)) return
ctx.body = { session }
}
export async function remove(ctx: any) {
const sessionId = ctx.params.id
const existing = localGetSession(sessionId)
const hermesProfile = existing?.profile || getActiveProfileName()
if (denySessionAccess(ctx, existing)) return
const hermesProfile = requestedProfile(ctx) || existing?.profile || getActiveProfileName()
const hermes = await deleteHermesSessionIfPresent(sessionId, hermesProfile)
const localDeleted = existing ? localDeleteSession(sessionId) : true
if (!localDeleted) {
@@ -272,6 +312,11 @@ export async function batchRemove(ctx: any) {
for (const id of validIds) {
const existing = localGetSession(id)
if (existing && !canAccessProfile(ctx, existing.profile)) {
results.failed++
results.errors.push({ id, error: `Profile "${existing.profile || 'default'}" is not available for this user` })
continue
}
const hermes = await deleteHermesSessionIfPresent(id, existing?.profile)
if (hermes.deleted) {
results.hermesDeleted++
@@ -304,6 +349,8 @@ export async function usageBatch(ctx: any) {
}
export async function usageSingle(ctx: any) {
const session = localGetSession(ctx.params.id)
if (denySessionAccess(ctx, session)) return
const result = getUsage(ctx.params.id)
if (!result) {
ctx.body = { input_tokens: 0, output_tokens: 0 }
@@ -319,6 +366,8 @@ export async function rename(ctx: any) {
ctx.body = { error: 'title is required' }
return
}
const existing = localGetSession(ctx.params.id)
if (denySessionAccess(ctx, existing)) return
const ok = localRenameSession(ctx.params.id, title.trim())
if (!ok) {
ctx.status = 500
@@ -336,10 +385,11 @@ export async function setWorkspace(ctx: any) {
return
}
const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store')
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
const id = ctx.params.id
if (!getSession(id)) {
createSession({ id, profile: getActiveProfileName(), title: '' })
const existing = getSession(id)
if (denySessionAccess(ctx, existing)) return
if (!existing) {
createSession({ id, profile: requestedProfile(ctx) || 'default', title: '' })
}
updateSession(id, { workspace: workspace || null } as any)
ctx.body = { ok: true }
@@ -358,17 +408,18 @@ export async function setModel(ctx: any) {
return
}
const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store')
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
const id = ctx.params.id
if (!getSession(id)) {
createSession({ id, profile: getActiveProfileName(), title: '' })
const existing = getSession(id)
if (denySessionAccess(ctx, existing)) return
if (!existing) {
createSession({ id, profile: requestedProfile(ctx) || 'default', title: '' })
}
updateSession(id, { model: model.trim(), provider: (provider || '').trim() } as any)
ctx.body = { ok: true }
}
export async function contextLength(ctx: any) {
const profile = (ctx.query.profile as string) || undefined
const profile = requestedProfile(ctx)
const model = typeof ctx.query.model === 'string' ? ctx.query.model : undefined
const provider = typeof ctx.query.provider === 'string' ? ctx.query.provider : undefined
ctx.body = { context_length: getModelContextLength({ profile, model, provider }) }
@@ -484,6 +535,7 @@ export async function exportSession(ctx: any) {
ctx.body = { error: 'Session not found' }
return
}
if (denySessionAccess(ctx, session)) return
const mode = (ctx.query.mode as string) || 'full'
const ext = (ctx.query.ext as string) || (mode === 'compressed' ? 'txt' : 'json')
@@ -560,6 +612,7 @@ export async function getConversationMessagesPaginated(ctx: any) {
ctx.body = { error: 'Conversation not found' }
return
}
if (denySessionAccess(ctx, result.session)) return
ctx.body = {
session: {