diff --git a/docs/websearch.md b/docs/websearch.md index 5421d0eb..054c75d3 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -1,6 +1,6 @@ # WebSearch Configuration Guide -Last Updated: 2026-03-30 +Last Updated: 2026-04-04 CCS provides automatic web search for third-party profiles that cannot access Anthropic's native WebSearch API. @@ -177,6 +177,7 @@ Queries are fingerprinted (`queryHash`, `queryLength`) instead of logged raw by 2. Keep DuckDuckGo enabled unless you have a strong reason to disable it 3. If using Exa, Tavily, or Brave, verify the matching API key 4. Run with `CCS_DEBUG=1` for runtime logs, or `CCS_WEBSEARCH_TRACE=1` for correlated launch/MCP/provider traces +5. If DuckDuckGo returns a non-result HTML error, retry later or enable another provider. CCS now treats that as a provider failure instead of a false empty result. ## Security Considerations diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index 1165bc36..79c5a433 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -67,6 +67,8 @@ const PROVIDER_CONFIG = { const ddgLinkRe = /]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g; const ddgSnippetRe = /([\s\S]*?)<\/a>/g; +const ddgNoResultsRe = /class=['"][^'"]*no-results(?:__message)?[^'"]*['"]/i; +const ddgNoResultsHeadingRe = /No results found for/i; const htmlTagRe = /<[^>]+>/g; function debug(message) { @@ -427,6 +429,30 @@ function extractDuckDuckGoResults(html, count) { }); } +function classifyDuckDuckGoHtml(html, count) { + const responseHtml = String(html || ''); + const results = extractDuckDuckGoResults(responseHtml, count); + if (results.length > 0) { + return { + kind: 'results', + results, + }; + } + + if (ddgNoResultsRe.test(responseHtml) || ddgNoResultsHeadingRe.test(responseHtml)) { + return { + kind: 'no_results', + results: [], + }; + } + + return { + kind: 'non_result_html', + results: [], + error: 'DuckDuckGo returned non-result HTML response (possible anti-bot/challenge page)', + }; +} + function formatStructuredSearchResults(query, providerName, results) { const lines = [ 'CCS local WebSearch evidence', @@ -680,10 +706,18 @@ async function tryDuckDuckGoSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { } const html = await response.text(); - const results = extractDuckDuckGoResults(html, getResultCount('duckduckgo')); + const parsed = classifyDuckDuckGoHtml(html, getResultCount('duckduckgo')); + if (parsed.kind === 'non_result_html') { + return { + success: false, + error: `${parsed.error} (status ${response.status})`, + statusCode: response.status, + }; + } + return { success: true, - content: formatStructuredSearchResults(query, 'DuckDuckGo', results), + content: formatStructuredSearchResults(query, 'DuckDuckGo', parsed.results), }; } catch (error) { return { @@ -1229,6 +1263,7 @@ if (require.main === module) { module.exports = { buildFailureHookOutput, buildSuccessHookOutput, + classifyDuckDuckGoHtml, extractDuckDuckGoResults, formatStructuredSearchResults, getActiveProviders, diff --git a/tests/unit/hooks/ccs-websearch-mcp-server.test.ts b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts index 4229687f..64593411 100644 --- a/tests/unit/hooks/ccs-websearch-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts @@ -198,6 +198,81 @@ describe('ccs-websearch MCP server', () => { } }); + it('returns an MCP error result when DuckDuckGo responds with non-result HTML', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-server-')); + const preloadPath = join(tempDir, 'mock-fetch.cjs'); + const html = ` + + + + + + `.trim(); + writeFileSync( + preloadPath, + `global.fetch = async () => ({ ok: true, status: 202, headers: { get: () => null }, text: async () => ${JSON.stringify(html)} });\n`, + 'utf8' + ); + + const child = spawn('node', ['-r', preloadPath, serverPath], { + env: { + ...process.env, + CCS_PROFILE_TYPE: 'settings', + CCS_WEBSEARCH_ENABLED: '1', + CCS_WEBSEARCH_SKIP: '0', + CCS_WEBSEARCH_BRAVE: '0', + CCS_WEBSEARCH_DUCKDUCKGO: '1', + CCS_WEBSEARCH_EXA: '0', + CCS_WEBSEARCH_GEMINI: '0', + CCS_WEBSEARCH_GROK: '0', + CCS_WEBSEARCH_OPENCODE: '0', + CCS_WEBSEARCH_TAVILY: '0', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + try { + const responsesPromise = collectResponses(child, 2); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bun-test', version: '1.0.0' }, + }, + }) + ); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'WebSearch', arguments: { query: 'btc price' } }, + }) + ); + + const responses = await responsesPromise; + const toolCall = responses.find((message) => message.id === 2); + + expect(toolCall?.result).toBeDefined(); + expect((toolCall?.result as { isError: boolean }).isError).toBe(true); + expect( + ((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text + ).toContain('DuckDuckGo returned non-result HTML response'); + expect( + ((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text + ).not.toContain('Result count: 0'); + } finally { + child.kill(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('accepts the legacy search alias for direct calls', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-server-')); const preloadPath = join(tempDir, 'mock-fetch.cjs'); diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index 43ebde85..80bb74d9 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -31,6 +31,14 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as { providerName: string, content: string ) => HookOutput; + classifyDuckDuckGoHtml: ( + html: string, + count: number + ) => { + error?: string; + kind: 'results' | 'no_results' | 'non_result_html'; + results: Array<{ title: string; url: string; description: string }>; + }; extractDuckDuckGoResults: (html: string, count: number) => Array<{ title: string; url: string; @@ -50,17 +58,37 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as { parseRetryAfterSeconds: (rawValue: string) => number | null; }; -function runHookWithMockedFetch(mode: 'success' | 'failure') { +function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'failure') { const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-')); const preloadPath = join(tempDir, 'mock-fetch.cjs'); - const html = ` + const successHtml = ` Example title Example snippet `.trim(); + const emptyHtml = ` + + + + `.trim(); + const nonResultHtml = ` + +
+ + + + `.trim(); const preloadScript = mode === 'success' - ? `global.fetch = async () => ({ ok: true, text: async () => ${JSON.stringify(html)} });\n` - : `global.fetch = async () => ({ ok: false, status: 503, text: async () => 'Service unavailable' });\n`; + ? `global.fetch = async () => ({ ok: true, status: 200, headers: { get: () => null }, text: async () => ${JSON.stringify(successHtml)} });\n` + : mode === 'empty' + ? `global.fetch = async () => ({ ok: true, status: 200, headers: { get: () => null }, text: async () => ${JSON.stringify(emptyHtml)} });\n` + : mode === 'non-result' + ? `global.fetch = async () => ({ ok: true, status: 202, headers: { get: () => null }, text: async () => ${JSON.stringify(nonResultHtml)} });\n` + : `global.fetch = async () => ({ ok: false, status: 503, headers: { get: () => null }, text: async () => 'Service unavailable' });\n`; writeFileSync(preloadPath, preloadScript, 'utf8'); @@ -149,6 +177,35 @@ describe('websearch-transformer hook helpers', () => { }); }); + it('distinguishes legitimate DuckDuckGo zero-result pages from unusable HTML', () => { + const emptyPage = ` + + + + `; + const nonResultPage = ` + + + + + + `; + + expect(hook.classifyDuckDuckGoHtml(emptyPage, 5)).toEqual({ + kind: 'no_results', + results: [], + }); + expect(hook.classifyDuckDuckGoHtml(nonResultPage, 5)).toEqual({ + kind: 'non_result_html', + results: [], + error: 'DuckDuckGo returned non-result HTML response (possible anti-bot/challenge page)', + }); + }); + it('formats structured search results for hook deny output', () => { const formatted = hook.formatStructuredSearchResults('ccs websearch', 'DuckDuckGo', [ { @@ -224,6 +281,18 @@ describe('websearch-transformer hook helpers', () => { expect(output).not.toHaveProperty('additionalContext'); }); + it('preserves genuine DuckDuckGo zero-result pages as successful empty searches', () => { + const result = runHookWithMockedFetch('empty'); + + expect(result.status).toBe(0); + expect(result.stderr.trim()).toBe(''); + + const output = JSON.parse(result.stdout.trim()) as HookOutput; + expect(output.hookSpecificOutput.additionalContext).toContain('Provider: DuckDuckGo'); + expect(output.hookSpecificOutput.additionalContext).toContain('Result count: 0'); + expect(output.hookSpecificOutput.additionalContext).toContain('No results found.'); + }); + it('emits runtime failure output with attempted provider details nested under hookSpecificOutput', () => { const result = runHookWithMockedFetch('failure'); @@ -244,6 +313,20 @@ describe('websearch-transformer hook helpers', () => { expect(output).not.toHaveProperty('additionalContext'); }); + it('treats DuckDuckGo non-result HTML as provider failure instead of fake empty results', () => { + const result = runHookWithMockedFetch('non-result'); + + expect(result.status).toBe(0); + expect(result.stderr.trim()).toBe(''); + + const output = JSON.parse(result.stdout.trim()) as HookOutput; + expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); + expect(output.hookSpecificOutput.additionalContext).toContain( + 'Attempted providers: DuckDuckGo: DuckDuckGo returned non-result HTML response' + ); + expect(output.hookSpecificOutput.additionalContext).not.toContain('Result count: 0'); + }); + it('writes opt-in trace records with redacted query fingerprints', () => { const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-trace-')); const preloadPath = join(tempDir, 'mock-fetch.cjs');