mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 18:21:09 +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:
@@ -147,6 +147,9 @@ The dashboard provides visual management for all account types:
|
||||
|
||||
**Ollama Integration**: Run local open-source models (qwen3-coder, gpt-oss:20b) with full privacy. Use `ccs api create --preset ollama` - requires [Ollama v0.14.0+](https://ollama.com) installed. For cloud models, use `ccs api create --preset ollama-cloud`.
|
||||
|
||||
> **Third-party WebSearch steering:** Claude-backed third-party launches keep Anthropic's native `WebSearch` disabled, expose `ccs-websearch.WebSearch`, and append a short system hint so Claude prefers that managed tool over ad hoc Bash or `curl` lookups whenever current web information is needed.
|
||||
> Setting `websearch.enabled: false` disables the managed local runtime, but CCS still suppresses Anthropic's native `WebSearch` on third-party backends because those providers cannot execute it correctly.
|
||||
|
||||
> **Copilot config behavior:** Opening the dashboard or other read-only Copilot endpoints does not rewrite `~/.ccs/copilot.settings.json`. If CCS detects deprecated Copilot model IDs such as `raptor-mini`, it shows warnings immediately and only persists replacements when you explicitly save the Copilot configuration.
|
||||
|
||||
**llama.cpp Integration**: Run a local llama.cpp OpenAI-compatible server and create a profile with `ccs api create --preset llamacpp`. CCS defaults to `http://127.0.0.1:8080`, matching the standard llama.cpp server port.
|
||||
@@ -637,11 +640,11 @@ Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native We
|
||||
| Profile Type | WebSearch Method |
|
||||
|--------------|------------------|
|
||||
| Claude (native) | Anthropic WebSearch API |
|
||||
| Third-party profiles | CCS local MCP search tool |
|
||||
| Third-party profiles | CCS local MCP `WebSearch` tool |
|
||||
|
||||
### Local Search Backend Chain
|
||||
|
||||
For third-party profiles, Claude uses the managed `ccs-websearch` MCP tool. CCS then routes that request through deterministic search providers in this order:
|
||||
For third-party profiles, Claude uses the managed `ccs-websearch.WebSearch` MCP tool. The tool is intentionally named to match the native `WebSearch` concept, which helps Claude prefer it over ad hoc Bash or `curl` fetches. CCS then routes that request through deterministic search providers in this order:
|
||||
|
||||
| Priority | Provider | Setup | Notes |
|
||||
|----------|----------|-------|-------|
|
||||
@@ -675,6 +678,9 @@ websearch:
|
||||
> **DuckDuckGo** still works out of the box. Add **Exa**, **Tavily**, or **Brave Search** if you want API-backed results, then keep Gemini/OpenCode/Grok only if you explicitly want legacy fallback behavior.
|
||||
> CCS manages the user-scope MCP entry in `~/.claude.json` and syncs it into isolated account configs when needed.
|
||||
|
||||
> [!NOTE]
|
||||
> Set `CCS_WEBSEARCH_TRACE=1` to write correlated launch, MCP, provider, and headless summary records to `~/.ccs/logs/websearch-trace.jsonl`. That trace is designed to answer whether CCS exposed the managed tool, whether Claude called it, which provider won, and when a headless run likely bypassed it via `Bash` or `WebFetch`.
|
||||
|
||||
See [docs/websearch.md](./docs/websearch.md) for detailed configuration and troubleshooting.
|
||||
|
||||
<br>
|
||||
|
||||
+25
-3
@@ -12,7 +12,7 @@ Native Claude subscription accounts still use Anthropic's server-side WebSearch
|
||||
|
||||
### Third-Party Profiles
|
||||
|
||||
Third-party profiles cannot execute Anthropic's server-side WebSearch because the tool never reaches their backend. CCS now solves that by provisioning a first-class local MCP tool, suppressing native `WebSearch` for those launches, and running real local search providers directly.
|
||||
Third-party profiles cannot execute Anthropic's server-side WebSearch because the tool never reaches their backend. CCS now solves that by provisioning a first-class local MCP tool, suppressing native `WebSearch` for those launches, appending a short launch-time steering hint, and running real local search providers directly.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -27,7 +27,7 @@ Third-party profiles cannot execute Anthropic's server-side WebSearch because th
|
||||
│ └── Third-party Profile? → native WebSearch disabled │
|
||||
│ │ │
|
||||
│ └── CCS MCP tool │
|
||||
│ ccs-websearch.search │
|
||||
│ ccs-websearch.WebSearch │
|
||||
│ │ │
|
||||
│ ├── 1. Exa │
|
||||
│ ├── 2. Tavily │
|
||||
@@ -52,6 +52,13 @@ The previous design asked another model CLI to perform web search and summarize
|
||||
|
||||
The new flow matches the `goclaw` model more closely: web search is treated as a first-class deterministic capability, not an LLM-to-LLM workaround or a denied native tool call.
|
||||
|
||||
The managed MCP tool is exposed as `ccs-websearch.WebSearch`, not a generic `search` helper. That naming is deliberate: it gives Claude a tool that matches the native `WebSearch` concept more directly, which should reduce cases where the model reaches for ad hoc Bash or `curl` fetches instead.
|
||||
|
||||
CCS also appends a third-party-only `--append-system-prompt` hint telling Claude to prefer that managed `WebSearch` tool for web lookups and current-information requests. This is soft steering only: if the user explicitly asks for shell commands, or the tool is unavailable, Claude can still fall back to Bash/network tools.
|
||||
That shared launch helper applies to normal third-party settings profiles, CLIProxy/Copilot-backed Claude launches, and CCS headless/delegation runs that execute through a settings profile.
|
||||
|
||||
`websearch.enabled: false` disables the managed local runtime, but CCS still suppresses Anthropic's native `WebSearch` on third-party profiles. That native tool cannot be satisfied by Exa, Tavily, Brave, DuckDuckGo, or other non-Anthropic backends, so CCS avoids sending a broken native-tool request and lets Claude fall back to normal shell/network tools instead.
|
||||
|
||||
## Providers
|
||||
|
||||
| Provider | Type | Setup | Default | Notes |
|
||||
@@ -108,6 +115,8 @@ websearch:
|
||||
timeout: 55
|
||||
```
|
||||
|
||||
Note: `enabled: false` stops provisioning the managed local `ccs-websearch.WebSearch` runtime. It does not re-enable Anthropic's native `WebSearch` for third-party backends.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
@@ -118,6 +127,8 @@ websearch:
|
||||
| `GROK_API_KEY` | Required only for legacy Grok CLI fallback |
|
||||
| `CCS_WEBSEARCH_SKIP` | Disable the CCS local WebSearch runtime for the current process |
|
||||
| `CCS_DEBUG` | Verbose WebSearch runtime logging |
|
||||
| `CCS_WEBSEARCH_TRACE` | Write opt-in JSONL trace records under `~/.ccs/logs/websearch-trace.jsonl` |
|
||||
| `CCS_WEBSEARCH_TRACE_FILE` | Override the trace file path (must stay inside `~/.ccs/`, `/tmp`, or `/var/log`) |
|
||||
|
||||
## Managed Runtime Files
|
||||
|
||||
@@ -148,12 +159,23 @@ If the dashboard says the key is stored but still not ready, check whether `Sett
|
||||
|
||||
Those providers remain supported, but they are no longer the primary path. Enable them explicitly in `config.yaml` if you want them as last-resort fallback.
|
||||
|
||||
### I need to see whether CCS exposed WebSearch or the model bypassed it
|
||||
|
||||
Run the launch with `CCS_WEBSEARCH_TRACE=1` (or `CCS_DEBUG=1`). CCS writes a JSONL trace to `~/.ccs/logs/websearch-trace.jsonl` with:
|
||||
|
||||
1. source-side launch records from CCS (`ccs_websearch_launch`)
|
||||
2. MCP exposure and call records (`mcp_initialize`, `mcp_tools_list`, `mcp_tool_call_*`)
|
||||
3. provider attempt and winner records (`websearch_provider_attempt`, `websearch_provider_success`)
|
||||
4. session summaries (`mcp_session_summary`, and headless `headless_websearch_summary` when applicable)
|
||||
|
||||
Queries are fingerprinted (`queryHash`, `queryLength`) instead of logged raw by default. For headless/delegation runs, `headless_websearch_summary.likelyBypassed=true` means the MCP tool was exposed, no WebSearch call occurred, and Claude fell back to `Bash` or `WebFetch`.
|
||||
|
||||
### WebSearch returns no results
|
||||
|
||||
1. Check `websearch.enabled: true`
|
||||
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
|
||||
4. Run with `CCS_DEBUG=1` for runtime logs, or `CCS_WEBSEARCH_TRACE=1` for correlated launch/MCP/provider traces
|
||||
|
||||
## Security Considerations
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
+14
-5
@@ -29,6 +29,7 @@ import {
|
||||
getWebSearchHookEnv,
|
||||
syncWebSearchMcpToConfigDir,
|
||||
appendThirdPartyWebSearchToolArgs,
|
||||
createWebSearchTraceContext,
|
||||
} from './utils/websearch-manager';
|
||||
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
|
||||
import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
|
||||
@@ -1053,11 +1054,19 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
execClaude(
|
||||
claudeCli,
|
||||
['--settings', expandedSettingsPath, ...appendThirdPartyWebSearchToolArgs(remainingArgs)],
|
||||
envVars
|
||||
);
|
||||
const launchArgs = [
|
||||
'--settings',
|
||||
expandedSettingsPath,
|
||||
...appendThirdPartyWebSearchToolArgs(remainingArgs),
|
||||
];
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
launcher: 'ccs.settings-profile',
|
||||
args: launchArgs,
|
||||
profile: profileInfo.name,
|
||||
profileType: profileInfo.type,
|
||||
settingsPath: expandedSettingsPath,
|
||||
});
|
||||
execClaude(claudeCli, launchArgs, { ...envVars, ...traceEnv });
|
||||
} else if (profileInfo.type === 'account') {
|
||||
// NEW FLOW: Account-based profile (work, personal)
|
||||
// All platforms: Use instance isolation with CLAUDE_CONFIG_DIR
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
ensureWebSearchMcpOrThrow,
|
||||
displayWebSearchStatus,
|
||||
appendThirdPartyWebSearchToolArgs,
|
||||
createWebSearchTraceContext,
|
||||
} from '../../utils/websearch-manager';
|
||||
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
|
||||
import { installImageAnalyzerHook } from '../../utils/hooks';
|
||||
@@ -1034,31 +1035,29 @@ export async function execClaudeWithCLIProxy(
|
||||
: getProviderSettingsPath(provider);
|
||||
|
||||
let claude: ChildProcess;
|
||||
const launchArgs = ['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(claudeArgs)];
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
launcher: 'cliproxy.executor',
|
||||
args: launchArgs,
|
||||
profile: provider,
|
||||
profileType: 'settings',
|
||||
settingsPath,
|
||||
});
|
||||
const tracedEnv = { ...env, ...traceEnv };
|
||||
if (needsShell) {
|
||||
const cmdString = [
|
||||
claudeCli,
|
||||
'--settings',
|
||||
settingsPath,
|
||||
...appendThirdPartyWebSearchToolArgs(claudeArgs),
|
||||
]
|
||||
.map(escapeShellArg)
|
||||
.join(' ');
|
||||
const cmdString = [claudeCli, ...launchArgs].map(escapeShellArg).join(' ');
|
||||
claude = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
env,
|
||||
env: tracedEnv,
|
||||
});
|
||||
} else {
|
||||
claude = spawn(
|
||||
claudeCli,
|
||||
['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(claudeArgs)],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env,
|
||||
}
|
||||
);
|
||||
claude = spawn(claudeCli, launchArgs, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env: tracedEnv,
|
||||
});
|
||||
}
|
||||
|
||||
// 12b. Start runtime quota monitor (adaptive polling during session)
|
||||
|
||||
@@ -17,6 +17,7 @@ import { fail, info, ok, warn } from '../utils/ui';
|
||||
import {
|
||||
getWebSearchHookEnv,
|
||||
appendThirdPartyWebSearchToolArgs,
|
||||
createWebSearchTraceContext,
|
||||
syncWebSearchMcpToConfigDir,
|
||||
} from '../utils/websearch-manager';
|
||||
import { getImageAnalysisHookEnv } from '../utils/hooks';
|
||||
@@ -181,9 +182,18 @@ export async function executeCopilotProfile(
|
||||
|
||||
// Spawn Claude CLI
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(claudeCliPath, appendThirdPartyWebSearchToolArgs(claudeArgs), {
|
||||
const launchArgs = appendThirdPartyWebSearchToolArgs(claudeArgs);
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
launcher: 'copilot.executor',
|
||||
args: launchArgs,
|
||||
profile: 'copilot',
|
||||
profileType: 'copilot',
|
||||
claudeConfigDir,
|
||||
});
|
||||
|
||||
const proc = spawn(claudeCliPath, launchArgs, {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
env: { ...env, ...traceEnv },
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
|
||||
@@ -2,9 +2,33 @@
|
||||
* Result aggregation utilities for headless executor
|
||||
*/
|
||||
|
||||
import type { ExecutionResult, StreamMessage } from './types';
|
||||
import type { ExecutionResult, StreamMessage, ToolUsageSummary } from './types';
|
||||
import { warn } from '../../utils/ui';
|
||||
|
||||
const WEBSEARCH_FALLBACK_TOOLS = new Set(['Bash', 'WebFetch']);
|
||||
|
||||
export function summarizeToolUsage(messages: StreamMessage[]): ToolUsageSummary {
|
||||
const toolNames = new Set<string>();
|
||||
|
||||
for (const message of messages) {
|
||||
const content = message.message?.content || [];
|
||||
for (const entry of content) {
|
||||
if (entry.type === 'tool_use' && entry.name) {
|
||||
toolNames.add(entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orderedToolNames = [...toolNames];
|
||||
return {
|
||||
toolNames: orderedToolNames,
|
||||
calledWebSearch: toolNames.has('WebSearch') || toolNames.has('search'),
|
||||
fallbackToolsUsed: orderedToolNames.filter((toolName) =>
|
||||
WEBSEARCH_FALLBACK_TOOLS.has(toolName)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build execution result from stream messages
|
||||
* @param params - Parameters for building result
|
||||
@@ -32,6 +56,7 @@ export function buildExecutionResult(params: {
|
||||
timedOut,
|
||||
success: exitCode === 0 && !timedOut,
|
||||
messages,
|
||||
toolUsageSummary: summarizeToolUsage(messages),
|
||||
};
|
||||
|
||||
// Extract metadata from final 'result' message in stream-json
|
||||
|
||||
@@ -44,6 +44,12 @@ export interface ExecutionError {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ToolUsageSummary {
|
||||
toolNames: string[];
|
||||
calledWebSearch: boolean;
|
||||
fallbackToolsUsed: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for headless execution
|
||||
*/
|
||||
@@ -86,6 +92,7 @@ export interface ExecutionResult {
|
||||
permissionDenials?: PermissionDenial[];
|
||||
errors?: ExecutionError[];
|
||||
content?: string;
|
||||
toolUsageSummary?: ToolUsageSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,13 @@ import { buildExecutionResult } from './executor/result-aggregator';
|
||||
import { getCcsDir, getModelDisplayName } from '../utils/config-manager';
|
||||
import { getProfileLookupCandidates } from '../utils/profile-compat';
|
||||
import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor';
|
||||
import {
|
||||
appendThirdPartyWebSearchToolArgs,
|
||||
appendWebSearchTrace,
|
||||
createWebSearchTraceContext,
|
||||
getWebSearchHookEnv,
|
||||
readWebSearchTraceRecords,
|
||||
} from '../utils/websearch-manager';
|
||||
|
||||
// Re-export types for consumers
|
||||
export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types';
|
||||
@@ -163,21 +170,32 @@ export class HeadlessExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
const launchArgs = appendThirdPartyWebSearchToolArgs(args);
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
launcher: 'delegation.headless-executor',
|
||||
args: launchArgs,
|
||||
cwd,
|
||||
profile,
|
||||
profileType: 'settings',
|
||||
settingsPath,
|
||||
});
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info(`Claude CLI args: ${args.join(' ')}`));
|
||||
console.error(info(`Claude CLI args: ${launchArgs.join(' ')}`));
|
||||
}
|
||||
|
||||
// Initialize UI before spawning
|
||||
await ui.init();
|
||||
|
||||
// Execute with spawn
|
||||
return this._spawnAndExecute(claudeCli, args, {
|
||||
return this._spawnAndExecute(claudeCli, launchArgs, {
|
||||
cwd,
|
||||
profile,
|
||||
timeout,
|
||||
resumeSession,
|
||||
sessionId,
|
||||
sessionMgr,
|
||||
traceEnv,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,9 +212,10 @@ export class HeadlessExecutor {
|
||||
resumeSession: boolean;
|
||||
sessionId: string | null;
|
||||
sessionMgr: SessionManager;
|
||||
traceEnv?: Record<string, string>;
|
||||
}
|
||||
): Promise<ExecutionResult> {
|
||||
const { cwd, profile, timeout, resumeSession, sessionId, sessionMgr } = ctx;
|
||||
const { cwd, profile, timeout, resumeSession, sessionId, sessionMgr, traceEnv = {} } = ctx;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
@@ -213,6 +232,9 @@ export class HeadlessExecutor {
|
||||
const cleanEnv = stripClaudeCodeEnv({
|
||||
...process.env,
|
||||
...getClaudeLaunchEnvOverrides(),
|
||||
...getWebSearchHookEnv(),
|
||||
...traceEnv,
|
||||
CCS_PROFILE_TYPE: 'settings',
|
||||
});
|
||||
|
||||
const proc = spawn(claudeCli, args, {
|
||||
@@ -313,6 +335,51 @@ export class HeadlessExecutor {
|
||||
messages,
|
||||
});
|
||||
|
||||
const launchId = traceEnv.CCS_WEBSEARCH_TRACE_LAUNCH_ID;
|
||||
if (launchId) {
|
||||
const launchTraceRecords = readWebSearchTraceRecords(launchId, {
|
||||
...process.env,
|
||||
...traceEnv,
|
||||
CCS_PROFILE_TYPE: 'settings',
|
||||
});
|
||||
const mcpSessionSummary = [...launchTraceRecords]
|
||||
.reverse()
|
||||
.find((record) => record.event === 'mcp_session_summary');
|
||||
const providerSuccess = [...launchTraceRecords]
|
||||
.reverse()
|
||||
.find((record) => record.event === 'websearch_provider_success');
|
||||
|
||||
const exposed = mcpSessionSummary?.exposed === true;
|
||||
const calledWebSearch = result.toolUsageSummary?.calledWebSearch === true;
|
||||
const fallbackToolsUsed = result.toolUsageSummary?.fallbackToolsUsed || [];
|
||||
|
||||
appendWebSearchTrace(
|
||||
'headless_websearch_summary',
|
||||
{
|
||||
profile,
|
||||
sessionId: result.sessionId || null,
|
||||
calledWebSearch,
|
||||
fallbackToolsUsed,
|
||||
providerUsed:
|
||||
typeof providerSuccess?.providerName === 'string'
|
||||
? providerSuccess.providerName
|
||||
: null,
|
||||
exposed,
|
||||
likelyBypassed:
|
||||
exposed && !calledWebSearch
|
||||
? fallbackToolsUsed.length > 0
|
||||
? true
|
||||
: 'unknown'
|
||||
: false,
|
||||
},
|
||||
{
|
||||
...process.env,
|
||||
...traceEnv,
|
||||
CCS_PROFILE_TYPE: 'settings',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Store session
|
||||
if (result.sessionId) {
|
||||
if (resumeSession || sessionId) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { spawn } from 'child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
@@ -73,8 +73,15 @@ function collectResponses(
|
||||
});
|
||||
}
|
||||
|
||||
function waitForClose(child: ReturnType<typeof spawn>): Promise<number | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once('close', (code) => resolve(code));
|
||||
child.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe('ccs-websearch MCP server', () => {
|
||||
it('lists the CCS search tool and returns provider-backed results', async () => {
|
||||
it('lists the CCS WebSearch tool and returns provider-backed results', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-server-'));
|
||||
const preloadPath = join(tempDir, 'mock-fetch.cjs');
|
||||
const html = `
|
||||
@@ -124,7 +131,7 @@ describe('ccs-websearch MCP server', () => {
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
method: 'tools/call',
|
||||
params: { name: 'search', arguments: { query: 'btc price' } },
|
||||
params: { name: 'WebSearch', arguments: { query: 'btc price' } },
|
||||
})
|
||||
);
|
||||
|
||||
@@ -135,15 +142,16 @@ describe('ccs-websearch MCP server', () => {
|
||||
expect(toolsList?.result).toEqual({
|
||||
tools: [
|
||||
{
|
||||
name: 'search',
|
||||
name: 'WebSearch',
|
||||
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.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
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'],
|
||||
@@ -165,6 +173,75 @@ describe('ccs-websearch MCP server', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
const html = `
|
||||
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
|
||||
<a class="result__snippet">Example snippet</a>
|
||||
`.trim();
|
||||
writeFileSync(
|
||||
preloadPath,
|
||||
`global.fetch = async () => ({ ok: true, 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: 'search', arguments: { query: 'btc price' } },
|
||||
})
|
||||
);
|
||||
|
||||
const responses = await responsesPromise;
|
||||
const toolCall = responses.find((message) => message.id === 2);
|
||||
|
||||
expect(toolCall?.result).toBeDefined();
|
||||
expect(
|
||||
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
|
||||
).toContain('CCS local WebSearch evidence');
|
||||
expect(
|
||||
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
|
||||
).toContain('Provider: DuckDuckGo');
|
||||
} finally {
|
||||
child.kill();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('hides the tool for native account profiles', async () => {
|
||||
const child = spawn('node', [serverPath], {
|
||||
env: {
|
||||
@@ -200,4 +277,101 @@ describe('ccs-websearch MCP server', () => {
|
||||
child.kill();
|
||||
}
|
||||
});
|
||||
|
||||
it('writes trace records for exposure, tool calls, provider success, and session summary', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-trace-'));
|
||||
const preloadPath = join(tempDir, 'mock-fetch.cjs');
|
||||
const ccsHome = join(tempDir, 'home');
|
||||
const tracePath = join(ccsHome, '.ccs', 'logs', 'websearch-trace.jsonl');
|
||||
const html = `
|
||||
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
|
||||
<a class="result__snippet">Example snippet</a>
|
||||
`.trim();
|
||||
writeFileSync(
|
||||
preloadPath,
|
||||
`global.fetch = async () => ({ ok: true, text: async () => ${JSON.stringify(html)} });\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const child = spawn('node', ['-r', preloadPath, serverPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_HOME: ccsHome,
|
||||
CCS_PROFILE_TYPE: 'settings',
|
||||
CCS_WEBSEARCH_TRACE: '1',
|
||||
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'mcp-trace-test',
|
||||
CCS_WEBSEARCH_TRACE_LAUNCHER: 'unit-test',
|
||||
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, 3);
|
||||
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/list' }));
|
||||
child.stdin.write(
|
||||
encodeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
method: 'tools/call',
|
||||
params: { name: 'WebSearch', arguments: { query: 'btc price' } },
|
||||
})
|
||||
);
|
||||
|
||||
await responsesPromise;
|
||||
} finally {
|
||||
child.kill();
|
||||
await waitForClose(child);
|
||||
}
|
||||
|
||||
const traceEvents = readFileSync(tracePath, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
|
||||
expect(
|
||||
traceEvents.some((event) => event.event === 'mcp_tools_list' && event.exposed === true)
|
||||
).toBe(true);
|
||||
expect(
|
||||
traceEvents.some(
|
||||
(event) => event.event === 'mcp_tool_call_received' && event.toolName === 'WebSearch'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
traceEvents.some(
|
||||
(event) =>
|
||||
event.event === 'websearch_provider_success' && event.providerName === 'DuckDuckGo'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
traceEvents.some(
|
||||
(event) =>
|
||||
event.event === 'mcp_session_summary' &&
|
||||
event.calledWebSearch === true &&
|
||||
event.toolCalls === 1
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
@@ -193,4 +193,78 @@ describe('websearch-transformer hook helpers', () => {
|
||||
);
|
||||
expect(output).not.toHaveProperty('additionalContext');
|
||||
});
|
||||
|
||||
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');
|
||||
const ccsHome = join(tempDir, 'home');
|
||||
const tracePath = join(ccsHome, '.ccs', 'logs', 'websearch-trace.jsonl');
|
||||
const html = `
|
||||
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
|
||||
<a class="result__snippet">Example snippet</a>
|
||||
`.trim();
|
||||
|
||||
writeFileSync(
|
||||
preloadPath,
|
||||
`global.fetch = async () => ({ ok: true, text: async () => ${JSON.stringify(html)} });\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
try {
|
||||
const result = spawnSync('node', ['-r', preloadPath, hookPath], {
|
||||
encoding: 'utf8',
|
||||
input: JSON.stringify({
|
||||
tool_name: 'WebSearch',
|
||||
tool_input: { query: 'btc price' },
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_HOME: ccsHome,
|
||||
CCS_WEBSEARCH_TRACE: '1',
|
||||
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'hook-trace-test',
|
||||
CCS_WEBSEARCH_TRACE_LAUNCHER: 'unit-test',
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
const traceContents = readFileSync(tracePath, 'utf8');
|
||||
expect(traceContents).not.toContain('btc price');
|
||||
|
||||
const traceEvents = traceContents
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
|
||||
expect(traceEvents.some((event) => event.event === 'websearch_hook_invoked')).toBe(true);
|
||||
expect(
|
||||
traceEvents.some(
|
||||
(event) =>
|
||||
event.event === 'websearch_provider_attempt' && event.providerName === 'DuckDuckGo'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
traceEvents.some(
|
||||
(event) =>
|
||||
event.event === 'websearch_provider_success' && event.providerName === 'DuckDuckGo'
|
||||
)
|
||||
).toBe(true);
|
||||
const fingerprintEvent = traceEvents.find(
|
||||
(event) => event.event === 'websearch_hook_invoked'
|
||||
) as { queryHash?: string; queryLength?: number } | undefined;
|
||||
expect(fingerprintEvent?.queryHash).toBeString();
|
||||
expect(fingerprintEvent?.queryLength).toBe(9);
|
||||
} finally {
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches';
|
||||
|
||||
interface RunResult {
|
||||
status: number | null;
|
||||
stdout: string;
|
||||
@@ -25,6 +27,15 @@ function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
|
||||
};
|
||||
}
|
||||
|
||||
function readTraceEvents(tracePath: string): Array<Record<string, unknown>> {
|
||||
return fs
|
||||
.readFileSync(tracePath, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
describe('settings profile WebSearch launch', () => {
|
||||
let tmpHome = '';
|
||||
let ccsDir = '';
|
||||
@@ -119,7 +130,40 @@ exit 0
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toContain('could not prepare the local WebSearch tool');
|
||||
expect(fs.existsSync(claudeArgsLogPath)).toBe(true);
|
||||
expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).toContain('--disallowedTools');
|
||||
expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).toContain('WebSearch');
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
expect(launchedArgs).toContain('--disallowedTools');
|
||||
expect(launchedArgs).toContain('WebSearch');
|
||||
expect(launchedArgs).toContain('--append-system-prompt');
|
||||
expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET);
|
||||
});
|
||||
|
||||
it('writes a source-side launch trace for settings profiles when tracing is enabled', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const tracePath = path.join(ccsDir, 'logs', 'websearch-trace.jsonl');
|
||||
const result = runCcs(['glm', 'smoke'], {
|
||||
...baseEnv,
|
||||
CCS_WEBSEARCH_TRACE: '1',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(fs.existsSync(tracePath)).toBe(true);
|
||||
|
||||
const traceEvents = readTraceEvents(tracePath);
|
||||
const launchEvent = traceEvents.find(
|
||||
(event) => event.event === 'ccs_websearch_launch'
|
||||
) as
|
||||
| {
|
||||
launcher?: string;
|
||||
nativeWebSearchDisallowed?: boolean;
|
||||
steeringPromptApplied?: boolean;
|
||||
settingsPath?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
expect(launchEvent?.launcher).toBe('ccs.settings-profile');
|
||||
expect(launchEvent?.nativeWebSearchDisallowed).toBe(true);
|
||||
expect(launchEvent?.steeringPromptApplied).toBe(true);
|
||||
expect(launchEvent?.settingsPath).toBe(settingsPath);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,12 +21,14 @@ type SpawnCall = {
|
||||
options: Record<string, unknown> | undefined;
|
||||
};
|
||||
|
||||
const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches';
|
||||
const spawnCalls: SpawnCall[] = [];
|
||||
const originalPlatform = process.platform;
|
||||
let baselineSigintListeners: Array<(...args: unknown[]) => void> = [];
|
||||
let baselineSigtermListeners: Array<(...args: unknown[]) => void> = [];
|
||||
let baselineSighupListeners: Array<(...args: unknown[]) => void> = [];
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsClaudePath: string | undefined;
|
||||
let originalDisableAutoUpdater: string | undefined;
|
||||
const realSpawn = childProcess.spawn.bind(childProcess);
|
||||
const realSpawnSync = childProcess.spawnSync.bind(childProcess);
|
||||
@@ -150,6 +152,7 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
spawnCalls.length = 0;
|
||||
process.env.CCS_QUIET = '1';
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsClaudePath = process.env.CCS_CLAUDE_PATH;
|
||||
originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER;
|
||||
delete process.env.DISABLE_AUTOUPDATER;
|
||||
baselineSigintListeners = process.listeners('SIGINT');
|
||||
@@ -162,8 +165,11 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
delete process.env.CLAUDECODE;
|
||||
delete process.env.claudecode;
|
||||
delete process.env.CCS_QUIET;
|
||||
delete process.env.CCS_WEBSEARCH_TRACE;
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
if (originalCcsClaudePath !== undefined) process.env.CCS_CLAUDE_PATH = originalCcsClaudePath;
|
||||
else delete process.env.CCS_CLAUDE_PATH;
|
||||
if (originalDisableAutoUpdater !== undefined) {
|
||||
process.env.DISABLE_AUTOUPDATER = originalDisableAutoUpdater;
|
||||
} else {
|
||||
@@ -325,4 +331,47 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
|
||||
expect(env.DISABLE_AUTOUPDATER).toBe('1');
|
||||
});
|
||||
|
||||
it('headless executor adds third-party WebSearch steering args and env', async () => {
|
||||
writeConfigWithAutoUpdatePreference(false);
|
||||
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
|
||||
fs.writeFileSync(path.join(ccsDir, 'glm.settings.json'), '{}\n', 'utf8');
|
||||
process.env.CCS_CLAUDE_PATH = 'claude';
|
||||
|
||||
const result = await HeadlessExecutor.execute('glm', 'latest AI chip news', {
|
||||
permissionMode: 'default',
|
||||
timeout: 1000,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(spawnCalls.length).toBeGreaterThan(0);
|
||||
const launch = spawnCalls[0];
|
||||
expect(launch.args).toContain('--disallowedTools');
|
||||
expect(launch.args).toContain('WebSearch');
|
||||
expect(launch.args).toContain('--append-system-prompt');
|
||||
expect(launch.args.join(' ')).toContain(STEERING_PROMPT_SNIPPET);
|
||||
const env = launch.options?.env as NodeJS.ProcessEnv;
|
||||
expect(env.CCS_PROFILE_TYPE).toBe('settings');
|
||||
expect(env.CCS_WEBSEARCH_ENABLED || env.CCS_WEBSEARCH_SKIP).toBeDefined();
|
||||
});
|
||||
|
||||
it('headless executor propagates a WebSearch trace launch id when tracing is enabled', async () => {
|
||||
writeConfigWithAutoUpdatePreference(false);
|
||||
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
|
||||
fs.writeFileSync(path.join(ccsDir, 'glm.settings.json'), '{}\n', 'utf8');
|
||||
process.env.CCS_CLAUDE_PATH = 'claude';
|
||||
process.env.CCS_WEBSEARCH_TRACE = '1';
|
||||
|
||||
const result = await HeadlessExecutor.execute('glm', 'latest AI chip news', {
|
||||
permissionMode: 'default',
|
||||
timeout: 1000,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(spawnCalls.length).toBeGreaterThan(0);
|
||||
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
||||
expect(env.CCS_WEBSEARCH_TRACE).toBe('1');
|
||||
expect(env.CCS_WEBSEARCH_TRACE_LAUNCH_ID).toBeString();
|
||||
expect(env.CCS_WEBSEARCH_TRACE_LAUNCHER).toBe('delegation.headless-executor');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { appendThirdPartyWebSearchToolArgs } from '../../../../src/utils/websearch/claude-tool-args';
|
||||
|
||||
const 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.';
|
||||
|
||||
describe('appendThirdPartyWebSearchToolArgs', () => {
|
||||
it('appends native WebSearch suppression when no tool flags are present', () => {
|
||||
it('appends native WebSearch suppression and steering prompt when no tool flags are present', () => {
|
||||
expect(appendThirdPartyWebSearchToolArgs(['smoke'])).toEqual([
|
||||
'smoke',
|
||||
'--disallowedTools',
|
||||
'WebSearch',
|
||||
'--append-system-prompt',
|
||||
STEERING_PROMPT,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not append duplicate suppression when WebSearch is already disallowed', () => {
|
||||
expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools', 'WebSearch'])).toEqual(
|
||||
['smoke', '--disallowedTools', 'WebSearch']
|
||||
);
|
||||
it('does not append duplicate suppression or steering prompt when both are already present', () => {
|
||||
expect(
|
||||
appendThirdPartyWebSearchToolArgs([
|
||||
'smoke',
|
||||
'--disallowedTools',
|
||||
'WebSearch',
|
||||
'--append-system-prompt',
|
||||
STEERING_PROMPT,
|
||||
])
|
||||
).toEqual([
|
||||
'smoke',
|
||||
'--disallowedTools',
|
||||
'WebSearch',
|
||||
'--append-system-prompt',
|
||||
STEERING_PROMPT,
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects comma-separated disallowed tool values', () => {
|
||||
expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools=Read,WebSearch'])).toEqual(
|
||||
['smoke', '--disallowedTools=Read,WebSearch']
|
||||
);
|
||||
expect(
|
||||
appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools=Read,WebSearch'])
|
||||
).toEqual([
|
||||
'smoke',
|
||||
'--disallowedTools=Read,WebSearch',
|
||||
'--append-system-prompt',
|
||||
STEERING_PROMPT,
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges WebSearch into an existing space-separated disallowed tool flag', () => {
|
||||
@@ -27,6 +49,8 @@ describe('appendThirdPartyWebSearchToolArgs', () => {
|
||||
'smoke',
|
||||
'--disallowedTools',
|
||||
'Read,WebSearch',
|
||||
'--append-system-prompt',
|
||||
STEERING_PROMPT,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -34,6 +58,75 @@ describe('appendThirdPartyWebSearchToolArgs', () => {
|
||||
expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools=Read'])).toEqual([
|
||||
'smoke',
|
||||
'--disallowedTools=Read,WebSearch',
|
||||
'--append-system-prompt',
|
||||
STEERING_PROMPT,
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves user-supplied append-system-prompt values and adds the CCS steering hint once', () => {
|
||||
expect(
|
||||
appendThirdPartyWebSearchToolArgs([
|
||||
'smoke',
|
||||
'--append-system-prompt',
|
||||
'User-provided instruction',
|
||||
])
|
||||
).toEqual([
|
||||
'smoke',
|
||||
'--append-system-prompt',
|
||||
'User-provided instruction',
|
||||
'--disallowedTools',
|
||||
'WebSearch',
|
||||
'--append-system-prompt',
|
||||
STEERING_PROMPT,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not duplicate the steering prompt when it already exists in equals form', () => {
|
||||
expect(
|
||||
appendThirdPartyWebSearchToolArgs([
|
||||
'smoke',
|
||||
'--disallowedTools',
|
||||
'WebSearch',
|
||||
`--append-system-prompt=${STEERING_PROMPT}`,
|
||||
])
|
||||
).toEqual([
|
||||
'smoke',
|
||||
'--disallowedTools',
|
||||
'WebSearch',
|
||||
`--append-system-prompt=${STEERING_PROMPT}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not consume positional args after a disallowed-tools flag value', () => {
|
||||
expect(
|
||||
appendThirdPartyWebSearchToolArgs(['--disallowedTools', 'Read', 'latest AI news'])
|
||||
).toEqual([
|
||||
'--disallowedTools',
|
||||
'Read,WebSearch',
|
||||
'latest AI news',
|
||||
'--append-system-prompt',
|
||||
STEERING_PROMPT,
|
||||
]);
|
||||
});
|
||||
|
||||
it('injects synthetic flags before an end-of-options marker', () => {
|
||||
expect(appendThirdPartyWebSearchToolArgs(['--', 'latest AI news'])).toEqual([
|
||||
'--disallowedTools',
|
||||
'WebSearch',
|
||||
'--append-system-prompt',
|
||||
STEERING_PROMPT,
|
||||
'--',
|
||||
'latest AI news',
|
||||
]);
|
||||
});
|
||||
|
||||
it('inserts the WebSearch disallow value when the flag is present without one', () => {
|
||||
expect(appendThirdPartyWebSearchToolArgs(['--disallowedTools', '--verbose'])).toEqual([
|
||||
'--disallowedTools',
|
||||
'WebSearch',
|
||||
'--verbose',
|
||||
'--append-system-prompt',
|
||||
STEERING_PROMPT,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user