diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 47e4433a..80526d0c 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-03-24 +Last Updated: 2026-03-27 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-03-27**: WebSearch dashboard cards now manage Exa, Tavily, and Brave API keys inline instead of relying on a separate manual env step. CCS stores those secrets through `global_env`, reflects masked key state in `/api/websearch`, and counts dashboard-managed keys as ready in the WebSearch status flow. - **2026-03-24**: Official Claude Channels now follow Anthropic's actual runtime contract. CCS blocks auto-enable unless Bun is available, Claude Code is verified at v2.1.80+, and `claude.ai` auth is verified; treats `--allow-dangerously-skip-permissions` as an explicit override; keeps Telegram/Discord bot tokens in Claude's shared `~/.claude/channels/` state (or official `*_STATE_DIR` overrides); and upgrades the dashboard/CLI status flow with Bun/version/auth/state-scope guidance, safer token draft retention on refresh failures, and a non-macOS iMessage toggle that can still be turned off when already selected. - **2026-03-23**: CLIProxy providers that do not expose an email no longer require a user-supplied nickname on first auth. CCS now derives a stable internal account identifier for Kiro/Copilot-style flows, preserves later rename support, hardens account discovery/registry sync around that identifier, and updates AI Provider CRUD to use stable entry IDs instead of dashboard list indexes. - **2026-03-23**: Sensitive dashboard management routes now fail closed to localhost-only access whenever dashboard auth is disabled. Remote access remains available after `ccs config auth setup`, but AI Provider management, CLIProxy auth/status helpers, and other write-capable settings endpoints no longer trust unauthenticated non-loopback requests. diff --git a/docs/websearch.md b/docs/websearch.md index 6a6803c9..914200b1 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -1,6 +1,6 @@ # WebSearch Configuration Guide -Last Updated: 2026-03-23 +Last Updated: 2026-03-27 CCS provides automatic web search for third-party profiles that cannot access Anthropic's native WebSearch API. @@ -64,7 +64,8 @@ The new flow matches the `goclaw` model more closely: web search is treated as a Open `ccs config` → `Settings` → `WebSearch`. - Enable Exa, Tavily, Brave, or DuckDuckGo in the backend chain -- Export the matching API key first for Exa, Tavily, or Brave +- Set or rotate Exa, Tavily, and Brave API keys directly inside each provider card +- Saved keys are persisted in `global_env` and injected at runtime, so readiness updates from the same screen - Review whether any legacy fallback CLIs are still enabled in config ### Via Config File @@ -119,7 +120,7 @@ That is expected. DuckDuckGo is the default zero-setup backend. ### Exa, Tavily, or Brave is enabled but not ready -Export the matching API key in the environment that launches CCS, then refresh status: +Set the matching API key in the WebSearch dashboard card, or export it in the environment that launches CCS, then refresh status: ```bash export EXA_API_KEY="your-api-key" @@ -128,6 +129,8 @@ export EXA_API_KEY="your-api-key" ccs config ``` +If the dashboard says the key is stored but still not ready, check whether `Settings -> Global Env` is disabled. WebSearch reuses that injection path for dashboard-managed keys. + ### I still want Gemini/OpenCode/Grok fallback Those providers remain supported, but they are no longer the primary path. Enable them explicitly in `config.yaml` if you want them as last-resort fallback. @@ -141,6 +144,7 @@ Those providers remain supported, but they are no longer the primary path. Enabl ## Security Considerations -- API keys stay in environment variables, not in dashboard state +- API keys entered from the dashboard are stored in `~/.ccs/config.yaml` under `global_env` and injected as environment variables at runtime +- Shell-exported keys still work and are detected as external environment input - Never commit API keys to version control -- Use shell profile or `.env` tooling with proper permissions +- Use the dashboard only on trusted machines, and protect `~/.ccs/config.yaml` with normal user-level filesystem permissions 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/components/cliproxy/extended-context-toggle.tsx b/ui/src/components/cliproxy/extended-context-toggle.tsx index 435d3d90..c695e36b 100644 --- a/ui/src/components/cliproxy/extended-context-toggle.tsx +++ b/ui/src/components/cliproxy/extended-context-toggle.tsx @@ -73,7 +73,9 @@ export function ExtendedContextToggle({
-

Applies the explicit [1m] long-context suffix to compatible saved mappings.

+

+ Applies the explicit [1m] long-context suffix to compatible saved mappings. +

{behaviorHint}

