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
+8
View File
@@ -70,6 +70,14 @@ export {
// Re-export Claude launch arg helpers
export { appendThirdPartyWebSearchToolArgs } from './websearch/claude-tool-args';
// Re-export trace helpers
export {
appendWebSearchTrace,
createWebSearchTraceContext,
isWebSearchTraceEnabled,
readWebSearchTraceRecords,
} from './websearch/trace';
// Re-export status and readiness functions
export {
getWebSearchCliProviders,
+91 -26
View File
@@ -4,6 +4,9 @@
const NATIVE_WEBSEARCH_TOOL = 'WebSearch';
const DISALLOWED_TOOLS_FLAG = '--disallowedTools';
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT =
'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.';
function parseToolValue(rawValue: string): string[] {
return rawValue
@@ -20,19 +23,34 @@ function mergeToolValues(rawValues: string[], toolName: string): string {
return merged.join(',');
}
function splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } {
const terminatorIndex = args.indexOf('--');
if (terminatorIndex === -1) {
return { optionArgs: args, trailingArgs: [] };
}
return {
optionArgs: args.slice(0, terminatorIndex),
trailingArgs: args.slice(terminatorIndex),
};
}
function getImmediateFlagValue(args: string[], index: number): string | null {
const value = args[index + 1];
if (value === undefined || value === '--' || value.startsWith('--')) {
return null;
}
return value;
}
function hasToolInFlag(args: string[], flag: string, toolName: string): boolean {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) {
for (let cursor = index + 1; cursor < args.length; cursor += 1) {
const value = args[cursor];
if (value.startsWith('--')) {
break;
}
if (parseToolValue(value).includes(toolName)) {
return true;
}
const value = getImmediateFlagValue(args, index);
if (value && parseToolValue(value).includes(toolName)) {
return true;
}
continue;
}
@@ -50,39 +68,86 @@ function hasToolInFlag(args: string[], flag: string, toolName: string): boolean
return false;
}
export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] {
if (hasToolInFlag(args, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL)) {
return args;
}
function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === DISALLOWED_TOOLS_FLAG) {
let cursor = index + 1;
const rawValues: string[] = [];
while (cursor < args.length && !args[cursor].startsWith('--')) {
rawValues.push(args[cursor]);
cursor += 1;
if (arg === flag) {
const value = getImmediateFlagValue(args, index);
if (value === expectedValue) {
return true;
}
continue;
}
if (arg === `${flag}=${expectedValue}`) {
return true;
}
if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) {
return true;
}
}
return false;
}
function ensureDisallowedNativeWebSearchTool(args: string[]): string[] {
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
if (hasToolInFlag(optionArgs, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL)) {
return args;
}
for (let index = 0; index < optionArgs.length; index += 1) {
const arg = optionArgs[index];
if (arg === DISALLOWED_TOOLS_FLAG) {
const currentValue = getImmediateFlagValue(optionArgs, index);
const mergedValue = mergeToolValues(
currentValue ? [currentValue] : [],
NATIVE_WEBSEARCH_TOOL
);
return [
...args.slice(0, index + 1),
mergeToolValues(rawValues, NATIVE_WEBSEARCH_TOOL),
...args.slice(cursor),
...optionArgs.slice(0, index + 1),
mergedValue,
...optionArgs.slice(currentValue === null ? index + 1 : index + 2),
...trailingArgs,
];
}
if (arg.startsWith(`${DISALLOWED_TOOLS_FLAG}=`)) {
const rawValue = arg.slice(DISALLOWED_TOOLS_FLAG.length + 1);
return [
...args.slice(0, index),
...optionArgs.slice(0, index),
`${DISALLOWED_TOOLS_FLAG}=${mergeToolValues([rawValue], NATIVE_WEBSEARCH_TOOL)}`,
...args.slice(index + 1),
...optionArgs.slice(index + 1),
...trailingArgs,
];
}
}
return [...args, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL];
return [...optionArgs, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL, ...trailingArgs];
}
function ensureWebSearchSteeringPrompt(args: string[]): string[] {
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
if (
hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, THIRD_PARTY_WEBSEARCH_STEERING_PROMPT)
) {
return args;
}
return [
...optionArgs,
APPEND_SYSTEM_PROMPT_FLAG,
THIRD_PARTY_WEBSEARCH_STEERING_PROMPT,
...trailingArgs,
];
}
export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] {
return ensureWebSearchSteeringPrompt(ensureDisallowedNativeWebSearchTool(args));
}
+7
View File
@@ -19,6 +19,13 @@ export function getWebSearchHookEnv(): Record<string, string> {
const wsConfig = getWebSearchConfig();
const env: Record<string, string> = {};
if (process.env.CCS_WEBSEARCH_TRACE === '1' || process.env.CCS_DEBUG === '1') {
env.CCS_WEBSEARCH_TRACE = '1';
}
if (process.env.CCS_WEBSEARCH_TRACE_FILE) {
env.CCS_WEBSEARCH_TRACE_FILE = process.env.CCS_WEBSEARCH_TRACE_FILE;
}
// Skip hook entirely if disabled
if (!wsConfig.enabled) {
env.CCS_WEBSEARCH_SKIP = '1';
+8
View File
@@ -67,6 +67,14 @@ export {
// Claude launch args
export { appendThirdPartyWebSearchToolArgs } from './claude-tool-args';
// Trace helpers
export {
appendWebSearchTrace,
createWebSearchTraceContext,
isWebSearchTraceEnabled,
readWebSearchTraceRecords,
} from './trace';
// Status and Readiness
export {
getWebSearchCliProviders,
+28 -2
View File
@@ -10,6 +10,7 @@ import { getClaudeUserConfigPath } from '../claude-config-path';
import { info, warn } from '../ui';
import { InstanceManager } from '../../management/instance-manager';
import { installWebSearchHook } from './hook-installer';
import { appendWebSearchTrace } from './trace';
const WEBSEARCH_MCP_SERVER = 'ccs-websearch-server.cjs';
const WEBSEARCH_MCP_SERVER_NAME = 'ccs-websearch';
@@ -154,10 +155,12 @@ function removeManagedServerConfig(configPath: string): boolean {
export function installWebSearchMcpServer(): boolean {
const wsConfig = getWebSearchConfig();
if (!wsConfig.enabled) {
appendWebSearchTrace('websearch_mcp_install_skipped', { reason: 'disabled' });
return false;
}
if (!installWebSearchHook()) {
appendWebSearchTrace('websearch_mcp_install_failed', { reason: 'hook_unavailable' });
if (process.env.CCS_DEBUG) {
console.error(
warn('WebSearch MCP server install skipped because hook runtime is unavailable')
@@ -168,6 +171,7 @@ export function installWebSearchMcpServer(): boolean {
const sourcePath = resolveBundledServerSourcePath();
if (!sourcePath) {
appendWebSearchTrace('websearch_mcp_install_failed', { reason: 'source_missing' });
if (process.env.CCS_DEBUG) {
console.error(warn(`WebSearch MCP server source not found: ${WEBSEARCH_MCP_SERVER}`));
}
@@ -181,6 +185,7 @@ export function installWebSearchMcpServer(): boolean {
const serverPath = getWebSearchMcpServerPath();
if (hasMatchingContents(sourcePath, serverPath)) {
appendWebSearchTrace('websearch_mcp_install_ready', { serverPath });
return true;
}
@@ -190,8 +195,13 @@ export function installWebSearchMcpServer(): boolean {
fs.copyFileSync(sourcePath, tempPath);
fs.chmodSync(tempPath, 0o755);
fs.renameSync(tempPath, serverPath);
appendWebSearchTrace('websearch_mcp_install_ready', { serverPath });
return true;
} catch (error) {
appendWebSearchTrace('websearch_mcp_install_failed', {
reason: 'copy_failed',
error: (error as Error).message,
});
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to install WebSearch MCP server: ${(error as Error).message}`));
}
@@ -206,6 +216,7 @@ export function installWebSearchMcpServer(): boolean {
export function ensureWebSearchMcpConfig(): boolean {
const wsConfig = getWebSearchConfig();
if (!wsConfig.enabled) {
appendWebSearchTrace('websearch_mcp_config_skipped', { reason: 'disabled' });
return false;
}
@@ -214,6 +225,7 @@ export function ensureWebSearchMcpConfig(): boolean {
const config = readClaudeUserConfig(claudeUserConfigPath);
if (config === null) {
appendWebSearchTrace('websearch_mcp_config_failed', { reason: 'malformed_user_config' });
if (process.env.CCS_DEBUG) {
console.error(warn('Malformed ~/.claude.json prevents WebSearch MCP provisioning'));
}
@@ -239,6 +251,7 @@ export function ensureWebSearchMcpConfig(): boolean {
currentConfig !== null &&
JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig)
) {
appendWebSearchTrace('websearch_mcp_config_ready', { configPath: claudeUserConfigPath });
return true;
}
@@ -252,11 +265,17 @@ export function ensureWebSearchMcpConfig(): boolean {
try {
writeClaudeUserConfig(claudeUserConfigPath, nextConfig);
appendWebSearchTrace('websearch_mcp_config_ready', { configPath: claudeUserConfigPath });
if (process.env.CCS_DEBUG) {
console.error(info(`Ensured WebSearch MCP config in ${claudeUserConfigPath}`));
}
return true;
} catch (error) {
appendWebSearchTrace('websearch_mcp_config_failed', {
reason: 'write_failed',
configPath: claudeUserConfigPath,
error: (error as Error).message,
});
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`));
}
@@ -267,18 +286,25 @@ export function ensureWebSearchMcpConfig(): boolean {
export function ensureWebSearchMcp(): boolean {
const wsConfig = getWebSearchConfig();
if (!wsConfig.enabled) {
appendWebSearchTrace('websearch_mcp_ensure_skipped', { reason: 'disabled' });
return false;
}
return installWebSearchMcpServer() && ensureWebSearchMcpConfig();
const installed = installWebSearchMcpServer();
const configured = installed && ensureWebSearchMcpConfig();
appendWebSearchTrace('websearch_mcp_ensure_result', { installed, configured });
return installed && configured;
}
export function syncWebSearchMcpToConfigDir(claudeConfigDir: string | undefined): boolean {
if (!claudeConfigDir) {
appendWebSearchTrace('websearch_mcp_sync_skipped', { reason: 'missing_config_dir' });
return false;
}
return new InstanceManager().syncMcpServers(claudeConfigDir);
const synced = new InstanceManager().syncMcpServers(claudeConfigDir);
appendWebSearchTrace('websearch_mcp_sync_result', { claudeConfigDir, synced });
return synced;
}
export function uninstallWebSearchMcpServer(): boolean {
+223
View File
@@ -0,0 +1,223 @@
/**
* Best-effort WebSearch trace helpers.
*
* Writes opt-in JSONL trace records to ~/.ccs/logs/websearch-trace.jsonl so
* CCS can explain launch intent, MCP exposure, provider selection, and likely
* bypass scenarios without polluting Claude/MCP stdout.
*/
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../config-manager';
const TRACE_FILE_NAME = 'websearch-trace.jsonl';
const SAFE_SYSTEM_TRACE_PREFIXES = ['/tmp/', '/var/log/'];
const NATIVE_WEBSEARCH_TOOL = 'WebSearch';
const DISALLOWED_TOOLS_FLAG = '--disallowedTools';
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT =
'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.';
function parseToolValue(rawValue: string): string[] {
return rawValue
.split(',')
.map((value) => value.trim())
.filter((value) => value.length > 0);
}
function getImmediateFlagValue(args: string[], index: number): string | null {
const value = args[index + 1];
if (value === undefined || value === '--' || value.startsWith('--')) {
return null;
}
return value;
}
function hasToolInFlag(args: string[], flag: string, toolName: string): boolean {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) {
const value = getImmediateFlagValue(args, index);
if (value && parseToolValue(value).includes(toolName)) {
return true;
}
continue;
}
if (arg.startsWith(`${flag}=`)) {
const rawValue = arg.slice(flag.length + 1);
if (parseToolValue(rawValue).includes(toolName)) {
return true;
}
}
}
return false;
}
function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) {
if (getImmediateFlagValue(args, index) === expectedValue) {
return true;
}
continue;
}
if (arg === `${flag}=${expectedValue}`) {
return true;
}
}
return false;
}
function getTraceFilePath(env: NodeJS.ProcessEnv): string {
const configured = env.CCS_WEBSEARCH_TRACE_FILE?.trim();
if (!configured) {
return path.join(getCcsDir(), 'logs', TRACE_FILE_NAME);
}
const resolved = path.resolve(configured);
const home = env.HOME || env.USERPROFILE || '';
const normalizedHome = home ? `${path.resolve(home)}${path.sep}` : '';
const normalizedCcsDir = `${path.resolve(getCcsDir())}${path.sep}`;
if (
resolved.startsWith(normalizedCcsDir) ||
(normalizedHome && resolved.startsWith(normalizedHome)) ||
SAFE_SYSTEM_TRACE_PREFIXES.some((prefix) => resolved.startsWith(prefix))
) {
return resolved;
}
return path.join(getCcsDir(), 'logs', TRACE_FILE_NAME);
}
export function isWebSearchTraceEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return env.CCS_WEBSEARCH_TRACE === '1' || env.CCS_DEBUG === '1';
}
export function appendWebSearchTrace(
event: string,
payload: Record<string, unknown> = {},
env: NodeJS.ProcessEnv = process.env
): void {
if (!isWebSearchTraceEnabled(env)) {
return;
}
try {
const traceFilePath = getTraceFilePath(env);
fs.mkdirSync(path.dirname(traceFilePath), { recursive: true });
fs.appendFileSync(
traceFilePath,
JSON.stringify({
at: new Date().toISOString(),
event,
launchId: env.CCS_WEBSEARCH_TRACE_LAUNCH_ID || null,
launcher: env.CCS_WEBSEARCH_TRACE_LAUNCHER || null,
profileType: env.CCS_PROFILE_TYPE || null,
pid: process.pid,
...payload,
}) + '\n',
'utf8'
);
} catch {
// Tracing must never affect launch behavior.
}
}
export function readWebSearchTraceRecords(
launchId: string,
env: NodeJS.ProcessEnv = process.env
): Array<Record<string, unknown>> {
if (!launchId) {
return [];
}
try {
const traceFilePath = getTraceFilePath(env);
if (!fs.existsSync(traceFilePath)) {
return [];
}
return fs
.readFileSync(traceFilePath, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as Record<string, unknown>)
.filter((record) => record.launchId === launchId);
} catch {
return [];
}
}
function buildLaunchId(): string {
const random = Math.random().toString(36).slice(2, 10);
return `websearch-${Date.now()}-${process.pid}-${random}`;
}
function summarizeLaunchArgs(args: string[]): Record<string, unknown> {
return {
argCount: args.length,
hasSettingsFlag: args.includes('--settings'),
nativeWebSearchDisallowed: hasToolInFlag(args, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL),
steeringPromptApplied: hasExactFlagValue(
args,
APPEND_SYSTEM_PROMPT_FLAG,
THIRD_PARTY_WEBSEARCH_STEERING_PROMPT
),
};
}
export function createWebSearchTraceContext(params: {
launcher: string;
args: string[];
cwd?: string;
profile?: string;
profileType?: string;
settingsPath?: string;
claudeConfigDir?: string;
env?: NodeJS.ProcessEnv;
}): Record<string, string> {
const env = params.env ?? process.env;
if (!isWebSearchTraceEnabled(env)) {
return {};
}
const launchId = buildLaunchId();
const traceEnv: Record<string, string> = {
CCS_WEBSEARCH_TRACE: '1',
CCS_WEBSEARCH_TRACE_LAUNCH_ID: launchId,
CCS_WEBSEARCH_TRACE_LAUNCHER: params.launcher,
};
if (env.CCS_WEBSEARCH_TRACE_FILE) {
traceEnv.CCS_WEBSEARCH_TRACE_FILE = env.CCS_WEBSEARCH_TRACE_FILE;
}
appendWebSearchTrace(
'ccs_websearch_launch',
{
launcher: params.launcher,
profile: params.profile || null,
profileType: params.profileType || null,
cwd: params.cwd || null,
settingsPath: params.settingsPath || null,
claudeConfigDir: params.claudeConfigDir || null,
...summarizeLaunchArgs(params.args),
},
{
...env,
...traceEnv,
CCS_PROFILE_TYPE: params.profileType || env.CCS_PROFILE_TYPE || '',
}
);
return traceEnv;
}