diff --git a/packages/server/src/controllers/hermes/providers.ts b/packages/server/src/controllers/hermes/providers.ts index 0124eace..e08fba7c 100644 --- a/packages/server/src/controllers/hermes/providers.ts +++ b/packages/server/src/controllers/hermes/providers.ts @@ -8,6 +8,27 @@ import { logger } from '../../services/logger' 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) { const entry: any = { name, base_url, api_key, model } if (context_length && context_length > 0) { @@ -150,24 +171,16 @@ export async function remove(ctx: any) { if (idx === -1) { 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 clearStoredAuthProvider(poolKey) } else { const envMapping = PROVIDER_ENV_MAP[poolKey] if (envMapping?.api_key_env) { await saveEnvValue(envMapping.api_key_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 if (currentProvider === poolKey) { diff --git a/tests/server/provider-delete-controller.test.ts b/tests/server/provider-delete-controller.test.ts new file mode 100644 index 00000000..b046cbcd --- /dev/null +++ b/tests/server/provider-delete-controller.test.ts @@ -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) + }) +})