diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 59ee2800..547ba845 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -573,8 +573,8 @@ }, "loggerCoverage": { "filesWithCreateLogger": 65, - "totalSourceFiles": 753, - "coverageRatio": 0.0863, + "totalSourceFiles": 754, + "coverageRatio": 0.0862, "subdomainsWithZeroCreateLogger": [ "api", "bin", @@ -605,7 +605,7 @@ }, { "subdomain": "utils", - "count": 89, + "count": 90, "withLogger": 1 }, { diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 734ddef6..0b264adb 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -60,7 +60,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | typed-error adoption (P4 locked subdomains) | 91.7% (22/24), target 40% | | hotpath console.error/warn occurrences | 266 (571 total, 305 CLI-UX exempt) | | hotpath console.error/warn files | 82 | -| files with createLogger | 65/753 | +| files with createLogger | 65/754 | | subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) | | files > 400 LOC | 91 | | files > 600 LOC | 42 | diff --git a/docs/websearch.md b/docs/websearch.md index a254f8ef..4cac685e 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -10,7 +10,8 @@ For a third-party Claude launch, CCS: 1. suppresses the native `WebSearch` tool because the third-party backend cannot execute it; -2. adds a short steering prompt that prefers the CCS MCP `WebSearch` tool; +2. when WebSearch is enabled, adds a short steering prompt that prefers the CCS + MCP `WebSearch` tool; 3. when WebSearch is enabled, installs the managed MCP server and adds the `ccs-websearch` entry to the applicable Claude configuration; 4. searches enabled and ready providers in deterministic order. diff --git a/eslint-rules/throw-error-baseline.json b/eslint-rules/throw-error-baseline.json index e95bfe53..9fa25647 100644 --- a/eslint-rules/throw-error-baseline.json +++ b/eslint-rules/throw-error-baseline.json @@ -49,7 +49,7 @@ "src/cliproxy/executor/auth-coordinator.ts:176", "src/cliproxy/executor/auth-coordinator.ts:317", "src/cliproxy/executor/browser-launch-setup.ts:120", - "src/cliproxy/executor/index.ts:391", + "src/cliproxy/executor/index.ts:390", "src/cliproxy/executor/lifecycle-manager.ts:129", "src/cliproxy/executor/lifecycle-manager.ts:162", "src/cliproxy/executor/lifecycle-manager.ts:59", @@ -161,14 +161,14 @@ "src/cursor/cursor-translator.ts:189", "src/delegation/headless-executor.ts:126", "src/delegation/headless-executor.ts:141", - "src/delegation/headless-executor.ts:270", - "src/delegation/headless-executor.ts:679", + "src/delegation/headless-executor.ts:272", + "src/delegation/headless-executor.ts:683", "src/dispatcher/cli-argument-parser.ts:310", "src/dispatcher/cli-argument-parser.ts:314", "src/dispatcher/cli-argument-parser.ts:324", "src/dispatcher/cli-argument-parser.ts:328", "src/dispatcher/flows/default-flow.ts:76", - "src/dispatcher/flows/settings-flow.ts:135", + "src/dispatcher/flows/settings-flow.ts:133", "src/docker/docker-assets.ts:35", "src/docker/docker-executor.ts:154", "src/docker/docker-executor.ts:435", @@ -280,7 +280,7 @@ "src/utils/shell-completion.ts:269", "src/utils/shell-completion.ts:288", "src/utils/shell-completion.ts:74", - "src/utils/websearch/mcp-installer.ts:438", + "src/utils/websearch/mcp-installer.ts:443", "src/utils/websearch/profile-hook-injector.ts:206", "src/web-server/index.ts:266", "src/web-server/services/claude-extension-binding-service.ts:144", diff --git a/src/cliproxy/executor/__tests__/claude-launcher.test.ts b/src/cliproxy/executor/__tests__/claude-launcher.test.ts index 3e587ca9..2afe136f 100644 --- a/src/cliproxy/executor/__tests__/claude-launcher.test.ts +++ b/src/cliproxy/executor/__tests__/claude-launcher.test.ts @@ -47,7 +47,10 @@ mock.module('../../config/config-generator', () => ({ })); mock.module('../../../utils/websearch-manager', () => ({ - appendThirdPartyWebSearchToolArgs: (args: string[]) => args, + appendThirdPartyWebSearchToolArgs: (args: string[], enabled = true) => + enabled + ? [...args, '--append-system-prompt', 'CCS WebSearch steering'] + : [...args, '--disallowedTools', 'WebSearch'], createWebSearchTraceContext: jest.fn().mockReturnValue({}), ensureWebSearchMcpOrThrow: jest.fn(), displayWebSearchStatus: jest.fn(), @@ -124,6 +127,11 @@ function baseContext(overrides: object = {}) { skipLocalAuth: true, sessionId: undefined, imageAnalysisMcpReady: false, + webSearchLaunch: { + config: { enabled: true }, + enabled: true, + hookEnv: { CCS_WEBSEARCH_ENABLED: '1' }, + }, browserRuntimeEnv: undefined, inheritedClaudeConfigDir: undefined, codexReasoningProxy: null, @@ -193,6 +201,22 @@ describe('launchClaude', () => { expect(spawnArgs).toContain('--browser'); }); + it('disallows native WebSearch without CCS steering when the launch snapshot is disabled', async () => { + await launchClaude( + baseContext({ + webSearchLaunch: { + config: { enabled: false }, + enabled: false, + hookEnv: { CCS_WEBSEARCH_ENABLED: '0', CCS_WEBSEARCH_SKIP: '1' }, + }, + }) + ); + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).toContain('--disallowedTools'); + expect(spawnArgs).toContain('WebSearch'); + expect(spawnArgs).not.toContain('CCS WebSearch steering'); + }); + it('registers cleanup handlers after spawn', async () => { await launchClaude(baseContext()); expect(mockSetupCleanupHandlers).toHaveBeenCalledTimes(1); diff --git a/src/cliproxy/executor/claude-launcher.ts b/src/cliproxy/executor/claude-launcher.ts index ff189bbf..4f03938a 100644 --- a/src/cliproxy/executor/claude-launcher.ts +++ b/src/cliproxy/executor/claude-launcher.ts @@ -15,6 +15,7 @@ import { appendThirdPartyWebSearchToolArgs, createWebSearchTraceContext, } from '../../utils/websearch-manager'; +import type { WebSearchLaunchState } from '../../utils/websearch-manager'; import { appendThirdPartyImageAnalysisToolArgs } from '../../utils/image-analysis'; import { appendBrowserToolArgs } from '../../utils/browser'; import { getDefaultAccount } from '../accounts/account-manager'; @@ -51,6 +52,8 @@ export interface ClaudeLaunchContext { sessionId: string | undefined; /** Whether image analysis MCP is ready */ imageAnalysisMcpReady: boolean; + /** WebSearch state captured when launch provisioning ran */ + webSearchLaunch: WebSearchLaunchState; /** Browser runtime environment variables (undefined if browser not active) */ browserRuntimeEnv: NodeJS.ProcessEnv | undefined; /** Inherited Claude config dir for continuity */ @@ -82,6 +85,7 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise; + /** WebSearch hook env captured from the launch configuration snapshot. */ + webSearchEnv?: Record; /** Optional browser runtime env for Claude browser MCP reuse. */ browserRuntimeEnv?: Record; } @@ -246,6 +247,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record { const { config: normalizedConfig, warnings } = normalizeCopilotConfigWithWarnings(config); @@ -276,7 +288,7 @@ export async function executeCopilotProfile( syncImageAnalysisMcpToConfigDir(claudeConfigDir); // Merge with current environment (global env first, copilot overrides, then hook env vars) - const webSearchEnv = getWebSearchHookEnv(); + const webSearchEnv = webSearchLaunch?.hookEnv ?? {}; const imageAnalysisResolution = await resolveCopilotImageAnalysisEnv(); const imageAnalysisProvisioningFailed = !imageAnalysisMcpReady && imageAnalysisResolution.env.CCS_IMAGE_ANALYSIS_ENABLED === '1'; @@ -305,10 +317,11 @@ export async function executeCopilotProfile( // Spawn Claude CLI const spawnStartedAt = Date.now(); return new Promise((resolve) => { - const imageAnalysisArgs = imageAnalysisMcpReady - ? appendThirdPartyImageAnalysisToolArgs(claudeArgs) - : claudeArgs; - const launchArgs = appendThirdPartyWebSearchToolArgs(imageAnalysisArgs); + const launchArgs = buildCopilotClaudeLaunchArgs( + claudeArgs, + imageAnalysisMcpReady, + webSearchLaunch?.enabled ?? true + ); const traceEnv = createWebSearchTraceContext({ launcher: 'copilot.executor', args: launchArgs, diff --git a/src/cursor/cursor-profile-executor.ts b/src/cursor/cursor-profile-executor.ts index 5716ace2..e88a59e0 100644 --- a/src/cursor/cursor-profile-executor.ts +++ b/src/cursor/cursor-profile-executor.ts @@ -9,9 +9,9 @@ import { fail, info, ok } from '../utils/ui'; import { appendThirdPartyWebSearchToolArgs, createWebSearchTraceContext, - getWebSearchHookEnv, syncWebSearchMcpToConfigDir, } from '../utils/websearch-manager'; +import type { WebSearchLaunchState } from '../utils/websearch-manager'; import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../utils/hooks'; import { stripClaudeCodeEnv } from '../utils/shell-executor'; import { checkAuthStatus } from './cursor-auth'; @@ -106,11 +106,19 @@ export async function resolveCursorImageAnalysisEnv( return { env, warning: null }; } +export function buildCursorClaudeLaunchArgs( + claudeArgs: string[], + webSearchEnabled: boolean +): string[] { + return appendThirdPartyWebSearchToolArgs(claudeArgs, webSearchEnabled); +} + export async function executeCursorProfile( config: CursorConfig, claudeArgs: string[], claudeConfigDir?: string, - claudeCliPath = 'claude' + claudeCliPath = 'claude', + webSearchLaunch?: WebSearchLaunchState ): Promise { if (!config.enabled) { process.stderr.write(fail('Cursor integration is not enabled.') + '\n'); @@ -163,7 +171,7 @@ export async function executeCursorProfile( const cursorEnv = generateCursorEnv(config, daemonToken, claudeConfigDir); const globalEnvConfig = getGlobalEnvConfig(); const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; - const webSearchEnv = getWebSearchHookEnv(); + const webSearchEnv = webSearchLaunch?.hookEnv ?? {}; const { env: imageAnalysisEnv, warning: imageAnalysisWarning } = await resolveCursorImageAnalysisEnv(); const env = stripClaudeCodeEnv({ @@ -184,7 +192,7 @@ export async function executeCursorProfile( syncWebSearchMcpToConfigDir(claudeConfigDir); return new Promise((resolve) => { - const launchArgs = appendThirdPartyWebSearchToolArgs(claudeArgs); + const launchArgs = buildCursorClaudeLaunchArgs(claudeArgs, webSearchLaunch?.enabled ?? true); const traceEnv = createWebSearchTraceContext({ launcher: 'cursor.executor', args: launchArgs, diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 3bb0d813..58d43b01 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -55,8 +55,8 @@ import { appendWebSearchTrace, createWebSearchTraceContext, ensureWebSearchMcpForLaunch, - getWebSearchHookEnv, readWebSearchTraceRecords, + resolveWebSearchLaunchState, syncWebSearchMcpToConfigDir, } from '../utils/websearch-manager'; import { getCcsDir, getGlobalEnvConfig, loadSettings } from '../config/config-loader-facade'; @@ -159,7 +159,8 @@ export class HeadlessExecutor { ); } - ensureWebSearchMcpForLaunch(); + const webSearchLaunch = resolveWebSearchLaunchState(); + ensureWebSearchMcpForLaunch(webSearchLaunch.config); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); @@ -257,6 +258,7 @@ export class HeadlessExecutor { let runtimeEnvVars: NodeJS.ProcessEnv = { ...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }, settingsEnv), + ...webSearchLaunch.hookEnv, ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), CCS_PROFILE_TYPE: 'settings', CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', @@ -380,7 +382,10 @@ export class HeadlessExecutor { const imageAnalysisArgs = imageAnalysisMcpReady ? appendThirdPartyImageAnalysisToolArgs(args) : args; - const launchArgs = appendThirdPartyWebSearchToolArgs(imageAnalysisArgs); + const launchArgs = appendThirdPartyWebSearchToolArgs( + imageAnalysisArgs, + webSearchLaunch.enabled + ); const traceEnv = createWebSearchTraceContext({ launcher: 'delegation.headless-executor', args: launchArgs, @@ -467,7 +472,6 @@ export class HeadlessExecutor { const cleanEnv = stripClaudeCodeEnv({ ...stripAnthropicRoutingEnv(process.env), ...getClaudeLaunchEnvOverrides(), - ...getWebSearchHookEnv(), ...runtimeEnvVars, ...imageAnalysisEnv, ...traceEnv, diff --git a/src/dispatcher/flows/copilot-flow.ts b/src/dispatcher/flows/copilot-flow.ts index d92d31e8..7afb9874 100644 --- a/src/dispatcher/flows/copilot-flow.ts +++ b/src/dispatcher/flows/copilot-flow.ts @@ -5,7 +5,10 @@ */ import { fail, info } from '../../utils/ui'; -import { ensureWebSearchMcpForLaunch } from '../../utils/websearch-manager'; +import { + ensureWebSearchMcpForLaunch, + resolveWebSearchLaunchState, +} from '../../utils/websearch-manager'; import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import { ensureProfileHooks as ensureImageAnalyzerHooks, @@ -23,7 +26,8 @@ export async function runCopilotFlow(ctx: ProfileDispatchContext): Promise resolveProfileContinuityInheritance, } = ctx; - ensureWebSearchMcpForLaunch(); + const webSearchLaunch = resolveWebSearchLaunchState(); + ensureWebSearchMcpForLaunch(webSearchLaunch.config); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); if (resolvedTarget === 'claude') { if (imageAnalysisMcpReady) { @@ -60,7 +64,8 @@ export async function runCopilotFlow(ctx: ProfileDispatchContext): Promise copilotConfig, remainingArgs, continuityInheritance.claudeConfigDir, - claudeCli + claudeCli, + webSearchLaunch ); process.exit(exitCode); } diff --git a/src/dispatcher/flows/cursor-flow.ts b/src/dispatcher/flows/cursor-flow.ts index 0d703e52..cc5af5ae 100644 --- a/src/dispatcher/flows/cursor-flow.ts +++ b/src/dispatcher/flows/cursor-flow.ts @@ -5,7 +5,10 @@ */ import { fail, info } from '../../utils/ui'; -import { ensureWebSearchMcpForLaunch } from '../../utils/websearch-manager'; +import { + ensureWebSearchMcpForLaunch, + resolveWebSearchLaunchState, +} from '../../utils/websearch-manager'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector'; import { installImageAnalyzerHook } from '../../utils/hooks'; import type { ProfileDispatchContext } from '../dispatcher-context'; @@ -19,7 +22,8 @@ export async function runCursorFlow(ctx: ProfileDispatchContext): Promise resolveProfileContinuityInheritance, } = ctx; - ensureWebSearchMcpForLaunch(); + const webSearchLaunch = resolveWebSearchLaunchState(); + ensureWebSearchMcpForLaunch(webSearchLaunch.config); installImageAnalyzerHook(); ensureImageAnalyzerHooks({ profileName: profileInfo.name, @@ -48,7 +52,8 @@ export async function runCursorFlow(ctx: ProfileDispatchContext): Promise cursorConfig, remainingArgs, continuityInheritance.claudeConfigDir, - claudeCli + claudeCli, + webSearchLaunch ); process.exit(exitCode); } diff --git a/src/dispatcher/flows/settings-flow.ts b/src/dispatcher/flows/settings-flow.ts index 83b8dc26..ef08ca51 100644 --- a/src/dispatcher/flows/settings-flow.ts +++ b/src/dispatcher/flows/settings-flow.ts @@ -16,10 +16,10 @@ import { import { ensureWebSearchMcpForLaunch, displayWebSearchStatus, - getWebSearchHookEnv, syncWebSearchMcpToConfigDir, appendThirdPartyWebSearchToolArgs, createWebSearchTraceContext, + resolveWebSearchLaunchState, } from '../../utils/websearch-manager'; import { ensureImageAnalysisMcpOrThrow, @@ -94,18 +94,16 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise { +export function getWebSearchHookConfig( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): Record { const hookPath = getHookPath(); - const wsConfig = getWebSearchConfig(); // Compute max timeout from enabled providers const timeouts: number[] = []; diff --git a/src/utils/websearch/hook-env.ts b/src/utils/websearch/hook-env.ts index 8a95bcbe..8eb5a83e 100644 --- a/src/utils/websearch/hook-env.ts +++ b/src/utils/websearch/hook-env.ts @@ -9,6 +9,7 @@ import { normalizeSearxngBaseUrl } from './types'; import { resolveAllowedWebSearchTraceFile } from './trace'; import { getWebSearchConfig } from '../../config/config-loader-facade'; +import type { WebSearchConfigSnapshot } from './launch-state'; /** * Get environment variables for WebSearch hook configuration. @@ -17,8 +18,9 @@ import { getWebSearchConfig } from '../../config/config-loader-facade'; * * @returns Record of environment variables to set before spawning Claude */ -export function getWebSearchHookEnv(): Record { - const wsConfig = getWebSearchConfig(); +export function getWebSearchHookEnv( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): Record { const env: Record = { CCS_WEBSEARCH_ENABLED: '0', CCS_WEBSEARCH_SKIP: '0', diff --git a/src/utils/websearch/hook-installer.ts b/src/utils/websearch/hook-installer.ts index 64a18797..02d1429c 100644 --- a/src/utils/websearch/hook-installer.ts +++ b/src/utils/websearch/hook-installer.ts @@ -13,6 +13,7 @@ import { info, warn } from '../ui'; import { getCcsDir, getCcsHooksDir } from '../config-manager'; import { getHookPath } from './hook-config'; import { getWebSearchConfig } from '../../config/config-loader-facade'; +import type { WebSearchConfigSnapshot } from './launch-state'; // Re-export from hook-config for backward compatibility export { getHookPath, getWebSearchHookConfig } from './hook-config'; @@ -76,10 +77,10 @@ export function hasWebSearchHook(): boolean { * * @returns true if hook installed successfully */ -export function installWebSearchHook(): boolean { +export function installWebSearchHook( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): boolean { try { - const wsConfig = getWebSearchConfig(); - // Skip if disabled if (!wsConfig.enabled) { if (process.env.CCS_DEBUG) { diff --git a/src/utils/websearch/index.ts b/src/utils/websearch/index.ts index 8d746801..c69b9b7f 100644 --- a/src/utils/websearch/index.ts +++ b/src/utils/websearch/index.ts @@ -71,6 +71,8 @@ export { // Claude launch args export { appendThirdPartyWebSearchToolArgs } from './claude-tool-args'; +export { resolveWebSearchLaunchState } from './launch-state'; +export type { WebSearchConfigSnapshot, WebSearchLaunchState } from './launch-state'; // Trace helpers export { diff --git a/src/utils/websearch/launch-state.ts b/src/utils/websearch/launch-state.ts new file mode 100644 index 00000000..1d1c606b --- /dev/null +++ b/src/utils/websearch/launch-state.ts @@ -0,0 +1,19 @@ +import { getWebSearchConfig } from '../../config/config-loader-facade'; +import { getWebSearchHookEnv } from './hook-env'; + +export type WebSearchConfigSnapshot = ReturnType; + +export interface WebSearchLaunchState { + config: WebSearchConfigSnapshot; + enabled: boolean; + hookEnv: Record; +} + +export function resolveWebSearchLaunchState(): WebSearchLaunchState { + const config = getWebSearchConfig(); + return { + config, + enabled: config.enabled, + hookEnv: getWebSearchHookEnv(config), + }; +} diff --git a/src/utils/websearch/mcp-installer.ts b/src/utils/websearch/mcp-installer.ts index 5944e500..e8965756 100644 --- a/src/utils/websearch/mcp-installer.ts +++ b/src/utils/websearch/mcp-installer.ts @@ -16,6 +16,7 @@ import { isClaudeUserConfigLockUnavailableError as isLockUnavailableError, withClaudeUserConfigLock, } from '../claude-user-config-lock'; +import type { WebSearchConfigSnapshot } from './launch-state'; const WEBSEARCH_MCP_SERVER = 'ccs-websearch-server.cjs'; const WEBSEARCH_MCP_SERVER_NAME = 'ccs-websearch'; @@ -189,14 +190,15 @@ function removeManagedServerConfig(configPath: string): boolean { } } -export function installWebSearchMcpServer(): boolean { - const wsConfig = getWebSearchConfig(); +export function installWebSearchMcpServer( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): boolean { if (!wsConfig.enabled) { appendWebSearchTrace('websearch_mcp_install_skipped', { reason: 'disabled' }); return false; } - if (!installWebSearchHook()) { + if (!installWebSearchHook(wsConfig)) { appendWebSearchTrace('websearch_mcp_install_failed', { reason: 'hook_unavailable' }); if (process.env.CCS_DEBUG) { process.stderr.write( @@ -267,8 +269,9 @@ export function installWebSearchMcpServer(): boolean { } } -export function ensureWebSearchMcpConfig(): boolean { - const wsConfig = getWebSearchConfig(); +export function ensureWebSearchMcpConfig( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): boolean { if (!wsConfig.enabled) { appendWebSearchTrace('websearch_mcp_config_skipped', { reason: 'disabled' }); return false; @@ -366,15 +369,16 @@ export function ensureWebSearchMcpConfig(): boolean { } } -export function ensureWebSearchMcp(): boolean { - const wsConfig = getWebSearchConfig(); +export function ensureWebSearchMcp( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): boolean { if (!wsConfig.enabled) { appendWebSearchTrace('websearch_mcp_ensure_skipped', { reason: 'disabled' }); return false; } - const installed = installWebSearchMcpServer(); - const configured = installed && ensureWebSearchMcpConfig(); + const installed = installWebSearchMcpServer(wsConfig); + const configured = installed && ensureWebSearchMcpConfig(wsConfig); appendWebSearchTrace('websearch_mcp_ensure_result', { installed, configured }); return installed && configured; } @@ -428,13 +432,14 @@ export function uninstallWebSearchMcp(): boolean { return removedConfig || removedServer; } -export function ensureWebSearchMcpOrThrow(): void { - const wsConfig = getWebSearchConfig(); +export function ensureWebSearchMcpOrThrow( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): void { if (!wsConfig.enabled) { return; } - if (!ensureWebSearchMcp()) { + if (!ensureWebSearchMcp(wsConfig)) { throw new Error('WebSearch is enabled, but CCS could not prepare the local WebSearch tool.'); } } @@ -447,7 +452,6 @@ export function ensureWebSearchMcpOrThrow(): void { * native WebSearch and inject fallback steering while the constrained MCP * search path is unavailable. */ -export function ensureWebSearchMcpForLaunch(): boolean { - ensureWebSearchMcpOrThrow(); - return true; +export function ensureWebSearchMcpForLaunch(wsConfig: WebSearchConfigSnapshot): void { + ensureWebSearchMcpOrThrow(wsConfig); } diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index af16138c..946fd28f 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -18,6 +18,7 @@ import { getOpenCodeCliStatus } from './opencode-cli'; import { getWebSearchApiKeyStates } from './provider-secrets'; import { normalizeSearxngBaseUrl, type WebSearchCliInfo, type WebSearchStatus } from './types'; import { getWebSearchConfig } from '../../config/config-loader-facade'; +import type { WebSearchConfigSnapshot } from './launch-state'; const PROVIDER_STATE_FILE = 'websearch-provider-state.json'; @@ -112,8 +113,7 @@ function applyCooldownStatus( }; } -function getLegacyProviderStatuses(): WebSearchCliInfo[] { - const wsConfig = getWebSearchConfig(); +function getLegacyProviderStatuses(wsConfig: WebSearchConfigSnapshot): WebSearchCliInfo[] { const agyStatus = getAgyCliStatus(); const geminiStatus = getGeminiCliStatus(); const grokStatus = getGrokCliStatus(); @@ -197,8 +197,9 @@ function getLegacyProviderStatuses(): WebSearchCliInfo[] { /** * Get all WebSearch providers with their current status. */ -export function getWebSearchCliProviders(): WebSearchCliInfo[] { - const wsConfig = getWebSearchConfig(); +export function getWebSearchCliProviders( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): WebSearchCliInfo[] { const apiKeyStates = getWebSearchApiKeyStates(); const cooldowns = readProviderCooldowns(); const providers: WebSearchCliInfo[] = [ @@ -283,7 +284,7 @@ export function getWebSearchCliProviders(): WebSearchCliInfo[] { }, ]; - return [...providers, ...getLegacyProviderStatuses()].map((provider) => + return [...providers, ...getLegacyProviderStatuses(wsConfig)].map((provider) => applyCooldownStatus(provider, cooldowns) ); } @@ -355,17 +356,20 @@ export function buildWebSearchReadiness( /** * Get WebSearch readiness status for display. */ -export function getWebSearchReadiness(): WebSearchStatus { - const wsConfig = getWebSearchConfig(); - const providers = getWebSearchCliProviders(); +export function getWebSearchReadiness( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): WebSearchStatus { + const providers = getWebSearchCliProviders(wsConfig); return buildWebSearchReadiness(wsConfig.enabled, providers); } /** * Display WebSearch status (single line, equilibrium UX). */ -export function displayWebSearchStatus(): void { - const status = getWebSearchReadiness(); +export function displayWebSearchStatus( + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() +): void { + const status = getWebSearchReadiness(wsConfig); switch (status.readiness) { case 'ready': diff --git a/tests/unit/copilot/copilot-executor-env.test.ts b/tests/unit/copilot/copilot-executor-env.test.ts index 982287eb..3b4f7289 100644 --- a/tests/unit/copilot/copilot-executor-env.test.ts +++ b/tests/unit/copilot/copilot-executor-env.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test'; import { + buildCopilotClaudeLaunchArgs, generateCopilotEnv, resolveCopilotImageAnalysisEnv, } from '../../../src/copilot/copilot-executor'; @@ -15,6 +16,13 @@ const baseConfig: CopilotConfig = { model: 'gpt-4.1', }; +it('disallows native WebSearch without CCS steering when the Copilot snapshot is disabled', () => { + const args = buildCopilotClaudeLaunchArgs(['smoke'], false, false); + expect(args).toContain('--disallowedTools'); + expect(args).toContain('WebSearch'); + expect(args).not.toContain('--append-system-prompt'); +}); + describe('generateCopilotEnv', () => { it('normalizes deprecated raptor-mini model selections to the safe default', () => { const env = generateCopilotEnv({ diff --git a/tests/unit/cursor/cursor-profile-executor.test.ts b/tests/unit/cursor/cursor-profile-executor.test.ts index 1f144896..40c6fdd6 100644 --- a/tests/unit/cursor/cursor-profile-executor.test.ts +++ b/tests/unit/cursor/cursor-profile-executor.test.ts @@ -4,6 +4,7 @@ import * as os from 'os'; import * as path from 'path'; import { + buildCursorClaudeLaunchArgs, executeCursorProfile, generateCursorEnv, resolveCursorImageAnalysisEnv, @@ -19,6 +20,13 @@ const BASE_CONFIG: CursorConfig = { model: 'gpt-5.3-codex', }; +it('disallows native WebSearch without CCS steering when the Cursor snapshot is disabled', () => { + const args = buildCursorClaudeLaunchArgs(['smoke'], false); + expect(args).toContain('--disallowedTools'); + expect(args).toContain('WebSearch'); + expect(args).not.toContain('--append-system-prompt'); +}); + describe('cursor-profile-executor', () => { let originalCcsHome: string | undefined; let tempDir: string; diff --git a/tests/unit/targets/settings-profile-websearch-launch.test.ts b/tests/unit/targets/settings-profile-websearch-launch.test.ts index 7e3f5346..feabb164 100644 --- a/tests/unit/targets/settings-profile-websearch-launch.test.ts +++ b/tests/unit/targets/settings-profile-websearch-launch.test.ts @@ -136,7 +136,7 @@ exit 0 expect(fs.existsSync(claudeArgsLogPath)).toBe(false); }); - it('keeps launch non-fatal when WebSearch is disabled', () => { + it('keeps native WebSearch disallowed without CCS steering when WebSearch is disabled', () => { if (process.platform === 'win32') return; fs.writeFileSync( @@ -154,8 +154,26 @@ exit 0 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); + expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET); + }); + + it('keeps native WebSearch disallowed without CCS steering for disabled headless launches', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 12', 'websearch:', ' enabled: false', ''].join('\n'), + 'utf8' + ); + + const result = runCcs(['glm', '-p', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + expect(fs.existsSync(claudeArgsLogPath)).toBe(true); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).toContain('--disallowedTools'); + expect(launchedArgs).toContain('WebSearch'); + expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET); }); it('writes a source-side launch trace for settings profiles when tracing is enabled', () => { @@ -187,7 +205,7 @@ exit 0 expect(launchEvent?.launcher).toBe('ccs.settings-profile'); expect(launchEvent?.nativeWebSearchDisallowed).toBe(true); - expect(launchEvent?.steeringPromptApplied).toBe(true); + expect(launchEvent?.steeringPromptApplied).toBe(false); expect(launchEvent?.settingsPath).toBe(settingsPath); }); }); diff --git a/tests/unit/utils/websearch/claude-tool-args.test.ts b/tests/unit/utils/websearch/claude-tool-args.test.ts index 59d484c0..6b3ed3df 100644 --- a/tests/unit/utils/websearch/claude-tool-args.test.ts +++ b/tests/unit/utils/websearch/claude-tool-args.test.ts @@ -4,9 +4,13 @@ import { appendThirdPartyWebSearchToolArgs } from '../../../../src/utils/websear 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.'; +function appendEnabledWebSearchArgs(args: string[]): string[] { + return appendThirdPartyWebSearchToolArgs(args, true); +} + describe('appendThirdPartyWebSearchToolArgs', () => { it('appends native WebSearch suppression and inline steering prompt when no prompt flags are present', () => { - expect(appendThirdPartyWebSearchToolArgs(['smoke'])).toEqual([ + expect(appendEnabledWebSearchArgs(['smoke'])).toEqual([ 'smoke', '--disallowedTools', 'WebSearch', @@ -17,7 +21,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { it('does not append duplicate suppression or steering prompt when both are already present', () => { expect( - appendThirdPartyWebSearchToolArgs([ + appendEnabledWebSearchArgs([ 'smoke', '--disallowedTools', 'WebSearch', @@ -34,9 +38,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { }); it('detects comma-separated disallowed tool values', () => { - expect( - appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools=Read,WebSearch']) - ).toEqual([ + expect(appendEnabledWebSearchArgs(['smoke', '--disallowedTools=Read,WebSearch'])).toEqual([ 'smoke', '--disallowedTools=Read,WebSearch', '--append-system-prompt', @@ -45,7 +47,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { }); it('merges WebSearch into an existing space-separated disallowed tool flag', () => { - expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools', 'Read'])).toEqual([ + expect(appendEnabledWebSearchArgs(['smoke', '--disallowedTools', 'Read'])).toEqual([ 'smoke', '--disallowedTools', 'Read,WebSearch', @@ -55,7 +57,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { }); it('merges WebSearch into an existing equals-form disallowed tool flag', () => { - expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools=Read'])).toEqual([ + expect(appendEnabledWebSearchArgs(['smoke', '--disallowedTools=Read'])).toEqual([ 'smoke', '--disallowedTools=Read,WebSearch', '--append-system-prompt', @@ -65,11 +67,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { it('preserves user-supplied append-system-prompt values and adds the CCS steering hint once', () => { expect( - appendThirdPartyWebSearchToolArgs([ - 'smoke', - '--append-system-prompt', - 'User-provided instruction', - ]) + appendEnabledWebSearchArgs(['smoke', '--append-system-prompt', 'User-provided instruction']) ).toEqual([ 'smoke', '--append-system-prompt', @@ -83,7 +81,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { it('does not duplicate the steering prompt when it already exists in equals form', () => { expect( - appendThirdPartyWebSearchToolArgs([ + appendEnabledWebSearchArgs([ 'smoke', '--disallowedTools', 'WebSearch', @@ -98,9 +96,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { }); it('does not consume positional args after a disallowed-tools flag value', () => { - expect( - appendThirdPartyWebSearchToolArgs(['--disallowedTools', 'Read', 'latest AI news']) - ).toEqual([ + expect(appendEnabledWebSearchArgs(['--disallowedTools', 'Read', 'latest AI news'])).toEqual([ '--disallowedTools', 'Read,WebSearch', 'latest AI news', @@ -110,7 +106,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { }); it('injects synthetic flags before an end-of-options marker', () => { - expect(appendThirdPartyWebSearchToolArgs(['--', 'latest AI news'])).toEqual([ + expect(appendEnabledWebSearchArgs(['--', 'latest AI news'])).toEqual([ '--disallowedTools', 'WebSearch', '--append-system-prompt', @@ -121,7 +117,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { }); it('inserts the WebSearch disallow value when the flag is present without one', () => { - expect(appendThirdPartyWebSearchToolArgs(['--disallowedTools', '--verbose'])).toEqual([ + expect(appendEnabledWebSearchArgs(['--disallowedTools', '--verbose'])).toEqual([ '--disallowedTools', 'WebSearch', '--verbose', @@ -133,7 +129,7 @@ describe('appendThirdPartyWebSearchToolArgs', () => { // File mode: --append-system-prompt-file when user passes --append-system-prompt-file it('uses --append-system-prompt-file when user passes --append-system-prompt-file', () => { - const result = appendThirdPartyWebSearchToolArgs([ + const result = appendEnabledWebSearchArgs([ 'smoke', '--append-system-prompt-file', '/tmp/user-prompt.txt', @@ -147,26 +143,54 @@ describe('appendThirdPartyWebSearchToolArgs', () => { }); it('uses --append-system-prompt-file when user passes --append-system-prompt-file= form', () => { - const result = appendThirdPartyWebSearchToolArgs([ + const result = appendEnabledWebSearchArgs([ 'smoke', '--append-system-prompt-file=/tmp/user-prompt.txt', ]); const fileFlags = result.filter( - (arg) => arg === '--append-system-prompt-file' || arg.startsWith('--append-system-prompt-file=') + (arg) => + arg === '--append-system-prompt-file' || arg.startsWith('--append-system-prompt-file=') ); expect(fileFlags.length).toBeGreaterThanOrEqual(2); expect(result).not.toContain('--append-system-prompt'); }); it('does not treat unrelated user prompt files as the managed CCS steering prompt', () => { - const result = appendThirdPartyWebSearchToolArgs([ + const result = appendEnabledWebSearchArgs([ 'smoke', '--append-system-prompt-file', '/tmp/user-ccs-prompt-websearch-tool-notes.txt', ]); - const filePaths = result.filter((arg, index) => result[index - 1] === '--append-system-prompt-file'); + const filePaths = result.filter( + (arg, index) => result[index - 1] === '--append-system-prompt-file' + ); expect(filePaths).toContain('/tmp/user-ccs-prompt-websearch-tool-notes.txt'); - expect(filePaths.some((filePath) => filePath.endsWith('/ccs-prompt-websearch-tool.txt'))).toBe(true); + expect(filePaths.some((filePath) => filePath.endsWith('/ccs-prompt-websearch-tool.txt'))).toBe( + true + ); + }); + + it('keeps native WebSearch disallowed but skips CCS steering when WebSearch is disabled', () => { + expect(appendThirdPartyWebSearchToolArgs(['smoke'], false)).toEqual([ + 'smoke', + '--disallowedTools', + 'WebSearch', + ]); + }); + + it('preserves user prompt args when WebSearch is disabled', () => { + expect( + appendThirdPartyWebSearchToolArgs( + ['smoke', '--append-system-prompt', 'User-provided instruction'], + false + ) + ).toEqual([ + 'smoke', + '--append-system-prompt', + 'User-provided instruction', + '--disallowedTools', + 'WebSearch', + ]); }); }); diff --git a/tests/unit/utils/websearch/launch-state.test.ts b/tests/unit/utils/websearch/launch-state.test.ts new file mode 100644 index 00000000..b56061ce --- /dev/null +++ b/tests/unit/utils/websearch/launch-state.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { appendThirdPartyWebSearchToolArgs } from '../../../../src/utils/websearch/claude-tool-args'; +import { resolveWebSearchLaunchState } from '../../../../src/utils/websearch/launch-state'; +import { ensureWebSearchMcpForLaunch } from '../../../../src/utils/websearch/mcp-installer'; + +describe('WebSearch launch state', () => { + let originalCcsHome: string | undefined; + let tempHome = ''; + let configPath = ''; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-launch-state-')); + const ccsDir = path.join(tempHome, '.ccs'); + configPath = path.join(ccsDir, 'config.yaml'); + fs.mkdirSync(ccsDir, { recursive: true }); + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('keeps provisioning, hook env, and steering on one snapshot when config changes', () => { + fs.writeFileSync(configPath, 'version: 12\nwebsearch:\n enabled: false\n', 'utf8'); + const launch = resolveWebSearchLaunchState(); + + ensureWebSearchMcpForLaunch(launch.config); + fs.writeFileSync(configPath, 'version: 12\nwebsearch:\n enabled: true\n', 'utf8'); + + const args = appendThirdPartyWebSearchToolArgs(['smoke'], launch.enabled); + expect(launch.enabled).toBe(false); + expect(launch.hookEnv.CCS_WEBSEARCH_ENABLED).toBe('0'); + expect(launch.hookEnv.CCS_WEBSEARCH_SKIP).toBe('1'); + expect(args).toContain('--disallowedTools'); + expect(args).toContain('WebSearch'); + expect(args).not.toContain('--append-system-prompt'); + }); +});