fix session profile listing and cli sqlite warning (#971)

This commit is contained in:
ekko
2026-05-24 17:54:17 +08:00
committed by GitHub
parent f61a1d9454
commit a7f0a92fe6
6 changed files with 159 additions and 11 deletions
+8 -5
View File
@@ -5,7 +5,6 @@ import { fileURLToPath } from 'url'
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync, chmodSync, statSync, existsSync, realpathSync } from 'fs'
import { randomBytes, scryptSync } from 'crypto'
import { homedir } from 'os'
import { DatabaseSync } from 'node:sqlite'
const __dirname = dirname(fileURLToPath(import.meta.url))
const __filename = fileURLToPath(import.meta.url)
@@ -497,9 +496,10 @@ function hashPassword(password) {
return `scrypt:${salt}:${hash}`
}
function resetDefaultLogin(options = {}) {
async function resetDefaultLogin(options = {}) {
const { silent = false } = options
mkdirSync(WEB_UI_HOME, { recursive: true })
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(WEB_UI_DB_FILE)
try {
db.exec(`
@@ -545,7 +545,7 @@ function resetDefaultLogin(options = {}) {
}
}
function main() {
async function main() {
const command = process.argv[2] || 'start'
if (['-v', '--version', 'version'].includes(command)) {
@@ -604,7 +604,7 @@ Options:
break
}
case 'reset-default-login':
resetDefaultLogin()
await resetDefaultLogin()
break
case 'update':
case 'upgrade':
@@ -690,7 +690,10 @@ function runUpdateInstall(npm) {
}
if (process.argv[1] && realpathSync(resolve(process.argv[1])) === __filename) {
main()
main().catch(err => {
console.error(`${err?.message || err}`)
process.exit(1)
})
}
export {
+9
View File
@@ -65,12 +65,21 @@ function shouldAttachProfileHeader(path: string, options: RequestInit): boolean
const url = new URL(path, 'http://hermes.local')
if (url.searchParams.has('profile')) return false
if (url.pathname.startsWith('/api/hermes/profiles')) return false
if (isProfileWideSessionCollection(url.pathname)) return false
} catch {
if (path.startsWith('/api/hermes/profiles')) return false
if (isProfileWideSessionCollection(path.split('?')[0] || path)) return false
}
return !bodyHasProfileSelector(options.body)
}
function isProfileWideSessionCollection(pathname: string): boolean {
return pathname === '/api/hermes/sessions' ||
pathname === '/api/hermes/search/sessions' ||
pathname === '/api/hermes/sessions/search' ||
pathname === '/api/hermes/sessions/conversations'
}
function emitAuthNotice(kind: 'expired' | 'forbidden') {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent('hermes-auth-notice', { detail: { kind } }))
@@ -38,6 +38,11 @@ function requestedProfile(ctx: any): string | undefined {
return value || undefined
}
function explicitProfileFilter(ctx: any): string | undefined {
const value = 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
@@ -103,7 +108,7 @@ 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 = requestedProfile(ctx)
const profile = explicitProfileFilter(ctx)
const sessions = localListSessions(profile, source, limit && limit > 0 ? limit : 200)
const summaries: ConversationSummary[] = sessions.map(s => ({
id: s.id,
@@ -168,7 +173,7 @@ 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 = requestedProfile(ctx)
const profile = explicitProfileFilter(ctx)
const effectiveLimit = limit && limit > 0 ? limit : 2000
const allSessions = localListSessions(profile, source, effectiveLimit)
@@ -199,7 +204,7 @@ export async function listHermesSessions(ctx: any) {
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 = requestedProfile(ctx)
const profile = explicitProfileFilter(ctx)
const results = localSearchSessions(profile, q, limit && limit > 0 ? limit : 20)
const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk())
ctx.body = {
+11 -1
View File
@@ -79,12 +79,22 @@ describe('API Client', () => {
localStorage.setItem('hermes_active_profile_name', 'default')
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
await request('/api/hermes/sessions')
await request('/api/hermes/sessions/session-1')
const [, options] = mockFetch.mock.calls[0]
expect(options.headers['X-Hermes-Profile']).toBe('default')
})
it('does not add the active profile header to profile-wide session collection requests', async () => {
localStorage.setItem('hermes_active_profile_name', 'research')
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
await request('/api/hermes/sessions')
const [, options] = mockFetch.mock.calls[0]
expect(options.headers['X-Hermes-Profile']).toBeUndefined()
})
it('does not add Authorization header when no token', async () => {
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
+2 -2
View File
@@ -132,7 +132,7 @@ describe('CLI port detection', () => {
try {
const { resetDefaultLogin } = await loadCli()
const created = resetDefaultLogin({ silent: true })
const created = await resetDefaultLogin({ silent: true })
expect(created.action).toBe('created')
const db = new DatabaseSync(dbPath)
@@ -144,7 +144,7 @@ describe('CLI port detection', () => {
db.close()
}
const updated = resetDefaultLogin({ silent: true })
const updated = await resetDefaultLogin({ silent: true })
expect(updated.action).toBe('updated')
const verifyDb = new DatabaseSync(dbPath)
+121
View File
@@ -22,6 +22,7 @@ const getLocalUsageStatsMock = vi.fn()
const getActiveProfileNameMock = vi.fn()
const loggerWarnMock = vi.fn()
const getCompressionSnapshotMock = vi.fn()
const listUserProfilesMock = vi.fn()
vi.mock('../../packages/server/src/db/hermes/conversations-db', () => ({
listConversationSummariesFromDb: listConversationSummariesFromDbMock,
@@ -68,6 +69,10 @@ vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
updateSession: localUpdateSessionMock,
}))
vi.mock('../../packages/server/src/db/hermes/users-store', () => ({
listUserProfiles: listUserProfilesMock,
}))
vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
deleteUsage: vi.fn(),
getUsage: vi.fn(),
@@ -130,6 +135,8 @@ describe('session conversations controller', () => {
getActiveProfileNameMock.mockReturnValue('default')
loggerWarnMock.mockReset()
getCompressionSnapshotMock.mockReset()
listUserProfilesMock.mockReset()
listUserProfilesMock.mockReturnValue([])
})
it('lists conversations from the local session store', async () => {
@@ -165,6 +172,120 @@ describe('session conversations controller', () => {
expect(ctx.body.sessions[0]).toMatchObject({ id: 'local-conversation', source: 'cli', title: 'Local' })
})
it('lists all account-accessible single-chat sessions when only the active profile header is present', async () => {
listUserProfilesMock.mockReturnValue([{ profile_name: 'default' }, { profile_name: 'travel' }])
localListSessionsMock.mockReturnValue([
{
id: 'default-session',
profile: 'default',
source: 'cli',
model: 'gpt-5',
title: 'Default',
started_at: 1,
ended_at: null,
last_active: 3,
message_count: 1,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: null,
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: '',
},
{
id: 'travel-session',
profile: 'travel',
source: 'cli',
model: 'gpt-5',
title: 'Travel',
started_at: 2,
ended_at: null,
last_active: 4,
message_count: 1,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: null,
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: '',
},
{
id: 'secret-session',
profile: 'secret',
source: 'cli',
model: 'gpt-5',
title: 'Secret',
started_at: 3,
ended_at: null,
last_active: 5,
message_count: 1,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: null,
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: '',
},
])
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = {
query: {},
state: {
user: { id: 1, role: 'admin' },
profile: { name: 'travel' },
},
body: null,
}
await mod.list(ctx)
expect(localListSessionsMock).toHaveBeenCalledWith(undefined, undefined, 2000)
expect(ctx.body.sessions.map((session: any) => session.id)).toEqual(['default-session', 'travel-session'])
})
it('filters the single-chat session list when profile is explicitly provided', async () => {
localListSessionsMock.mockReturnValue([])
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = {
query: { profile: 'travel' },
state: { profile: { name: 'default' } },
body: null,
}
await mod.list(ctx)
expect(localListSessionsMock).toHaveBeenCalledWith('travel', undefined, 2000)
})
it('searches all account-accessible single-chat sessions unless profile is explicit', async () => {
localSearchSessionsMock.mockReturnValue([])
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = {
query: { q: 'docker', limit: '10' },
state: { profile: { name: 'travel' } },
body: null,
}
await mod.search(ctx)
expect(localSearchSessionsMock).toHaveBeenCalledWith(undefined, 'docker', 10)
})
it('propagates local session store errors for conversation summaries', async () => {
localListSessionsMock.mockImplementation(() => {
throw new Error('db unavailable')