mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 14:19:56 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user