From 1a0398579a974efa8adaa65446c51c5f512efa77 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:56:20 -0400 Subject: [PATCH] fix: degrade WebSearch launch provisioning failures (#1571) --- eslint-rules/throw-error-baseline.json | 4 +- .../__tests__/executor-option-value.test.ts | 125 ++++++++++++++++++ src/cliproxy/executor/index.ts | 21 ++- src/delegation/headless-executor.ts | 4 +- src/dispatcher/flows/cliproxy-flow.ts | 4 - src/dispatcher/flows/copilot-flow.ts | 4 +- src/dispatcher/flows/cursor-flow.ts | 4 +- src/dispatcher/flows/settings-flow.ts | 9 +- src/utils/websearch-manager.ts | 1 + src/utils/websearch/index.ts | 1 + src/utils/websearch/mcp-installer.ts | 27 ++++ tests/e2e/openai-provider-routing.e2e.test.ts | 23 +++- .../settings-profile-websearch-launch.test.ts | 31 +++-- 13 files changed, 225 insertions(+), 33 deletions(-) diff --git a/eslint-rules/throw-error-baseline.json b/eslint-rules/throw-error-baseline.json index 44743d23..79ab7670 100644 --- a/eslint-rules/throw-error-baseline.json +++ b/eslint-rules/throw-error-baseline.json @@ -53,7 +53,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:378", + "src/cliproxy/executor/index.ts:391", "src/cliproxy/executor/lifecycle-manager.ts:129", "src/cliproxy/executor/lifecycle-manager.ts:162", "src/cliproxy/executor/lifecycle-manager.ts:59", @@ -174,7 +174,7 @@ "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:132", + "src/dispatcher/flows/settings-flow.ts:135", "src/docker/docker-assets.ts:35", "src/docker/docker-executor.ts:154", "src/docker/docker-executor.ts:435", diff --git a/src/cliproxy/executor/__tests__/executor-option-value.test.ts b/src/cliproxy/executor/__tests__/executor-option-value.test.ts index 45e1eba0..bb147205 100644 --- a/src/cliproxy/executor/__tests__/executor-option-value.test.ts +++ b/src/cliproxy/executor/__tests__/executor-option-value.test.ts @@ -71,6 +71,17 @@ describe('execClaudeWithCLIProxy browser flag validation', () => { return fs.existsSync(filePath); } + function makeWebSearchProvisioningFail(): void { + const ccsDir = path.join(tmpHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + 'version: 13\nwebsearch:\n enabled: true\n providers:\n duckduckgo:\n enabled: true\n', + 'utf8' + ); + fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8'); + } + afterEach(() => { if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; @@ -81,6 +92,120 @@ describe('execClaudeWithCLIProxy browser flag validation', () => { fs.rmSync(tmpHome, { recursive: true, force: true }); }); + it('keeps WebSearch provisioning strict for --config settings writes', async () => { + makeWebSearchProvisioningFail(); + + let requestCount = 0; + const server = http.createServer((_req, res) => { + requestCount += 1; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Test server did not bind to a TCP port'); + } + + try { + await expect( + execClaudeWithCLIProxy( + fakeClaudePath, + 'gemini', + [ + '--proxy-host', + '127.0.0.1', + '--proxy-port', + String(address.port), + '--proxy-auth-token', + 'SECRET_TOKEN_FOR_VALIDATION', + '--remote-only', + '--config', + ], + {} + ) + ).rejects.toThrow( + 'WebSearch is enabled, but CCS could not prepare the local WebSearch tool.' + ); + + expect(requestCount).toBeGreaterThan(0); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + + it('degrades WebSearch provisioning failures for CLIProxy launches', async () => { + makeWebSearchProvisioningFail(); + + const markerPath = path.join(tmpHome, 'fake-claude-launched'); + fs.writeFileSync( + fakeClaudePath, + `#!/bin/sh\nprintf launched > ${JSON.stringify(markerPath)}\nexit 0\n`, + { mode: 0o755 } + ); + fs.chmodSync(fakeClaudePath, 0o755); + + let requestCount = 0; + const server = http.createServer((_req, res) => { + requestCount += 1; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Test server did not bind to a TCP port'); + } + + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + await execClaudeWithCLIProxy( + fakeClaudePath, + 'gemini', + [ + '--proxy-host', + '127.0.0.1', + '--proxy-port', + String(address.port), + '--proxy-auth-token', + 'SECRET_TOKEN_FOR_VALIDATION', + '--remote-only', + '--print', + 'hello', + ], + {} + ); + + expect(await waitForFile(markerPath)).toBe(true); + expect(requestCount).toBeGreaterThan(0); + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + exitSpy.mockRestore(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + it('validates conflicting browser launch flags before remote proxy checks', async () => { let requestCount = 0; const server = http.createServer((_req, res) => { diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index d5974d82..cb389095 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -26,7 +26,11 @@ import { supportsModelConfig } from '../model-catalog'; import { CLIProxyProvider, ExecutorConfig } from '../types'; import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy'; import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy'; -import { ensureWebSearchMcpOrThrow, displayWebSearchStatus } from '../../utils/websearch-manager'; +import { + ensureWebSearchMcpForLaunch, + ensureWebSearchMcpOrThrow, + displayWebSearchStatus, +} from '../../utils/websearch-manager'; import { ensureImageAnalysisMcpOrThrow, syncImageAnalysisMcpToConfigDir, @@ -157,10 +161,7 @@ export async function execClaudeWithCLIProxy( log, }); - // Setup first-class CCS WebSearch runtime - ensureWebSearchMcpOrThrow(); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); - displayWebSearchStatus(); const providerConfig = getProviderConfig(provider); log(`Provider: ${providerConfig.displayName}`); @@ -191,6 +192,7 @@ export async function execClaudeWithCLIProxy( const { forceConfig, + forceImport, addAccount, showAccounts, useAccount, @@ -224,6 +226,8 @@ export async function execClaudeWithCLIProxy( // Handle --config if (forceConfig && supportsModelConfig(provider)) { + ensureWebSearchMcpOrThrow(); + // Block --config for composite variants (per-tier models in config.yaml) if (cfg.isComposite) { const variantName = cfg.profileName || provider; @@ -266,8 +270,17 @@ export async function execClaudeWithCLIProxy( await handleLogout(authCtx); // Handle --import (early exit, Kiro only) + if (forceImport) { + ensureWebSearchMcpOrThrow(); + } await handleImport(authCtx); + // Setup first-class CCS WebSearch runtime for non-strict user launches. + const shouldDisplayWebSearchStatus = ensureWebSearchMcpForLaunch(); + if (shouldDisplayWebSearchStatus) { + displayWebSearchStatus(); + } + // 3. Ensure OAuth completed (if provider requires it) const remoteAuthToken = proxyConfig.authToken?.trim(); const skipLocalAuth = resolveSkipLocalAuth(remoteAuthToken, useRemoteProxy); diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 085fe30e..3bb0d813 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -54,7 +54,7 @@ import { appendThirdPartyWebSearchToolArgs, appendWebSearchTrace, createWebSearchTraceContext, - ensureWebSearchMcpOrThrow, + ensureWebSearchMcpForLaunch, getWebSearchHookEnv, readWebSearchTraceRecords, syncWebSearchMcpToConfigDir, @@ -159,7 +159,7 @@ export class HeadlessExecutor { ); } - ensureWebSearchMcpOrThrow(); + ensureWebSearchMcpForLaunch(); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); diff --git a/src/dispatcher/flows/cliproxy-flow.ts b/src/dispatcher/flows/cliproxy-flow.ts index da554754..d8e75642 100644 --- a/src/dispatcher/flows/cliproxy-flow.ts +++ b/src/dispatcher/flows/cliproxy-flow.ts @@ -14,7 +14,6 @@ import { } from '../../cliproxy'; import { getEffectiveEnvVars, getCompositeEnvVars } from '../../cliproxy/config/env-builder'; import { resolveLifecyclePort } from '../../cliproxy/config/port-manager'; -import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import { ensureProfileHooks as ensureImageAnalyzerHooks, @@ -38,9 +37,6 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise resolveProfileContinuityInheritance, } = ctx; - ensureWebSearchMcpOrThrow(); + ensureWebSearchMcpForLaunch(); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); if (resolvedTarget === 'claude') { if (imageAnalysisMcpReady) { diff --git a/src/dispatcher/flows/cursor-flow.ts b/src/dispatcher/flows/cursor-flow.ts index 8a98c0b8..0d703e52 100644 --- a/src/dispatcher/flows/cursor-flow.ts +++ b/src/dispatcher/flows/cursor-flow.ts @@ -5,7 +5,7 @@ */ import { fail, info } from '../../utils/ui'; -import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureWebSearchMcpForLaunch } 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 +19,7 @@ export async function runCursorFlow(ctx: ProfileDispatchContext): Promise resolveProfileContinuityInheritance, } = ctx; - ensureWebSearchMcpOrThrow(); + ensureWebSearchMcpForLaunch(); installImageAnalyzerHook(); ensureImageAnalyzerHooks({ profileName: profileInfo.name, diff --git a/src/dispatcher/flows/settings-flow.ts b/src/dispatcher/flows/settings-flow.ts index 87a4c102..83b8dc26 100644 --- a/src/dispatcher/flows/settings-flow.ts +++ b/src/dispatcher/flows/settings-flow.ts @@ -14,7 +14,7 @@ import { validateAnthropicKey, } from '../../utils/api-key-validator'; import { - ensureWebSearchMcpOrThrow, + ensureWebSearchMcpForLaunch, displayWebSearchStatus, getWebSearchHookEnv, syncWebSearchMcpToConfigDir, @@ -94,15 +94,18 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise { }); } -function runCli(args: string[], env: Record): Promise<{ code: number | null; stdout: string; stderr: string }> { +function runCli( + args: string[], + env: Record +): Promise<{ code: number | null; stdout: string; stderr: string }> { return new Promise((resolve) => { const child = spawn(process.execPath, [DIST_ENTRY, ...args], { env: { @@ -101,6 +104,11 @@ describe('openai provider routing e2e', () => { const ccsDir = path.join(tempDir, '.ccs'); const binDir = path.join(tempDir, 'bin'); const outputPath = path.join(tempDir, 'claude-output.json'); + const fakeClaudeRuntimePath = path.join(binDir, 'fake-claude.cjs'); + const fakeClaudePath = path.join( + binDir, + process.platform === 'win32' ? 'claude.cmd' : 'claude' + ); fs.mkdirSync(ccsDir, { recursive: true }); fs.mkdirSync(binDir, { recursive: true }); @@ -123,8 +131,8 @@ describe('openai provider routing e2e', () => { 'utf8' ); fs.writeFileSync( - path.join(binDir, 'claude'), - `#!/usr/bin/env node + fakeClaudeRuntimePath, + ` const fs = require('fs'); (async () => { const response = await fetch(\`\${process.env.ANTHROPIC_BASE_URL}/v1/messages\`, { @@ -153,6 +161,13 @@ const fs = require('fs'); process.exit(1); }); `, + 'utf8' + ); + fs.writeFileSync( + fakeClaudePath, + process.platform === 'win32' + ? '@echo off\r\nnode "%~dp0fake-claude.cjs" %*\r\n' + : '#!/bin/sh\nexec node "$(dirname "$0")/fake-claude.cjs" "$@"\n', { mode: 0o755 } ); @@ -160,7 +175,7 @@ const fs = require('fs'); ...process.env, CCS_HOME: tempDir, CCS_E2E_OUTPUT: outputPath, - PATH: `${binDir}:${process.env.PATH || ''}`, + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`, }); expect(result.code).toBe(0); diff --git a/tests/unit/targets/settings-profile-websearch-launch.test.ts b/tests/unit/targets/settings-profile-websearch-launch.test.ts index 6064575a..9061efb5 100644 --- a/tests/unit/targets/settings-profile-websearch-launch.test.ts +++ b/tests/unit/targets/settings-profile-websearch-launch.test.ts @@ -4,7 +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'; +const STEERING_PROMPT_SNIPPET = + 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches'; interface RunResult { status: number | null; @@ -103,28 +104,40 @@ exit 0 fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it('fails before Claude launch when the local WebSearch tool runtime cannot be prepared', () => { + it('continues without local WebSearch when the tool runtime cannot be prepared', () => { if (process.platform === 'win32') return; fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8'); const result = runCcs(['glm', 'smoke'], baseEnv); - expect(result.status).toBe(1); + expect(result.status).toBe(0); expect(result.stderr).toContain('could not prepare the local WebSearch tool'); - expect(fs.existsSync(claudeArgsLogPath)).toBe(false); + expect(result.stderr).toContain('This session will continue without local WebSearch'); + expect(fs.existsSync(claudeArgsLogPath)).toBe(true); + 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('fails before delegated headless launch when the local WebSearch tool runtime cannot be prepared', () => { + it('continues delegated headless launch when the local WebSearch tool runtime cannot be prepared', () => { if (process.platform === 'win32') return; fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8'); const result = runCcs(['glm', '-p', 'smoke'], baseEnv); - expect(result.status).toBe(1); + expect(result.status).toBe(0); expect(result.stderr).toContain('could not prepare the local WebSearch tool'); - expect(fs.existsSync(claudeArgsLogPath)).toBe(false); + expect(result.stderr).toContain('This session will continue without local WebSearch'); + expect(fs.existsSync(claudeArgsLogPath)).toBe(true); + 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('keeps launch non-fatal when WebSearch is disabled', () => { @@ -162,9 +175,7 @@ exit 0 expect(fs.existsSync(tracePath)).toBe(true); const traceEvents = readTraceEvents(tracePath); - const launchEvent = traceEvents.find( - (event) => event.event === 'ccs_websearch_launch' - ) as + const launchEvent = traceEvents.find((event) => event.event === 'ccs_websearch_launch') as | { launcher?: string; nativeWebSearchDisallowed?: boolean;