From f2843ad2ce8f929e46a75ca8692d8f3bb9c0034c Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:00:41 -0400 Subject: [PATCH] fix: fail closed on websearch launch provisioning (#1621) --- .../__tests__/executor-option-value.test.ts | 40 ++++++++++--------- src/utils/websearch/mcp-installer.ts | 28 ++++--------- .../settings-profile-websearch-launch.test.ts | 39 +++++++++--------- 3 files changed, 49 insertions(+), 58 deletions(-) diff --git a/src/cliproxy/executor/__tests__/executor-option-value.test.ts b/src/cliproxy/executor/__tests__/executor-option-value.test.ts index bb147205..a04b2ed5 100644 --- a/src/cliproxy/executor/__tests__/executor-option-value.test.ts +++ b/src/cliproxy/executor/__tests__/executor-option-value.test.ts @@ -141,7 +141,7 @@ describe('execClaudeWithCLIProxy browser flag validation', () => { } }); - it('degrades WebSearch provisioning failures for CLIProxy launches', async () => { + it('fails closed on WebSearch provisioning failures for CLIProxy launches', async () => { makeWebSearchProvisioningFail(); const markerPath = path.join(tmpHome, 'fake-claude-launched'); @@ -176,26 +176,30 @@ describe('execClaudeWithCLIProxy browser flag validation', () => { 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', - ], - {} + 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', + '--print', + 'hello', + ], + {} + ) + ).rejects.toThrow( + 'WebSearch is enabled, but CCS could not prepare the local WebSearch tool.' ); - expect(await waitForFile(markerPath)).toBe(true); + expect(await waitForFile(markerPath)).toBe(false); expect(requestCount).toBeGreaterThan(0); - expect(exitSpy).toHaveBeenCalledWith(0); + expect(exitSpy).not.toHaveBeenCalledWith(0); } finally { exitSpy.mockRestore(); logSpy.mockRestore(); diff --git a/src/utils/websearch/mcp-installer.ts b/src/utils/websearch/mcp-installer.ts index 419ab925..5944e500 100644 --- a/src/utils/websearch/mcp-installer.ts +++ b/src/utils/websearch/mcp-installer.ts @@ -440,28 +440,14 @@ export function ensureWebSearchMcpOrThrow(): void { } /** - * Prepare WebSearch for a user launch without blocking Claude startup. + * Prepare WebSearch for a user launch. * - * 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. + * WebSearch-enabled launches must fail closed when the managed local MCP + * runtime cannot be prepared. Otherwise CCS would still suppress Claude's + * native WebSearch and inject fallback steering while the constrained MCP + * search path is unavailable. */ 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; + ensureWebSearchMcpOrThrow(); + return true; } diff --git a/tests/unit/targets/settings-profile-websearch-launch.test.ts b/tests/unit/targets/settings-profile-websearch-launch.test.ts index 9061efb5..7e3f5346 100644 --- a/tests/unit/targets/settings-profile-websearch-launch.test.ts +++ b/tests/unit/targets/settings-profile-websearch-launch.test.ts @@ -61,6 +61,11 @@ describe('settings profile WebSearch launch', () => { path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: { glm: settingsPath } }, null, 2) + '\n' ); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 12', 'websearch:', ' enabled: true', ''].join('\n'), + 'utf8' + ); fs.writeFileSync( settingsPath, JSON.stringify( @@ -93,6 +98,7 @@ exit 0 CCS_HOME: tmpHome, CCS_CLAUDE_PATH: fakeClaudePath, CCS_DEBUG: '1', + CCS_SKIP_PREFLIGHT: '1', }; }); @@ -104,40 +110,30 @@ exit 0 fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it('continues without local WebSearch when the tool runtime cannot be prepared', () => { + it('fails closed 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', 'smoke'], baseEnv); - expect(result.status).toBe(0); + expect(result.status).not.toBe(0); expect(result.stderr).toContain('could not prepare the local WebSearch tool'); - 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); + expect(result.stderr).not.toContain('This session will continue without local WebSearch'); + expect(fs.existsSync(claudeArgsLogPath)).toBe(false); }); - it('continues delegated headless launch when the local WebSearch tool runtime cannot be prepared', () => { + it('fails closed for 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(0); + expect(result.status).not.toBe(0); expect(result.stderr).toContain('could not prepare the local WebSearch tool'); - 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); + expect(result.stderr).not.toContain('This session will continue without local WebSearch'); + expect(fs.existsSync(claudeArgsLogPath)).toBe(false); }); it('keeps launch non-fatal when WebSearch is disabled', () => { @@ -145,7 +141,7 @@ exit 0 fs.writeFileSync( path.join(ccsDir, 'config.yaml'), - 'version: 12\nwebsearch:\n enabled: false\n', + ['version: 12', 'websearch:', ' enabled: false', ''].join('\n'), 'utf8' ); fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8'); @@ -165,6 +161,11 @@ exit 0 it('writes a source-side launch trace for settings profiles when tracing is enabled', () => { if (process.platform === 'win32') return; + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 12', 'websearch:', ' enabled: false', ''].join('\n'), + 'utf8' + ); const tracePath = path.join(ccsDir, 'logs', 'websearch-trace.jsonl'); const result = runCcs(['glm', 'smoke'], { ...baseEnv,