CCS only saves [1m]. Provider pricing and entitlement are separate, and 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/index.tsx b/ui/src/pages/settings/index.tsx index b01539f2..5d1bf4a1 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -210,12 +210,7 @@ function SettingsPageInner() { {/* Config Content - scrollable */}

- {rawConfigLoading ? ( -
- - {t('settings.loading')} -
- ) : rawConfig ? ( + {rawConfig ? ( {}} @@ -224,6 +219,11 @@ function SettingsPageInner() { minHeight="auto" className="min-h-full" /> + ) : rawConfigLoading ? ( +
+ + {t('settings.loading')} +
) : (
diff --git a/ui/src/pages/settings/sections/websearch/index.tsx b/ui/src/pages/settings/sections/websearch/index.tsx index 2cc1eb7d..b662d2e0 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}`; @@ -578,6 +659,7 @@ export default function WebSearchSection() { const enabled = config?.providers?.[provider.id]?.enabled ?? provider.defaultEnabled; const tone = getStatusTone(currentStatus, enabled); + const apiKeyProviderId = isApiKeyProvider(provider.id) ? provider.id : null; return (
+ > + {apiKeyProviderId && ( +
+
+
+

+ API Key +

+

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

+

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

+
+ {savedApiKeyProvider === provider.id && ( + + Saved + + )} +
+ + + setApiKeyDrafts((current) => ({ + ...current, + [apiKeyProviderId]: event.target.value, + })) + } + placeholder={ + config?.apiKeys?.[apiKeyProviderId]?.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?.[apiKeyProviderId]?.source === 'global_env' || + config?.apiKeys?.[apiKeyProviderId]?.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/components/account/add-account-dialog.test.tsx b/ui/tests/unit/components/account/add-account-dialog.test.tsx index b56aa08a..e39e271a 100644 --- a/ui/tests/unit/components/account/add-account-dialog.test.tsx +++ b/ui/tests/unit/components/account/add-account-dialog.test.tsx @@ -68,9 +68,7 @@ describe('AddAccountDialog power user mode', () => { it('skips the Gemini typed acknowledgement when power user mode is enabled', async () => { fetchMock.mockResolvedValue(createJsonResponse({ antigravityAckBypass: true })); - render( - - ); + render(); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/settings/auth/antigravity-risk') @@ -95,9 +93,7 @@ describe('AddAccountDialog power user mode', () => { it('keeps the Gemini typed acknowledgement when power user mode is disabled', async () => { fetchMock.mockResolvedValue(createJsonResponse({ antigravityAckBypass: false })); - render( - - ); + render(); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/settings/auth/antigravity-risk') @@ -114,9 +110,7 @@ describe('AddAccountDialog power user mode', () => { it('surfaces a power user mode fetch failure and fails closed for Gemini', async () => { fetchMock.mockRejectedValue(new Error('network down')); - render( - - ); + render(); await waitFor(() => expect(toast.error).toHaveBeenCalledWith( @@ -125,7 +119,9 @@ describe('AddAccountDialog power user mode', () => { ); expect( - screen.getByText('Failed to load power user mode settings. Check Settings > Proxy and try again.') + screen.getByText( + 'Failed to load power user mode settings. Check Settings > Proxy and try again.' + ) ).toBeInTheDocument(); expect(screen.getByText(/Type exact phrase to continue/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Authenticate' })).toBeDisabled(); diff --git a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx index 31391e58..c1546fb2 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx @@ -11,9 +11,7 @@ function createJsonResponse(body: Record, status = 200): Respon }); } -const wrapper = ({ children }: { children: ReactNode }) => ( - {children} -); +const wrapper = ({ children }: { children: ReactNode }) => {children}; describe('useProviderEditor', () => { beforeEach(() => { diff --git a/ui/tests/unit/components/ui/code-editor-usage-contract.test.ts b/ui/tests/unit/components/ui/code-editor-usage-contract.test.ts index 57c638c6..f38a0dc8 100644 --- a/ui/tests/unit/components/ui/code-editor-usage-contract.test.ts +++ b/ui/tests/unit/components/ui/code-editor-usage-contract.test.ts @@ -51,15 +51,15 @@ const boundedLayoutContracts = [ ] as const; describe('bounded CodeEditor consumers', () => { - it.each(boundedConsumers)('$file opts into fill-parent mode for every bounded editor', ({ - file, - expectedCount, - }) => { - const source = readFileSync(resolve(process.cwd(), file), 'utf8'); - const matches = source.match(/heightMode="fill-parent"/g) ?? []; + it.each(boundedConsumers)( + '$file opts into fill-parent mode for every bounded editor', + ({ file, expectedCount }) => { + const source = readFileSync(resolve(process.cwd(), file), 'utf8'); + const matches = source.match(/heightMode="fill-parent"/g) ?? []; - expect(matches).toHaveLength(expectedCount); - }); + expect(matches).toHaveLength(expectedCount); + } + ); it.each(boundedLayoutContracts)( '$file keeps bounded editor ancestors shrinkable', diff --git a/ui/tests/unit/components/ui/code-editor.test.tsx b/ui/tests/unit/components/ui/code-editor.test.tsx index 67e8b4c8..e19f6df4 100644 --- a/ui/tests/unit/components/ui/code-editor.test.tsx +++ b/ui/tests/unit/components/ui/code-editor.test.tsx @@ -50,11 +50,7 @@ describe('CodeEditor', () => { it('preserves content mode as the default layout contract', () => { const { container } = render( - + ); expect(container.querySelector('[data-slot="code-editor-viewport"]')).not.toBeInTheDocument(); diff --git a/ui/tests/unit/ui/pages/settings/settings-page.test.tsx b/ui/tests/unit/ui/pages/settings/settings-page.test.tsx new file mode 100644 index 00000000..651a3adb --- /dev/null +++ b/ui/tests/unit/ui/pages/settings/settings-page.test.tsx @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { render, screen } from '@tests/setup/test-utils'; + +const mocks = vi.hoisted(() => ({ + useSettingsTab: vi.fn(), + useRawConfig: vi.fn(), + setActiveTab: vi.fn(), + fetchRawConfig: vi.fn(), + copyToClipboard: vi.fn(), +})); + +vi.mock('@/pages/settings/hooks', () => ({ + useSettingsTab: mocks.useSettingsTab, + useRawConfig: mocks.useRawConfig, +})); + +vi.mock('react-resizable-panels', () => ({ + PanelGroup: ({ children }: { children: ReactNode }) =>
{children}
, + Panel: ({ children }: { children: ReactNode }) =>
{children}
, + PanelResizeHandle: () =>
, +})); + +vi.mock('@/components/shared/code-editor', () => ({ + CodeEditor: ({ value }: { value: string }) => ( +