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
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
+37 -2
View File
@@ -67,6 +67,8 @@ const PROVIDER_CONFIG = {
const ddgLinkRe = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\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;
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,
@@ -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 () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-server-'));
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,
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 = `
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
<a class="result__snippet">Example snippet</a>
`.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 =
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 = `
<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', () => {
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');