From 12ed784fecc45637e51d2830511850eec0a4f7bf Mon Sep 17 00:00:00 2001 From: chioarub Date: Tue, 7 Jul 2026 18:32:29 +0300 Subject: [PATCH 1/7] feat(doctor): add WebSearch backend diagnostics --- scripts/system-check.test.ts | 346 ++++++++++++++++++++++++++++++++ scripts/system-check.ts | 379 +++++++++++++++++++++++++++++++++++ 2 files changed, 725 insertions(+) diff --git a/scripts/system-check.test.ts b/scripts/system-check.test.ts index ec60ee1fe0..45613c1fda 100644 --- a/scripts/system-check.test.ts +++ b/scripts/system-check.test.ts @@ -1,8 +1,12 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' import { buildMemoryGuardChecks, buildSandboxRuntimeCheck, + checkWebSearchEnv, checkOpenAIEnv, checkNodeVersion, formatReachabilityFailureDetail, @@ -11,42 +15,113 @@ import { serializeSafeEnvSummary, } from './system-check.ts' import { DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP } from '../src/utils/maxActiveMessages.ts' +import { resetSettingsCache } from '../src/utils/settings/settingsCache.ts' const ENV_KEYS = [ 'CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_USE_GITHUB', 'CLAUDE_CODE_USE_GEMINI', 'CLAUDE_CODE_USE_MISTRAL', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', + 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID', + 'CLAUDE_CODE_PROVIDER_ROUTE_ID', + 'CLAUDE_CODE_DEFAULT_STARTUP_PROVIDER', 'CLAUDE_CODE_SIMPLE', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_CUSTOM_HEADERS', + 'ANTHROPIC_BEDROCK_BASE_URL', + 'ANTHROPIC_VERTEX_BASE_URL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_MODEL', + 'GEMINI_BASE_URL', + 'GEMINI_AUTH_MODE', + 'GEMINI_ACCESS_TOKEN', 'MISTRAL_API_KEY', 'MISTRAL_MODEL', + 'MISTRAL_BASE_URL', 'OPENAI_MODEL', 'OPENAI_BASE_URL', + 'OPENAI_API_BASE', + 'OPENAI_API_FORMAT', + 'OPENAI_AUTH_HEADER', + 'OPENAI_AUTH_SCHEME', + 'OPENAI_AUTH_HEADER_VALUE', 'OPENAI_API_KEYS', 'OPENAI_API_KEY', 'OPENGATEWAY_API_KEY', 'GITHUB_TOKEN', 'GH_TOKEN', + 'GITHUB_COPILOT_KEY', + 'GITHUB_ENTERPRISE_URL', 'CODEX_API_KEY', + 'CODEX_CREDENTIAL_SOURCE', 'CODEX_AUTH_JSON_PATH', 'CODEX_HOME', + 'CHATGPT_ACCOUNT_ID', + 'CODEX_ACCOUNT_ID', + 'NVIDIA_NIM', + 'NVIDIA_API_KEY', + 'NVIDIA_MODEL', + 'MINIMAX_API_KEY', + 'MINIMAX_BASE_URL', + 'MINIMAX_MODEL', + 'BANKR_BASE_URL', + 'BNKR_API_KEY', + 'BANKR_MODEL', + 'XAI_API_KEY', + 'XAI_CREDENTIAL_SOURCE', + 'AIMLAPI_API_KEY', + 'VENICE_API_KEY', + 'MIMO_API_KEY', + 'ATLAS_CLOUD_API_KEY', + 'NEARAI_API_KEY', + 'FIREWORKS_API_KEY', + 'CLINE_API_KEY', + 'OPENCODE_API_KEY', 'DISABLE_COMPACT', 'DISABLE_AUTO_COMPACT', 'OPENCLAUDE_MAX_ACTIVE_MESSAGES', 'OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP', 'OPENCLAUDE_MAX_MEMORY_MB', + 'OPENCLAUDE_CONFIG_DIR', + 'WEB_SEARCH_PROVIDER', + 'WEB_SEARCH_TIMEOUT_SEC', + 'WEB_SEARCH_API', + 'WEB_PROVIDER', + 'WEB_URL_TEMPLATE', + 'WEB_KEY', + 'GOOGLE_CSE_ID', + 'FIRECRAWL_API_KEY', + 'FIRECRAWL_API_URL', + 'TAVILY_API_KEY', + 'EXA_API_KEY', + 'YOU_API_KEY', + 'JINA_API_KEY', + 'BRAVE_API_KEY', + 'BING_API_KEY', + 'MOJEEK_API_KEY', + 'LINKUP_API_KEY', ] as const const originalEnv: Record = {} +let tempConfigDir: string | undefined beforeEach(() => { for (const key of ENV_KEYS) { originalEnv[key] = process.env[key] delete process.env[key] } + tempConfigDir = mkdtempSync(join(tmpdir(), 'openclaude-system-check-')) + process.env.OPENCLAUDE_CONFIG_DIR = tempConfigDir + resetSettingsCache() }) afterEach(() => { @@ -57,6 +132,11 @@ afterEach(() => { process.env[key] = originalEnv[key] } } + resetSettingsCache() + if (tempConfigDir) { + rmSync(tempConfigDir, { recursive: true, force: true }) + tempConfigDir = undefined + } }) describe('formatReachabilityFailureDetail', () => { @@ -266,6 +346,272 @@ describe('system-check provider diagnostics', () => { }) }) +describe('system-check WebSearch diagnostics', () => { + const reliableBackendHint = + 'FIRECRAWL_API_KEY, TAVILY_API_KEY, EXA_API_KEY, YOU_API_KEY, JINA_API_KEY, BRAVE_API_KEY, BING_API_KEY, MOJEEK_API_KEY, or LINKUP_API_KEY' + + function expectWebSearchBackend( + ok: boolean, + detail: string, + timeoutSeconds: string | false = '15', + ) { + expect(checkWebSearchEnv()).toEqual([ + { + ok, + label: 'Web search backend', + detail: timeoutSeconds === false + ? detail + : `${detail} Built-in provider timeout: ${timeoutSeconds}s.`, + }, + ]) + } + + function useOpenAICompatibleProvider() { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_MODEL = 'gpt-4o' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + } + + function useOpenAICompatibleProviderWithoutModel() { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + } + + test('reports auto mode using native first-party search before adapters', () => { + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=auto; firstParty native web search will be used before adapter providers.', + false, + ) + }) + + test('reports configured API-backed providers when auto mode uses native search first', () => { + process.env.BRAVE_API_KEY = 'brave-secret-value-123' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=auto; firstParty native web search will be used before adapter providers. Configured API-backed providers: brave.', + false, + ) + }) + + test('fails auto mode for unsupported Vertex native model instead of claiming DuckDuckGo fallback', () => { + process.env.CLAUDE_CODE_USE_VERTEX = '1' + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'claude-3-7-sonnet@20250219' + + expectWebSearchBackend( + false, + `WEB_SEARCH_PROVIDER=auto selected, but vertex model claude-3-7-sonnet@20250219 does not support native web search and runtime will not use adapter providers in auto mode. Use a Claude 4 Vertex model or set an explicit WEB_SEARCH_PROVIDER adapter mode with ${reliableBackendHint}.`, + false, + ) + }) + + test('fails auto mode for unsupported Vertex native model even when adapter keys are configured', () => { + process.env.CLAUDE_CODE_USE_VERTEX = '1' + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'claude-3-7-sonnet@20250219' + process.env.BRAVE_API_KEY = 'brave-secret-value-123' + + expectWebSearchBackend( + false, + `WEB_SEARCH_PROVIDER=auto selected, but vertex model claude-3-7-sonnet@20250219 does not support native web search and runtime will not use adapter providers in auto mode. Use a Claude 4 Vertex model or set an explicit WEB_SEARCH_PROVIDER adapter mode with ${reliableBackendHint}. Configured API-backed providers: brave.`, + false, + ) + }) + + test('reports auto mode with only DuckDuckGo fallback available', () => { + useOpenAICompatibleProvider() + + expectWebSearchBackend( + true, + `WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${reliableBackendHint} for reliable search.`, + ) + }) + + test('uses the runtime OpenAI default model in auto mode when OPENAI_MODEL is unset', () => { + useOpenAICompatibleProviderWithoutModel() + + expectWebSearchBackend( + true, + `WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${reliableBackendHint} for reliable search.`, + ) + }) + + test('reports the configured built-in provider timeout', () => { + useOpenAICompatibleProvider() + process.env.WEB_SEARCH_TIMEOUT_SEC = '30' + + expectWebSearchBackend( + true, + `WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${reliableBackendHint} for reliable search.`, + '30', + ) + }) + + test('fails Firecrawl cloud URL without an API key in auto mode', () => { + useOpenAICompatibleProvider() + process.env.FIRECRAWL_API_URL = 'https://api.firecrawl.dev' + + expectWebSearchBackend( + false, + 'WEB_SEARCH_PROVIDER=auto; FIRECRAWL_API_URL points to the Firecrawl cloud API but FIRECRAWL_API_KEY is missing. Runtime will try firecrawl before fallback providers; set FIRECRAWL_API_KEY, use a self-hosted FIRECRAWL_API_URL, or unset FIRECRAWL_API_URL.', + ) + }) + + test('reports configured API-backed providers in auto mode', () => { + useOpenAICompatibleProvider() + process.env.BRAVE_API_KEY = 'brave-secret-value-123' + process.env.EXA_API_KEY = 'exa-secret-value-123' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=auto; configured providers: exa, brave; fallback includes duckduckgo.', + ) + }) + + test('fails explicit provider mode when required credentials are missing', () => { + process.env.WEB_SEARCH_PROVIDER = 'brave' + + expectWebSearchBackend( + false, + 'WEB_SEARCH_PROVIDER=brave but BRAVE_API_KEY is missing.', + ) + }) + + test('passes explicit provider mode when required credentials are configured', () => { + process.env.WEB_SEARCH_PROVIDER = 'brave' + process.env.BRAVE_API_KEY = 'brave-secret-value-123' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=brave; BRAVE_API_KEY configured.', + ) + }) + + test('reports supported native mode without requiring API-backed provider credentials', () => { + process.env.WEB_SEARCH_PROVIDER = 'native' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=native selected; firstParty provider supports native web search.', + false, + ) + }) + + test('reports configured API-backed providers when native mode is selected', () => { + process.env.WEB_SEARCH_PROVIDER = 'native' + process.env.BRAVE_API_KEY = 'brave-secret-value-123' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=native selected; firstParty provider supports native web search. Configured API-backed providers: brave.', + false, + ) + }) + + test('fails native mode when the active provider does not support native web search', () => { + process.env.WEB_SEARCH_PROVIDER = 'native' + useOpenAICompatibleProvider() + + expectWebSearchBackend( + false, + `WEB_SEARCH_PROVIDER=native selected, but openai provider does not support native web search. Configure ${reliableBackendHint}, or switch to an Anthropic, Vertex, Foundry, or Codex responses provider.`, + false, + ) + }) + + test('uses the runtime OpenAI default model in native mode when OPENAI_MODEL is unset', () => { + process.env.WEB_SEARCH_PROVIDER = 'native' + useOpenAICompatibleProviderWithoutModel() + + expectWebSearchBackend( + false, + `WEB_SEARCH_PROVIDER=native selected, but openai provider does not support native web search. Configure ${reliableBackendHint}, or switch to an Anthropic, Vertex, Foundry, or Codex responses provider.`, + false, + ) + }) + + test('fails native mode for Codex aliases when the runtime tool gate rejects that provider', () => { + process.env.WEB_SEARCH_PROVIDER = 'native' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_MODEL = 'codexspark' + + expectWebSearchBackend( + false, + `WEB_SEARCH_PROVIDER=native selected, but codex provider does not support native web search. Configure ${reliableBackendHint}, or switch to an Anthropic, Vertex, Foundry, or Codex responses provider.`, + false, + ) + }) + + test('fails Firecrawl cloud mode when the API key is missing', () => { + process.env.WEB_SEARCH_PROVIDER = 'firecrawl' + process.env.FIRECRAWL_API_URL = 'https://api.firecrawl.dev' + + expectWebSearchBackend( + false, + 'WEB_SEARCH_PROVIDER=firecrawl but FIRECRAWL_API_KEY is missing for the Firecrawl cloud API.', + ) + }) + + test('passes Firecrawl self-hosted mode without an API key', () => { + process.env.WEB_SEARCH_PROVIDER = 'firecrawl' + process.env.FIRECRAWL_API_URL = 'https://self-hosted.firecrawl.dev' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=firecrawl; FIRECRAWL_API_URL configured.', + ) + }) + + test('fails custom Google preset when GOOGLE_CSE_ID is missing', () => { + process.env.WEB_SEARCH_PROVIDER = 'custom' + process.env.WEB_PROVIDER = 'google' + process.env.WEB_KEY = 'google-secret-value-123' + + expectWebSearchBackend( + false, + 'WEB_SEARCH_PROVIDER=custom with WEB_PROVIDER=google but GOOGLE_CSE_ID is missing.', + false, + ) + }) + + test('fails custom Google preset when WEB_KEY is missing', () => { + process.env.WEB_SEARCH_PROVIDER = 'custom' + process.env.WEB_PROVIDER = 'google' + process.env.GOOGLE_CSE_ID = 'cse-test-id' + + expectWebSearchBackend( + false, + 'WEB_SEARCH_PROVIDER=custom with WEB_PROVIDER=google but WEB_KEY is missing.', + false, + ) + }) + + test('passes custom Google preset when required preset credentials are configured', () => { + process.env.WEB_SEARCH_PROVIDER = 'custom' + process.env.WEB_PROVIDER = 'google' + process.env.WEB_KEY = 'google-secret-value-123' + process.env.GOOGLE_CSE_ID = 'cse-test-id' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=custom; WEB_PROVIDER, WEB_KEY, and GOOGLE_CSE_ID configured.', + false, + ) + }) + + test('does not expose WebSearch secret values in diagnostics', () => { + const secret = 'brave-secret-value-123' + process.env.WEB_SEARCH_PROVIDER = 'brave' + process.env.BRAVE_API_KEY = secret + + const results = checkWebSearchEnv() + const serialized = JSON.stringify(results) + + expect(serialized).toContain('BRAVE_API_KEY configured') + expect(serialized).not.toContain(secret) + }) +}) + describe('system-check memory guard diagnostics', () => { test('reports safe default auto-compact and hard-cap guards', () => { const results = buildMemoryGuardChecks({ diff --git a/scripts/system-check.ts b/scripts/system-check.ts index b96dacdaf3..8e7e170a20 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -36,6 +36,15 @@ import { DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP, getMaxActiveMessagesHardCap, } from '../src/utils/maxActiveMessages.js' +import { + getAvailableProviders, + getProviderChain, + getProviderMode, + type ProviderMode, +} from '../src/tools/WebSearchTool/providers/index.js' +import { getWebSearchTimeoutMs } from '../src/tools/WebSearchTool/providers/timeout.js' +import { getAPIProvider } from '../src/utils/model/providers.js' +import { getMainLoopModel } from '../src/utils/model/model.js' type CheckResult = { ok: boolean @@ -157,6 +166,375 @@ export function buildMemoryGuardChecks( return results } +const WEB_SEARCH_API_PROVIDER_NAMES = new Set([ + 'firecrawl', + 'tavily', + 'exa', + 'you', + 'jina', + 'brave', + 'bing', + 'mojeek', + 'linkup', +]) + +const WEB_SEARCH_RELIABLE_BACKEND_ENV_HINT = + 'FIRECRAWL_API_KEY, TAVILY_API_KEY, EXA_API_KEY, YOU_API_KEY, JINA_API_KEY, BRAVE_API_KEY, BING_API_KEY, MOJEEK_API_KEY, or LINKUP_API_KEY' + +const WEB_SEARCH_PROVIDER_ENV_VARS: Record< + Exclude, + string[] +> = { + custom: ['WEB_SEARCH_API', 'WEB_PROVIDER', 'WEB_URL_TEMPLATE'], + firecrawl: ['FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL'], + ddg: [], + tavily: ['TAVILY_API_KEY'], + exa: ['EXA_API_KEY'], + you: ['YOU_API_KEY'], + jina: ['JINA_API_KEY'], + brave: ['BRAVE_API_KEY'], + bing: ['BING_API_KEY'], + mojeek: ['MOJEEK_API_KEY'], + linkup: ['LINKUP_API_KEY'], +} + +const WEB_SEARCH_CUSTOM_PRESET_REQUIRED_ENV_VARS: Record = { + google: ['WEB_KEY', 'GOOGLE_CSE_ID'], + brave: ['WEB_KEY'], + serpapi: ['WEB_KEY'], + searxng: [], +} + +function formatEnvVarList(envVars: string[]): string { + if (envVars.length <= 1) return envVars[0] ?? '' + if (envVars.length === 2) return `${envVars[0]} or ${envVars[1]}` + return `${envVars.slice(0, -1).join(', ')}, or ${envVars[envVars.length - 1]}` +} + +function formatAndList(values: string[]): string { + if (values.length <= 1) return values[0] ?? '' + if (values.length === 2) return `${values[0]} and ${values[1]}` + return `${values.slice(0, -1).join(', ')}, and ${values[values.length - 1]}` +} + +function formatDuckDuckGoReliabilityDetail(providerMode: string): string { + return `${providerMode}; DuckDuckGo selected. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${WEB_SEARCH_RELIABLE_BACKEND_ENV_HINT} for reliable search.` +} + +function vertexModelSupportsNativeWebSearch(model: string): boolean { + return ( + model.includes('claude-opus-4') || + model.includes('claude-sonnet-4') || + model.includes('claude-haiku-4') + ) +} + +function isCodexResponsesWebSearchEnabledForDoctor(): boolean { + const request = resolveProviderRequest({ + model: getMainLoopModel(), + baseUrl: process.env.OPENAI_BASE_URL, + }) + return request.transport === 'codex_responses' +} + +function buildNativeWebSearchCheck(): CheckResult { + const provider = getAPIProvider() + + if (provider === 'openai' && isCodexResponsesWebSearchEnabledForDoctor()) { + return pass( + 'Web search backend', + 'WEB_SEARCH_PROVIDER=native selected; Codex responses provider supports web search.', + ) + } + + if (provider === 'firstParty' || provider === 'foundry') { + return pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=native selected; ${provider} provider supports native web search.`, + ) + } + + if (provider === 'vertex') { + const model = getMainLoopModel() + if (vertexModelSupportsNativeWebSearch(model)) { + return pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=native selected; vertex provider supports native web search for ${safeDisplayValue(model, 'the active model')}.`, + ) + } + return fail( + 'Web search backend', + `WEB_SEARCH_PROVIDER=native selected, but vertex model ${safeDisplayValue(model, 'the active model')} does not support native web search. Use a Claude 4 Vertex model or configure ${WEB_SEARCH_RELIABLE_BACKEND_ENV_HINT}.`, + ) + } + + return fail( + 'Web search backend', + `WEB_SEARCH_PROVIDER=native selected, but ${provider} provider does not support native web search. Configure ${WEB_SEARCH_RELIABLE_BACKEND_ENV_HINT}, or switch to an Anthropic, Vertex, Foundry, or Codex responses provider.`, + ) +} + +function buildAutoNativeWebSearchCheck(): CheckResult | undefined { + const provider = getAPIProvider() + + if (provider === 'openai' && isCodexResponsesWebSearchEnabledForDoctor()) { + return pass( + 'Web search backend', + 'WEB_SEARCH_PROVIDER=auto; Codex responses web search will be used before adapter providers.', + ) + } + + if (provider === 'firstParty' || provider === 'foundry') { + return pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=auto; ${provider} native web search will be used before adapter providers.`, + ) + } + + if (provider === 'vertex') { + const model = getMainLoopModel() + if (vertexModelSupportsNativeWebSearch(model)) { + return pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=auto; vertex native web search will be used before adapter providers for ${safeDisplayValue(model, 'the active model')}.`, + ) + } + return fail( + 'Web search backend', + `WEB_SEARCH_PROVIDER=auto selected, but vertex model ${safeDisplayValue(model, 'the active model')} does not support native web search and runtime will not use adapter providers in auto mode. Use a Claude 4 Vertex model or set an explicit WEB_SEARCH_PROVIDER adapter mode with ${WEB_SEARCH_RELIABLE_BACKEND_ENV_HINT}.`, + ) + } + + return undefined +} + +function getConfiguredWebSearchApiProviderNames(): string[] { + return getAvailableProviders() + .map(provider => provider.name) + .filter(providerName => WEB_SEARCH_API_PROVIDER_NAMES.has(providerName)) + .filter(isWebSearchApiProviderConfiguredForDoctor) +} + +function appendConfiguredWebSearchApiProviderDetail(result: CheckResult): CheckResult { + const configuredProviders = getConfiguredWebSearchApiProviderNames() + if (configuredProviders.length === 0) return result + + const providerDetail = `Configured API-backed providers: ${configuredProviders.join(', ')}.` + return { + ...result, + detail: result.detail ? `${result.detail} ${providerDetail}` : providerDetail, + } +} + +function isFirecrawlCloudApiUrl(apiUrl: string | undefined): boolean { + const normalized = (apiUrl ?? 'https://api.firecrawl.dev').replace(/\/$/, '') + return normalized.includes('api.firecrawl.dev') +} + +function hasFirecrawlRunnableConfig(): boolean { + return Boolean(process.env.FIRECRAWL_API_KEY) || + Boolean(process.env.FIRECRAWL_API_URL && !isFirecrawlCloudApiUrl(process.env.FIRECRAWL_API_URL)) +} + +function buildFirecrawlWebSearchCheck(): CheckResult { + const apiKey = process.env.FIRECRAWL_API_KEY + const apiUrl = process.env.FIRECRAWL_API_URL + + if (!apiKey && isFirecrawlCloudApiUrl(apiUrl)) { + return fail( + 'Web search backend', + 'WEB_SEARCH_PROVIDER=firecrawl but FIRECRAWL_API_KEY is missing for the Firecrawl cloud API.', + ) + } + + if (apiKey) { + return pass( + 'Web search backend', + 'WEB_SEARCH_PROVIDER=firecrawl; FIRECRAWL_API_KEY configured.', + ) + } + + if (apiUrl) { + return pass( + 'Web search backend', + 'WEB_SEARCH_PROVIDER=firecrawl; FIRECRAWL_API_URL configured.', + ) + } + + return fail( + 'Web search backend', + 'WEB_SEARCH_PROVIDER=firecrawl but FIRECRAWL_API_KEY is missing.', + ) +} + +function buildAutoFirecrawlMissingCredentialCheck(): CheckResult | undefined { + const firecrawlSelectedByAutoChain = getAvailableProviders() + .some(provider => provider.name === 'firecrawl') + if (!firecrawlSelectedByAutoChain || hasFirecrawlRunnableConfig()) return undefined + + return fail( + 'Web search backend', + 'WEB_SEARCH_PROVIDER=auto; FIRECRAWL_API_URL points to the Firecrawl cloud API but FIRECRAWL_API_KEY is missing. Runtime will try firecrawl before fallback providers; set FIRECRAWL_API_KEY, use a self-hosted FIRECRAWL_API_URL, or unset FIRECRAWL_API_URL.', + ) +} + +function isWebSearchApiProviderConfiguredForDoctor(providerName: string): boolean { + return providerName !== 'firecrawl' || hasFirecrawlRunnableConfig() +} + +function buildCustomWebSearchCheck(providerConfigured: boolean): CheckResult { + const providerPreset = process.env.WEB_PROVIDER?.trim() + const customUrlEnv = process.env.WEB_URL_TEMPLATE + ? 'WEB_URL_TEMPLATE' + : process.env.WEB_SEARCH_API + ? 'WEB_SEARCH_API' + : undefined + + if (!providerConfigured) { + return fail( + 'Web search backend', + 'WEB_SEARCH_PROVIDER=custom but WEB_SEARCH_API, WEB_PROVIDER, or WEB_URL_TEMPLATE is missing.', + ) + } + + if (!providerPreset) { + return pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=custom; ${customUrlEnv ?? formatEnvVarList(WEB_SEARCH_PROVIDER_ENV_VARS.custom)} configured.`, + ) + } + + const requiredEnvVars = WEB_SEARCH_CUSTOM_PRESET_REQUIRED_ENV_VARS[providerPreset] + if (!requiredEnvVars) { + if (!customUrlEnv) { + return fail( + 'Web search backend', + `WEB_SEARCH_PROVIDER=custom with WEB_PROVIDER=${providerPreset} but WEB_SEARCH_API or WEB_URL_TEMPLATE is missing for an unknown custom preset.`, + ) + } + return pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=custom; WEB_PROVIDER and ${customUrlEnv} configured.`, + ) + } + + if (providerPreset === 'searxng' && !customUrlEnv) { + return fail( + 'Web search backend', + 'WEB_SEARCH_PROVIDER=custom with WEB_PROVIDER=searxng but WEB_SEARCH_API or WEB_URL_TEMPLATE is missing for the SearXNG endpoint.', + ) + } + + const missingEnvVars = requiredEnvVars.filter(envVar => !process.env[envVar]) + if (missingEnvVars.length > 0) { + return fail( + 'Web search backend', + `WEB_SEARCH_PROVIDER=custom with WEB_PROVIDER=${providerPreset} but ${formatAndList(missingEnvVars)} ${missingEnvVars.length === 1 ? 'is' : 'are'} missing.`, + ) + } + + return pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=custom; ${formatAndList(['WEB_PROVIDER', ...requiredEnvVars])} configured.`, + ) +} + +function formatWebSearchTimeoutSeconds(): string { + return String(getWebSearchTimeoutMs() / 1000) +} + +function appendWebSearchTimeoutDetail(result: CheckResult): CheckResult { + const timeoutDetail = `Built-in provider timeout: ${formatWebSearchTimeoutSeconds()}s.` + return { + ...result, + detail: result.detail ? `${result.detail} ${timeoutDetail}` : timeoutDetail, + } +} + +function appendWebSearchTimeoutDetails(results: CheckResult[]): CheckResult[] { + return results.map(appendWebSearchTimeoutDetail) +} + +function buildWebSearchEnvChecks(): CheckResult[] { + const mode = getProviderMode() + + if (mode === 'native') { + return [appendConfiguredWebSearchApiProviderDetail(buildNativeWebSearchCheck())] + } + + if (mode === 'auto') { + const nativeCheck = buildAutoNativeWebSearchCheck() + if (nativeCheck) { + return [appendConfiguredWebSearchApiProviderDetail(nativeCheck)] + } + + const firecrawlMissingCredentialCheck = buildAutoFirecrawlMissingCredentialCheck() + if (firecrawlMissingCredentialCheck) { + return appendWebSearchTimeoutDetails([firecrawlMissingCredentialCheck]) + } + + const configuredProviders = getConfiguredWebSearchApiProviderNames() + + if (configuredProviders.length > 0) { + return appendWebSearchTimeoutDetails([ + pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=auto; configured providers: ${configuredProviders.join(', ')}; fallback includes duckduckgo.`, + ), + ]) + } + + return appendWebSearchTimeoutDetails([ + pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${WEB_SEARCH_RELIABLE_BACKEND_ENV_HINT} for reliable search.`, + ), + ]) + } + + if (mode === 'ddg') { + return appendWebSearchTimeoutDetails([ + pass( + 'Web search backend', + formatDuckDuckGoReliabilityDetail('WEB_SEARCH_PROVIDER=ddg'), + ), + ]) + } + + const provider = getProviderChain(mode)[0] + const envVars = WEB_SEARCH_PROVIDER_ENV_VARS[mode] ?? [] + const envVarLabel = formatEnvVarList(envVars) + const providerConfigured = Boolean(provider?.isConfigured()) + + if (mode === 'firecrawl') { + return appendWebSearchTimeoutDetails([buildFirecrawlWebSearchCheck()]) + } + + if (mode === 'custom') { + return [buildCustomWebSearchCheck(providerConfigured)] + } + + if (!providerConfigured) { + return appendWebSearchTimeoutDetails([ + fail( + 'Web search backend', + `WEB_SEARCH_PROVIDER=${mode} but ${envVarLabel} is missing.`, + ), + ]) + } + + return appendWebSearchTimeoutDetails([ + pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=${mode}; ${envVarLabel} configured.`, + ), + ]) +} + +export function checkWebSearchEnv(): CheckResult[] { + return buildWebSearchEnvChecks() +} + function parseOptions(argv: string[]): CliOptions { const options: CliOptions = { json: false, @@ -1003,6 +1381,7 @@ async function main(): Promise { globalConfig.maxMessagesCompactionThreshold, }), ) + results.push(...checkWebSearchEnv()) results.push(...checkOpenAIEnv()) results.push(await checkBaseUrlReachability()) results.push(await checkProviderGenerationReadiness()) From dab231445cdf131afabe0f88b3b2ab7e096c988b Mon Sep 17 00:00:00 2001 From: chioarub Date: Tue, 7 Jul 2026 18:44:58 +0300 Subject: [PATCH 2/7] fix(doctor): tighten Firecrawl cloud URL diagnostics --- scripts/system-check.test.ts | 10 ++++++++++ scripts/system-check.ts | 12 ++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/system-check.test.ts b/scripts/system-check.test.ts index 45613c1fda..60c11c9103 100644 --- a/scripts/system-check.test.ts +++ b/scripts/system-check.test.ts @@ -562,6 +562,16 @@ describe('system-check WebSearch diagnostics', () => { ) }) + test('does not classify Firecrawl proxy URLs as the cloud API', () => { + process.env.WEB_SEARCH_PROVIDER = 'firecrawl' + process.env.FIRECRAWL_API_URL = 'https://proxy.example.com/api.firecrawl.dev' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=firecrawl; FIRECRAWL_API_URL configured.', + ) + }) + test('fails custom Google preset when GOOGLE_CSE_ID is missing', () => { process.env.WEB_SEARCH_PROVIDER = 'custom' process.env.WEB_PROVIDER = 'google' diff --git a/scripts/system-check.ts b/scripts/system-check.ts index 8e7e170a20..bcaf065658 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -327,8 +327,16 @@ function appendConfiguredWebSearchApiProviderDetail(result: CheckResult): CheckR } function isFirecrawlCloudApiUrl(apiUrl: string | undefined): boolean { - const normalized = (apiUrl ?? 'https://api.firecrawl.dev').replace(/\/$/, '') - return normalized.includes('api.firecrawl.dev') + const normalized = (apiUrl ?? 'https://api.firecrawl.dev').trim() + try { + return new URL(normalized).hostname === 'api.firecrawl.dev' + } catch { + const withoutTrailingSlash = normalized.replace(/\/+$/, '') + return ( + withoutTrailingSlash === 'https://api.firecrawl.dev' || + withoutTrailingSlash === 'api.firecrawl.dev' + ) + } } function hasFirecrawlRunnableConfig(): boolean { From 53ec1906a868f7f0e2b86f8d88a43e470ad9ec1e Mon Sep 17 00:00:00 2001 From: chioarub Date: Tue, 7 Jul 2026 19:31:36 +0300 Subject: [PATCH 3/7] fix(firecrawl): align cloud URL detection --- scripts/system-check.ts | 14 +------------- src/tools/firecrawl/client.test.ts | 24 ++++++++++++++++++++++++ src/tools/firecrawl/client.ts | 15 ++++++++++++++- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/scripts/system-check.ts b/scripts/system-check.ts index bcaf065658..046b2053e0 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -43,6 +43,7 @@ import { type ProviderMode, } from '../src/tools/WebSearchTool/providers/index.js' import { getWebSearchTimeoutMs } from '../src/tools/WebSearchTool/providers/timeout.js' +import { isFirecrawlCloudApiUrl } from '../src/tools/firecrawl/client.js' import { getAPIProvider } from '../src/utils/model/providers.js' import { getMainLoopModel } from '../src/utils/model/model.js' @@ -326,19 +327,6 @@ function appendConfiguredWebSearchApiProviderDetail(result: CheckResult): CheckR } } -function isFirecrawlCloudApiUrl(apiUrl: string | undefined): boolean { - const normalized = (apiUrl ?? 'https://api.firecrawl.dev').trim() - try { - return new URL(normalized).hostname === 'api.firecrawl.dev' - } catch { - const withoutTrailingSlash = normalized.replace(/\/+$/, '') - return ( - withoutTrailingSlash === 'https://api.firecrawl.dev' || - withoutTrailingSlash === 'api.firecrawl.dev' - ) - } -} - function hasFirecrawlRunnableConfig(): boolean { return Boolean(process.env.FIRECRAWL_API_KEY) || Boolean(process.env.FIRECRAWL_API_URL && !isFirecrawlCloudApiUrl(process.env.FIRECRAWL_API_URL)) diff --git a/src/tools/firecrawl/client.test.ts b/src/tools/firecrawl/client.test.ts index 671afc161d..509b3492df 100644 --- a/src/tools/firecrawl/client.test.ts +++ b/src/tools/firecrawl/client.test.ts @@ -87,6 +87,30 @@ describe('firecrawl client', () => { }) }) + test('allows proxy api urls containing the cloud hostname in the path without an api key', async () => { + delete process.env.FIRECRAWL_API_KEY + process.env.FIRECRAWL_API_URL = 'https://proxy.example.com/api.firecrawl.dev' + + globalThis.fetch = asMockFetch(mock(async (input, init) => { + expect(String(input)).toBe('https://proxy.example.com/api.firecrawl.dev/v2/search') + expect((init?.headers as Record).Authorization).toBeUndefined() + + return new Response( + JSON.stringify({ + success: true, + data: { + web: [{ url: 'https://example.com/proxy' }], + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + })) + + await expect(firecrawlSearch('openclaude', { maxRetries: 1 })).resolves.toEqual({ + web: [{ url: 'https://example.com/proxy' }], + }) + }) + test('cloud api requires an api key', async () => { delete process.env.FIRECRAWL_API_KEY delete process.env.FIRECRAWL_API_URL diff --git a/src/tools/firecrawl/client.ts b/src/tools/firecrawl/client.ts index 95fc6ae093..135e212eff 100644 --- a/src/tools/firecrawl/client.ts +++ b/src/tools/firecrawl/client.ts @@ -42,11 +42,24 @@ interface FirecrawlScrapeOptions extends FirecrawlRequestOptions { formats?: string[] } +export function isFirecrawlCloudApiUrl(apiUrl: string | undefined): boolean { + const normalized = (apiUrl ?? DEFAULT_FIRECRAWL_API_URL).trim() + try { + return new URL(normalized).hostname === 'api.firecrawl.dev' + } catch { + const withoutTrailingSlash = normalized.replace(/\/+$/, '') + return ( + withoutTrailingSlash === DEFAULT_FIRECRAWL_API_URL || + withoutTrailingSlash === 'api.firecrawl.dev' + ) + } +} + function getFirecrawlConfig(options: FirecrawlRequestOptions) { const apiKey = options.apiKey ?? process.env.FIRECRAWL_API_KEY ?? '' const apiUrl = (options.apiUrl ?? process.env.FIRECRAWL_API_URL ?? DEFAULT_FIRECRAWL_API_URL).replace(/\/$/, '') - if (apiUrl.includes('api.firecrawl.dev') && !apiKey) { + if (isFirecrawlCloudApiUrl(apiUrl) && !apiKey) { throw new Error( 'Firecrawl API key is required for the cloud API. Set FIRECRAWL_API_KEY or use FIRECRAWL_API_URL for a self-hosted instance.', ) From d5121167394e65880d4073f4b5319be5de84b268 Mon Sep 17 00:00:00 2001 From: chioarub Date: Tue, 7 Jul 2026 19:39:32 +0300 Subject: [PATCH 4/7] test(websearch): stabilize Brave timeout assertion --- .../WebSearchTool/providers/brave.test.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/tools/WebSearchTool/providers/brave.test.ts b/src/tools/WebSearchTool/providers/brave.test.ts index 59cd7fb7cb..442c96191e 100644 --- a/src/tools/WebSearchTool/providers/brave.test.ts +++ b/src/tools/WebSearchTool/providers/brave.test.ts @@ -115,17 +115,28 @@ describe('braveProvider search', () => { test('rejects when the provider-level timeout elapses', async () => { process.env.WEB_SEARCH_TIMEOUT_SEC = '1' - let capturedSignal: AbortSignal | undefined + let abortObserved = false globalThis.fetch = (async (_input: any, init: any) => { - capturedSignal = init?.signal as AbortSignal | undefined - return new Promise(() => undefined) + const signal = init?.signal as AbortSignal | undefined + expect(signal).toBeInstanceOf(AbortSignal) + + return new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => { + abortObserved = true + reject(signal.reason) + }, + { once: true }, + ) + }) }) as typeof fetch await expect(braveProvider.search({ query: 'q' })).rejects.toThrow( /Brave search timed out/, ) - expect(capturedSignal?.aborted).toBe(true) + expect(abortObserved).toBe(true) }) test('rejects when the response body stalls after headers arrive', async () => { From ee0bd4c53528dd6f71413558c60d62ccbf9099a7 Mon Sep 17 00:00:00 2001 From: chioarub Date: Tue, 7 Jul 2026 19:43:31 +0300 Subject: [PATCH 5/7] fix(firecrawl): handle bare cloud host casing --- src/tools/firecrawl/client.test.ts | 18 ++++++++++++++++++ src/tools/firecrawl/client.ts | 5 +---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/tools/firecrawl/client.test.ts b/src/tools/firecrawl/client.test.ts index 509b3492df..23e8f40d53 100644 --- a/src/tools/firecrawl/client.test.ts +++ b/src/tools/firecrawl/client.test.ts @@ -120,6 +120,24 @@ describe('firecrawl client', () => { ) }) + test('bare cloud api host requires an api key case-insensitively', async () => { + for (const apiUrl of ['API.FIRECRAWL.DEV', 'API.FIRECRAWL.DEV/']) { + delete process.env.FIRECRAWL_API_KEY + process.env.FIRECRAWL_API_URL = apiUrl + + globalThis.fetch = asMockFetch(mock(async () => { + return new Response(JSON.stringify({ success: true, data: { web: [] } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + })) + + await expect(firecrawlSearch('openclaude')).rejects.toThrow( + 'Firecrawl API key is required for the cloud API.', + ) + } + }) + test('retries transient 502 responses before succeeding', async () => { process.env.FIRECRAWL_API_KEY = 'fc-test-key' delete process.env.FIRECRAWL_API_URL diff --git a/src/tools/firecrawl/client.ts b/src/tools/firecrawl/client.ts index 135e212eff..7ab81718e5 100644 --- a/src/tools/firecrawl/client.ts +++ b/src/tools/firecrawl/client.ts @@ -48,10 +48,7 @@ export function isFirecrawlCloudApiUrl(apiUrl: string | undefined): boolean { return new URL(normalized).hostname === 'api.firecrawl.dev' } catch { const withoutTrailingSlash = normalized.replace(/\/+$/, '') - return ( - withoutTrailingSlash === DEFAULT_FIRECRAWL_API_URL || - withoutTrailingSlash === 'api.firecrawl.dev' - ) + return withoutTrailingSlash.toLowerCase() === 'api.firecrawl.dev' } } From b26b3e0f448f1d585225b35b5b92b6b155f1340f Mon Sep 17 00:00:00 2001 From: chioarub Date: Tue, 7 Jul 2026 22:01:05 +0300 Subject: [PATCH 6/7] fix(doctor): align WebSearch auto diagnostics with fallback --- scripts/system-check.test.ts | 17 +++++++++++++--- scripts/system-check.ts | 38 +++++++++++++++++++++--------------- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/scripts/system-check.test.ts b/scripts/system-check.test.ts index 60c11c9103..89dd1bf475 100644 --- a/scripts/system-check.test.ts +++ b/scripts/system-check.test.ts @@ -447,13 +447,24 @@ describe('system-check WebSearch diagnostics', () => { ) }) - test('fails Firecrawl cloud URL without an API key in auto mode', () => { + test('reports Firecrawl cloud URL without an API key in auto mode with DuckDuckGo fallback', () => { useOpenAICompatibleProvider() process.env.FIRECRAWL_API_URL = 'https://api.firecrawl.dev' expectWebSearchBackend( - false, - 'WEB_SEARCH_PROVIDER=auto; FIRECRAWL_API_URL points to the Firecrawl cloud API but FIRECRAWL_API_KEY is missing. Runtime will try firecrawl before fallback providers; set FIRECRAWL_API_KEY, use a self-hosted FIRECRAWL_API_URL, or unset FIRECRAWL_API_URL.', + true, + `WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${reliableBackendHint} for reliable search. FIRECRAWL_API_URL points to the Firecrawl cloud API but FIRECRAWL_API_KEY is missing; runtime will try firecrawl first and then fall through to the next provider in auto mode.`, + ) + }) + + test('reports Firecrawl cloud URL without an API key in auto mode with a later API fallback', () => { + useOpenAICompatibleProvider() + process.env.FIRECRAWL_API_URL = 'https://api.firecrawl.dev' + process.env.BRAVE_API_KEY = 'brave-secret-value-123' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=auto; configured providers: brave; fallback includes duckduckgo. FIRECRAWL_API_URL points to the Firecrawl cloud API but FIRECRAWL_API_KEY is missing; runtime will try firecrawl first and then fall through to the next provider in auto mode.', ) }) diff --git a/scripts/system-check.ts b/scripts/system-check.ts index 046b2053e0..ff70f0cc0b 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -363,15 +363,22 @@ function buildFirecrawlWebSearchCheck(): CheckResult { ) } -function buildAutoFirecrawlMissingCredentialCheck(): CheckResult | undefined { +function getAutoFirecrawlMissingCredentialDetail(): string | undefined { const firecrawlSelectedByAutoChain = getAvailableProviders() .some(provider => provider.name === 'firecrawl') if (!firecrawlSelectedByAutoChain || hasFirecrawlRunnableConfig()) return undefined - return fail( - 'Web search backend', - 'WEB_SEARCH_PROVIDER=auto; FIRECRAWL_API_URL points to the Firecrawl cloud API but FIRECRAWL_API_KEY is missing. Runtime will try firecrawl before fallback providers; set FIRECRAWL_API_KEY, use a self-hosted FIRECRAWL_API_URL, or unset FIRECRAWL_API_URL.', - ) + return 'FIRECRAWL_API_URL points to the Firecrawl cloud API but FIRECRAWL_API_KEY is missing; runtime will try firecrawl first and then fall through to the next provider in auto mode.' +} + +function appendAutoFirecrawlMissingCredentialDetail(result: CheckResult): CheckResult { + const firecrawlDetail = getAutoFirecrawlMissingCredentialDetail() + if (!firecrawlDetail) return result + + return { + ...result, + detail: result.detail ? `${result.detail} ${firecrawlDetail}` : firecrawlDetail, + } } function isWebSearchApiProviderConfiguredForDoctor(providerName: string): boolean { @@ -464,26 +471,25 @@ function buildWebSearchEnvChecks(): CheckResult[] { return [appendConfiguredWebSearchApiProviderDetail(nativeCheck)] } - const firecrawlMissingCredentialCheck = buildAutoFirecrawlMissingCredentialCheck() - if (firecrawlMissingCredentialCheck) { - return appendWebSearchTimeoutDetails([firecrawlMissingCredentialCheck]) - } - const configuredProviders = getConfiguredWebSearchApiProviderNames() if (configuredProviders.length > 0) { return appendWebSearchTimeoutDetails([ - pass( - 'Web search backend', - `WEB_SEARCH_PROVIDER=auto; configured providers: ${configuredProviders.join(', ')}; fallback includes duckduckgo.`, + appendAutoFirecrawlMissingCredentialDetail( + pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=auto; configured providers: ${configuredProviders.join(', ')}; fallback includes duckduckgo.`, + ), ), ]) } return appendWebSearchTimeoutDetails([ - pass( - 'Web search backend', - `WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${WEB_SEARCH_RELIABLE_BACKEND_ENV_HINT} for reliable search.`, + appendAutoFirecrawlMissingCredentialDetail( + pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${WEB_SEARCH_RELIABLE_BACKEND_ENV_HINT} for reliable search.`, + ), ), ]) } From d1e74e31dcbbe9ecd9148bdc146fb7b3b24cce7e Mon Sep 17 00:00:00 2001 From: chioarub Date: Tue, 7 Jul 2026 23:55:36 +0300 Subject: [PATCH 7/7] fix(doctor): align custom preset diagnostics --- scripts/system-check.test.ts | 24 ++++++++++++++++++++++++ scripts/system-check.ts | 14 +++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/scripts/system-check.test.ts b/scripts/system-check.test.ts index 89dd1bf475..ebea4b1ebf 100644 --- a/scripts/system-check.test.ts +++ b/scripts/system-check.test.ts @@ -620,6 +620,30 @@ describe('system-check WebSearch diagnostics', () => { ) }) + test('fails custom preset when WEB_PROVIDER has surrounding whitespace', () => { + process.env.WEB_SEARCH_PROVIDER = 'custom' + process.env.WEB_PROVIDER = 'brave ' + process.env.WEB_KEY = 'brave-secret-value-123' + + expectWebSearchBackend( + false, + 'WEB_SEARCH_PROVIDER=custom with WEB_PROVIDER=brave but the raw WEB_PROVIDER value has surrounding whitespace and does not match a runtime custom preset. Remove the whitespace or configure WEB_SEARCH_API or WEB_URL_TEMPLATE.', + false, + ) + }) + + test('passes custom provider with surrounding whitespace when a custom endpoint is configured', () => { + process.env.WEB_SEARCH_PROVIDER = 'custom' + process.env.WEB_PROVIDER = 'brave ' + process.env.WEB_SEARCH_API = 'https://example.com/search' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=custom; WEB_PROVIDER and WEB_SEARCH_API configured.', + false, + ) + }) + test('does not expose WebSearch secret values in diagnostics', () => { const secret = 'brave-secret-value-123' process.env.WEB_SEARCH_PROVIDER = 'brave' diff --git a/scripts/system-check.ts b/scripts/system-check.ts index ff70f0cc0b..9e67d27995 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -386,7 +386,8 @@ function isWebSearchApiProviderConfiguredForDoctor(providerName: string): boolea } function buildCustomWebSearchCheck(providerConfigured: boolean): CheckResult { - const providerPreset = process.env.WEB_PROVIDER?.trim() + const providerPreset = process.env.WEB_PROVIDER + const trimmedProviderPreset = providerPreset?.trim() const customUrlEnv = process.env.WEB_URL_TEMPLATE ? 'WEB_URL_TEMPLATE' : process.env.WEB_SEARCH_API @@ -410,6 +411,17 @@ function buildCustomWebSearchCheck(providerConfigured: boolean): CheckResult { const requiredEnvVars = WEB_SEARCH_CUSTOM_PRESET_REQUIRED_ENV_VARS[providerPreset] if (!requiredEnvVars) { if (!customUrlEnv) { + if ( + trimmedProviderPreset && + trimmedProviderPreset !== providerPreset && + WEB_SEARCH_CUSTOM_PRESET_REQUIRED_ENV_VARS[trimmedProviderPreset] + ) { + return fail( + 'Web search backend', + `WEB_SEARCH_PROVIDER=custom with WEB_PROVIDER=${trimmedProviderPreset} but the raw WEB_PROVIDER value has surrounding whitespace and does not match a runtime custom preset. Remove the whitespace or configure WEB_SEARCH_API or WEB_URL_TEMPLATE.`, + ) + } + return fail( 'Web search backend', `WEB_SEARCH_PROVIDER=custom with WEB_PROVIDER=${providerPreset} but WEB_SEARCH_API or WEB_URL_TEMPLATE is missing for an unknown custom preset.`,