mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 08:17:22 +00:00
fix(websearch): harden searxng url handling
This commit is contained in:
+2
-1
@@ -81,7 +81,8 @@ That shared launch helper applies to normal third-party settings profiles, CLIPr
|
||||
Open `ccs config` → `Settings` → `WebSearch`.
|
||||
|
||||
- Enable Exa, Tavily, Brave, SearXNG, or DuckDuckGo in the backend chain
|
||||
- Configure SearXNG base URL (for example `https://search.example.com`) when SearXNG is enabled
|
||||
- Configure the SearXNG base URL (for example `https://search.example.com`) when SearXNG is enabled
|
||||
Do not include `/search`, embedded credentials, query parameters, or URL fragments. CCS appends `/search?format=json`.
|
||||
- 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
|
||||
|
||||
@@ -378,6 +378,22 @@ function getSearxngBaseUrl() {
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.search || parsed.hash) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let pathname = parsed.pathname.replace(/\/+$/, '');
|
||||
if (pathname.toLowerCase().endsWith('/search')) {
|
||||
pathname = pathname.slice(0, -'/search'.length);
|
||||
}
|
||||
|
||||
parsed.pathname = pathname || '/';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return '';
|
||||
@@ -587,7 +603,7 @@ async function tryBraveSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||
async function trySearxngSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||
const baseUrl = getSearxngBaseUrl();
|
||||
if (!baseUrl) {
|
||||
return { success: false, error: 'SearXNG URL is not configured' };
|
||||
return { success: false, error: 'SearXNG URL is invalid or not configured' };
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
|
||||
@@ -16,7 +16,7 @@ const SERVER_VERSION = '1.0.0';
|
||||
const TOOL_NAME = 'WebSearch';
|
||||
const TOOL_ALIASES = ['search'];
|
||||
const TOOL_DESCRIPTION =
|
||||
'Third-party WebSearch replacement for CCS-managed Claude launches. Use this instead of Bash/curl/http fetches for web lookups. Provider order: Exa, Tavily, Brave Search, DuckDuckGo, then optional legacy CLI fallback.';
|
||||
'Third-party WebSearch replacement for CCS-managed Claude launches. Use this instead of Bash/curl/http fetches for web lookups. Provider order: Exa, Tavily, Brave Search, SearXNG, DuckDuckGo, then optional legacy CLI fallback.';
|
||||
|
||||
function isSupportedToolName(name) {
|
||||
return name === TOOL_NAME || TOOL_ALIASES.includes(name);
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
resolveLegacyDiscordSelection,
|
||||
} from '../channels/official-channels-runtime';
|
||||
import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver';
|
||||
import { normalizeSearxngBaseUrl } from '../utils/websearch/types';
|
||||
|
||||
const CONFIG_YAML = 'config.yaml';
|
||||
const CONFIG_JSON = 'config.json';
|
||||
@@ -413,7 +414,7 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
},
|
||||
searxng: {
|
||||
enabled: partial.websearch?.providers?.searxng?.enabled ?? false,
|
||||
url: partial.websearch?.providers?.searxng?.url ?? '',
|
||||
url: normalizeSearxngBaseUrl(partial.websearch?.providers?.searxng?.url) ?? '',
|
||||
max_results: partial.websearch?.providers?.searxng?.max_results ?? 5,
|
||||
},
|
||||
duckduckgo: {
|
||||
@@ -1140,7 +1141,7 @@ export function getWebSearchConfig(): {
|
||||
|
||||
const searxngConfig = {
|
||||
enabled: config.websearch?.providers?.searxng?.enabled ?? false,
|
||||
url: config.websearch?.providers?.searxng?.url ?? '',
|
||||
url: normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? '',
|
||||
max_results: config.websearch?.providers?.searxng?.max_results ?? 5,
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { getWebSearchConfig } from '../../config/unified-config-loader';
|
||||
import { normalizeSearxngBaseUrl } from './types';
|
||||
import { resolveAllowedWebSearchTraceFile } from './trace';
|
||||
|
||||
/**
|
||||
@@ -18,7 +19,18 @@ import { resolveAllowedWebSearchTraceFile } from './trace';
|
||||
*/
|
||||
export function getWebSearchHookEnv(): Record<string, string> {
|
||||
const wsConfig = getWebSearchConfig();
|
||||
const env: Record<string, string> = {};
|
||||
const env: Record<string, string> = {
|
||||
CCS_WEBSEARCH_ENABLED: '0',
|
||||
CCS_WEBSEARCH_SKIP: '0',
|
||||
CCS_WEBSEARCH_EXA: '0',
|
||||
CCS_WEBSEARCH_TAVILY: '0',
|
||||
CCS_WEBSEARCH_BRAVE: '0',
|
||||
CCS_WEBSEARCH_SEARXNG: '0',
|
||||
CCS_WEBSEARCH_DUCKDUCKGO: '0',
|
||||
CCS_WEBSEARCH_GEMINI: '0',
|
||||
CCS_WEBSEARCH_OPENCODE: '0',
|
||||
CCS_WEBSEARCH_GROK: '0',
|
||||
};
|
||||
|
||||
if (process.env.CCS_WEBSEARCH_TRACE === '1' || process.env.CCS_DEBUG === '1') {
|
||||
env.CCS_WEBSEARCH_TRACE = '1';
|
||||
@@ -61,9 +73,10 @@ export function getWebSearchHookEnv(): Record<string, string> {
|
||||
env.CCS_WEBSEARCH_BRAVE_MAX_RESULTS = String(wsConfig.providers.brave.max_results || 5);
|
||||
}
|
||||
|
||||
if (wsConfig.providers?.searxng?.enabled && wsConfig.providers.searxng.url?.trim()) {
|
||||
const searxngBaseUrl = normalizeSearxngBaseUrl(wsConfig.providers?.searxng?.url);
|
||||
if (wsConfig.providers?.searxng?.enabled && searxngBaseUrl) {
|
||||
env.CCS_WEBSEARCH_SEARXNG = '1';
|
||||
env.CCS_WEBSEARCH_SEARXNG_URL = wsConfig.providers.searxng.url.trim();
|
||||
env.CCS_WEBSEARCH_SEARXNG_URL = searxngBaseUrl;
|
||||
env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS = String(wsConfig.providers.searxng.max_results || 5);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ 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';
|
||||
import { normalizeSearxngBaseUrl, type WebSearchCliInfo, type WebSearchStatus } from './types';
|
||||
|
||||
const PROVIDER_STATE_FILE = 'websearch-provider-state.json';
|
||||
|
||||
@@ -29,17 +29,7 @@ function hasEnvValue(name: string): boolean {
|
||||
}
|
||||
|
||||
function hasValidSearxngUrl(url: string | undefined): boolean {
|
||||
const normalized = String(url || '').trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
return Boolean(parsed.protocol && parsed.host);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return normalizeSearxngBaseUrl(url) !== null;
|
||||
}
|
||||
|
||||
function getProviderStatePath(): string {
|
||||
|
||||
@@ -117,3 +117,45 @@ export interface WebSearchConfig {
|
||||
grok?: WebSearchProviderConfig;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a SearXNG base URL so runtime code can safely append `/search`.
|
||||
*
|
||||
* Accepts optional subpaths (for reverse-proxy deployments), strips a trailing
|
||||
* `/search` endpoint suffix, and rejects query/hash-bearing URLs because the
|
||||
* runtime owns the request path and query params.
|
||||
*/
|
||||
export function normalizeSearxngBaseUrl(url: string | undefined): string | null {
|
||||
const normalized = String(url || '').trim();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.search || parsed.hash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let pathname = parsed.pathname.replace(/\/+$/, '');
|
||||
if (pathname.toLowerCase().endsWith('/search')) {
|
||||
pathname = pathname.slice(0, -'/search'.length);
|
||||
}
|
||||
|
||||
parsed.pathname = pathname || '/';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
WEBSEARCH_API_KEY_PROVIDERS,
|
||||
type WebSearchApiKeyProviderId,
|
||||
} from '../../utils/websearch/provider-secrets';
|
||||
import { normalizeSearxngBaseUrl } from '../../utils/websearch/types';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
|
||||
const router = Router();
|
||||
@@ -95,6 +96,19 @@ router.put('/', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSearxngUrl =
|
||||
providers?.searxng?.url !== undefined
|
||||
? normalizeSearxngBaseUrl(providers.searxng.url)
|
||||
: undefined;
|
||||
|
||||
if (providers?.searxng?.url !== undefined && normalizedSearxngUrl === null) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'Invalid value for providers.searxng.url. Must be an http(s) base URL without credentials, query, or hash.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
providers?.searxng?.max_results !== undefined &&
|
||||
(typeof providers.searxng.max_results !== 'number' ||
|
||||
@@ -130,6 +144,9 @@ router.put('/', (req: Request, res: Response): void => {
|
||||
|
||||
try {
|
||||
mutateUnifiedConfig((config) => {
|
||||
const existingSearxngUrl =
|
||||
normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? '';
|
||||
|
||||
config.websearch = {
|
||||
enabled: enabled ?? config.websearch?.enabled ?? true,
|
||||
providers: providers
|
||||
@@ -173,7 +190,7 @@ router.put('/', (req: Request, res: Response): void => {
|
||||
providers.searxng?.enabled ??
|
||||
config.websearch?.providers?.searxng?.enabled ??
|
||||
false,
|
||||
url: providers.searxng?.url ?? config.websearch?.providers?.searxng?.url ?? '',
|
||||
url: normalizedSearxngUrl ?? existingSearxngUrl,
|
||||
max_results: clampWebSearchMaxResults(
|
||||
providers.searxng?.max_results,
|
||||
config.websearch?.providers?.searxng?.max_results ?? DEFAULT_WEBSEARCH_MAX_RESULTS
|
||||
|
||||
@@ -166,13 +166,13 @@ describe('ccs-websearch MCP server', () => {
|
||||
|
||||
expect(toolsList?.result).toEqual({
|
||||
tools: [
|
||||
{
|
||||
name: 'WebSearch',
|
||||
description:
|
||||
'Third-party WebSearch replacement for CCS-managed Claude launches. Use this instead of Bash/curl/http fetches for web lookups. Provider order: Exa, Tavily, Brave Search, DuckDuckGo, then optional legacy CLI fallback.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
{
|
||||
name: 'WebSearch',
|
||||
description:
|
||||
'Third-party WebSearch replacement for CCS-managed Claude launches. Use this instead of Bash/curl/http fetches for web lookups. Provider order: Exa, Tavily, Brave Search, SearXNG, DuckDuckGo, then optional legacy CLI fallback.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description:
|
||||
|
||||
@@ -115,6 +115,7 @@ function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'fail
|
||||
CCS_WEBSEARCH_GEMINI: '0',
|
||||
CCS_WEBSEARCH_GROK: '0',
|
||||
CCS_WEBSEARCH_OPENCODE: '0',
|
||||
CCS_WEBSEARCH_SEARXNG: '0',
|
||||
CCS_WEBSEARCH_TAVILY: '0',
|
||||
},
|
||||
});
|
||||
@@ -166,11 +167,13 @@ describe('websearch-transformer hook helpers', () => {
|
||||
CCS_WEBSEARCH_SEARXNG_MAX_RESULTS: process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS,
|
||||
CCS_WEBSEARCH_SEARXNG_URL: process.env.CCS_WEBSEARCH_SEARXNG_URL,
|
||||
};
|
||||
const requests: string[] = [];
|
||||
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com/';
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com/search/';
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS = '3';
|
||||
global.fetch = async () =>
|
||||
({
|
||||
global.fetch = async (url) => {
|
||||
requests.push(String(url));
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
results: [
|
||||
@@ -181,13 +184,15 @@ describe('websearch-transformer hook helpers', () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
}) as Response;
|
||||
} as Response;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await hook.trySearxngSearch('btc price', 1);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('Provider: SearXNG');
|
||||
expect(result.content).toContain('URL: https://example.com/searxng');
|
||||
expect(requests).toEqual(['https://search.example.com/search?q=btc+price&format=json']);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
if (originalEnv.CCS_WEBSEARCH_SEARXNG_URL === undefined) {
|
||||
@@ -205,6 +210,42 @@ describe('websearch-transformer hook helpers', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects SearXNG URLs that include query parameters', async () => {
|
||||
const originalUrl = process.env.CCS_WEBSEARCH_SEARXNG_URL;
|
||||
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com/search?format=json';
|
||||
|
||||
try {
|
||||
const result = await hook.trySearxngSearch('btc price', 1);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('invalid or not configured');
|
||||
} finally {
|
||||
if (originalUrl === undefined) {
|
||||
delete process.env.CCS_WEBSEARCH_SEARXNG_URL;
|
||||
} else {
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = originalUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects credential-bearing SearXNG URLs', async () => {
|
||||
const originalUrl = process.env.CCS_WEBSEARCH_SEARXNG_URL;
|
||||
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://user:pass@search.example.com';
|
||||
|
||||
try {
|
||||
const result = await hook.trySearxngSearch('btc price', 1);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('invalid or not configured');
|
||||
} finally {
|
||||
if (originalUrl === undefined) {
|
||||
delete process.env.CCS_WEBSEARCH_SEARXNG_URL;
|
||||
} else {
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = originalUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('marks SearXNG format-disabled 403 responses as non-retryable failures', async () => {
|
||||
const originalFetch = global.fetch;
|
||||
const originalUrl = process.env.CCS_WEBSEARCH_SEARXNG_URL;
|
||||
|
||||
@@ -124,6 +124,20 @@ preferences:
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8');
|
||||
}
|
||||
|
||||
function writeConfigWithWebSearchSettings(yamlBody: string): void {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-env-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
const yaml = `version: 8
|
||||
preferences:
|
||||
auto_update: true
|
||||
websearch:
|
||||
${yamlBody}
|
||||
`;
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8');
|
||||
}
|
||||
|
||||
let execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
|
||||
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
|
||||
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
|
||||
@@ -220,7 +234,7 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
||||
expect(env).toBeDefined();
|
||||
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
|
||||
expect(env.CCS_WEBSEARCH_SKIP).toBe('1');
|
||||
expect(env.CCS_WEBSEARCH_ENABLED || env.CCS_WEBSEARCH_SKIP).toBeDefined();
|
||||
});
|
||||
|
||||
it('execClaude keeps behavior when CLAUDECODE is absent', () => {
|
||||
@@ -262,6 +276,29 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
expect(env.DISABLE_AUTOUPDATER).toBeUndefined();
|
||||
});
|
||||
|
||||
it('execClaude overrides stale inherited WebSearch provider flags with config-derived values', () => {
|
||||
writeConfigWithWebSearchSettings(` enabled: true
|
||||
providers:
|
||||
duckduckgo:
|
||||
enabled: true
|
||||
searxng:
|
||||
enabled: false
|
||||
url: ''
|
||||
`);
|
||||
process.env.CCS_WEBSEARCH_SEARXNG = '1';
|
||||
process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com';
|
||||
process.env.CCS_WEBSEARCH_SKIP = '1';
|
||||
|
||||
execClaude('claude', ['--version'], { CCS_PROFILE_TYPE: 'settings' });
|
||||
|
||||
expect(spawnCalls.length).toBeGreaterThan(0);
|
||||
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
||||
expect(env.CCS_WEBSEARCH_ENABLED).toBe('1');
|
||||
expect(env.CCS_WEBSEARCH_SKIP).toBe('0');
|
||||
expect(env.CCS_WEBSEARCH_DUCKDUCKGO).toBe('1');
|
||||
expect(env.CCS_WEBSEARCH_SEARXNG).toBe('0');
|
||||
});
|
||||
|
||||
it('execClaude normalizes shared plugin metadata before default-profile launch', () => {
|
||||
const normalizeSpy = spyOn(
|
||||
SharedManager.prototype,
|
||||
|
||||
@@ -115,6 +115,60 @@ describe('websearch readiness', () => {
|
||||
expect(readiness.message).toContain('SearXNG');
|
||||
});
|
||||
|
||||
it('marks SearXNG as unavailable when config uses a query-bearing endpoint URL', () => {
|
||||
const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({
|
||||
enabled: true,
|
||||
providers: {
|
||||
exa: { enabled: false, max_results: 5 },
|
||||
tavily: { enabled: false, max_results: 5 },
|
||||
brave: { enabled: false, max_results: 5 },
|
||||
searxng: {
|
||||
enabled: true,
|
||||
url: 'https://search.example.com/search?format=json',
|
||||
max_results: 5,
|
||||
},
|
||||
duckduckgo: { enabled: false, max_results: 5 },
|
||||
gemini: { enabled: false },
|
||||
grok: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
},
|
||||
} as any);
|
||||
const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({
|
||||
exa: { envVar: 'EXA_API_KEY', configured: false, available: false, source: 'none' },
|
||||
tavily: { envVar: 'TAVILY_API_KEY', configured: false, available: false, source: 'none' },
|
||||
brave: { envVar: 'BRAVE_API_KEY', configured: false, available: false, source: 'none' },
|
||||
});
|
||||
const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({
|
||||
installed: false,
|
||||
version: null,
|
||||
} as any);
|
||||
const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false);
|
||||
const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({
|
||||
installed: false,
|
||||
version: null,
|
||||
} as any);
|
||||
const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({
|
||||
installed: false,
|
||||
version: null,
|
||||
} as any);
|
||||
|
||||
try {
|
||||
const providers = getWebSearchCliProviders();
|
||||
const searxng = providers.find((entry) => entry.id === 'searxng');
|
||||
|
||||
expect(searxng?.enabled).toBe(true);
|
||||
expect(searxng?.available).toBe(false);
|
||||
expect(searxng?.detail).toContain('Set a valid SearXNG base URL');
|
||||
} finally {
|
||||
getConfigSpy.mockRestore();
|
||||
apiKeySpy.mockRestore();
|
||||
geminiStatusSpy.mockRestore();
|
||||
geminiAuthSpy.mockRestore();
|
||||
grokStatusSpy.mockRestore();
|
||||
opencodeStatusSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats cooled-down providers as temporarily unavailable in readiness status', () => {
|
||||
const tempHome = mkdtempSync(join(tmpdir(), 'websearch-status-cooldown-'));
|
||||
const statePath = join(tempHome, '.ccs', 'cache', 'websearch-provider-state.json');
|
||||
|
||||
@@ -315,6 +315,86 @@ describe('websearch routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes searxng endpoint-style urls to the instance base URL', async () => {
|
||||
const response = await putWebsearch({
|
||||
providers: {
|
||||
searxng: {
|
||||
enabled: true,
|
||||
url: 'https://search.example.com/custom/search/',
|
||||
max_results: 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const payload = await response.json();
|
||||
expect(payload.websearch.providers.searxng).toEqual({
|
||||
enabled: true,
|
||||
url: 'https://search.example.com/custom',
|
||||
max_results: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows clearing a searxng url back to blank', async () => {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.websearch = {
|
||||
enabled: true,
|
||||
providers: {
|
||||
...config.websearch?.providers,
|
||||
searxng: {
|
||||
enabled: false,
|
||||
url: 'https://search.example.com/custom',
|
||||
max_results: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const response = await putWebsearch({
|
||||
providers: {
|
||||
searxng: {
|
||||
enabled: false,
|
||||
url: '',
|
||||
max_results: 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const payload = await response.json();
|
||||
expect(payload.websearch.providers.searxng).toEqual({
|
||||
enabled: false,
|
||||
url: '',
|
||||
max_results: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes credential-bearing searxng urls out of the GET response', async () => {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.websearch = {
|
||||
enabled: true,
|
||||
providers: {
|
||||
...config.websearch?.providers,
|
||||
searxng: {
|
||||
enabled: true,
|
||||
url: 'https://user:pass@search.example.com/search',
|
||||
max_results: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/websearch`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const payload = await response.json();
|
||||
expect(payload.providers.searxng).toEqual({
|
||||
enabled: true,
|
||||
url: '',
|
||||
max_results: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-object request bodies', async () => {
|
||||
const response = await putWebsearch('[]');
|
||||
|
||||
@@ -400,6 +480,38 @@ describe('websearch routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects searxng urls with query parameters', async () => {
|
||||
const response = await putWebsearch({
|
||||
providers: {
|
||||
searxng: {
|
||||
url: 'https://search.example.com/search?format=json',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
error:
|
||||
'Invalid value for providers.searxng.url. Must be an http(s) base URL without credentials, query, or hash.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects credential-bearing searxng urls', async () => {
|
||||
const response = await putWebsearch({
|
||||
providers: {
|
||||
searxng: {
|
||||
url: 'https://user:pass@search.example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
error:
|
||||
'Invalid value for providers.searxng.url. Must be an http(s) base URL without credentials, query, or hash.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-number searxng max_results values', async () => {
|
||||
const response = await putWebsearch({
|
||||
providers: {
|
||||
|
||||
@@ -154,7 +154,8 @@ const BACKEND_PROVIDERS: ProviderDefinition[] = [
|
||||
label: 'Base URL',
|
||||
type: 'text',
|
||||
placeholder: 'https://search.example.com',
|
||||
helpText: 'Must expose /search with format=json enabled.',
|
||||
helpText:
|
||||
'Paste the instance base URL only. CCS appends /search?format=json for you and rejects embedded credentials.',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user