fix: classify DuckDuckGo non-result HTML as failure

This commit is contained in:
Tam Nhu Tran
2026-04-04 13:05:31 -04:00
parent 6201005c8f
commit a33d3f4481
4 changed files with 201 additions and 7 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
# WebSearch Configuration Guide # 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. 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 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 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 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 ## Security Considerations
+37 -2
View File
@@ -67,6 +67,8 @@ const PROVIDER_CONFIG = {
const ddgLinkRe = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g; const ddgLinkRe = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
const ddgSnippetRe = /<a class="result__snippet[^"]*".*?>([\s\S]*?)<\/a>/g; const ddgSnippetRe = /<a class="result__snippet[^"]*".*?>([\s\S]*?)<\/a>/g;
const ddgNoResultsRe = /class=['"][^'"]*no-results(?:__message)?[^'"]*['"]/i;
const ddgNoResultsHeadingRe = /No results found for/i;
const htmlTagRe = /<[^>]+>/g; const htmlTagRe = /<[^>]+>/g;
function debug(message) { 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) { function formatStructuredSearchResults(query, providerName, results) {
const lines = [ const lines = [
'CCS local WebSearch evidence', 'CCS local WebSearch evidence',
@@ -680,10 +706,18 @@ async function tryDuckDuckGoSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
} }
const html = await response.text(); 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 { return {
success: true, success: true,
content: formatStructuredSearchResults(query, 'DuckDuckGo', results), content: formatStructuredSearchResults(query, 'DuckDuckGo', parsed.results),
}; };
} catch (error) { } catch (error) {
return { return {
@@ -1229,6 +1263,7 @@ if (require.main === module) {
module.exports = { module.exports = {
buildFailureHookOutput, buildFailureHookOutput,
buildSuccessHookOutput, buildSuccessHookOutput,
classifyDuckDuckGoHtml,
extractDuckDuckGoResults, extractDuckDuckGoResults,
formatStructuredSearchResults, formatStructuredSearchResults,
getActiveProviders, getActiveProviders,
@@ -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 = `
<html>
<body>
<form action="/anomaly.js" method="post">
<input type="hidden" name="q" value="btc price" />
</form>
</body>
</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 () => { it('accepts the legacy search alias for direct calls', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-server-')); const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-server-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs'); const preloadPath = join(tempDir, 'mock-fetch.cjs');
+87 -4
View File
@@ -31,6 +31,14 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as {
providerName: string, providerName: string,
content: string content: string
) => HookOutput; ) => 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<{ extractDuckDuckGoResults: (html: string, count: number) => Array<{
title: string; title: string;
url: string; url: string;
@@ -50,17 +58,37 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as {
parseRetryAfterSeconds: (rawValue: string) => number | null; 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 tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs'); const preloadPath = join(tempDir, 'mock-fetch.cjs');
const html = ` const successHtml = `
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a> <a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
<a class="result__snippet">Example snippet</a> <a class="result__snippet">Example snippet</a>
`.trim(); `.trim();
const emptyHtml = `
<span class="no-results">
<div class="no-results__message">
<h1>No results found for <strong>btc price</strong></h1>
</div>
</span>
`.trim();
const nonResultHtml = `
<html>
<body>
<form action="/anomaly.js" method="post">
<input type="hidden" name="q" value="btc price" />
</form>
</body>
</html>
`.trim();
const preloadScript = const preloadScript =
mode === 'success' mode === 'success'
? `global.fetch = async () => ({ ok: true, text: async () => ${JSON.stringify(html)} });\n` ? `global.fetch = async () => ({ ok: true, status: 200, headers: { get: () => null }, text: async () => ${JSON.stringify(successHtml)} });\n`
: `global.fetch = async () => ({ ok: false, status: 503, text: async () => 'Service unavailable' });\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'); 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 = `
<span class="no-results">
<div class="no-results__message">
<h1>No results found for <strong>btc price</strong></h1>
</div>
</span>
`;
const nonResultPage = `
<html>
<body>
<form action="/anomaly.js" method="post">
<input type="hidden" name="q" value="btc price" />
</form>
</body>
</html>
`;
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', () => { it('formats structured search results for hook deny output', () => {
const formatted = hook.formatStructuredSearchResults('ccs websearch', 'DuckDuckGo', [ const formatted = hook.formatStructuredSearchResults('ccs websearch', 'DuckDuckGo', [
{ {
@@ -224,6 +281,18 @@ describe('websearch-transformer hook helpers', () => {
expect(output).not.toHaveProperty('additionalContext'); 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', () => { it('emits runtime failure output with attempted provider details nested under hookSpecificOutput', () => {
const result = runHookWithMockedFetch('failure'); const result = runHookWithMockedFetch('failure');
@@ -244,6 +313,20 @@ describe('websearch-transformer hook helpers', () => {
expect(output).not.toHaveProperty('additionalContext'); 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', () => { it('writes opt-in trace records with redacted query fingerprints', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-trace-')); const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-trace-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs'); const preloadPath = join(tempDir, 'mock-fetch.cjs');