From a7f0a92fe61500f2538bc812588b11197f846836 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Sun, 24 May 2026 17:54:17 +0800 Subject: [PATCH] fix session profile listing and cli sqlite warning (#971) --- bin/hermes-web-ui.mjs | 13 +- packages/client/src/api/client.ts | 9 ++ .../server/src/controllers/hermes/sessions.ts | 11 +- tests/client/api.test.ts | 12 +- tests/server/cli-port-detection.test.ts | 4 +- tests/server/sessions-controller.test.ts | 121 ++++++++++++++++++ 6 files changed, 159 insertions(+), 11 deletions(-) diff --git a/bin/hermes-web-ui.mjs b/bin/hermes-web-ui.mjs index 0e11ba3..3f88d78 100755 --- a/bin/hermes-web-ui.mjs +++ b/bin/hermes-web-ui.mjs @@ -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 { diff --git a/packages/client/src/api/client.ts b/packages/client/src/api/client.ts index 6493f5f..ed5c695 100644 --- a/packages/client/src/api/client.ts +++ b/packages/client/src/api/client.ts @@ -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 } })) diff --git a/packages/server/src/controllers/hermes/sessions.ts b/packages/server/src/controllers/hermes/sessions.ts index a748167..6d62dfb 100644 --- a/packages/server/src/controllers/hermes/sessions.ts +++ b/packages/server/src/controllers/hermes/sessions.ts @@ -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 | 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 = { diff --git a/tests/client/api.test.ts b/tests/client/api.test.ts index 451d492..cbc92c5 100644 --- a/tests/client/api.test.ts +++ b/tests/client/api.test.ts @@ -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 }) }) diff --git a/tests/server/cli-port-detection.test.ts b/tests/server/cli-port-detection.test.ts index 2386ad8..907d4aa 100644 --- a/tests/server/cli-port-detection.test.ts +++ b/tests/server/cli-port-detection.test.ts @@ -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) diff --git a/tests/server/sessions-controller.test.ts b/tests/server/sessions-controller.test.ts index 8abbf0b..bd493c8 100644 --- a/tests/server/sessions-controller.test.ts +++ b/tests/server/sessions-controller.test.ts @@ -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')