diff --git a/src/utils/websearch/index.ts b/src/utils/websearch/index.ts index 4bcfd134..50d142ab 100644 --- a/src/utils/websearch/index.ts +++ b/src/utils/websearch/index.ts @@ -18,6 +18,8 @@ export type { WebSearchConfig, } from './types'; +export type { WebSearchApiKeyState } from './provider-secrets'; + // Gemini CLI export { getGeminiCliStatus, @@ -56,5 +58,7 @@ export { displayWebSearchStatus, } from './status'; +export { WEBSEARCH_API_KEY_PROVIDERS, getWebSearchApiKeyStates } from './provider-secrets'; + // Profile Hook Injection export { ensureProfileHooks, removeMigrationMarker } from './profile-hook-injector'; diff --git a/src/utils/websearch/provider-secrets.ts b/src/utils/websearch/provider-secrets.ts new file mode 100644 index 00000000..26ab1a77 --- /dev/null +++ b/src/utils/websearch/provider-secrets.ts @@ -0,0 +1,132 @@ +import { getGlobalEnvConfig } from '../../config/unified-config-loader'; +import { maskSensitiveValue } from '../sensitive-keys'; + +export type WebSearchApiKeyProviderId = 'exa' | 'tavily' | 'brave'; +export type WebSearchApiKeySource = 'global_env' | 'process_env' | 'both' | 'none'; + +export interface WebSearchApiKeyState { + envVar: string; + configured: boolean; + available: boolean; + source: WebSearchApiKeySource; + maskedValue?: string; +} + +interface WebSearchApiKeyDescriptor { + envVar: string; + alternateEnvVar: string; +} + +export const WEBSEARCH_API_KEY_PROVIDERS: Record< + WebSearchApiKeyProviderId, + WebSearchApiKeyDescriptor +> = { + exa: { + envVar: 'EXA_API_KEY', + alternateEnvVar: 'CCS_WEBSEARCH_EXA_API_KEY', + }, + tavily: { + envVar: 'TAVILY_API_KEY', + alternateEnvVar: 'CCS_WEBSEARCH_TAVILY_API_KEY', + }, + brave: { + envVar: 'BRAVE_API_KEY', + alternateEnvVar: 'CCS_WEBSEARCH_BRAVE_API_KEY', + }, +}; + +function getTrimmedEnvValue( + env: Record, + names: string[] +): string | undefined { + for (const name of names) { + const value = env[name]?.trim(); + if (value) { + return value; + } + } + + return undefined; +} + +function resolveSource( + hasGlobalEnvValue: boolean, + hasProcessEnvValue: boolean +): WebSearchApiKeySource { + if (hasGlobalEnvValue && hasProcessEnvValue) { + return 'both'; + } + if (hasGlobalEnvValue) { + return 'global_env'; + } + if (hasProcessEnvValue) { + return 'process_env'; + } + return 'none'; +} + +export function getWebSearchApiKeyStates(): Record< + WebSearchApiKeyProviderId, + WebSearchApiKeyState +> { + const globalEnvConfig = getGlobalEnvConfig(); + const storedEnv = globalEnvConfig.env ?? {}; + + return ( + Object.entries(WEBSEARCH_API_KEY_PROVIDERS) as Array< + [WebSearchApiKeyProviderId, WebSearchApiKeyDescriptor] + > + ).reduce( + (acc, [providerId, descriptor]) => { + const names = [descriptor.envVar, descriptor.alternateEnvVar]; + const globalEnvValue = getTrimmedEnvValue(storedEnv, names); + const processEnvValue = getTrimmedEnvValue(process.env, names); + const configured = Boolean(globalEnvValue || processEnvValue); + const available = Boolean(processEnvValue || (globalEnvConfig.enabled && globalEnvValue)); + const visibleValue = globalEnvValue || processEnvValue; + + acc[providerId] = { + envVar: descriptor.envVar, + configured, + available, + source: resolveSource(Boolean(globalEnvValue), Boolean(processEnvValue)), + maskedValue: visibleValue ? maskSensitiveValue(visibleValue) : undefined, + }; + + return acc; + }, + {} as Record + ); +} + +export function applyWebSearchApiKeyUpdates( + env: Record, + apiKeys: Partial> | undefined +): Record { + if (!apiKeys) { + return env; + } + + const nextEnv = { ...env }; + + for (const [providerId, rawValue] of Object.entries(apiKeys) as Array< + [WebSearchApiKeyProviderId, string | null | undefined] + >) { + if (rawValue === undefined) { + continue; + } + + const descriptor = WEBSEARCH_API_KEY_PROVIDERS[providerId]; + delete nextEnv[descriptor.alternateEnvVar]; + + const normalizedValue = typeof rawValue === 'string' ? rawValue.trim() : ''; + if (!normalizedValue) { + delete nextEnv[descriptor.envVar]; + continue; + } + + nextEnv[descriptor.envVar] = normalizedValue; + } + + return nextEnv; +} diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index 4021d178..b812f7d9 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -11,16 +11,13 @@ import { getWebSearchConfig } from '../../config/unified-config-loader'; import { getGeminiCliStatus, isGeminiAuthenticated } from './gemini-cli'; import { getGrokCliStatus } from './grok-cli'; import { getOpenCodeCliStatus } from './opencode-cli'; +import { getWebSearchApiKeyStates } from './provider-secrets'; import type { WebSearchCliInfo, WebSearchStatus } from './types'; function hasEnvValue(name: string): boolean { return (process.env[name] || '').trim().length > 0; } -function hasAnyEnvValue(names: string[]): boolean { - return names.some((name) => hasEnvValue(name)); -} - function getLegacyProviderStatuses(): WebSearchCliInfo[] { const wsConfig = getWebSearchConfig(); const geminiStatus = getGeminiCliStatus(); @@ -88,40 +85,41 @@ function getLegacyProviderStatuses(): WebSearchCliInfo[] { */ export function getWebSearchCliProviders(): WebSearchCliInfo[] { const wsConfig = getWebSearchConfig(); + const apiKeyStates = getWebSearchApiKeyStates(); const providers: WebSearchCliInfo[] = [ { id: 'exa', kind: 'backend', name: 'Exa', enabled: wsConfig.providers?.exa?.enabled ?? false, - available: - (wsConfig.providers?.exa?.enabled ?? false) && - hasAnyEnvValue(['EXA_API_KEY', 'CCS_WEBSEARCH_EXA_API_KEY']), + available: (wsConfig.providers?.exa?.enabled ?? false) && apiKeyStates.exa.available, version: null, docsUrl: 'https://docs.exa.ai/reference/search', requiresApiKey: true, apiKeyEnvVar: 'EXA_API_KEY', description: 'API-backed search with strong relevance and content extraction.', - detail: hasAnyEnvValue(['EXA_API_KEY', 'CCS_WEBSEARCH_EXA_API_KEY']) + detail: apiKeyStates.exa.available ? `API key detected (${wsConfig.providers?.exa?.max_results ?? 5} results)` - : 'Set EXA_API_KEY', + : apiKeyStates.exa.configured + ? 'Stored in dashboard, but Global Env is disabled' + : 'Set EXA_API_KEY', }, { id: 'tavily', kind: 'backend', name: 'Tavily', enabled: wsConfig.providers?.tavily?.enabled ?? false, - available: - (wsConfig.providers?.tavily?.enabled ?? false) && - hasAnyEnvValue(['TAVILY_API_KEY', 'CCS_WEBSEARCH_TAVILY_API_KEY']), + available: (wsConfig.providers?.tavily?.enabled ?? false) && apiKeyStates.tavily.available, version: null, docsUrl: 'https://docs.tavily.com/documentation/api-reference/endpoint/search', requiresApiKey: true, apiKeyEnvVar: 'TAVILY_API_KEY', description: 'Search API optimized for agent workflows and concise web result synthesis.', - detail: hasAnyEnvValue(['TAVILY_API_KEY', 'CCS_WEBSEARCH_TAVILY_API_KEY']) + detail: apiKeyStates.tavily.available ? `API key detected (${wsConfig.providers?.tavily?.max_results ?? 5} results)` - : 'Set TAVILY_API_KEY', + : apiKeyStates.tavily.configured + ? 'Stored in dashboard, but Global Env is disabled' + : 'Set TAVILY_API_KEY', }, { id: 'duckduckgo', @@ -140,17 +138,17 @@ export function getWebSearchCliProviders(): WebSearchCliInfo[] { kind: 'backend', name: 'Brave Search', enabled: wsConfig.providers?.brave?.enabled ?? false, - available: - (wsConfig.providers?.brave?.enabled ?? false) && - hasAnyEnvValue(['BRAVE_API_KEY', 'CCS_WEBSEARCH_BRAVE_API_KEY']), + available: (wsConfig.providers?.brave?.enabled ?? false) && apiKeyStates.brave.available, version: null, docsUrl: 'https://brave.com/search/api/', requiresApiKey: true, apiKeyEnvVar: 'BRAVE_API_KEY', description: 'API-backed web search with cleaner result metadata.', - detail: hasAnyEnvValue(['BRAVE_API_KEY', 'CCS_WEBSEARCH_BRAVE_API_KEY']) + detail: apiKeyStates.brave.available ? `API key detected (${wsConfig.providers?.brave?.max_results ?? 5} results)` - : 'Set BRAVE_API_KEY', + : apiKeyStates.brave.configured + ? 'Stored in dashboard, but Global Env is disabled' + : 'Set BRAVE_API_KEY', }, ]; diff --git a/src/web-server/routes/websearch-routes.ts b/src/web-server/routes/websearch-routes.ts index bbdcd557..ebcaf489 100644 --- a/src/web-server/routes/websearch-routes.ts +++ b/src/web-server/routes/websearch-routes.ts @@ -6,9 +6,20 @@ import { Router, Request, Response } from 'express'; import { mutateUnifiedConfig, getWebSearchConfig } from '../../config/unified-config-loader'; import type { WebSearchConfig } from '../../config/unified-config-types'; import { getWebSearchReadiness, getWebSearchCliProviders } from '../../utils/websearch-manager'; +import { + applyWebSearchApiKeyUpdates, + getWebSearchApiKeyStates, + type WebSearchApiKeyProviderId, +} from '../../utils/websearch/provider-secrets'; const router = Router(); +type WebSearchApiKeyUpdates = Partial>; + +interface WebSearchDashboardPayload extends Partial { + apiKeys?: WebSearchApiKeyUpdates; +} + /** * GET /api/websearch - Get WebSearch configuration * Returns: normalized WebSearch configuration @@ -16,7 +27,10 @@ const router = Router(); router.get('/', (_req: Request, res: Response): void => { try { const config = getWebSearchConfig(); - res.json(config); + res.json({ + ...config, + apiKeys: getWebSearchApiKeyStates(), + }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -27,7 +41,7 @@ router.get('/', (_req: Request, res: Response): void => { * Body: WebSearchConfig fields (enabled, providers) */ router.put('/', (req: Request, res: Response): void => { - const { enabled, providers } = req.body as Partial; + const { enabled, providers, apiKeys } = req.body as WebSearchDashboardPayload; // Validate enabled if (enabled !== undefined && typeof enabled !== 'boolean') { @@ -41,8 +55,27 @@ router.put('/', (req: Request, res: Response): void => { return; } + if (apiKeys !== undefined && typeof apiKeys !== 'object') { + res.status(400).json({ error: 'Invalid value for apiKeys. Must be an object.' }); + return; + } + + if (apiKeys) { + for (const [providerId, value] of Object.entries(apiKeys)) { + if (!['exa', 'tavily', 'brave'].includes(providerId)) { + res.status(400).json({ error: `Unsupported WebSearch provider: ${providerId}` }); + return; + } + + if (value !== null && value !== undefined && typeof value !== 'string') { + res.status(400).json({ error: `Invalid value for ${providerId} API key` }); + return; + } + } + } + try { - const existingConfig = mutateUnifiedConfig((config) => { + mutateUnifiedConfig((config) => { config.websearch = { enabled: enabled ?? config.websearch?.enabled ?? true, providers: providers @@ -116,11 +149,21 @@ router.put('/', (req: Request, res: Response): void => { } : config.websearch?.providers, }; + + if (apiKeys) { + config.global_env = { + enabled: config.global_env?.enabled ?? true, + env: applyWebSearchApiKeyUpdates(config.global_env?.env ?? {}, apiKeys), + }; + } }); res.json({ success: true, - websearch: existingConfig.websearch, + websearch: { + ...getWebSearchConfig(), + apiKeys: getWebSearchApiKeyStates(), + }, }); } catch (error) { res.status(500).json({ error: (error as Error).message }); diff --git a/tests/unit/web-server/websearch-routes.test.ts b/tests/unit/web-server/websearch-routes.test.ts new file mode 100644 index 00000000..b7a946f7 --- /dev/null +++ b/tests/unit/web-server/websearch-routes.test.ts @@ -0,0 +1,221 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; +import websearchRoutes from '../../../src/web-server/routes/websearch-routes'; + +const WEBSEARCH_ENV_KEYS = [ + 'EXA_API_KEY', + 'TAVILY_API_KEY', + 'BRAVE_API_KEY', + 'CCS_WEBSEARCH_EXA_API_KEY', + 'CCS_WEBSEARCH_TAVILY_API_KEY', + 'CCS_WEBSEARCH_BRAVE_API_KEY', +] as const; + +describe('websearch routes', () => { + let server: Server; + let baseUrl = ''; + let tempHome: string; + let originalCcsHome: string | undefined; + let originalEnvValues: Record<(typeof WEBSEARCH_ENV_KEYS)[number], string | undefined>; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/websearch', websearchRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-routes-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + originalEnvValues = WEBSEARCH_ENV_KEYS.reduce( + (acc, key) => { + acc[key] = process.env[key]; + delete process.env[key]; + return acc; + }, + {} as Record<(typeof WEBSEARCH_ENV_KEYS)[number], string | undefined> + ); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + for (const key of WEBSEARCH_ENV_KEYS) { + const value = originalEnvValues[key]; + if (value !== undefined) { + process.env[key] = value; + } else { + delete process.env[key]; + } + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('returns masked API key state from dashboard-managed global env', async () => { + mutateUnifiedConfig((config) => { + config.websearch = { + enabled: true, + providers: { + ...config.websearch?.providers, + exa: { enabled: true, max_results: 7 }, + duckduckgo: { enabled: false, max_results: 5 }, + }, + }; + config.global_env = { + enabled: true, + env: { + ...(config.global_env?.env ?? {}), + EXA_API_KEY: 'exa-secret-12345678', + }, + }; + }); + + const response = await fetch(`${baseUrl}/api/websearch`); + expect(response.status).toBe(200); + const payload = await response.json(); + + expect(payload.providers.exa).toMatchObject({ + enabled: true, + max_results: 7, + }); + expect(payload.apiKeys.exa).toMatchObject({ + envVar: 'EXA_API_KEY', + configured: true, + available: true, + source: 'global_env', + }); + expect(payload.apiKeys.exa.maskedValue).toContain('*'); + + const statusResponse = await fetch(`${baseUrl}/api/websearch/status`); + expect(statusResponse.status).toBe(200); + const statusPayload = await statusResponse.json(); + expect(statusPayload.readiness).toMatchObject({ + status: 'ready', + }); + expect(statusPayload.providers.find((provider: { id: string }) => provider.id === 'exa')).toMatchObject({ + available: true, + detail: 'API key detected (7 results)', + }); + }); + + it('stores and removes WebSearch API keys via the dashboard route', async () => { + const createResponse = await fetch(`${baseUrl}/api/websearch`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providers: { + exa: { enabled: true, max_results: 6 }, + }, + apiKeys: { + exa: 'exa-secret-abcdefgh', + }, + }), + }); + + expect(createResponse.status).toBe(200); + const createdPayload = await createResponse.json(); + expect(createdPayload.websearch.apiKeys.exa).toMatchObject({ + configured: true, + source: 'global_env', + }); + + let config = loadOrCreateUnifiedConfig(); + expect(config.global_env?.env.EXA_API_KEY).toBe('exa-secret-abcdefgh'); + + const removeResponse = await fetch(`${baseUrl}/api/websearch`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providers: { + exa: { enabled: true, max_results: 6 }, + }, + apiKeys: { + exa: '', + }, + }), + }); + + expect(removeResponse.status).toBe(200); + const removedPayload = await removeResponse.json(); + expect(removedPayload.websearch.apiKeys.exa).toMatchObject({ + configured: false, + source: 'none', + }); + + config = loadOrCreateUnifiedConfig(); + expect(config.global_env?.env.EXA_API_KEY).toBeUndefined(); + }); + + it('preserves stored API keys when only provider settings change', async () => { + mutateUnifiedConfig((config) => { + config.global_env = { + enabled: true, + env: { + ...(config.global_env?.env ?? {}), + EXA_API_KEY: 'exa-secret-12345678', + }, + }; + }); + + const response = await fetch(`${baseUrl}/api/websearch`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providers: { + exa: { enabled: true, max_results: 9 }, + duckduckgo: { enabled: false, max_results: 5 }, + }, + }), + }); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.websearch.providers.exa).toMatchObject({ + enabled: true, + max_results: 9, + }); + expect(payload.websearch.apiKeys.exa).toMatchObject({ + configured: true, + source: 'global_env', + }); + + const config = loadOrCreateUnifiedConfig(); + expect(config.global_env?.env.EXA_API_KEY).toBe('exa-secret-12345678'); + }); +}); diff --git a/ui/src/pages/settings/hooks/use-websearch-config.ts b/ui/src/pages/settings/hooks/use-websearch-config.ts index 9d5a381c..e1e51463 100644 --- a/ui/src/pages/settings/hooks/use-websearch-config.ts +++ b/ui/src/pages/settings/hooks/use-websearch-config.ts @@ -4,7 +4,7 @@ import { useCallback } from 'react'; import { useSettingsContext, useSettingsActions } from './context-hooks'; -import type { WebSearchConfig } from '../types'; +import type { WebSearchConfig, WebSearchSavePayload } from '../types'; export function useWebSearchConfig() { const { state } = useSettingsContext(); @@ -40,14 +40,15 @@ export function useWebSearchConfig() { }, [actions]); const saveConfig = useCallback( - async (updates: Partial) => { + async (updates: WebSearchSavePayload) => { const config = state.webSearchConfig; if (!config) return false; - const optimisticConfig = { + const optimisticConfig: WebSearchConfig = { ...config, ...updates, providers: { ...config.providers, ...updates.providers }, + apiKeys: config.apiKeys, }; actions.setWebSearchConfig(optimisticConfig); @@ -55,10 +56,18 @@ export function useWebSearchConfig() { actions.setWebSearchSaving(true); actions.setWebSearchError(null); + const requestBody: WebSearchSavePayload = { + enabled: optimisticConfig.enabled, + providers: optimisticConfig.providers, + }; + if (updates.apiKeys) { + requestBody.apiKeys = updates.apiKeys; + } + const res = await fetch('/api/websearch', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(optimisticConfig), + body: JSON.stringify(requestBody), }); if (!res.ok) { diff --git a/ui/src/pages/settings/sections/websearch/index.tsx b/ui/src/pages/settings/sections/websearch/index.tsx index 2cc1eb7d..f3ee8d22 100644 --- a/ui/src/pages/settings/sections/websearch/index.tsx +++ b/ui/src/pages/settings/sections/websearch/index.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; +import { MaskedInput } from '@/components/ui/masked-input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { AlertCircle, @@ -19,7 +20,12 @@ import { import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { useRawConfig, useWebSearchConfig } from '../../hooks'; -import type { CliStatus, WebSearchProvidersConfig } from '../../types'; +import type { + CliStatus, + WebSearchApiKeyProviderId, + WebSearchApiKeyState, + WebSearchProvidersConfig, +} from '../../types'; import { ProviderCard, type ProviderFieldConfig } from './provider-card'; type ProviderId = 'exa' | 'tavily' | 'brave' | 'duckduckgo' | 'gemini' | 'opencode' | 'grok'; @@ -325,6 +331,31 @@ function getConfiguredValue( return String(configured ?? field.defaultValue); } +function isApiKeyProvider(providerId: ProviderId): providerId is WebSearchApiKeyProviderId { + return providerId === 'exa' || providerId === 'tavily' || providerId === 'brave'; +} + +function getApiKeySummary(apiKeyState: WebSearchApiKeyState | undefined): string { + if (!apiKeyState?.configured) { + return 'Not stored'; + } + + if (!apiKeyState.available && apiKeyState.source === 'global_env') { + return 'Stored in dashboard, but Global Env is disabled'; + } + + switch (apiKeyState.source) { + case 'global_env': + return 'Stored in dashboard'; + case 'process_env': + return 'Detected from shell env'; + case 'both': + return 'Stored in dashboard + shell env'; + case 'none': + return 'Not stored'; + } +} + export default function WebSearchSection() { const { t } = useTranslation(); const { @@ -341,7 +372,13 @@ export default function WebSearchSection() { } = useWebSearchConfig(); const { fetchRawConfig } = useRawConfig(); const [fieldDrafts, setFieldDrafts] = useState>({}); + const [apiKeyDrafts, setApiKeyDrafts] = useState< + Partial> + >({}); const [savedFieldId, setSavedFieldId] = useState(null); + const [savedApiKeyProvider, setSavedApiKeyProvider] = useState( + null + ); const [legacyExpanded, setLegacyExpanded] = useState(false); useEffect(() => { @@ -368,7 +405,7 @@ export default function WebSearchSection() { const toggleProvider = async (providerId: ProviderId, enabled: boolean) => { const currentProviders = (config?.providers ?? {}) as WebSearchProvidersConfig; - await saveConfig({ + const saved = await saveConfig({ providers: { ...currentProviders, [providerId]: { @@ -377,6 +414,10 @@ export default function WebSearchSection() { }, }, }); + + if (saved) { + await fetchRawConfig(); + } }; const updateDraft = (providerId: ProviderId, fieldKey: ProviderFieldKey, value: string) => { @@ -414,6 +455,7 @@ export default function WebSearchSection() { }); if (saved) { + await fetchRawConfig(); setSavedFieldId(fieldId); setTimeout(() => { setSavedFieldId((current) => (current === fieldId ? null : current)); @@ -421,6 +463,45 @@ export default function WebSearchSection() { } }; + const saveApiKey = async (providerId: WebSearchApiKeyProviderId) => { + const nextValue = apiKeyDrafts[providerId]?.trim() ?? ''; + if (!nextValue) { + return; + } + + const saved = await saveConfig({ + apiKeys: { + [providerId]: nextValue, + }, + }); + + if (saved) { + await fetchRawConfig(); + setApiKeyDrafts((current) => ({ ...current, [providerId]: '' })); + setSavedApiKeyProvider(providerId); + setTimeout(() => { + setSavedApiKeyProvider((current) => (current === providerId ? null : current)); + }, 1200); + } + }; + + const removeApiKey = async (providerId: WebSearchApiKeyProviderId) => { + const saved = await saveConfig({ + apiKeys: { + [providerId]: '', + }, + }); + + if (saved) { + await fetchRawConfig(); + setApiKeyDrafts((current) => ({ ...current, [providerId]: '' })); + setSavedApiKeyProvider(providerId); + setTimeout(() => { + setSavedApiKeyProvider((current) => (current === providerId ? null : current)); + }, 1200); + } + }; + const buildFields = (provider: ProviderDefinition): ProviderFieldConfig[] => (provider.fields ?? []).map((field) => { const fieldId = `${provider.id}.${field.key}`; @@ -611,7 +692,80 @@ export default function WebSearchSection() { void toggleProvider(provider.id, enabled); }} fields={buildFields(provider)} - /> + > + {isApiKeyProvider(provider.id) && ( +
+
+
+

