fix: clear provider auth entries on delete (#616)
This commit is contained in:
@@ -8,6 +8,27 @@ import { logger } from '../../services/logger'
|
|||||||
|
|
||||||
const OPTIONAL_API_KEY_PROVIDERS = new Set(['cliproxyapi'])
|
const OPTIONAL_API_KEY_PROVIDERS = new Set(['cliproxyapi'])
|
||||||
|
|
||||||
|
async function clearStoredAuthProvider(poolKey: string) {
|
||||||
|
try {
|
||||||
|
const authPath = getActiveAuthPath()
|
||||||
|
if (!existsSync(authPath)) return
|
||||||
|
|
||||||
|
const auth = JSON.parse(readFileSync(authPath, 'utf-8'))
|
||||||
|
let changed = false
|
||||||
|
if (auth.providers && Object.prototype.hasOwnProperty.call(auth.providers, poolKey)) {
|
||||||
|
delete auth.providers[poolKey]
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (auth.credential_pool && Object.prototype.hasOwnProperty.call(auth.credential_pool, poolKey)) {
|
||||||
|
delete auth.credential_pool[poolKey]
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
|
||||||
|
}
|
||||||
|
} catch (err: any) { logger.error(err, 'Failed to clear auth credentials for %s', poolKey) }
|
||||||
|
}
|
||||||
|
|
||||||
function buildProviderEntry(name: string, base_url: string, api_key: string, model: string, context_length?: number) {
|
function buildProviderEntry(name: string, base_url: string, api_key: string, model: string, context_length?: number) {
|
||||||
const entry: any = { name, base_url, api_key, model }
|
const entry: any = { name, base_url, api_key, model }
|
||||||
if (context_length && context_length > 0) {
|
if (context_length && context_length > 0) {
|
||||||
@@ -150,24 +171,16 @@ export async function remove(ctx: any) {
|
|||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return
|
ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return
|
||||||
}
|
}
|
||||||
(config.custom_providers as any[]).splice(idx, 1)
|
;(config.custom_providers as any[]).splice(idx, 1)
|
||||||
await writeConfigYaml(config)
|
await writeConfigYaml(config)
|
||||||
|
await clearStoredAuthProvider(poolKey)
|
||||||
} else {
|
} else {
|
||||||
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
||||||
if (envMapping?.api_key_env) {
|
if (envMapping?.api_key_env) {
|
||||||
await saveEnvValue(envMapping.api_key_env, '')
|
await saveEnvValue(envMapping.api_key_env, '')
|
||||||
if (envMapping.base_url_env) { await saveEnvValue(envMapping.base_url_env, '') }
|
if (envMapping.base_url_env) { await saveEnvValue(envMapping.base_url_env, '') }
|
||||||
} else if (!envMapping?.api_key_env) {
|
|
||||||
try {
|
|
||||||
const authPath = getActiveAuthPath()
|
|
||||||
if (existsSync(authPath)) {
|
|
||||||
const auth = JSON.parse(readFileSync(authPath, 'utf-8'))
|
|
||||||
if (auth.providers?.[poolKey]) { delete auth.providers[poolKey] }
|
|
||||||
if (auth.credential_pool?.[poolKey]) { delete auth.credential_pool[poolKey] }
|
|
||||||
await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
|
|
||||||
}
|
|
||||||
} catch (err: any) { logger.error(err, 'Failed to clear OAuth tokens for %s', poolKey) }
|
|
||||||
}
|
}
|
||||||
|
await clearStoredAuthProvider(poolKey)
|
||||||
}
|
}
|
||||||
const currentProvider = config.model?.provider
|
const currentProvider = config.model?.provider
|
||||||
if (currentProvider === poolKey) {
|
if (currentProvider === poolKey) {
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||||
|
restartGateway: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}))
|
||||||
|
|
||||||
|
let hermesHome = ''
|
||||||
|
|
||||||
|
async function loadProvidersController() {
|
||||||
|
vi.resetModules()
|
||||||
|
process.env.HERMES_HOME = hermesHome
|
||||||
|
return import('../../packages/server/src/controllers/hermes/providers')
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(poolKey: string) {
|
||||||
|
return {
|
||||||
|
params: { poolKey: encodeURIComponent(poolKey) },
|
||||||
|
request: { body: {} },
|
||||||
|
status: 200,
|
||||||
|
body: undefined as unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAuth() {
|
||||||
|
return JSON.parse(readFileSync(join(hermesHome, 'auth.json'), 'utf-8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('providers controller delete', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
hermesHome = mkdtempSync(join(tmpdir(), 'hwui-provider-delete-'))
|
||||||
|
mkdirSync(hermesHome, { recursive: true })
|
||||||
|
writeFileSync(join(hermesHome, 'config.yaml'), 'model:\n provider: openai-codex\n default: gpt-5.5\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.HERMES_HOME
|
||||||
|
vi.doUnmock('../../packages/server/src/controllers/hermes/providers')
|
||||||
|
vi.clearAllMocks()
|
||||||
|
if (hermesHome) rmSync(hermesHome, { recursive: true, force: true })
|
||||||
|
hermesHome = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes built-in API-key provider credentials from env and auth pool', async () => {
|
||||||
|
writeFileSync(join(hermesHome, '.env'), [
|
||||||
|
['DEEPSEEK_API_KEY', 'deepseek-placeholder'].join('='),
|
||||||
|
['OPENROUTER_API_KEY', 'openrouter-placeholder'].join('='),
|
||||||
|
'',
|
||||||
|
].join('\n'))
|
||||||
|
writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({
|
||||||
|
providers: {
|
||||||
|
deepseek: { access_token: 'legacy-token' },
|
||||||
|
openrouter: { access_token: 'keep-token' },
|
||||||
|
},
|
||||||
|
credential_pool: {
|
||||||
|
deepseek: [{ label: 'DEEPSEEK_API_KEY', source: 'env:DEEPSEEK_API_KEY' }],
|
||||||
|
openrouter: [{ label: 'OPENROUTER_API_KEY', source: 'env:OPENROUTER_API_KEY' }],
|
||||||
|
},
|
||||||
|
}, null, 2))
|
||||||
|
|
||||||
|
const { remove } = await loadProvidersController()
|
||||||
|
const ctx = makeCtx('deepseek')
|
||||||
|
|
||||||
|
await remove(ctx)
|
||||||
|
|
||||||
|
expect(ctx.body).toEqual({ success: true })
|
||||||
|
const envAfter = readFileSync(join(hermesHome, '.env'), 'utf-8')
|
||||||
|
expect(envAfter).not.toContain('DEEPSEEK_API_KEY')
|
||||||
|
expect(envAfter).toContain(['OPENROUTER_API_KEY', 'openrouter-placeholder'].join('='))
|
||||||
|
|
||||||
|
const authAfter = readAuth()
|
||||||
|
expect(authAfter.providers).not.toHaveProperty('deepseek')
|
||||||
|
expect(authAfter.credential_pool).not.toHaveProperty('deepseek')
|
||||||
|
expect(authAfter.providers.openrouter).toEqual({ access_token: 'keep-token' })
|
||||||
|
expect(authAfter.credential_pool.openrouter).toEqual([
|
||||||
|
{ label: 'OPENROUTER_API_KEY', source: 'env:OPENROUTER_API_KEY' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes custom provider config and any matching stored auth entry', async () => {
|
||||||
|
writeFileSync(join(hermesHome, 'config.yaml'), [
|
||||||
|
'model:',
|
||||||
|
' provider: openai-codex',
|
||||||
|
' default: gpt-5.5',
|
||||||
|
'custom_providers:',
|
||||||
|
' - name: deepseek-proxy',
|
||||||
|
' base_url: https://example.invalid/v1',
|
||||||
|
' api_key: placeholder',
|
||||||
|
' model: deepseek-chat',
|
||||||
|
' - name: keep-provider',
|
||||||
|
' base_url: https://keep.invalid/v1',
|
||||||
|
' api_key: placeholder',
|
||||||
|
' model: keep-model',
|
||||||
|
'',
|
||||||
|
].join('\n'))
|
||||||
|
writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({
|
||||||
|
credential_pool: {
|
||||||
|
'custom:deepseek-proxy': [{ label: 'custom' }],
|
||||||
|
'custom:keep-provider': [{ label: 'keep' }],
|
||||||
|
},
|
||||||
|
}, null, 2))
|
||||||
|
|
||||||
|
const { remove } = await loadProvidersController()
|
||||||
|
const ctx = makeCtx('custom:deepseek-proxy')
|
||||||
|
|
||||||
|
await remove(ctx)
|
||||||
|
|
||||||
|
expect(ctx.body).toEqual({ success: true })
|
||||||
|
const configAfter = readFileSync(join(hermesHome, 'config.yaml'), 'utf-8')
|
||||||
|
expect(configAfter).not.toContain('deepseek-proxy')
|
||||||
|
expect(configAfter).toContain('keep-provider')
|
||||||
|
|
||||||
|
const authAfter = readAuth()
|
||||||
|
expect(authAfter.credential_pool).not.toHaveProperty('custom:deepseek-proxy')
|
||||||
|
expect(authAfter.credential_pool['custom:keep-provider']).toEqual([{ label: 'keep' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps OAuth-style provider deletion clearing stored auth entries', async () => {
|
||||||
|
writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({
|
||||||
|
providers: {
|
||||||
|
'openai-codex': { account_id: 'remove-me' },
|
||||||
|
copilot: { account_id: 'keep-me' },
|
||||||
|
},
|
||||||
|
credential_pool: {
|
||||||
|
'openai-codex': [{ label: 'remove-me' }],
|
||||||
|
copilot: [{ label: 'keep-me' }],
|
||||||
|
},
|
||||||
|
}, null, 2))
|
||||||
|
|
||||||
|
const { remove } = await loadProvidersController()
|
||||||
|
const ctx = makeCtx('openai-codex')
|
||||||
|
|
||||||
|
await remove(ctx)
|
||||||
|
|
||||||
|
expect(ctx.body).toEqual({ success: true })
|
||||||
|
const authAfter = readAuth()
|
||||||
|
expect(authAfter.providers).not.toHaveProperty('openai-codex')
|
||||||
|
expect(authAfter.credential_pool).not.toHaveProperty('openai-codex')
|
||||||
|
expect(authAfter.providers.copilot).toEqual({ account_id: 'keep-me' })
|
||||||
|
expect(authAfter.credential_pool.copilot).toEqual([{ label: 'keep-me' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not create auth.json when deleting a provider without stored auth credentials', async () => {
|
||||||
|
writeFileSync(join(hermesHome, '.env'), [['DEEPSEEK_API_KEY', 'deepseek-placeholder'].join('='), ''].join('\n'))
|
||||||
|
|
||||||
|
const { remove } = await loadProvidersController()
|
||||||
|
const ctx = makeCtx('deepseek')
|
||||||
|
|
||||||
|
await remove(ctx)
|
||||||
|
|
||||||
|
expect(ctx.body).toEqual({ success: true })
|
||||||
|
expect(existsSync(join(hermesHome, 'auth.json'))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user