feat(websearch): finish managed third-party rollout

- steer third-party launches toward the managed WebSearch MCP tool

- add opt-in trace diagnostics across launch, MCP, provider, and headless paths

- extend docs and regression coverage for the first-class runtime

Refs #862
This commit is contained in:
Tam Nhu Tran
2026-03-30 22:41:51 -04:00
parent b087c02738
commit de7171d810
21 changed files with 1281 additions and 92 deletions
+164 -5
View File
@@ -15,6 +15,9 @@
*/
const { spawnSync } = require('child_process');
const { createHash } = require('crypto');
const fs = require('fs');
const path = require('path');
const isWindows = process.platform === 'win32';
const DEFAULT_TIMEOUT_SEC = 55;
@@ -64,12 +67,96 @@ function debug(message) {
}
}
function shouldSkipHook() {
if (process.env.CCS_WEBSEARCH_SKIP === '1') return true;
function getCcsDirPath() {
if ((process.env.CCS_DIR || '').trim()) {
return path.resolve(process.env.CCS_DIR.trim());
}
if ((process.env.CCS_HOME || '').trim()) {
return path.join(path.resolve(process.env.CCS_HOME.trim()), '.ccs');
}
const home = (process.env.HOME || process.env.USERPROFILE || '').trim();
if (home) {
return path.join(home, '.ccs');
}
return path.join(process.cwd(), '.ccs');
}
function isTraceEnabled() {
return process.env.CCS_WEBSEARCH_TRACE === '1' || process.env.CCS_DEBUG === '1';
}
function getTraceFilePath() {
const fallback = path.join(getCcsDirPath(), 'logs', 'websearch-trace.jsonl');
const configured = (process.env.CCS_WEBSEARCH_TRACE_FILE || '').trim();
if (!configured) {
return fallback;
}
const resolved = path.resolve(configured);
const safePrefixes = [
`${path.resolve(getCcsDirPath())}${path.sep}`,
`${path.resolve(process.env.HOME || process.env.USERPROFILE || process.cwd())}${path.sep}`,
'/tmp/',
'/var/log/',
];
if (safePrefixes.some((prefix) => resolved.startsWith(prefix))) {
return resolved;
}
return fallback;
}
function traceWebSearchEvent(event, payload = {}) {
if (!isTraceEnabled()) {
return;
}
try {
const traceFilePath = getTraceFilePath();
fs.mkdirSync(path.dirname(traceFilePath), { recursive: true });
fs.appendFileSync(
traceFilePath,
JSON.stringify({
at: new Date().toISOString(),
event,
launchId: process.env.CCS_WEBSEARCH_TRACE_LAUNCH_ID || null,
launcher: process.env.CCS_WEBSEARCH_TRACE_LAUNCHER || null,
profileType: process.env.CCS_PROFILE_TYPE || null,
pid: process.pid,
...payload,
}) + '\n',
'utf8'
);
} catch {
// Best-effort only.
}
}
function getQueryFingerprint(query) {
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
return {
queryHash: normalizedQuery
? createHash('sha256').update(normalizedQuery).digest('hex').slice(0, 16)
: null,
queryLength: normalizedQuery.length,
};
}
function getSkipReason() {
if (process.env.CCS_WEBSEARCH_SKIP === '1') return 'skip_flag';
const profileType = process.env.CCS_PROFILE_TYPE;
if (profileType === 'account' || profileType === 'default') return true;
if (process.env.CCS_WEBSEARCH_ENABLED === '0') return true;
return false;
if (profileType === 'account') return 'native_account_profile';
if (profileType === 'default') return 'native_default_profile';
if (process.env.CCS_WEBSEARCH_ENABLED === '0') return 'disabled';
return null;
}
function shouldSkipHook() {
return getSkipReason() !== null;
}
function isCliAvailable(cmd) {
@@ -627,26 +714,53 @@ function getActiveProviders() {
return getConfiguredProviders().filter((provider) => provider.available());
}
function getActiveProviderIds() {
return getActiveProviders().map((provider) => provider.id);
}
function hasAnyActiveProviders() {
return getActiveProviders().length > 0;
}
async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
const activeProviders = getActiveProviders();
const fingerprint = getQueryFingerprint(query);
debug(
`Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}`
);
traceWebSearchEvent('websearch_provider_run_started', {
source: 'provider',
activeProviderIds: activeProviders.map((provider) => provider.id),
...fingerprint,
});
if (activeProviders.length === 0) {
traceWebSearchEvent('websearch_provider_run_unavailable', {
source: 'provider',
activeProviderIds: [],
...fingerprint,
});
return { success: false, noActiveProviders: true, errors: [] };
}
const errors = [];
for (const provider of activeProviders) {
debug(`Trying ${provider.name}`);
traceWebSearchEvent('websearch_provider_attempt', {
source: 'provider',
providerId: provider.id,
providerName: provider.name,
...fingerprint,
});
const result = await provider.fn(query, timeoutSec);
if (result.success) {
traceWebSearchEvent('websearch_provider_success', {
source: 'provider',
providerId: provider.id,
providerName: provider.name,
...fingerprint,
});
return {
success: true,
providerId: provider.id,
@@ -654,15 +768,32 @@ async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
content: result.content,
};
}
traceWebSearchEvent('websearch_provider_failure', {
source: 'provider',
providerId: provider.id,
providerName: provider.name,
error: result.error,
...fingerprint,
});
errors.push({ provider: provider.name, error: result.error });
}
traceWebSearchEvent('websearch_provider_run_failed', {
source: 'provider',
errorCount: errors.length,
activeProviderIds: activeProviders.map((provider) => provider.id),
...fingerprint,
});
return { success: false, noActiveProviders: false, errors };
}
async function processHook(input) {
try {
if (shouldSkipHook()) {
traceWebSearchEvent('websearch_hook_skipped', {
source: 'hook',
reason: getSkipReason(),
});
process.exit(0);
}
@@ -676,23 +807,47 @@ async function processHook(input) {
process.exit(0);
}
traceWebSearchEvent('websearch_hook_invoked', {
source: 'hook',
...getQueryFingerprint(query),
});
const timeout = Number.parseInt(
process.env.CCS_WEBSEARCH_TIMEOUT || `${DEFAULT_TIMEOUT_SEC}`,
10
);
const result = await runLocalWebSearch(query, timeout);
if (result.noActiveProviders) {
traceWebSearchEvent('websearch_hook_no_active_providers', {
source: 'hook',
...getQueryFingerprint(query),
});
process.exit(0);
}
if (result.success) {
traceWebSearchEvent('websearch_hook_success', {
source: 'hook',
providerId: result.providerId,
providerName: result.providerName,
...getQueryFingerprint(query),
});
outputSuccess(query, result.content, result.providerName);
return;
}
traceWebSearchEvent('websearch_hook_failure', {
source: 'hook',
errorCount: result.errors.length,
...getQueryFingerprint(query),
});
outputAllFailedMessage(query, result.errors);
} catch (error) {
debug(`Hook error: ${error.message}`);
traceWebSearchEvent('websearch_hook_error', {
source: 'hook',
error: error.message,
});
process.exit(0);
}
}
@@ -724,6 +879,10 @@ module.exports = {
hasAnyActiveProviders,
runLocalWebSearch,
shouldSkipHook,
getActiveProviderIds,
getQueryFingerprint,
getSkipReason,
traceWebSearchEvent,
tryExaSearch,
tryTavilySearch,
tryDuckDuckGoSearch,
+122 -8
View File
@@ -1,19 +1,35 @@
#!/usr/bin/env node
const {
getActiveProviderIds,
getQueryFingerprint,
getSkipReason,
hasAnyActiveProviders,
runLocalWebSearch,
shouldSkipHook,
traceWebSearchEvent,
} = require('../hooks/websearch-transformer.cjs');
const PROTOCOL_VERSION = '2024-11-05';
const SERVER_NAME = 'ccs-websearch';
const SERVER_VERSION = '1.0.0';
const TOOL_NAME = 'search';
const TOOL_NAME = 'WebSearch';
const TOOL_ALIASES = ['search'];
const TOOL_DESCRIPTION =
'Search the web through CCS-managed providers. 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, DuckDuckGo, then optional legacy CLI fallback.';
function isSupportedToolName(name) {
return name === TOOL_NAME || TOOL_ALIASES.includes(name);
}
let inputBuffer = Buffer.alloc(0);
const sessionState = {
initializeCount: 0,
toolsListCount: 0,
exposed: false,
toolCalls: 0,
};
let sessionSummaryWritten = false;
function shouldExposeTools() {
return !shouldSkipHook() && hasAnyActiveProviders();
@@ -33,7 +49,8 @@ function getTools() {
properties: {
query: {
type: 'string',
description: 'The search query to run against CCS WebSearch providers.',
description:
'Web query to resolve through CCS providers. Prefer this tool over ad hoc Bash/curl lookups when you need current web information.',
},
},
required: ['query'],
@@ -72,13 +89,36 @@ async function handleToolCall(message) {
const id = message.id;
const params = message.params || {};
const toolArgs = params.arguments || {};
const toolName = params.name || '<missing>';
const query = typeof toolArgs.query === 'string' ? toolArgs.query.trim() : '';
const fingerprint = getQueryFingerprint(query);
if (params.name !== TOOL_NAME) {
writeError(id, -32602, `Unknown tool: ${params.name || '<missing>'}`);
if (!isSupportedToolName(toolName)) {
traceWebSearchEvent('mcp_tool_call_rejected', {
source: 'mcp',
reason: 'unknown_tool',
toolName,
});
writeError(id, -32602, `Unknown tool: ${toolName}`);
return;
}
sessionState.toolCalls += 1;
traceWebSearchEvent('mcp_tool_call_received', {
source: 'mcp',
toolName,
...fingerprint,
});
if (!shouldExposeTools()) {
traceWebSearchEvent('mcp_tool_call_unavailable', {
source: 'mcp',
toolName,
exposed: false,
skipReason: getSkipReason(),
activeProviderIds: getActiveProviderIds(),
...fingerprint,
});
writeResponse(id, {
content: [
{
@@ -91,20 +131,41 @@ async function handleToolCall(message) {
return;
}
const query = typeof toolArgs.query === 'string' ? toolArgs.query.trim() : '';
if (!query) {
writeError(id, -32602, 'Tool "search" requires a non-empty string query.');
traceWebSearchEvent('mcp_tool_call_rejected', {
source: 'mcp',
reason: 'empty_query',
toolName,
});
writeError(id, -32602, `Tool "${TOOL_NAME}" requires a non-empty string query.`);
return;
}
const result = await runLocalWebSearch(query);
if (result.success) {
traceWebSearchEvent('mcp_tool_call_result', {
source: 'mcp',
toolName,
success: true,
providerId: result.providerId,
providerName: result.providerName,
...fingerprint,
});
writeResponse(id, {
content: [{ type: 'text', text: result.content }],
});
return;
}
traceWebSearchEvent('mcp_tool_call_result', {
source: 'mcp',
toolName,
success: false,
noActiveProviders: Boolean(result.noActiveProviders),
errorCount: result.errors.length,
...fingerprint,
});
const errorDetail =
result.noActiveProviders || result.errors.length === 0
? 'No active WebSearch providers are ready.'
@@ -128,6 +189,14 @@ async function handleMessage(message) {
switch (message.method) {
case 'initialize':
sessionState.initializeCount += 1;
sessionState.exposed = sessionState.exposed || shouldExposeTools();
traceWebSearchEvent('mcp_initialize', {
source: 'mcp',
exposed: shouldExposeTools(),
skipReason: getSkipReason(),
activeProviderIds: getActiveProviderIds(),
});
writeResponse(message.id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: {
@@ -145,7 +214,20 @@ async function handleMessage(message) {
writeResponse(message.id, {});
return;
case 'tools/list':
writeResponse(message.id, { tools: getTools() });
sessionState.toolsListCount += 1;
{
const tools = getTools();
const exposed = tools.length > 0;
sessionState.exposed = sessionState.exposed || exposed;
traceWebSearchEvent('mcp_tools_list', {
source: 'mcp',
exposed,
toolNames: tools.map((tool) => tool.name),
activeProviderIds: getActiveProviderIds(),
skipReason: getSkipReason(),
});
writeResponse(message.id, { tools });
}
return;
case 'tools/call':
await handleToolCall(message);
@@ -157,6 +239,27 @@ async function handleMessage(message) {
}
}
function writeSessionSummary(exitCodeOrSignal) {
if (sessionSummaryWritten) {
return;
}
sessionSummaryWritten = true;
traceWebSearchEvent('mcp_session_summary', {
source: 'mcp',
initializeCount: sessionState.initializeCount,
toolsListCount: sessionState.toolsListCount,
exposed: sessionState.exposed,
toolCalls: sessionState.toolCalls,
calledWebSearch: sessionState.toolCalls > 0,
likelyBypassed: sessionState.exposed && sessionState.toolCalls === 0 ? 'unknown' : false,
activeProviderIds: getActiveProviderIds(),
skipReason: getSkipReason(),
exitCode: typeof exitCodeOrSignal === 'number' ? exitCodeOrSignal : null,
exitSignal: typeof exitCodeOrSignal === 'string' ? exitCodeOrSignal : null,
});
}
function parseMessages() {
while (true) {
const headerEnd = inputBuffer.indexOf('\r\n\r\n');
@@ -204,4 +307,15 @@ process.stdin.on('error', () => {
process.exit(0);
});
process.on('exit', (code) => {
writeSessionSummary(code);
});
['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((signal) => {
process.on(signal, () => {
writeSessionSummary(signal);
process.exit(0);
});
});
process.stdin.resume();