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/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'); + }); +});