fix: degrade WebSearch launch provisioning failures (#1571)

This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-18 22:56:20 -04:00
1 parent 813a7a043a
commit 1a0398579a
13 files changed
+225 -33

No files matched your search

+2 -2
View File
@@ -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",
@@ -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<void>((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<void>((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<void>((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<void>((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) => {
+17 -4
View File
@@ -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);
+2 -2
View File
@@ -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);
-4
View File
@@ -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<void
const imageAnalysisMcpReady =
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
}
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
const expandedCliproxySettingsPath = profileInfo.settingsPath
? expandPath(profileInfo.settingsPath)
+2 -2
View File
@@ -5,7 +5,7 @@
*/
import { fail, info } from '../../utils/ui';
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import { ensureWebSearchMcpForLaunch } from '../../utils/websearch-manager';
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
import {
ensureProfileHooks as ensureImageAnalyzerHooks,
@@ -23,7 +23,7 @@ export async function runCopilotFlow(ctx: ProfileDispatchContext): Promise<void>
resolveProfileContinuityInheritance,
} = ctx;
ensureWebSearchMcpOrThrow();
ensureWebSearchMcpForLaunch();
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
if (resolvedTarget === 'claude') {
if (imageAnalysisMcpReady) {
+2 -2
View File
@@ -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<void>
resolveProfileContinuityInheritance,
} = ctx;
ensureWebSearchMcpOrThrow();
ensureWebSearchMcpForLaunch();
installImageAnalyzerHook();
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
+6 -3
View File
@@ -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<void
if (browserAttachRuntime?.warning) {
process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`);
}
let shouldDisplayWebSearchStatus = true;
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
shouldDisplayWebSearchStatus = ensureWebSearchMcpForLaunch();
if (browserRuntimeEnv) {
ensureBrowserMcpOrThrow();
}
}
// Display WebSearch status (single line, equilibrium UX)
displayWebSearchStatus();
if (shouldDisplayWebSearchStatus) {
displayWebSearchStatus();
}
const continuityInheritance =
resolvedTarget === 'claude'
+1
View File
@@ -64,6 +64,7 @@ export {
removeWebSearchMcpConfig,
uninstallWebSearchMcp,
syncWebSearchMcpToConfigDir,
ensureWebSearchMcpForLaunch,
ensureWebSearchMcpOrThrow,
} from './websearch/mcp-installer';
+1
View File
@@ -61,6 +61,7 @@ export {
removeWebSearchMcpConfig,
uninstallWebSearchMcp,
syncWebSearchMcpToConfigDir,
ensureWebSearchMcpForLaunch,
ensureWebSearchMcpOrThrow,
} from './mcp-installer';
+27
View File
@@ -438,3 +438,30 @@ export function ensureWebSearchMcpOrThrow(): void {
throw new Error('WebSearch is enabled, but CCS could not prepare the local WebSearch tool.');
}
}
/**
* Prepare WebSearch for a user launch without blocking Claude startup.
*
* Returns true when the normal WebSearch status line is still accurate. A
* failed MCP prepare already prints a degraded-path warning, so callers should
* skip the ready/status line when this returns false.
*/
export function ensureWebSearchMcpForLaunch(): boolean {
const wsConfig = getWebSearchConfig();
if (!wsConfig.enabled) {
return true;
}
const ready = ensureWebSearchMcp();
if (!ready) {
process.stderr.write(
String(
warn(
'WebSearch is enabled, but CCS could not prepare the local WebSearch tool. This session will continue without local WebSearch.'
)
) + '\n'
);
}
return ready;
}
+19 -4
View File
@@ -61,7 +61,10 @@ function startMockUpstream(port: number): Promise<void> {
});
}
function runCli(args: string[], env: Record<string, string>): Promise<{ code: number | null; stdout: string; stderr: string }> {
function runCli(
args: string[],
env: Record<string, string>
): 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);
@@ -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;