Add user-scoped Hermes profile access

This commit is contained in:
ekko
2026-05-24 10:11:03 +08:00
committed by ekko
parent 56e7716302
commit 3f6a25d8f1
54 changed files with 2656 additions and 592 deletions
+283 -54
View File
@@ -1,24 +1,68 @@
import type { Context } from 'koa'
import { getCredentials, setCredentials, verifyCredentials, deleteCredentials } from '../services/credentials'
import { getToken } from '../services/auth'
import { checkPassword, recordPasswordFailure, recordPasswordSuccess, extractIp, getLockedIps, unlockIp, unlockAll } from '../services/login-limiter'
import {
DEFAULT_USERNAME,
bootstrapDefaultSuperAdmin,
countActiveSuperAdmins,
countUsers,
createUser,
deleteUser,
findFirstUser,
findUserById,
findUserByUsername,
listUsers,
updateUser,
updateUsername,
updateUserPassword,
verifyPassword,
type UserRole,
type UserStatus,
} from '../db/hermes/users-store'
import { issueUserJwt } from '../middleware/user-auth'
import { listProfileNamesFromDisk } from '../services/hermes/hermes-profile'
/**
* GET /api/auth/status
* Check if username/password login is configured (public).
*/
export async function authStatus(ctx: Context) {
const cred = await getCredentials()
const firstUser = findFirstUser()
ctx.body = {
hasPasswordLogin: !!cred,
username: cred?.username || null,
hasPasswordLogin: true,
username: firstUser?.username || DEFAULT_USERNAME,
hasUsers: countUsers() > 0,
}
}
/**
* GET /api/auth/me
* Return the authenticated account.
*/
export async function currentUser(ctx: Context) {
const userId = ctx.state.user?.id
const user = userId ? findUserById(userId) : null
if (!user) {
ctx.status = 404
ctx.body = { error: 'User not found' }
return
}
ctx.body = {
user: {
id: user.id,
username: user.username,
role: user.role,
status: user.status,
created_at: user.created_at,
updated_at: user.updated_at,
last_login_at: user.last_login_at,
},
}
}
/**
* POST /api/auth/login
* Authenticate with username/password (public).
* Returns the static token on success.
* Returns a user-scoped JWT on success.
*/
export async function login(ctx: Context) {
const { username, password } = ctx.request.body as { username?: string; password?: string }
@@ -36,18 +80,24 @@ export async function login(ctx: Context) {
return
}
const valid = await verifyCredentials(username, password)
if (!valid) {
const existingUserCount = countUsers()
const user = existingUserCount === 0
? bootstrapDefaultSuperAdmin(username, password)
: findUserByUsername(username)
if (!user || user.status !== 'active' || (existingUserCount > 0 && !verifyPassword(password, user.password_hash))) {
recordPasswordFailure(ip)
ctx.status = 401
ctx.body = { error: 'Invalid username or password' }
return
}
const token = await getToken()
if (!token) {
let token: string
try {
token = await issueUserJwt(user)
} catch (err: any) {
ctx.status = 500
ctx.body = { error: 'Auth is disabled on this server' }
ctx.body = { error: err?.message || 'Auth is disabled on this server' }
return
}
@@ -60,25 +110,8 @@ export async function login(ctx: Context) {
* Set up username/password (protected).
*/
export async function setupPassword(ctx: Context) {
const { username, password } = ctx.request.body as { username?: string; password?: string }
if (!username || !password) {
ctx.status = 400
ctx.body = { error: 'Username and password are required' }
return
}
if (username.length < 2) {
ctx.status = 400
ctx.body = { error: 'Username must be at least 2 characters' }
return
}
if (password.length < 6) {
ctx.status = 400
ctx.body = { error: 'Password must be at least 6 characters' }
return
}
await setCredentials(username, password)
ctx.body = { success: true }
ctx.status = 400
ctx.body = { error: 'Password login is managed by user accounts' }
}
/**
@@ -98,22 +131,15 @@ export async function changePassword(ctx: Context) {
return
}
const cred = await getCredentials()
if (!cred) {
ctx.status = 400
ctx.body = { error: 'Password login not configured' }
return
}
// Verify current password — use the username from stored credentials
const valid = await verifyCredentials(cred.username, currentPassword)
if (!valid) {
const userId = ctx.state.user?.id
const user = userId ? findUserById(userId) : null
if (!user || !verifyPassword(currentPassword, user.password_hash)) {
ctx.status = 400
ctx.body = { error: 'Current password is incorrect' }
return
}
await setCredentials(cred.username, newPassword)
updateUserPassword(user.id, newPassword)
ctx.body = { success: true }
}
@@ -134,22 +160,22 @@ export async function changeUsername(ctx: Context) {
return
}
const cred = await getCredentials()
if (!cred) {
ctx.status = 400
ctx.body = { error: 'Password login not configured' }
return
}
const valid = await verifyCredentials(cred.username, currentPassword)
if (!valid) {
const userId = ctx.state.user?.id
const user = userId ? findUserById(userId) : null
if (!user || !verifyPassword(currentPassword, user.password_hash)) {
ctx.status = 400
ctx.body = { error: 'Current password is incorrect' }
return
}
// Update username, keep the same password
await setCredentials(newUsername, currentPassword)
const existing = findUserByUsername(newUsername)
if (existing && existing.id !== user.id) {
ctx.status = 409
ctx.body = { error: 'Username already exists' }
return
}
updateUsername(user.id, newUsername)
ctx.body = { success: true }
}
@@ -158,8 +184,211 @@ export async function changeUsername(ctx: Context) {
* Remove username/password login (protected).
*/
export async function removePassword(ctx: Context) {
await deleteCredentials()
ctx.body = { success: true }
ctx.status = 400
ctx.body = { error: 'Password login cannot be removed for user accounts' }
}
function normalizeRole(value: unknown): UserRole | null {
return value === 'super_admin' || value === 'admin' ? value : null
}
function normalizeStatus(value: unknown): UserStatus | null {
return value === 'active' || value === 'disabled' ? value : null
}
function normalizeProfiles(value: unknown): string[] {
if (!Array.isArray(value)) return []
return [...new Set(value.map(item => String(item || '').trim()).filter(Boolean))]
}
function validateProfiles(profiles: string[]): string | null {
const available = new Set(listProfileNamesFromDisk())
const missing = profiles.find(profile => !available.has(profile))
return missing || null
}
/**
* GET /api/auth/users
* Super admin user management list.
*/
export async function listManagedUsers(ctx: Context) {
ctx.body = {
users: listUsers(),
profiles: listProfileNamesFromDisk(),
}
}
/**
* POST /api/auth/users
* Create a user account. Super admin only.
*/
export async function createManagedUser(ctx: Context) {
const body = ctx.request.body as {
username?: string
password?: string
role?: unknown
status?: unknown
profiles?: unknown
defaultProfile?: string | null
}
const username = String(body.username || '').trim()
const password = String(body.password || '')
const role = normalizeRole(body.role || 'admin')
const status = normalizeStatus(body.status || 'active')
const profiles = normalizeProfiles(body.profiles)
if (username.length < 2) {
ctx.status = 400
ctx.body = { error: 'Username must be at least 2 characters' }
return
}
if (password.length < 6) {
ctx.status = 400
ctx.body = { error: 'Password must be at least 6 characters' }
return
}
if (!role || !status) {
ctx.status = 400
ctx.body = { error: 'Invalid role or status' }
return
}
if (findUserByUsername(username)) {
ctx.status = 409
ctx.body = { error: 'Username already exists' }
return
}
const missingProfile = validateProfiles(profiles)
if (missingProfile) {
ctx.status = 400
ctx.body = { error: `Profile "${missingProfile}" does not exist` }
return
}
const user = createUser({
username,
password,
role,
status,
profiles: role === 'super_admin' ? [] : profiles,
defaultProfile: body.defaultProfile,
})
ctx.status = 201
ctx.body = { user, users: listUsers() }
}
/**
* PUT /api/auth/users/:id
* Update user account metadata, password, and profile bindings.
*/
export async function updateManagedUser(ctx: Context) {
const id = Number(ctx.params.id)
const user = Number.isInteger(id) ? findUserById(id) : null
if (!user) {
ctx.status = 404
ctx.body = { error: 'User not found' }
return
}
const body = ctx.request.body as {
username?: string
password?: string
role?: unknown
status?: unknown
profiles?: unknown
defaultProfile?: string | null
}
const username = body.username == null ? undefined : String(body.username).trim()
const password = body.password == null ? undefined : String(body.password)
const role = body.role == null ? undefined : normalizeRole(body.role)
const status = body.status == null ? undefined : normalizeStatus(body.status)
const profiles = body.profiles == null ? undefined : normalizeProfiles(body.profiles)
if (username !== undefined && username.length < 2) {
ctx.status = 400
ctx.body = { error: 'Username must be at least 2 characters' }
return
}
if (password !== undefined && password.length > 0 && password.length < 6) {
ctx.status = 400
ctx.body = { error: 'Password must be at least 6 characters' }
return
}
if (body.role != null && !role || body.status != null && !status) {
ctx.status = 400
ctx.body = { error: 'Invalid role or status' }
return
}
if (username && username !== user.username) {
const existing = findUserByUsername(username)
if (existing && existing.id !== user.id) {
ctx.status = 409
ctx.body = { error: 'Username already exists' }
return
}
}
const nextRole = role || user.role
const nextStatus = status || user.status
const currentUserId = ctx.state.user?.id
if (user.id === currentUserId && nextStatus !== 'active') {
ctx.status = 400
ctx.body = { error: 'You cannot disable your own account' }
return
}
if (user.role === 'super_admin' && user.status === 'active' && (nextRole !== 'super_admin' || nextStatus !== 'active') && countActiveSuperAdmins(user.id) === 0) {
ctx.status = 400
ctx.body = { error: 'At least one active super administrator is required' }
return
}
if (profiles) {
const missingProfile = validateProfiles(profiles)
if (missingProfile) {
ctx.status = 400
ctx.body = { error: `Profile "${missingProfile}" does not exist` }
return
}
}
updateUser({
userId: user.id,
username,
password: password || undefined,
role: role || undefined,
status: status || undefined,
profiles: nextRole === 'super_admin' ? [] : profiles,
defaultProfile: body.defaultProfile,
})
ctx.body = { user: findUserById(user.id), users: listUsers() }
}
/**
* DELETE /api/auth/users/:id
* Delete a user account. Super admin only.
*/
export async function deleteManagedUser(ctx: Context) {
const id = Number(ctx.params.id)
const user = Number.isInteger(id) ? findUserById(id) : null
if (!user) {
ctx.status = 404
ctx.body = { error: 'User not found' }
return
}
if (ctx.state.user?.id === user.id) {
ctx.status = 400
ctx.body = { error: 'You cannot delete your own account' }
return
}
if (user.role === 'super_admin' && user.status === 'active' && countActiveSuperAdmins(user.id) === 0) {
ctx.status = 400
ctx.body = { error: 'At least one active super administrator is required' }
return
}
deleteUser(user.id)
ctx.body = { success: true, users: listUsers() }
}
/**
@@ -10,6 +10,85 @@ import {
getExactSessionDetailFromDbWithProfile,
findLatestExactSessionIdWithProfile,
} from '../../db/hermes/sessions-db'
import { listUserProfiles } from '../../db/hermes/users-store'
const DEFAULT_PROFILE = 'default'
function profileName(value: string | null | undefined): string {
return value?.trim() || DEFAULT_PROFILE
}
function requestedProfile(ctx: Context): string | null {
return ctx.state?.profile?.name || null
}
function allowedProfileSet(ctx: Context): 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 visibleProfileSet(ctx: Context): Set<string> | null {
const profile = requestedProfile(ctx)
if (profile) return new Set([profile])
return allowedProfileSet(ctx)
}
function canUseProfile(ctx: Context, profile: string | null | undefined): boolean {
const allowed = allowedProfileSet(ctx)
return !allowed || allowed.has(profileName(profile))
}
function denyProfileAccess(ctx: Context, profile: string | null | undefined): boolean {
if (canUseProfile(ctx, profile)) return false
ctx.status = 403
ctx.body = { error: `Profile "${profileName(profile)}" is not available for this user` }
return true
}
function taskAssigneeProfile(task: { assignee: string | null }): string {
return profileName(task.assignee)
}
function filterTasksByVisibleProfiles(ctx: Context, tasks: kanbanCli.KanbanTask[]): kanbanCli.KanbanTask[] {
const visible = visibleProfileSet(ctx)
if (!visible) return tasks
return tasks.filter(task => visible.has(taskAssigneeProfile(task)))
}
function statsForTasks(tasks: kanbanCli.KanbanTask[]): kanbanCli.KanbanStats {
const by_status: Record<string, number> = {}
const by_assignee: Record<string, number> = {}
for (const task of tasks) {
by_status[task.status] = (by_status[task.status] || 0) + 1
const assignee = taskAssigneeProfile(task)
by_assignee[assignee] = (by_assignee[assignee] || 0) + 1
}
return { by_status, by_assignee, total: tasks.length }
}
function filterAssigneesByVisibleProfiles(ctx: Context, assignees: kanbanCli.KanbanAssignee[]): kanbanCli.KanbanAssignee[] {
const visible = visibleProfileSet(ctx)
if (!visible) return assignees
return assignees.filter(assignee => visible.has(profileName(assignee.name)))
}
async function getVisibleTasksForBoard(ctx: Context, board: string, opts: {
status?: string
assignee?: string
tenant?: string
includeArchived?: boolean
} = {}): Promise<kanbanCli.KanbanTask[]> {
if (opts.assignee && denyProfileAccess(ctx, opts.assignee)) return []
const tasks = await kanbanCli.listTasks({
board,
status: opts.status,
assignee: opts.assignee,
tenant: opts.tenant,
includeArchived: opts.includeArchived,
})
return filterTasksByVisibleProfiles(ctx, tasks)
}
function getLatestRunProfile(detail: { runs: Array<{ profile: string | null }> }): string | null {
return [...detail.runs].reverse().find(run => run.profile)?.profile || null
@@ -211,7 +290,8 @@ export async function list(ctx: Context) {
const board = requestBoard(ctx)
if (!board) return
try {
const tasks = await kanbanCli.listTasks({ board, status, assignee, tenant, includeArchived })
const tasks = await getVisibleTasksForBoard(ctx, board, { status, assignee, tenant, includeArchived })
if (ctx.status === 403) return
ctx.body = { tasks }
} catch (err: any) {
ctx.status = 500
@@ -229,6 +309,11 @@ export async function get(ctx: Context) {
ctx.body = { error: 'Task not found' }
return
}
if (!filterTasksByVisibleProfiles(ctx, [detail.task]).length) {
ctx.status = 404
ctx.body = { error: 'Task not found' }
return
}
// For terminal tasks, find related session from the worker's profile DB.
// Archived tasks can still carry the worker result/session users need to inspect.
@@ -291,10 +376,12 @@ export async function create(ctx: Context) {
const priority = optionalInteger(payload.priority, 'priority')
const tenant = optionalString(payload.tenant, 'tenant')
if (rejectBadRequest(ctx, title.error || body.error || assignee.error || priority.error || tenant.error)) return
const targetAssignee = assignee.value || requestedProfile(ctx) || undefined
if (targetAssignee && denyProfileAccess(ctx, targetAssignee)) return
const board = requestBoard(ctx)
if (!board) return
try {
const task = await kanbanCli.createTask(title.value!, { board, body: body.value, assignee: assignee.value, priority: priority.value, tenant: tenant.value })
const task = await kanbanCli.createTask(title.value!, { board, body: body.value, assignee: targetAssignee, priority: priority.value, tenant: tenant.value })
ctx.body = { task }
} catch (err: any) {
ctx.status = 500
@@ -357,6 +444,7 @@ export async function assign(ctx: Context) {
if (rejectBadRequest(ctx, bodyResult.error)) return
const profile = requiredNonEmptyString(bodyResult.body.profile, 'profile')
if (rejectBadRequest(ctx, profile.error)) return
if (denyProfileAccess(ctx, profile.value)) return
const board = requestBoard(ctx)
if (!board) return
try {
@@ -426,6 +514,7 @@ export async function bulkUpdateTasks(ctx: Context) {
const summary = optionalString(body.summary, 'summary')
const reason = optionalString(body.reason, 'reason')
if (rejectBadRequest(ctx, ids.error || status.error || assignee.error || archive.error || summary.error || reason.error)) return
if (assignee.value && denyProfileAccess(ctx, assignee.value)) return
if (!archive.value && status.value === undefined && !hasOwn(body, 'assignee')) {
ctx.status = 400
ctx.body = { error: 'at least one bulk action is required' }
@@ -516,6 +605,7 @@ export async function reassign(ctx: Context) {
const reclaim = optionalBoolean(body.reclaim, 'reclaim')
const reason = optionalString(body.reason, 'reason')
if (rejectBadRequest(ctx, profile.error || reclaim.error || reason.error)) return
if (denyProfileAccess(ctx, profile.value)) return
const board = requestBoard(ctx)
if (!board) return
try {
@@ -566,7 +656,10 @@ export async function stats(ctx: Context) {
const board = requestBoard(ctx)
if (!board) return
try {
const stats = await kanbanCli.getStats({ board })
const visible = visibleProfileSet(ctx)
const stats = visible
? statsForTasks(await getVisibleTasksForBoard(ctx, board, { includeArchived: true }))
: await kanbanCli.getStats({ board })
ctx.body = { stats }
} catch (err: any) {
ctx.status = 500
@@ -578,7 +671,7 @@ export async function assignees(ctx: Context) {
const board = requestBoard(ctx)
if (!board) return
try {
const assignees = await kanbanCli.getAssignees({ board })
const assignees = filterAssigneesByVisibleProfiles(ctx, await kanbanCli.getAssignees({ board }))
ctx.body = { assignees }
} catch (err: any) {
ctx.status = 500
@@ -628,6 +721,7 @@ export async function searchSessions(ctx: Context) {
ctx.body = { error: 'task_id and profile are required' }
return
}
if (denyProfileAccess(ctx, profile)) return
try {
if (!q) {
const exactSessionId = await findLatestExactSessionIdWithProfile(task_id, profile)
@@ -8,6 +8,7 @@ import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMe
import { readAppConfig, writeAppConfig, type ModelVisibilityRule } from '../../services/app-config'
import { getDb } from '../../db'
import { MODEL_CONTEXT_TABLE } from '../../db/hermes/schemas'
import { listUserProfiles } from '../../db/hermes/users-store'
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
@@ -194,6 +195,19 @@ function mergeAvailableGroups(groups: AvailableGroup[]): AvailableGroup[] {
type ProviderFetchCache = Map<string, Promise<string[]>>
function requestedProfileName(ctx: any): string {
const queryProfile = ctx.query?.profile
return typeof queryProfile === 'string' && queryProfile.trim() ? queryProfile.trim() : ''
}
function visibleProfileNamesForUser(ctx: any): string[] {
const diskProfiles = listProfileNamesFromDisk()
const user = ctx.state?.user
if (!user || user.role === 'super_admin') return diskProfiles
const allowed = new Set(listUserProfiles(user.id).map(profile => profile.profile_name))
return diskProfiles.filter(profile => allowed.has(profile))
}
function cachedProviderModels(
cache: ProviderFetchCache,
baseUrl: string,
@@ -379,17 +393,16 @@ async function buildAvailableForProfile(
export async function getAvailable(ctx: any) {
try {
const requestedProfile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim()
? ctx.query.profile.trim()
: ''
const requestedProfile = requestedProfileName(ctx)
if (!requestedProfile) {
const appConfig = await readAppConfig()
const modelAliases = normalizeAliases(appConfig.modelAliases)
const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility)
const customModels = normalizeCustomModels(appConfig.customModels)
const fetchCache: ProviderFetchCache = new Map()
const visibleProfiles = visibleProfileNamesForUser(ctx)
const profileResults = await Promise.all(
listProfileNamesFromDisk().map(profile => buildAvailableForProfile(profile, fetchCache, appConfig)),
visibleProfiles.map(profile => buildAvailableForProfile(profile, fetchCache, appConfig)),
)
const mergedGroups = mergeAvailableGroups(profileResults.flatMap(result => result.groups))
const groupsWithAliases = applyModelAliases(mergedGroups, modelAliases)
@@ -16,6 +16,7 @@ import { detectHermesRootHome } from '../../services/hermes/hermes-path'
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
import { HermesSkillInjector } from '../../services/hermes/skill-injector'
import type { HermesProfile } from '../../services/hermes/hermes-cli'
import { listUserProfiles } from '../../db/hermes/users-store'
const bridgeCleanupClient = () => new AgentBridgeClient({ connectRetryMs: 0, timeoutMs: 5000 })
@@ -127,6 +128,30 @@ function filterVisibleProfiles(profiles: HermesProfile[]): HermesProfile[] {
return profiles.filter(profile => !isForbiddenProfileName(profile.name))
}
function requestedProfileName(ctx: any): string {
return ctx.state?.profile?.name || ctx.get?.('x-hermes-profile') || getActiveProfileName()
}
function filterProfilesForUser(ctx: any, profiles: HermesProfile[]): HermesProfile[] {
const user = ctx.state?.user
if (!user || user.role === 'super_admin') return profiles
const allowed = new Set(listUserProfiles(user.id).map(profile => profile.profile_name))
return profiles.filter(profile => allowed.has(profile.name))
}
function canAccessProfile(ctx: any, profileName: string): boolean {
const user = ctx.state?.user
if (!user || user.role === 'super_admin') return true
return listUserProfiles(user.id).some(profile => profile.profile_name === profileName)
}
function denyProfile(ctx: any, profileName: string): boolean {
if (canAccessProfile(ctx, profileName)) return false
ctx.status = 403
ctx.body = { error: `Profile "${profileName}" is not available for this user` }
return true
}
function profileMetadataRoot(): string {
return join(getWebUiHome(), 'profile-metadata')
}
@@ -299,21 +324,12 @@ export async function list(ctx: any) {
profiles = listProfilesFromDisk('default')
}
// Override active flag from the authoritative source (active_profile file)
// CLI output may be stale, but the file is written by hermes profile use
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
const activeProfileName = getActiveProfileName()
const activeProfileName = requestedProfileName(ctx)
profiles = filterVisibleProfiles(profiles)
profiles = filterProfilesForUser(ctx, profiles)
// Check if CLI's active flag matches the file (warn if inconsistent)
const cliActive = profiles.find(p => p.active)
if (cliActive?.name !== activeProfileName) {
logger.warn('[listProfiles] CLI active flag (%s) differs from active_profile file (%s) - using file as authoritative source',
cliActive?.name || 'none', activeProfileName)
}
// Fix the active flag based on the actual active_profile file
// Web UI active profile is request-scoped and comes from X-Hermes-Profile.
profiles.forEach(p => {
p.active = (p.name === activeProfileName)
})
@@ -388,8 +404,10 @@ export async function create(ctx: any) {
}
export async function get(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
if (denyProfile(ctx, name)) return
try {
const profile = await hermesCli.getProfile(ctx.params.name)
const profile = await hermesCli.getProfile(name)
ctx.body = { profile: { ...profile, avatar: readProfileAvatar(profile.name) } }
} catch (err: any) {
ctx.status = err.message.includes('not found') ? 404 : 500
@@ -399,6 +417,7 @@ export async function get(ctx: any) {
export async function updateAvatar(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
if (denyProfile(ctx, name)) return
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved` }
@@ -438,6 +457,7 @@ export async function updateAvatar(ctx: any) {
export async function deleteAvatar(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
if (denyProfile(ctx, name)) return
try {
removeProfileMetadata(name)
ctx.body = { success: true }
@@ -449,6 +469,7 @@ export async function deleteAvatar(ctx: any) {
export async function runtimeStatus(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
if (denyProfile(ctx, name)) return
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved` }
@@ -465,7 +486,7 @@ export async function runtimeStatus(ctx: any) {
export async function runtimeStatuses(ctx: any) {
try {
const profiles = await listProfilesForStatus()
const profiles = filterProfilesForUser(ctx, await listProfilesForStatus())
const bridge = await readBridgeWorkers()
const statuses = await Promise.all(profiles.map(profile => buildRuntimeStatus(profile, bridge)))
ctx.body = { profiles: statuses }
@@ -487,6 +508,7 @@ async function listProfilesForStatus(): Promise<HermesProfile[]> {
export async function restartGatewayForProfile(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
if (denyProfile(ctx, name)) return
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved` }
@@ -509,6 +531,7 @@ export async function restartGatewayForProfile(ctx: any) {
export async function restartProfileRuntime(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
if (denyProfile(ctx, name)) return
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved` }
@@ -532,6 +555,7 @@ export async function restartProfileRuntime(ctx: any) {
export async function remove(ctx: any) {
const { name } = ctx.params
if (denyProfile(ctx, name)) return
if (name === 'default') {
ctx.status = 400
ctx.body = { error: 'Cannot delete the default profile' }
@@ -562,6 +586,7 @@ export async function remove(ctx: any) {
}
export async function rename(ctx: any) {
if (denyProfile(ctx, ctx.params.name)) return
const { new_name } = ctx.request.body as { new_name?: string }
if (!new_name) {
ctx.status = 400
@@ -596,32 +621,20 @@ export async function switchProfile(ctx: any) {
return
}
try {
if (denyProfile(ctx, name)) return
const output = await useProfileWithFallback(name)
// Verify the active_profile file immediately (Hermes CLI writes synchronously)
// Quick verification with 2 retries to handle edge cases (filesystem delays, concurrency)
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
let actualActive = getActiveProfileName()
// Quick retry (max 2 times, 100ms delay each)
for (let i = 0; i < 2; i++) {
if (actualActive === name) break
logger.debug('[switchProfile] Quick retry %d: current=%s, expected=%s', i + 1, actualActive, name)
await new Promise(r => setTimeout(r, 100))
actualActive = getActiveProfileName()
}
const actualActive = getActiveProfileName()
if (actualActive !== name) {
logger.error('[switchProfile] Verification failed: active_profile is %s (expected %s)', actualActive, name)
ctx.status = 500
ctx.body = { error: `Profile switch verification failed - active profile is ${actualActive}` }
return
}
// Destroy all bridge sessions so they get recreated with the new profile config
try {
await bridgeCleanupClient().destroyAll()
logger.info('[switchProfile] destroyed all bridge sessions for profile "%s"', name)
const result = await bridgeCleanupClient().destroyAll()
logger.info('[switchProfile] destroyed all bridge sessions for Hermes profile "%s" destroyed=%s', name, result.destroyed)
} catch (err: any) {
logger.warn(err, '[switchProfile] failed to destroy bridge sessions')
}
@@ -630,7 +643,6 @@ export async function switchProfile(ctx: any) {
const detail = await hermesCli.getProfile(name)
logger.debug('Profile detail.path = %s', detail.path)
// 确保配置文件存在,但不调用 setupReset()(会重置端口配置)
const profileConfig = join(detail.path, 'config.yaml')
if (!existsSync(profileConfig)) {
writeFileSync(profileConfig, '# Hermes Agent Configuration\n', 'utf-8')
@@ -647,20 +659,13 @@ export async function switchProfile(ctx: any) {
}
await injectBundledSkillsForProfile(name)
// TODO: re-enable pending session delete drain after confirming safety
// const drainResult = await SessionDeleter.getInstance().drain(name)
SessionDeleter.getInstance().switchProfile(name)
logger.info('[switchProfile] switched session deleter to profile "%s"', name)
// if (drainResult.failed.length > 0) {
// logger.warn({ profile: name, failed: drainResult.failed }, 'Failed to drain some pending session deletes after profile switch')
// }
logger.info('[switchProfile] switched session deleter to Hermes profile "%s"', name)
ctx.body = {
success: true,
message: output.trim(),
// drained_session_deletes: drainResult.deleted.length,
// failed_session_deletes: drainResult.failed.length,
active: name,
}
} catch (err: any) {
ctx.status = 500
@@ -670,6 +675,7 @@ export async function switchProfile(ctx: any) {
export async function exportProfile(ctx: any) {
const { name } = ctx.params
if (denyProfile(ctx, name)) return
const outputPath = join(tmpdir(), `hermes-profile-${name}.tar.gz`)
try {
await hermesCli.exportProfile(name, outputPath)
@@ -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: {