Add default credential reset safeguards

This commit is contained in:
ekko
2026-05-24 09:49:21 +08:00
committed by ekko
parent 9708a6a521
commit f8a1b2f6ae
22 changed files with 565 additions and 7 deletions
+72
View File
@@ -1,4 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { scryptSync, timingSafeEqual } from 'crypto'
import { DatabaseSync } from 'node:sqlite'
type ChildProcessMocks = {
execFileSync: ReturnType<typeof vi.fn>
@@ -21,10 +26,20 @@ async function loadCli(overrides: Partial<ChildProcessMocks> = {}) {
}
}
function verifyPassword(password: string, passwordHash: string): boolean {
const [scheme, salt, expectedHex] = passwordHash.split(':')
if (scheme !== 'scrypt' || !salt || !expectedHex) return false
const expected = Buffer.from(expectedHex, 'hex')
const actual = scryptSync(password, salt, expected.length)
return actual.length === expected.length && timingSafeEqual(actual, expected)
}
describe('CLI port detection', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
const originalEnv = { ...process.env }
afterEach(() => {
process.env = { ...originalEnv }
vi.doUnmock('child_process')
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -89,4 +104,61 @@ describe('CLI port detection', () => {
8648,
)).toEqual([2468])
})
it('clears the login lock file from the configured Web UI home', async () => {
const home = mkdtempSync(join(tmpdir(), 'hermes-web-ui-cli-locks-'))
process.env.HERMES_WEB_UI_HOME = home
const lockFile = join(home, '.login-lock.json')
writeFileSync(lockFile, '{"passwordIpMap":{}}\n')
try {
const { clearLoginLocks } = await loadCli()
const result = clearLoginLocks({ silent: true, checkRunning: false })
expect(result).toEqual({ path: lockFile, removed: true, serverRunning: false })
expect(existsSync(lockFile)).toBe(false)
const second = clearLoginLocks({ silent: true, checkRunning: false })
expect(second).toEqual({ path: lockFile, removed: false, serverRunning: false })
} finally {
rmSync(home, { recursive: true, force: true })
}
})
it('resets an existing admin user to the default password', async () => {
const home = mkdtempSync(join(tmpdir(), 'hermes-web-ui-cli-default-login-'))
process.env.HERMES_WEB_UI_HOME = home
const dbPath = join(home, 'hermes-web-ui.db')
try {
const { resetDefaultLogin } = await loadCli()
const created = resetDefaultLogin({ silent: true })
expect(created.action).toBe('created')
const db = new DatabaseSync(dbPath)
try {
const initial = db.prepare('SELECT id, username, password_hash FROM users WHERE username = ?').get('admin') as any
expect(verifyPassword('123456', initial.password_hash)).toBe(true)
db.prepare('UPDATE users SET password_hash = ? WHERE username = ?').run('scrypt:bad:bad', 'admin')
} finally {
db.close()
}
const updated = resetDefaultLogin({ silent: true })
expect(updated.action).toBe('updated')
const verifyDb = new DatabaseSync(dbPath)
try {
const rows = verifyDb.prepare('SELECT id, username, password_hash, role, status FROM users WHERE username = ?').all('admin') as any[]
expect(rows).toHaveLength(1)
expect(verifyPassword('123456', rows[0].password_hash)).toBe(true)
expect(rows[0].role).toBe('super_admin')
expect(rows[0].status).toBe('active')
} finally {
verifyDb.close()
}
} finally {
rmSync(home, { recursive: true, force: true })
}
})
})
+75
View File
@@ -4,6 +4,20 @@ import { tmpdir } from 'os'
import { join } from 'path'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const agentBridgeMocks = vi.hoisted(() => ({
destroyAll: vi.fn(),
destroyProfile: vi.fn(),
}))
const skillInjectorMocks = vi.hoisted(() => ({
injectMissingSkills: vi.fn(),
resolveTargetDirForProfile: vi.fn(),
}))
const sessionDeleterMocks = vi.hoisted(() => ({
switchProfile: vi.fn(),
}))
// Mock hermes-cli
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
listProfiles: vi.fn(),
@@ -20,6 +34,27 @@ vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
importProfile: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({
AgentBridgeClient: vi.fn(() => ({
destroyAll: agentBridgeMocks.destroyAll,
destroyProfile: agentBridgeMocks.destroyProfile,
})),
}))
vi.mock('../../packages/server/src/services/hermes/skill-injector', () => {
const HermesSkillInjector = vi.fn(() => ({
injectMissingSkills: skillInjectorMocks.injectMissingSkills,
})) as any
HermesSkillInjector.resolveTargetDirForProfile = skillInjectorMocks.resolveTargetDirForProfile
return { HermesSkillInjector }
})
vi.mock('../../packages/server/src/services/hermes/session-deleter', () => ({
SessionDeleter: {
getInstance: vi.fn(() => sessionDeleterMocks),
},
}))
import * as hermesCli from '../../packages/server/src/services/hermes/hermes-cli'
describe('Profile Routes', () => {
@@ -29,6 +64,9 @@ describe('Profile Routes', () => {
beforeEach(() => {
vi.clearAllMocks()
agentBridgeMocks.destroyProfile.mockResolvedValue({ destroyed: 0 })
skillInjectorMocks.injectMissingSkills.mockResolvedValue({ targets: [] })
skillInjectorMocks.resolveTargetDirForProfile.mockImplementation((name: string) => join('/tmp/hermes-skills', name))
})
afterEach(async () => {
@@ -121,6 +159,43 @@ describe('Profile Routes', () => {
})
})
describe('Hermes CLI active profile switch', () => {
it('only destroys bridge sessions for the target profile', async () => {
const hermesHome = await mkdtemp(join(tmpdir(), 'hermes-profile-switch-'))
tempHomes.push(hermesHome)
process.env.HERMES_HOME = hermesHome
const profileDir = join(hermesHome, 'profiles', 'work')
await mkdir(profileDir, { recursive: true })
await writeFile(join(profileDir, 'config.yaml'), 'model:\n default: gpt-test\n', 'utf-8')
await writeFile(join(hermesHome, 'active_profile'), 'work\n', 'utf-8')
vi.mocked(hermesCli.useProfile).mockResolvedValue('Switched to work')
vi.mocked(hermesCli.getProfile).mockResolvedValue({
name: 'work',
path: profileDir,
model: 'gpt-test',
provider: 'test',
skills: 0,
hasEnv: false,
hasSoulMd: false,
} as any)
agentBridgeMocks.destroyProfile.mockResolvedValue({ destroyed: 2 })
const { switchProfile } = await import('../../packages/server/src/controllers/hermes/profiles')
const ctx: any = {
request: { body: { name: 'work' } },
status: 200,
body: undefined,
}
await switchProfile(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toMatchObject({ success: true, active: 'work' })
expect(agentBridgeMocks.destroyProfile).toHaveBeenCalledWith('work')
expect(agentBridgeMocks.destroyAll).not.toHaveBeenCalled()
expect(sessionDeleterMocks.switchProfile).toHaveBeenCalledWith('work')
})
})
describe('profile avatars', () => {
it('stores generated avatar metadata under the Web UI home', async () => {
const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-web-ui-avatar-'))
+24
View File
@@ -200,6 +200,30 @@ describe('user auth tables and middleware', () => {
expect(ctx.body.token).toMatch(/^[^.]+\.[^.]+\.[^.]+$/)
})
it('marks the default account credentials as requiring a change', async () => {
const { users } = await initUsers()
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
const ctrl = await import('../../packages/server/src/controllers/auth')
const defaultCtx = {
state: { user: { id: admin.id, username: 'admin', role: 'super_admin' } },
status: 200,
body: null,
} as any
await ctrl.currentUser(defaultCtx)
expect(defaultCtx.body.user.requiresCredentialChange).toBe(true)
users.updateUsername(admin.id, 'owner')
users.updateUserPassword(admin.id, 'stronger-password')
const changedCtx = {
state: { user: { id: admin.id, username: 'owner', role: 'super_admin' } },
status: 200,
body: null,
} as any
await ctrl.currentUser(changedCtx)
expect(changedCtx.body.user.requiresCredentialChange).toBe(false)
})
it('lets super admins create regular admins with profile bindings', async () => {
const { users } = await initUsers()
vi.doMock('../../packages/server/src/services/hermes/hermes-profile', () => ({