+ API Key +

+

+ {getApiKeySummary(config?.apiKeys?.[provider.id])} +

+

+ {config?.apiKeys?.[provider.id]?.maskedValue + ? `${config.apiKeys[provider.id]?.envVar} ${config.apiKeys[provider.id]?.maskedValue}` + : `Store ${provider.badge} here so the backend is ready immediately after you enable it.`} +

+
+ {savedApiKeyProvider === provider.id && ( + + Saved + + )} +
+ + + setApiKeyDrafts((current) => ({ + ...current, + [provider.id]: event.target.value, + })) + } + placeholder={ + config?.apiKeys?.[provider.id]?.configured + ? 'Enter a new key to rotate the stored secret' + : `Paste ${provider.badge}` + } + className="bg-background/80 font-mono text-sm" + disabled={saving} + /> + +
+ + + {(config?.apiKeys?.[provider.id]?.source === 'global_env' || + config?.apiKeys?.[provider.id]?.source === 'both') && ( + + )} +
+
+ )} + ); })} diff --git a/ui/src/pages/settings/sections/websearch/provider-card.tsx b/ui/src/pages/settings/sections/websearch/provider-card.tsx index 5e16d51e..9f144a33 100644 --- a/ui/src/pages/settings/sections/websearch/provider-card.tsx +++ b/ui/src/pages/settings/sections/websearch/provider-card.tsx @@ -1,4 +1,4 @@ -import type { KeyboardEvent } from 'react'; +import type { KeyboardEvent, ReactNode } from 'react'; import { ExternalLink } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -33,6 +33,7 @@ export interface ProviderCardProps { docsUrl?: string; installCommand?: string; footerNote?: string; + children?: ReactNode; } const PROVIDER_TONE_STYLES = { @@ -113,6 +114,7 @@ export function ProviderCard({ docsUrl, installCommand, footerNote, + children, }: ProviderCardProps) { const tone = PROVIDER_TONE_STYLES[badgeTone]; const status = getStatusToneStyles(statusTone); @@ -217,6 +219,12 @@ export function ProviderCard({ )} + {enabled && children && ( +
+ {children} +
+ )} + {(footerNote || installCommand || docsUrl) && (
{footerNote &&

{footerNote}

} diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index a1f10a9f..de75c527 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -14,6 +14,17 @@ export interface ProviderConfig { max_results?: number; } +export type WebSearchApiKeyProviderId = 'exa' | 'tavily' | 'brave'; +export type WebSearchApiKeySource = 'global_env' | 'process_env' | 'both' | 'none'; + +export interface WebSearchApiKeyState { + envVar: string; + configured: boolean; + available: boolean; + source: WebSearchApiKeySource; + maskedValue?: string; +} + export interface WebSearchProvidersConfig { exa?: ProviderConfig; tavily?: ProviderConfig; @@ -27,6 +38,13 @@ export interface WebSearchProvidersConfig { export interface WebSearchConfig { enabled: boolean; providers?: WebSearchProvidersConfig; + apiKeys?: Partial>; +} + +export interface WebSearchSavePayload { + enabled?: boolean; + providers?: WebSearchProvidersConfig; + apiKeys?: Partial>; } export interface CliStatus { diff --git a/ui/tests/unit/ui/pages/websearch-section.test.tsx b/ui/tests/unit/ui/pages/websearch-section.test.tsx new file mode 100644 index 00000000..ac59f658 --- /dev/null +++ b/ui/tests/unit/ui/pages/websearch-section.test.tsx @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor, userEvent } from '@tests/setup/test-utils'; +import WebSearchSection from '@/pages/settings/sections/websearch'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('WebSearchSection', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + let config = { + enabled: true, + providers: { + exa: { enabled: true, max_results: 5 }, + tavily: { enabled: false, max_results: 5 }, + brave: { enabled: false, max_results: 5 }, + duckduckgo: { enabled: false, max_results: 5 }, + gemini: { enabled: false, model: 'gemini-2.5-flash', timeout: 55 }, + grok: { enabled: false, timeout: 55 }, + opencode: { enabled: false, model: 'opencode/grok-code', timeout: 60 }, + }, + apiKeys: { + exa: { + envVar: 'EXA_API_KEY', + configured: false, + available: false, + source: 'none', + }, + }, + }; + + let status = { + providers: [ + { + id: 'exa', + kind: 'backend', + name: 'Exa', + enabled: true, + available: false, + version: null, + requiresApiKey: true, + apiKeyEnvVar: 'EXA_API_KEY', + description: 'API-backed search with strong relevance and content extraction.', + detail: 'Set EXA_API_KEY', + }, + ], + readiness: { + status: 'needs_setup', + message: 'Exa: Set EXA_API_KEY', + }, + }; + + fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + const method = init?.method ?? 'GET'; + + if (url === '/api/websearch' && method === 'GET') { + return jsonResponse(config); + } + + if (url === '/api/websearch/status' && method === 'GET') { + return jsonResponse(status); + } + + if (url === '/api/config/raw' && method === 'GET') { + return new Response('websearch:\n enabled: true\n'); + } + + if (url === '/api/websearch' && method === 'PUT') { + const body = JSON.parse(String(init?.body ?? '{}')) as { + enabled?: boolean; + providers?: typeof config.providers; + apiKeys?: { exa?: string | null }; + }; + + config = { + ...config, + enabled: body.enabled ?? config.enabled, + providers: body.providers ?? config.providers, + apiKeys: body.apiKeys?.exa + ? { + ...config.apiKeys, + exa: { + envVar: 'EXA_API_KEY', + configured: true, + available: true, + source: 'global_env', + maskedValue: 'exa-************5678', + }, + } + : config.apiKeys, + }; + + status = { + providers: [ + { + ...status.providers[0], + enabled: config.providers.exa.enabled, + available: true, + detail: 'API key detected (5 results)', + }, + ], + readiness: { + status: 'ready', + message: 'Ready (Exa)', + }, + }; + + return jsonResponse({ websearch: config }); + } + + return jsonResponse({ error: `Unhandled request: ${method} ${url}` }, 500); + }); + + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('saves an Exa API key from the WebSearch card', async () => { + render(, { withSettingsProvider: true }); + + const input = await screen.findByPlaceholderText('Paste EXA_API_KEY'); + await userEvent.type(input, 'exa-secret-12345678'); + await userEvent.click(screen.getByRole('button', { name: 'Save key' })); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + '/api/websearch', + expect.objectContaining({ + method: 'PUT', + }) + ); + }); + + const putCall = fetchMock.mock.calls.find( + ([url, init]) => + url === '/api/websearch' && (init as RequestInit | undefined)?.method === 'PUT' + ); + expect(putCall).toBeDefined(); + + const requestBody = JSON.parse(String((putCall?.[1] as RequestInit | undefined)?.body ?? '{}')); + expect(requestBody.apiKeys).toMatchObject({ + exa: 'exa-secret-12345678', + }); + + await screen.findByText('Stored in dashboard'); + expect(await screen.findByText('Ready')).toBeInTheDocument(); + }); +});