mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 00:14:15 +00:00
fix(websearch): harden searxng url handling
This commit is contained in:
1 parent
36d828f99c
commit
8b553c35c1
14 files changed
+359
-34
No files matched your search
+2
-1
@@ -81,7 +81,8 @@ That shared launch helper applies to normal third-party settings profiles, CLIPr
|
|||||||
Open `ccs config` → `Settings` → `WebSearch`.
|
Open `ccs config` → `Settings` → `WebSearch`.
|
||||||
|
|
||||||
- Enable Exa, Tavily, Brave, SearXNG, or DuckDuckGo in the backend chain
|
- 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
|
- 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
|
- 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
|
- Review whether any legacy fallback CLIs are still enabled in config
|
||||||
|
|||||||
@@ -378,6 +378,22 @@ function getSearxngBaseUrl() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(raw);
|
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(/\/+$/, '');
|
return parsed.toString().replace(/\/+$/, '');
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
@@ -587,7 +603,7 @@ async function tryBraveSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
|||||||
async function trySearxngSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
async function trySearxngSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||||
const baseUrl = getSearxngBaseUrl();
|
const baseUrl = getSearxngBaseUrl();
|
||||||
if (!baseUrl) {
|
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({
|
const params = new URLSearchParams({
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const SERVER_VERSION = '1.0.0';
|
|||||||
const TOOL_NAME = 'WebSearch';
|
const TOOL_NAME = 'WebSearch';
|
||||||
const TOOL_ALIASES = ['search'];
|
const TOOL_ALIASES = ['search'];
|
||||||
const TOOL_DESCRIPTION =
|
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) {
|
function isSupportedToolName(name) {
|
||||||
return name === TOOL_NAME || TOOL_ALIASES.includes(name);
|
return name === TOOL_NAME || TOOL_ALIASES.includes(name);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
resolveLegacyDiscordSelection,
|
resolveLegacyDiscordSelection,
|
||||||
} from '../channels/official-channels-runtime';
|
} from '../channels/official-channels-runtime';
|
||||||
import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver';
|
import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver';
|
||||||
|
import { normalizeSearxngBaseUrl } from '../utils/websearch/types';
|
||||||
|
|
||||||
const CONFIG_YAML = 'config.yaml';
|
const CONFIG_YAML = 'config.yaml';
|
||||||
const CONFIG_JSON = 'config.json';
|
const CONFIG_JSON = 'config.json';
|
||||||
@@ -413,7 +414,7 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
|||||||
},
|
},
|
||||||
searxng: {
|
searxng: {
|
||||||
enabled: partial.websearch?.providers?.searxng?.enabled ?? false,
|
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,
|
max_results: partial.websearch?.providers?.searxng?.max_results ?? 5,
|
||||||
},
|
},
|
||||||
duckduckgo: {
|
duckduckgo: {
|
||||||
@@ -1140,7 +1141,7 @@ export function getWebSearchConfig(): {
|
|||||||
|
|
||||||
const searxngConfig = {
|
const searxngConfig = {
|
||||||
enabled: config.websearch?.providers?.searxng?.enabled ?? false,
|
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,
|
max_results: config.websearch?.providers?.searxng?.max_results ?? 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getWebSearchConfig } from '../../config/unified-config-loader';
|
import { getWebSearchConfig } from '../../config/unified-config-loader';
|
||||||
|
import { normalizeSearxngBaseUrl } from './types';
|
||||||
import { resolveAllowedWebSearchTraceFile } from './trace';
|
import { resolveAllowedWebSearchTraceFile } from './trace';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,7 +19,18 @@ import { resolveAllowedWebSearchTraceFile } from './trace';
|
|||||||
*/
|
*/
|
||||||
export function getWebSearchHookEnv(): Record<string, string> {
|
export function getWebSearchHookEnv(): Record<string, string> {
|
||||||
const wsConfig = getWebSearchConfig();
|
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') {
|
if (process.env.CCS_WEBSEARCH_TRACE === '1' || process.env.CCS_DEBUG === '1') {
|
||||||
env.CCS_WEBSEARCH_TRACE = '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);
|
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 = '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);
|
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 { getGrokCliStatus } from './grok-cli';
|
||||||
import { getOpenCodeCliStatus } from './opencode-cli';
|
import { getOpenCodeCliStatus } from './opencode-cli';
|
||||||
import { getWebSearchApiKeyStates } from './provider-secrets';
|
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';
|
const PROVIDER_STATE_FILE = 'websearch-provider-state.json';
|
||||||
|
|
||||||
@@ -29,17 +29,7 @@ function hasEnvValue(name: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasValidSearxngUrl(url: string | undefined): boolean {
|
function hasValidSearxngUrl(url: string | undefined): boolean {
|
||||||
const normalized = String(url || '').trim();
|
return normalizeSearxngBaseUrl(url) !== null;
|
||||||
if (!normalized) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = new URL(normalized);
|
|
||||||
return Boolean(parsed.protocol && parsed.host);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProviderStatePath(): string {
|
function getProviderStatePath(): string {
|
||||||
|
|||||||
@@ -117,3 +117,45 @@ export interface WebSearchConfig {
|
|||||||
grok?: WebSearchProviderConfig;
|
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,
|
WEBSEARCH_API_KEY_PROVIDERS,
|
||||||
type WebSearchApiKeyProviderId,
|
type WebSearchApiKeyProviderId,
|
||||||
} from '../../utils/websearch/provider-secrets';
|
} from '../../utils/websearch/provider-secrets';
|
||||||
|
import { normalizeSearxngBaseUrl } from '../../utils/websearch/types';
|
||||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -95,6 +96,19 @@ router.put('/', (req: Request, res: Response): void => {
|
|||||||
return;
|
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 (
|
if (
|
||||||
providers?.searxng?.max_results !== undefined &&
|
providers?.searxng?.max_results !== undefined &&
|
||||||
(typeof providers.searxng.max_results !== 'number' ||
|
(typeof providers.searxng.max_results !== 'number' ||
|
||||||
@@ -130,6 +144,9 @@ router.put('/', (req: Request, res: Response): void => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
mutateUnifiedConfig((config) => {
|
mutateUnifiedConfig((config) => {
|
||||||
|
const existingSearxngUrl =
|
||||||
|
normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? '';
|
||||||
|
|
||||||
config.websearch = {
|
config.websearch = {
|
||||||
enabled: enabled ?? config.websearch?.enabled ?? true,
|
enabled: enabled ?? config.websearch?.enabled ?? true,
|
||||||
providers: providers
|
providers: providers
|
||||||
@@ -173,7 +190,7 @@ router.put('/', (req: Request, res: Response): void => {
|
|||||||
providers.searxng?.enabled ??
|
providers.searxng?.enabled ??
|
||||||
config.websearch?.providers?.searxng?.enabled ??
|
config.websearch?.providers?.searxng?.enabled ??
|
||||||
false,
|
false,
|
||||||
url: providers.searxng?.url ?? config.websearch?.providers?.searxng?.url ?? '',
|
url: normalizedSearxngUrl ?? existingSearxngUrl,
|
||||||
max_results: clampWebSearchMaxResults(
|
max_results: clampWebSearchMaxResults(
|
||||||
providers.searxng?.max_results,
|
providers.searxng?.max_results,
|
||||||
config.websearch?.providers?.searxng?.max_results ?? DEFAULT_WEBSEARCH_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({
|
expect(toolsList?.result).toEqual({
|
||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
name: 'WebSearch',
|
name: 'WebSearch',
|
||||||
description:
|
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.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
query: {
|
query: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'fail
|
|||||||
CCS_WEBSEARCH_GEMINI: '0',
|
CCS_WEBSEARCH_GEMINI: '0',
|
||||||
CCS_WEBSEARCH_GROK: '0',
|
CCS_WEBSEARCH_GROK: '0',
|
||||||
CCS_WEBSEARCH_OPENCODE: '0',
|
CCS_WEBSEARCH_OPENCODE: '0',
|
||||||
|
CCS_WEBSEARCH_SEARXNG: '0',
|
||||||
CCS_WEBSEARCH_TAVILY: '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_MAX_RESULTS: process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS,
|
||||||
CCS_WEBSEARCH_SEARXNG_URL: process.env.CCS_WEBSEARCH_SEARXNG_URL,
|
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';
|
process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS = '3';
|
||||||
global.fetch = async () =>
|
global.fetch = async (url) => {
|
||||||
({
|
requests.push(String(url));
|
||||||
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
results: [
|
results: [
|
||||||
@@ -181,13 +184,15 @@ describe('websearch-transformer hook helpers', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
}) as Response;
|
} as Response;
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await hook.trySearxngSearch('btc price', 1);
|
const result = await hook.trySearxngSearch('btc price', 1);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.content).toContain('Provider: SearXNG');
|
expect(result.content).toContain('Provider: SearXNG');
|
||||||
expect(result.content).toContain('URL: https://example.com/searxng');
|
expect(result.content).toContain('URL: https://example.com/searxng');
|
||||||
|
expect(requests).toEqual(['https://search.example.com/search?q=btc+price&format=json']);
|
||||||
} finally {
|
} finally {
|
||||||
global.fetch = originalFetch;
|
global.fetch = originalFetch;
|
||||||
if (originalEnv.CCS_WEBSEARCH_SEARXNG_URL === undefined) {
|
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 () => {
|
it('marks SearXNG format-disabled 403 responses as non-retryable failures', async () => {
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
const originalUrl = process.env.CCS_WEBSEARCH_SEARXNG_URL;
|
const originalUrl = process.env.CCS_WEBSEARCH_SEARXNG_URL;
|
||||||
|
|||||||
@@ -124,6 +124,20 @@ preferences:
|
|||||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8');
|
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 execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
|
||||||
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
|
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
|
||||||
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
|
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;
|
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
||||||
expect(env).toBeDefined();
|
expect(env).toBeDefined();
|
||||||
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
|
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', () => {
|
it('execClaude keeps behavior when CLAUDECODE is absent', () => {
|
||||||
@@ -262,6 +276,29 @@ describe('CLAUDECODE environment stripping', () => {
|
|||||||
expect(env.DISABLE_AUTOUPDATER).toBeUndefined();
|
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', () => {
|
it('execClaude normalizes shared plugin metadata before default-profile launch', () => {
|
||||||
const normalizeSpy = spyOn(
|
const normalizeSpy = spyOn(
|
||||||
SharedManager.prototype,
|
SharedManager.prototype,
|
||||||
|
|||||||
@@ -115,6 +115,60 @@ describe('websearch readiness', () => {
|
|||||||
expect(readiness.message).toContain('SearXNG');
|
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', () => {
|
it('treats cooled-down providers as temporarily unavailable in readiness status', () => {
|
||||||
const tempHome = mkdtempSync(join(tmpdir(), 'websearch-status-cooldown-'));
|
const tempHome = mkdtempSync(join(tmpdir(), 'websearch-status-cooldown-'));
|
||||||
const statePath = join(tempHome, '.ccs', 'cache', 'websearch-provider-state.json');
|
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 () => {
|
it('rejects non-object request bodies', async () => {
|
||||||
const response = await putWebsearch('[]');
|
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 () => {
|
it('rejects non-number searxng max_results values', async () => {
|
||||||
const response = await putWebsearch({
|
const response = await putWebsearch({
|
||||||
providers: {
|
providers: {
|
||||||
|
|||||||
@@ -154,7 +154,8 @@ const BACKEND_PROVIDERS: ProviderDefinition[] = [
|
|||||||
label: 'Base URL',
|
label: 'Base URL',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
placeholder: 'https://search.example.com',
|
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: '',
|
defaultValue: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user