diff --git a/packages/client/src/utils/hermes/kanban-assignees.ts b/packages/client/src/utils/hermes/kanban-assignees.ts index cfc5a8e..6cf9099 100644 --- a/packages/client/src/utils/hermes/kanban-assignees.ts +++ b/packages/client/src/utils/hermes/kanban-assignees.ts @@ -14,14 +14,8 @@ export function withDefaultAssignee( byAssignee: Record = {}, ): KanbanAssigneeSummary[] { const defaultCount = byAssignee[DEFAULT_KANBAN_ASSIGNEE] || 0 - const hasDefault = assignees.some(assignee => assignee.name === DEFAULT_KANBAN_ASSIGNEE) - const normalized = assignees.map(assignee => { + return assignees.map(assignee => { if (assignee.name !== DEFAULT_KANBAN_ASSIGNEE || assignee.counts) return assignee return { ...assignee, counts: { total: defaultCount } } }) - if (hasDefault) return normalized - return [ - { name: DEFAULT_KANBAN_ASSIGNEE, counts: { total: defaultCount } }, - ...normalized, - ] } diff --git a/packages/server/src/controllers/hermes/memory.ts b/packages/server/src/controllers/hermes/memory.ts index 7c6f776..7a06b7a 100644 --- a/packages/server/src/controllers/hermes/memory.ts +++ b/packages/server/src/controllers/hermes/memory.ts @@ -1,9 +1,18 @@ -import { writeFile } from 'fs/promises' +import { mkdir, writeFile } from 'fs/promises' import { join } from 'path' -import { safeReadFile, safeStat, getHermesDir } from '../../services/config-helpers' +import { safeReadFile, safeStat } from '../../services/config-helpers' +import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile' + +function requestedProfile(ctx: any): string { + return ctx.state?.profile?.name || getActiveProfileName() || 'default' +} + +function requestProfileDir(ctx: any): string { + return getProfileDir(requestedProfile(ctx)) +} export async function get(ctx: any) { - const hd = getHermesDir() + const hd = requestProfileDir(ctx) const memoryPath = join(hd, 'memories', 'MEMORY.md') const userPath = join(hd, 'memories', 'USER.md') const soulPath = join(hd, 'SOUL.md') @@ -30,11 +39,13 @@ export async function save(ctx: any) { return } let filePath: string + const hd = requestProfileDir(ctx) if (section === 'soul') { - filePath = join(getHermesDir(), 'SOUL.md') + filePath = join(hd, 'SOUL.md') } else { const fileName = section === 'memory' ? 'MEMORY.md' : 'USER.md' - filePath = join(getHermesDir(), 'memories', fileName) + await mkdir(join(hd, 'memories'), { recursive: true }) + filePath = join(hd, 'memories', fileName) } try { await writeFile(filePath, content, 'utf-8') diff --git a/packages/server/src/controllers/hermes/skills.ts b/packages/server/src/controllers/hermes/skills.ts index 45e1ee8..3f89275 100644 --- a/packages/server/src/controllers/hermes/skills.ts +++ b/packages/server/src/controllers/hermes/skills.ts @@ -1,19 +1,26 @@ -import { readdir, readFile } from 'fs/promises' +import { mkdir, readdir, readFile, writeFile } from 'fs/promises' import { join, resolve } from 'path' import { createHash } from 'crypto' import { - readConfigYaml, updateConfigYaml, - safeReadFile, extractDescription, listFilesRecursive, getHermesDir, + readConfigYamlForProfile, updateConfigYamlForProfile, + safeReadFile, extractDescription, listFilesRecursive, } from '../../services/config-helpers' -import { pinSkill } from '../../services/hermes/hermes-cli' import { isPathWithin } from '../../services/hermes/hermes-path' -import { getActiveProfileName } from '../../services/hermes/hermes-profile' +import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile' import { getSkillUsageStatsFromDb } from '../../db/hermes/sessions-db' function requestedProfile(ctx: any): string { return ctx.state?.profile?.name || getActiveProfileName() || 'default' } +function requestProfileDir(ctx: any): string { + return getProfileDir(requestedProfile(ctx)) +} + +function requestSkillsDir(ctx: any): string { + return join(requestProfileDir(ctx), 'skills') +} + /** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */ function readBundledManifest(manifestContent: string | null): Map { const map = new Map() @@ -242,9 +249,9 @@ async function scanSkillsDir(skillsDir: string, bundledManifest: Map { + await updateConfigYamlForProfile(requestedProfile(ctx), (config) => { if (!config.skills) config.skills = {} if (!Array.isArray(config.skills.disabled)) config.skills.disabled = [] const disabled = config.skills.disabled as string[] @@ -322,10 +329,11 @@ export async function toggle(ctx: any) { export async function listFiles(ctx: any) { const { category, skill } = ctx.params - const hd = getHermesDir() - const skillsDir = join(hd, 'skills', category) + const profileDir = requestProfileDir(ctx) + const profileSkillsDir = join(profileDir, 'skills') + const skillsDir = join(profileSkillsDir, category) if (category === 'misc') { - const skillDir = join(hd, 'skills', skill) + const skillDir = join(profileSkillsDir, skill) try { const allFiles = await listFilesRecursive(skillDir, '') const files = allFiles.filter((f: any) => f.path !== 'SKILL.md') @@ -355,14 +363,14 @@ export async function listFiles(ctx: any) { export async function readFile_(ctx: any) { const filePath = (ctx.params as any).path - const hd = getHermesDir() + const profileSkillsDir = join(requestProfileDir(ctx), 'skills') // Handle 'misc' category: real skill dir is skills/, not skills/misc/ let realPath = filePath if (filePath.startsWith('misc/')) { realPath = filePath.slice(5) } - const fullPath = resolve(join(hd, 'skills', realPath)) - if (!isPathWithin(fullPath, join(hd, 'skills'))) { + const fullPath = resolve(join(profileSkillsDir, realPath)) + if (!isPathWithin(fullPath, profileSkillsDir)) { ctx.status = 403 ctx.body = { error: 'Access denied' } return @@ -376,7 +384,7 @@ export async function readFile_(ctx: any) { const category = parts[0] const skillName = parts[1] const restPath = parts.slice(2).join('/') - const catDir = join(hd, 'skills', category) + const catDir = join(profileSkillsDir, category) const skillDir = await findSkillDirByName(catDir, skillName) if (skillDir) { const resolvedPath = resolve(join(skillDir, restPath)) @@ -396,6 +404,24 @@ export async function readFile_(ctx: any) { ctx.body = { content } } +async function updatePinnedSkill(skillsDir: string, name: string, pinned: boolean): Promise { + await mkdir(skillsDir, { recursive: true }) + const usagePath = join(skillsDir, '.usage.json') + let usage: Record = {} + const raw = await safeReadFile(usagePath) + if (raw) { + try { + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) usage = parsed + } catch { /* rewrite malformed usage file with the requested pin state */ } + } + const current = usage[name] + usage[name] = current && typeof current === 'object' && !Array.isArray(current) + ? { ...current, pinned } + : { patch_count: 0, use_count: 0, view_count: 0, pinned } + await writeFile(usagePath, `${JSON.stringify(usage, null, 2)}\n`, 'utf-8') +} + export async function pin_(ctx: any) { const { name, pinned } = ctx.request.body as { name?: string; pinned?: boolean } if (!name || typeof pinned !== 'boolean') { @@ -404,7 +430,7 @@ export async function pin_(ctx: any) { return } try { - await pinSkill(name, pinned) + await updatePinnedSkill(requestSkillsDir(ctx), name, pinned) ctx.body = { success: true } } catch (err: any) { ctx.status = 500 diff --git a/tests/client/kanban-create-form.test.ts b/tests/client/kanban-create-form.test.ts index 99f95f2..afaa371 100644 --- a/tests/client/kanban-create-form.test.ts +++ b/tests/client/kanban-create-form.test.ts @@ -90,8 +90,8 @@ describe('KanbanCreateForm', () => { it('uses compact profile names for assignee options', () => { const wrapper = mount(KanbanCreateForm) - expect(wrapper.text()).toContain('default') expect(wrapper.text()).toContain('alice') + expect(wrapper.text()).not.toContain('default') expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks') }) }) diff --git a/tests/client/kanban-view.test.ts b/tests/client/kanban-view.test.ts index 4619ea0..8891542 100644 --- a/tests/client/kanban-view.test.ts +++ b/tests/client/kanban-view.test.ts @@ -224,8 +224,9 @@ describe('KanbanView', () => { expect(wrapper.text()).toContain('kanban.title: Default · kanban.stats.tasks: 0') expect(wrapper.text()).toContain('kanban.title: Project A · kanban.stats.tasks: 2') - expect(wrapper.text()).toContain('default') - expect(wrapper.text()).toContain('alice') + const assigneeSelect = wrapper.findAll('.n-select-stub')[2] + expect(assigneeSelect.text()).toContain('alice') + expect(assigneeSelect.text()).not.toContain('default') expect(wrapper.text()).not.toContain('kanban.detail.assignee: alice') expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks') }) diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts index 617f9eb..e36b283 100644 --- a/tests/e2e/auth.spec.ts +++ b/tests/e2e/auth.spec.ts @@ -8,37 +8,38 @@ test('redirects protected routes to the login screen without a token', async ({ await expect(page).toHaveURL(/#\/$/) await expect(page.getByRole('heading', { name: 'Hermes Web UI' })).toBeVisible() - await expect(page.getByPlaceholder('Access token')).toBeVisible() + await expect(page.getByPlaceholder('Username')).toBeVisible() + await expect(page.getByPlaceholder('Password')).toBeVisible() expect(api.unexpectedRequests).toEqual([]) }) -test('rejects an invalid access token without persisting it', async ({ page }) => { +test('rejects invalid credentials without persisting a token', async ({ page }) => { const api = await mockHermesApi(page, { tokenValidationStatus: 401 }) await page.goto('/') - await page.getByPlaceholder('Access token').fill('bad-token') + await page.getByPlaceholder('Username').fill('playwright') + await page.getByPlaceholder('Password').fill('bad-password') await page.getByRole('button', { name: 'Login' }).click() - await expect(page.getByText('Invalid token')).toBeVisible() + await expect(page.getByText('Invalid username or password')).toBeVisible() await expect(page).toHaveURL(/#\/$/) await expect(page.evaluate(() => window.localStorage.getItem('hermes_api_key'))).resolves.toBeNull() expect(api.unexpectedRequests).toEqual([]) }) -test('validates token login through the BFF before entering the app', async ({ page }) => { +test('logs in with password through the BFF before entering the app', async ({ page }) => { const api = await mockHermesApi(page) await page.goto('/') - await page.getByPlaceholder('Access token').fill(TEST_ACCESS_KEY) + await page.getByPlaceholder('Username').fill('playwright') + await page.getByPlaceholder('Password').fill('correct-password') await page.getByRole('button', { name: 'Login' }).click() await expect(page).toHaveURL(/#\/hermes\/chat$/) await expect(page.evaluate(() => window.localStorage.getItem('hermes_api_key'))).resolves.toBe(TEST_ACCESS_KEY) - const validationRequest = api.requests.find((request) => ( - request.pathname === '/api/hermes/sessions' && - request.headers.authorization === `Bearer ${TEST_ACCESS_KEY}` - )) - expect(validationRequest).toBeTruthy() + const loginRequest = api.requests.find((request) => request.pathname === '/api/auth/login') + expect(loginRequest?.method).toBe('POST') + expect(loginRequest?.postData).toBe(JSON.stringify({ username: 'playwright', password: 'correct-password' })) expect(api.unexpectedRequests).toEqual([]) }) diff --git a/tests/e2e/chat-streaming.spec.ts b/tests/e2e/chat-streaming.spec.ts index b0c1009..8e65377 100644 --- a/tests/e2e/chat-streaming.spec.ts +++ b/tests/e2e/chat-streaming.spec.ts @@ -101,7 +101,7 @@ test('uses the newly selected profile for the next chat-run socket after profile await page.getByTestId('profile-selector-select').click() await expect(page.getByRole('dialog').filter({ hasText: 'research' })).toBeVisible() const reloadPromise = page.waitForEvent('framenavigated', frame => frame === page.mainFrame()) - await page.locator('.profile-runtime-item').filter({ hasText: /^research/ }).getByRole('button', { name: 'Switch Profile' }).click() + await page.locator('.profile-runtime-item').filter({ hasText: /^research/ }).getByRole('button', { name: 'Switch Frontend Profile' }).click() await reloadPromise await page.waitForLoadState('domcontentloaded') await expect(page.getByTestId('profile-selector-select').filter({ hasText: 'research' })).toBeVisible() @@ -115,9 +115,7 @@ test('uses the newly selected profile for the next chat-run socket after profile expect(run.input).toBe('Use the active research profile') expect(await page.evaluate(() => window.localStorage.getItem('hermes_active_profile_name'))).toBe('research') - const switchRequest = api.requests.find((request) => request.pathname === '/api/hermes/profiles/active') - expect(switchRequest?.method).toBe('PUT') - expect(switchRequest?.postData).toBe(JSON.stringify({ name: 'research' })) + expect(api.requests.some((request) => request.pathname === '/api/hermes/profiles/active')).toBe(false) expect(api.unexpectedRequests).toEqual([]) }) diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index af45d9a..03ac1d3 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -98,7 +98,35 @@ export async function mockHermesApi(page: Page, options: MockHermesApiOptions = } if (pathname === '/api/auth/status') { - await route.fulfill(jsonResponse({ hasPasswordLogin: false, username: null })) + await route.fulfill(jsonResponse({ hasPasswordLogin: true, username: 'playwright' })) + return + } + + if (pathname === '/api/auth/login') { + if (request.method() !== 'POST') { + await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405)) + return + } + if (tokenValidationStatus !== 200) { + await route.fulfill(jsonResponse({ error: 'Invalid username or password' }, tokenValidationStatus)) + return + } + await route.fulfill(jsonResponse({ token: TEST_ACCESS_KEY })) + return + } + + if (pathname === '/api/auth/me') { + await route.fulfill(jsonResponse({ + user: { + id: 1, + username: 'playwright', + role: 'super_admin', + status: 'active', + created_at: 0, + updated_at: 0, + last_login_at: 0, + }, + })) return } @@ -234,7 +262,7 @@ export async function authenticate(page: Page, accessKey = TEST_ACCESS_KEY, prof await page.addInitScript((state: { storedToken: string; storedProfileName?: string }) => { const { storedToken, storedProfileName } = state window.localStorage.setItem('hermes_api_key', storedToken) - if (storedProfileName) { + if (storedProfileName && !window.localStorage.getItem('hermes_active_profile_name')) { window.localStorage.setItem('hermes_active_profile_name', storedProfileName) } }, { storedToken: accessKey, storedProfileName: profileName }) diff --git a/tests/server/run-chat-bridge-final-context.test.ts b/tests/server/run-chat-bridge-final-context.test.ts index c9ddd1c..b06db80 100644 --- a/tests/server/run-chat-bridge-final-context.test.ts +++ b/tests/server/run-chat-bridge-final-context.test.ts @@ -92,6 +92,7 @@ function makeSocket() { connected: true, emit: vi.fn(), join: vi.fn(), + to: vi.fn(() => ({ emit: vi.fn() })), } as any } diff --git a/tests/server/skills-controller.test.ts b/tests/server/skills-controller.test.ts index f281cd6..173148f 100644 --- a/tests/server/skills-controller.test.ts +++ b/tests/server/skills-controller.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mockGetSkillUsageStatsFromDb = vi.hoisted(() => vi.fn()) const mockGetActiveProfileName = vi.hoisted(() => vi.fn()) +const mockGetProfileDir = vi.hoisted(() => vi.fn()) +const mockUpdateConfigYamlForProfile = vi.hoisted(() => vi.fn()) vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({ getSkillUsageStatsFromDb: mockGetSkillUsageStatsFromDb, @@ -9,10 +11,15 @@ vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({ vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({ getActiveProfileName: mockGetActiveProfileName, + getProfileDir: mockGetProfileDir, })) -vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({ - pinSkill: vi.fn(), +vi.mock('../../packages/server/src/services/config-helpers', () => ({ + readConfigYamlForProfile: vi.fn(), + updateConfigYamlForProfile: mockUpdateConfigYamlForProfile, + safeReadFile: vi.fn(), + extractDescription: vi.fn(), + listFilesRecursive: vi.fn(), })) async function loadController() { @@ -24,6 +31,8 @@ describe('skills controller', () => { beforeEach(() => { vi.clearAllMocks() mockGetActiveProfileName.mockReturnValue('default') + mockGetProfileDir.mockImplementation((profile: string) => `/tmp/hermes-${profile}`) + mockUpdateConfigYamlForProfile.mockImplementation(async (_profile: string, updater: (config: Record) => Record) => updater({})) mockGetSkillUsageStatsFromDb.mockResolvedValue({ period_days: 7, summary: { @@ -56,4 +65,27 @@ describe('skills controller', () => { expect(mockGetSkillUsageStatsFromDb).toHaveBeenCalledWith(7, undefined, 'travel') }) + + it('toggles skills in the request-scoped profile config', async () => { + let updatedConfig: Record | undefined + mockUpdateConfigYamlForProfile.mockImplementation(async (_profile: string, updater: (config: Record) => Record) => { + updatedConfig = await updater({ skills: { disabled: ['old-skill'] }, model: { default: 'glm-5.1' } }) + return undefined + }) + const { toggle } = await loadController() + const ctx: any = { + request: { body: { name: 'new-skill', enabled: false } }, + state: { profile: { name: 'research' } }, + body: null, + } + + await toggle(ctx) + + expect(mockUpdateConfigYamlForProfile).toHaveBeenCalledWith('research', expect.any(Function)) + expect(updatedConfig).toEqual({ + skills: { disabled: ['old-skill', 'new-skill'] }, + model: { default: 'glm-5.1' }, + }) + expect(ctx.body).toEqual({ success: true }) + }) })