From 7f83e041b7fa8907a5d9484acd21ea88edac67e7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 31 Mar 2026 14:22:11 -0400 Subject: [PATCH] feat(websearch): add managed mcp runtime and provider cooldowns - switch ccs-websearch MCP to stdio-first framing and keep legacy compatibility - add cooldown persistence and bounded retries for provider failures - surface cooldown status in readiness output and regression tests --- lib/hooks/websearch-transformer.cjs | 372 +++++++++++++++++- lib/mcp/ccs-websearch-server.cjs | 58 ++- src/utils/websearch/mcp-installer.ts | 13 +- src/utils/websearch/status.ts | 92 ++++- .../hooks/ccs-websearch-mcp-server.test.ts | 97 ++++- .../unit/hooks/websearch-transformer.test.ts | 368 ++++++++++++++++- tests/unit/instance-manager-mcp-sync.test.ts | 21 +- .../utils/claudecode-env-stripping.test.ts | 4 +- .../utils/websearch/mcp-installer.test.ts | 39 +- tests/unit/utils/websearch/status.test.ts | 114 +++++- 10 files changed, 1115 insertions(+), 63 deletions(-) diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index 42d2022b..1165bc36 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -30,6 +30,13 @@ const DDG_URL = 'https://html.duckduckgo.com/html/'; const BRAVE_URL = 'https://api.search.brave.com/res/v1/web/search'; const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; +const PROVIDER_STATE_FILE = 'websearch-provider-state.json'; +const SHORT_RETRY_AFTER_MAX_SEC = 3; +const TRANSIENT_RETRY_DELAY_MS = 750; +const TRANSIENT_RETRY_ATTEMPTS = 1; +const DEFAULT_RATE_LIMIT_COOLDOWN_SEC = 120; +const DEFAULT_QUOTA_COOLDOWN_SEC = 900; +const MAX_PROVIDER_COOLDOWN_SEC = 60 * 60; const SHARED_INSTRUCTIONS = `Instructions: 1. Search the web for current, up-to-date information @@ -101,6 +108,111 @@ function getSafeTracePrefixes() { ]; } +function getProviderStatePath() { + return path.join(getCcsDirPath(), 'cache', PROVIDER_STATE_FILE); +} + +function readProviderState() { + try { + const statePath = getProviderStatePath(); + if (!fs.existsSync(statePath)) { + return { cooldowns: {} }; + } + + const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')); + const cooldowns = + parsed && typeof parsed === 'object' && parsed.cooldowns && typeof parsed.cooldowns === 'object' + ? parsed.cooldowns + : {}; + return { cooldowns }; + } catch { + return { cooldowns: {} }; + } +} + +function writeProviderState(state) { + try { + const statePath = getProviderStatePath(); + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + const tempPath = `${statePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(state, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, statePath); + } catch { + // Best-effort only. + } +} + +function sanitizeProviderState(state) { + const now = Date.now(); + const nextCooldowns = {}; + let changed = false; + + for (const [providerId, entry] of Object.entries(state.cooldowns || {})) { + if (!entry || typeof entry !== 'object') { + changed = true; + continue; + } + + const until = Number.parseInt(String(entry.until || ''), 10); + if (!Number.isFinite(until) || until <= now) { + changed = true; + continue; + } + + nextCooldowns[providerId] = { + until, + reason: typeof entry.reason === 'string' ? entry.reason : 'rate_limited', + updatedAt: Number.parseInt(String(entry.updatedAt || ''), 10) || now, + sourceError: typeof entry.sourceError === 'string' ? entry.sourceError : '', + }; + } + + return { + state: { cooldowns: nextCooldowns }, + changed, + }; +} + +function getProviderCooldown(providerId) { + const { state, changed } = sanitizeProviderState(readProviderState()); + if (changed) { + writeProviderState(state); + } + + return state.cooldowns[providerId] || null; +} + +function clearProviderCooldown(providerId) { + const { state } = sanitizeProviderState(readProviderState()); + if (!(providerId in state.cooldowns)) { + return; + } + + delete state.cooldowns[providerId]; + writeProviderState(state); +} + +function applyProviderCooldown(providerId, cooldownSec, reason, sourceError) { + const clampedCooldownSec = Math.max( + 1, + Math.min(MAX_PROVIDER_COOLDOWN_SEC, Math.floor(cooldownSec)) + ); + const { state } = sanitizeProviderState(readProviderState()); + const until = Date.now() + clampedCooldownSec * 1000; + state.cooldowns[providerId] = { + until, + reason, + updatedAt: Date.now(), + sourceError: sourceError || '', + }; + writeProviderState(state); + return until; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function getAllowedTraceFileOverride() { const configured = (process.env.CCS_WEBSEARCH_TRACE_FILE || '').trim(); if (!configured) { @@ -146,6 +258,42 @@ function traceWebSearchEvent(event, payload = {}) { } } +function readHeaderValue(headers, headerName) { + if (!headers) { + return ''; + } + + if (typeof headers.get === 'function') { + return headers.get(headerName) || ''; + } + + const direct = headers[headerName] ?? headers[String(headerName).toLowerCase()]; + if (Array.isArray(direct)) { + return direct[0] || ''; + } + return typeof direct === 'string' ? direct : ''; +} + +function parseRetryAfterSeconds(rawValue) { + const value = String(rawValue || '').trim(); + if (!value) { + return null; + } + + const asSeconds = Number.parseInt(value, 10); + if (Number.isFinite(asSeconds) && asSeconds > 0) { + return asSeconds; + } + + const asDate = Date.parse(value); + if (Number.isFinite(asDate)) { + const deltaSec = Math.ceil((asDate - Date.now()) / 1000); + return deltaSec > 0 ? deltaSec : null; + } + + return null; +} + function getQueryFingerprint(query) { const normalizedQuery = typeof query === 'string' ? query.trim() : ''; return { @@ -371,6 +519,8 @@ async function tryBraveSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { return { success: false, error: `Brave Search returned ${response.status}: ${body.slice(0, 160)}`, + statusCode: response.status, + retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')), }; } @@ -422,7 +572,12 @@ async function tryExaSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { if (!response.ok) { const body = await response.text(); - return { success: false, error: `Exa returned ${response.status}: ${body.slice(0, 160)}` }; + return { + success: false, + error: `Exa returned ${response.status}: ${body.slice(0, 160)}`, + statusCode: response.status, + retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')), + }; } const body = await response.json(); @@ -474,7 +629,12 @@ async function tryTavilySearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { if (!response.ok) { const body = await response.text(); - return { success: false, error: `Tavily returned ${response.status}: ${body.slice(0, 160)}` }; + return { + success: false, + error: `Tavily returned ${response.status}: ${body.slice(0, 160)}`, + statusCode: response.status, + retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')), + }; } const body = await response.json(); @@ -511,7 +671,12 @@ async function tryDuckDuckGoSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { ); if (!response.ok) { - return { success: false, error: `DuckDuckGo returned ${response.status}` }; + return { + success: false, + error: `DuckDuckGo returned ${response.status}`, + statusCode: response.status, + retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')), + }; } const html = await response.text(); @@ -720,8 +885,174 @@ function getConfiguredProviders() { ]; } +function looksLikeQuotaExhaustion(errorMessage) { + const lower = String(errorMessage || '').toLowerCase(); + return ( + (lower.includes('quota') && + (lower.includes('exceed') || + lower.includes('exhaust') || + lower.includes('deplet') || + lower.includes('limit') || + lower.includes('used up'))) || + lower.includes('insufficient credits') || + lower.includes('credit balance') || + lower.includes('out of credits') || + lower.includes('billing hard limit') || + lower.includes('monthly usage cap') + ); +} + +function looksLikeTransientFailure(errorMessage) { + const lower = String(errorMessage || '').toLowerCase(); + return ( + lower.includes('timed out') || + lower.includes('timeout') || + lower.includes('temporarily unavailable') || + lower.includes('service unavailable') || + lower.includes('bad gateway') || + lower.includes('gateway timeout') || + lower.includes('socket hang up') || + lower.includes('econnreset') || + lower.includes('fetch failed') || + lower.includes('network') + ); +} + +function classifyProviderFailure(result) { + const errorMessage = String(result.error || ''); + const statusCode = + Number.isFinite(result.statusCode) && result.statusCode > 0 ? result.statusCode : null; + const retryAfterSec = Number.isFinite(result.retryAfterSec) ? result.retryAfterSec : null; + + if (looksLikeQuotaExhaustion(errorMessage)) { + return { + kind: 'cooldown', + reason: 'quota_exhausted', + cooldownSec: retryAfterSec || DEFAULT_QUOTA_COOLDOWN_SEC, + retryAfterSec, + }; + } + + if (statusCode === 429 || /too many requests|rate limit/i.test(errorMessage)) { + if (retryAfterSec && retryAfterSec <= SHORT_RETRY_AFTER_MAX_SEC) { + return { + kind: 'retry', + delayMs: retryAfterSec * 1000, + reason: 'rate_limited_short_backoff', + retryAfterSec, + }; + } + + return { + kind: 'cooldown', + reason: 'rate_limited', + cooldownSec: retryAfterSec || DEFAULT_RATE_LIMIT_COOLDOWN_SEC, + retryAfterSec, + }; + } + + if ( + (statusCode && [502, 503, 504].includes(statusCode)) || + looksLikeTransientFailure(errorMessage) + ) { + return { + kind: 'retry', + delayMs: TRANSIENT_RETRY_DELAY_MS, + reason: 'transient_failure', + retryAfterSec, + }; + } + + return { + kind: 'fail', + reason: 'non_retryable', + retryAfterSec, + }; +} + +async function runProviderWithPolicy(provider, query, timeoutSec, fingerprint) { + for (let attempt = 0; attempt <= TRANSIENT_RETRY_ATTEMPTS; attempt += 1) { + traceWebSearchEvent('websearch_provider_attempt', { + source: 'provider', + providerId: provider.id, + providerName: provider.name, + attempt: attempt + 1, + ...fingerprint, + }); + + const result = await provider.fn(query, timeoutSec); + if (result.success) { + clearProviderCooldown(provider.id); + return result; + } + + const policy = classifyProviderFailure(result); + if (policy.kind === 'retry' && attempt < TRANSIENT_RETRY_ATTEMPTS) { + traceWebSearchEvent('websearch_provider_retry_scheduled', { + source: 'provider', + providerId: provider.id, + providerName: provider.name, + attempt: attempt + 1, + delayMs: policy.delayMs, + reason: policy.reason, + retryAfterSec: policy.retryAfterSec, + ...fingerprint, + }); + await sleep(policy.delayMs); + continue; + } + + if (policy.kind === 'retry' && policy.reason === 'rate_limited_short_backoff') { + const cooldownSec = policy.retryAfterSec || DEFAULT_RATE_LIMIT_COOLDOWN_SEC; + const until = applyProviderCooldown(provider.id, cooldownSec, 'rate_limited', result.error); + traceWebSearchEvent('websearch_provider_cooldown_applied', { + source: 'provider', + providerId: provider.id, + providerName: provider.name, + cooldownUntil: until, + cooldownSec, + reason: 'rate_limited', + retryAfterSec: policy.retryAfterSec, + afterRetryExhausted: true, + ...fingerprint, + }); + return { + ...result, + error: `${result.error} (cooldown ${cooldownSec}s)`, + }; + } + + if (policy.kind === 'cooldown') { + const until = applyProviderCooldown( + provider.id, + policy.cooldownSec, + policy.reason, + result.error + ); + traceWebSearchEvent('websearch_provider_cooldown_applied', { + source: 'provider', + providerId: provider.id, + providerName: provider.name, + cooldownUntil: until, + cooldownSec: policy.cooldownSec, + reason: policy.reason, + retryAfterSec: policy.retryAfterSec, + ...fingerprint, + }); + return { + ...result, + error: `${result.error} (cooldown ${policy.cooldownSec}s)`, + }; + } + + return result; + } + + return { success: false, error: 'Provider retry policy exhausted' }; +} + function getActiveProviders() { - return getConfiguredProviders().filter((provider) => provider.available()); + return getConfiguredProviders().filter((provider) => !getProviderCooldown(provider.id) && provider.available()); } function getActiveProviderIds() { @@ -733,8 +1064,29 @@ function hasAnyActiveProviders() { } async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { - const activeProviders = getActiveProviders(); const fingerprint = getQueryFingerprint(query); + const configuredProviders = getConfiguredProviders(); + const activeProviders = []; + + for (const provider of configuredProviders) { + const cooldown = getProviderCooldown(provider.id); + if (cooldown) { + traceWebSearchEvent('websearch_provider_cooldown_skip', { + source: 'provider', + providerId: provider.id, + providerName: provider.name, + cooldownUntil: cooldown.until, + cooldownReason: cooldown.reason, + remainingMs: Math.max(0, cooldown.until - Date.now()), + ...fingerprint, + }); + continue; + } + + if (provider.available()) { + activeProviders.push(provider); + } + } debug( `Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}` @@ -757,13 +1109,7 @@ async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { const errors = []; for (const provider of activeProviders) { debug(`Trying ${provider.name}`); - traceWebSearchEvent('websearch_provider_attempt', { - source: 'provider', - providerId: provider.id, - providerName: provider.name, - ...fingerprint, - }); - const result = await provider.fn(query, timeoutSec); + const result = await runProviderWithPolicy(provider, query, timeoutSec, fingerprint); if (result.success) { traceWebSearchEvent('websearch_provider_success', { source: 'provider', @@ -890,8 +1236,10 @@ module.exports = { runLocalWebSearch, shouldSkipHook, getActiveProviderIds, + classifyProviderFailure, getQueryFingerprint, getSkipReason, + parseRetryAfterSeconds, traceWebSearchEvent, tryExaSearch, tryTavilySearch, diff --git a/lib/mcp/ccs-websearch-server.cjs b/lib/mcp/ccs-websearch-server.cjs index c3f7c137..13880600 100644 --- a/lib/mcp/ccs-websearch-server.cjs +++ b/lib/mcp/ccs-websearch-server.cjs @@ -61,9 +61,7 @@ function getTools() { } function writeMessage(message) { - const body = Buffer.from(JSON.stringify(message), 'utf8'); - process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`); - process.stdout.write(body); + process.stdout.write(`${JSON.stringify(message)}\n`); } function writeResponse(id, result) { @@ -262,26 +260,46 @@ function writeSessionSummary(exitCodeOrSignal) { function parseMessages() { while (true) { - const headerEnd = inputBuffer.indexOf('\r\n\r\n'); - if (headerEnd === -1) { - return; - } + let body; + const startsWithLegacyHeaders = inputBuffer + .slice(0, Math.min(inputBuffer.length, 32)) + .toString('utf8') + .toLowerCase() + .startsWith('content-length:'); - const headerText = inputBuffer.slice(0, headerEnd).toString('utf8'); - const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i); - if (!contentLengthMatch) { - inputBuffer = Buffer.alloc(0); - return; - } + if (startsWithLegacyHeaders) { + const headerEnd = inputBuffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + return; + } - const contentLength = Number.parseInt(contentLengthMatch[1], 10); - const messageEnd = headerEnd + 4 + contentLength; - if (inputBuffer.length < messageEnd) { - return; - } + const headerText = inputBuffer.slice(0, headerEnd).toString('utf8'); + const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i); + if (!contentLengthMatch) { + inputBuffer = Buffer.alloc(0); + return; + } - const body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8'); - inputBuffer = inputBuffer.slice(messageEnd); + const contentLength = Number.parseInt(contentLengthMatch[1], 10); + const messageEnd = headerEnd + 4 + contentLength; + if (inputBuffer.length < messageEnd) { + return; + } + + body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8'); + inputBuffer = inputBuffer.slice(messageEnd); + } else { + const newlineIndex = inputBuffer.indexOf('\n'); + if (newlineIndex === -1) { + return; + } + + body = inputBuffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); + inputBuffer = inputBuffer.slice(newlineIndex + 1); + if (!body) { + continue; + } + } let message; try { diff --git a/src/utils/websearch/mcp-installer.ts b/src/utils/websearch/mcp-installer.ts index 8466b08e..5f7260e9 100644 --- a/src/utils/websearch/mcp-installer.ts +++ b/src/utils/websearch/mcp-installer.ts @@ -20,6 +20,13 @@ interface ClaudeUserConfig { [key: string]: unknown; } +interface ManagedWebSearchMcpConfig { + type: 'stdio'; + command: 'node'; + args: [string]; + env: Record; +} + function getCcsMcpDir(): string { return path.join(getCcsDir(), 'mcp'); } @@ -91,9 +98,11 @@ function readClaudeUserConfig(configPath: string): ClaudeUserConfig | null { function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): boolean { const tempPath = getTempPath(configPath); + const fileMode = fs.existsSync(configPath) ? fs.statSync(configPath).mode & 0o777 : 0o600; try { fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.chmodSync(tempPath, fileMode); fs.renameSync(tempPath, configPath); return true; } finally { @@ -252,9 +261,11 @@ export function ensureWebSearchMcpConfig(): boolean { config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) ? (config.mcpServers as Record) : {}; - const desiredServerConfig = { + const desiredServerConfig: ManagedWebSearchMcpConfig = { + type: 'stdio', command: 'node', args: [getWebSearchMcpServerPath()], + env: {}, }; const currentConfig = existingServers[WEBSEARCH_MCP_SERVER_NAME]; diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index b812f7d9..ecb5ce94 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -6,18 +6,105 @@ * @module utils/websearch/status */ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; import { ok, warn, fail, info } from '../ui'; import { getWebSearchConfig } from '../../config/unified-config-loader'; +import { getCcsDir } from '../config-manager'; import { getGeminiCliStatus, isGeminiAuthenticated } from './gemini-cli'; import { getGrokCliStatus } from './grok-cli'; import { getOpenCodeCliStatus } from './opencode-cli'; import { getWebSearchApiKeyStates } from './provider-secrets'; import type { WebSearchCliInfo, WebSearchStatus } from './types'; +const PROVIDER_STATE_FILE = 'websearch-provider-state.json'; + +type ProviderCooldown = { + reason: string; + until: number; +}; + function hasEnvValue(name: string): boolean { return (process.env[name] || '').trim().length > 0; } +function getProviderStatePath(): string { + return join(getCcsDir(), 'cache', PROVIDER_STATE_FILE); +} + +function readProviderCooldowns(now = Date.now()): Record { + try { + const statePath = getProviderStatePath(); + if (!existsSync(statePath)) { + return {}; + } + + const parsed = JSON.parse(readFileSync(statePath, 'utf8')) as { + cooldowns?: Record; + }; + const nextCooldowns: Record = {}; + + for (const [providerId, entry] of Object.entries(parsed.cooldowns || {})) { + const until = Number.parseInt(String(entry?.until || ''), 10); + if (!Number.isFinite(until) || until <= now) { + continue; + } + + nextCooldowns[providerId] = { + reason: typeof entry?.reason === 'string' ? entry.reason : 'rate_limited', + until, + }; + } + + return nextCooldowns; + } catch { + return {}; + } +} + +function formatCooldownDuration(until: number, now = Date.now()): string { + const remainingSec = Math.max(1, Math.ceil((until - now) / 1000)); + if (remainingSec >= 3600) { + return `~${Math.ceil(remainingSec / 3600)}h`; + } + if (remainingSec >= 60) { + return `~${Math.ceil(remainingSec / 60)}m`; + } + return `~${remainingSec}s`; +} + +function formatCooldownReason(reason: string): string { + switch (reason) { + case 'quota_exhausted': + return 'quota exhaustion'; + case 'rate_limited': + return 'rate limiting'; + default: + return 'a temporary provider error'; + } +} + +function applyCooldownStatus( + provider: WebSearchCliInfo, + cooldowns: Record, + now = Date.now() +): WebSearchCliInfo { + if (!(provider.enabled && provider.available)) { + return provider; + } + + const cooldown = cooldowns[provider.id]; + if (!cooldown) { + return provider; + } + + return { + ...provider, + available: false, + detail: `Cooling down ${formatCooldownDuration(cooldown.until, now)} after ${formatCooldownReason(cooldown.reason)}`, + }; +} + function getLegacyProviderStatuses(): WebSearchCliInfo[] { const wsConfig = getWebSearchConfig(); const geminiStatus = getGeminiCliStatus(); @@ -86,6 +173,7 @@ function getLegacyProviderStatuses(): WebSearchCliInfo[] { export function getWebSearchCliProviders(): WebSearchCliInfo[] { const wsConfig = getWebSearchConfig(); const apiKeyStates = getWebSearchApiKeyStates(); + const cooldowns = readProviderCooldowns(); const providers: WebSearchCliInfo[] = [ { id: 'exa', @@ -152,7 +240,9 @@ export function getWebSearchCliProviders(): WebSearchCliInfo[] { }, ]; - return [...providers, ...getLegacyProviderStatuses()]; + return [...providers, ...getLegacyProviderStatuses()].map((provider) => + applyCooldownStatus(provider, cooldowns) + ); } /** diff --git a/tests/unit/hooks/ccs-websearch-mcp-server.test.ts b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts index 79f4fdaf..4229687f 100644 --- a/tests/unit/hooks/ccs-websearch-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts @@ -7,6 +7,10 @@ import { join } from 'node:path'; const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-websearch-server.cjs'); function encodeMessage(message: unknown): string { + return `${JSON.stringify(message)}\n`; +} + +function encodeLegacyMessage(message: unknown): string { const body = JSON.stringify(message); return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`; } @@ -22,26 +26,47 @@ function collectResponses( function tryParse(): void { while (true) { - const headerEnd = buffer.indexOf('\r\n\r\n'); - if (headerEnd === -1) { - return; + const startsWithLegacyHeaders = buffer + .slice(0, Math.min(buffer.length, 32)) + .toString('utf8') + .toLowerCase() + .startsWith('content-length:'); + + let body: string; + if (startsWithLegacyHeaders) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + return; + } + + const headerText = buffer.slice(0, headerEnd).toString('utf8'); + const match = headerText.match(/content-length:\s*(\d+)/i); + if (!match) { + reject(new Error('Missing Content-Length header')); + return; + } + + const contentLength = Number.parseInt(match[1], 10); + const messageEnd = headerEnd + 4 + contentLength; + if (buffer.length < messageEnd) { + return; + } + + body = buffer.slice(headerEnd + 4, messageEnd).toString('utf8'); + buffer = buffer.slice(messageEnd); + } else { + const newlineIndex = buffer.indexOf('\n'); + if (newlineIndex === -1) { + return; + } + + body = buffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); + buffer = buffer.slice(newlineIndex + 1); + if (!body) { + continue; + } } - const headerText = buffer.slice(0, headerEnd).toString('utf8'); - const match = headerText.match(/content-length:\s*(\d+)/i); - if (!match) { - reject(new Error('Missing Content-Length header')); - return; - } - - const contentLength = Number.parseInt(match[1], 10); - const messageEnd = headerEnd + 4 + contentLength; - if (buffer.length < messageEnd) { - return; - } - - const body = buffer.slice(headerEnd + 4, messageEnd).toString('utf8'); - buffer = buffer.slice(messageEnd); responses.push(JSON.parse(body) as Record); if (responses.length >= expectedCount) { @@ -374,4 +399,40 @@ describe('ccs-websearch MCP server', () => { rmSync(tempDir, { recursive: true, force: true }); }); + + it('accepts legacy Content-Length framed requests for compatibility', async () => { + const child = spawn('node', [serverPath], { + env: { + ...process.env, + CCS_PROFILE_TYPE: 'account', + CCS_WEBSEARCH_ENABLED: '1', + CCS_WEBSEARCH_SKIP: '1', + CCS_WEBSEARCH_DUCKDUCKGO: '1', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + try { + const responsesPromise = collectResponses(child, 2); + child.stdin.write( + encodeLegacyMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bun-test', version: '1.0.0' }, + }, + }) + ); + child.stdin.write(encodeLegacyMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' })); + + const responses = await responsesPromise; + const toolsList = responses.find((message) => message.id === 2); + expect(toolsList?.result).toEqual({ tools: [] }); + } finally { + child.kill(); + } + }); }); diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index 9c9f51ee..43ebde85 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'bun:test'; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; @@ -29,11 +36,18 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as { url: string; description: string; }>; + classifyProviderFailure: (result: { + error?: string; + retryAfterSec?: number | null; + statusCode?: number | null; + success?: boolean; + }) => Record; formatStructuredSearchResults: ( query: string, providerName: string, results: Array<{ title: string; url: string; description: string }> ) => string; + parseRetryAfterSeconds: (rawValue: string) => number | null; }; function runHookWithMockedFetch(mode: 'success' | 'failure') { @@ -76,6 +90,42 @@ function runHookWithMockedFetch(mode: 'success' | 'failure') { } describe('websearch-transformer hook helpers', () => { + it('parses Retry-After seconds and HTTP dates', () => { + expect(hook.parseRetryAfterSeconds('2')).toBe(2); + expect( + hook.parseRetryAfterSeconds(new Date(Date.now() + 2000).toUTCString()) + ).toBeGreaterThanOrEqual(1); + expect(hook.parseRetryAfterSeconds('invalid')).toBeNull(); + }); + + it('classifies quota exhaustion and short rate limits into the correct provider policy', () => { + expect( + hook.classifyProviderFailure({ + success: false, + statusCode: 429, + error: 'Exa returned 429: quota exceeded for current plan', + }) + ).toMatchObject({ + kind: 'cooldown', + reason: 'quota_exhausted', + cooldownSec: 900, + }); + + expect( + hook.classifyProviderFailure({ + success: false, + statusCode: 429, + retryAfterSec: 2, + error: 'Brave Search returned 429: rate limit exceeded', + }) + ).toMatchObject({ + kind: 'retry', + reason: 'rate_limited_short_backoff', + delayMs: 2000, + retryAfterSec: 2, + }); + }); + it('extracts DuckDuckGo results and unwraps uddg redirect URLs', () => { const html = ` Example title @@ -320,4 +370,320 @@ describe('websearch-transformer hook helpers', () => { rmSync(tempDir, { recursive: true, force: true }); } }); + + it('applies provider cooldown on quota exhaustion and falls back to the next backend', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-quota-')); + const preloadPath = join(tempDir, 'mock-fetch.cjs'); + const requestLogPath = join(tempDir, 'requests.json'); + const ccsHome = join(tempDir, 'home'); + const statePath = join(ccsHome, '.ccs', 'cache', 'websearch-provider-state.json'); + const tracePath = join(ccsHome, '.ccs', 'logs', 'websearch-trace.jsonl'); + const html = ` + Fallback title + Fallback snippet + `.trim(); + + writeFileSync( + preloadPath, + ` +const fs = require('fs'); +const requestLogPath = ${JSON.stringify(requestLogPath)}; +const html = ${JSON.stringify(html)}; +function record(url) { + const requests = fs.existsSync(requestLogPath) + ? JSON.parse(fs.readFileSync(requestLogPath, 'utf8')) + : []; + requests.push(String(url)); + fs.writeFileSync(requestLogPath, JSON.stringify(requests), 'utf8'); +} +global.fetch = async (url) => { + const resolvedUrl = String(url); + record(resolvedUrl); + if (resolvedUrl.includes('api.exa.ai')) { + return { + ok: false, + status: 429, + headers: { get: () => null }, + text: async () => 'quota exceeded for current plan', + }; + } + return { + ok: true, + headers: { get: () => null }, + text: async () => html, + }; +}; + `.trimStart(), + 'utf8' + ); + + try { + const result = spawnSync('node', ['-r', preloadPath, hookPath], { + encoding: 'utf8', + input: JSON.stringify({ + tool_name: 'WebSearch', + tool_input: { query: 'btc price' }, + }), + env: { + ...process.env, + CCS_HOME: ccsHome, + CCS_WEBSEARCH_TRACE: '1', + CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'quota-fallback-test', + CCS_WEBSEARCH_TRACE_LAUNCHER: 'unit-test', + CCS_WEBSEARCH_ENABLED: '1', + CCS_WEBSEARCH_SKIP: '0', + CCS_WEBSEARCH_BRAVE: '0', + CCS_WEBSEARCH_DUCKDUCKGO: '1', + CCS_WEBSEARCH_EXA: '1', + CCS_WEBSEARCH_GEMINI: '0', + CCS_WEBSEARCH_GROK: '0', + CCS_WEBSEARCH_OPENCODE: '0', + CCS_WEBSEARCH_TAVILY: '0', + EXA_API_KEY: 'exa-test-key', + }, + }); + + expect(result.status).toBe(0); + const output = JSON.parse(result.stdout.trim()) as HookOutput; + expect(output.hookSpecificOutput.additionalContext).toContain('Provider: DuckDuckGo'); + + const providerState = JSON.parse(readFileSync(statePath, 'utf8')) as { + cooldowns?: Record; + }; + expect(providerState.cooldowns?.exa?.reason).toBe('quota_exhausted'); + expect(providerState.cooldowns?.exa?.until).toBeGreaterThan(Date.now()); + + const traceEvents = readFileSync(tracePath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + expect( + traceEvents.some( + (event) => + event.event === 'websearch_provider_cooldown_applied' && + event.providerId === 'exa' && + event.reason === 'quota_exhausted' + ) + ).toBe(true); + expect( + traceEvents.some( + (event) => + event.event === 'websearch_provider_success' && + event.providerId === 'duckduckgo' + ) + ).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('skips providers that are already cooling down on later WebSearch calls', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-cooldown-skip-')); + const preloadPath = join(tempDir, 'mock-fetch.cjs'); + const requestLogPath = join(tempDir, 'requests.json'); + const ccsHome = join(tempDir, 'home'); + const statePath = join(ccsHome, '.ccs', 'cache', 'websearch-provider-state.json'); + const tracePath = join(ccsHome, '.ccs', 'logs', 'websearch-trace.jsonl'); + const html = ` + Cooldown title + Cooldown snippet + `.trim(); + + mkdirSync(join(ccsHome, '.ccs', 'cache'), { recursive: true }); + writeFileSync( + statePath, + JSON.stringify( + { + cooldowns: { + exa: { + until: Date.now() + 10 * 60 * 1000, + reason: 'quota_exhausted', + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + writeFileSync( + preloadPath, + ` +const fs = require('fs'); +const requestLogPath = ${JSON.stringify(requestLogPath)}; +const html = ${JSON.stringify(html)}; +function record(url) { + const requests = fs.existsSync(requestLogPath) + ? JSON.parse(fs.readFileSync(requestLogPath, 'utf8')) + : []; + requests.push(String(url)); + fs.writeFileSync(requestLogPath, JSON.stringify(requests), 'utf8'); +} +global.fetch = async (url) => { + record(url); + return { + ok: true, + headers: { get: () => null }, + text: async () => html, + }; +}; + `.trimStart(), + 'utf8' + ); + + try { + const result = spawnSync('node', ['-r', preloadPath, hookPath], { + encoding: 'utf8', + input: JSON.stringify({ + tool_name: 'WebSearch', + tool_input: { query: 'btc price' }, + }), + env: { + ...process.env, + CCS_HOME: ccsHome, + CCS_WEBSEARCH_TRACE: '1', + CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'cooldown-skip-test', + CCS_WEBSEARCH_TRACE_LAUNCHER: 'unit-test', + CCS_WEBSEARCH_ENABLED: '1', + CCS_WEBSEARCH_SKIP: '0', + CCS_WEBSEARCH_BRAVE: '0', + CCS_WEBSEARCH_DUCKDUCKGO: '1', + CCS_WEBSEARCH_EXA: '1', + CCS_WEBSEARCH_GEMINI: '0', + CCS_WEBSEARCH_GROK: '0', + CCS_WEBSEARCH_OPENCODE: '0', + CCS_WEBSEARCH_TAVILY: '0', + EXA_API_KEY: 'exa-test-key', + }, + }); + + expect(result.status).toBe(0); + const output = JSON.parse(result.stdout.trim()) as HookOutput; + expect(output.hookSpecificOutput.additionalContext).toContain('Provider: DuckDuckGo'); + + const requests = JSON.parse(readFileSync(requestLogPath, 'utf8')) as string[]; + expect(requests.some((url) => url.includes('api.exa.ai'))).toBe(false); + + const traceEvents = readFileSync(tracePath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + expect( + traceEvents.some( + (event) => + event.event === 'websearch_provider_cooldown_skip' && + event.providerId === 'exa' && + event.cooldownReason === 'quota_exhausted' + ) + ).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('retries transient backend failures once before succeeding', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-retry-')); + const preloadPath = join(tempDir, 'mock-fetch.cjs'); + const requestLogPath = join(tempDir, 'requests.json'); + const ccsHome = join(tempDir, 'home'); + const tracePath = join(ccsHome, '.ccs', 'logs', 'websearch-trace.jsonl'); + + writeFileSync( + preloadPath, + ` +const fs = require('fs'); +const requestLogPath = ${JSON.stringify(requestLogPath)}; +let exaAttempts = 0; +function record(url) { + const requests = fs.existsSync(requestLogPath) + ? JSON.parse(fs.readFileSync(requestLogPath, 'utf8')) + : []; + requests.push(String(url)); + fs.writeFileSync(requestLogPath, JSON.stringify(requests), 'utf8'); +} +global.fetch = async (url) => { + const resolvedUrl = String(url); + record(resolvedUrl); + exaAttempts += 1; + if (exaAttempts === 1) { + return { + ok: false, + status: 503, + headers: { get: () => null }, + text: async () => 'service unavailable', + }; + } + return { + ok: true, + headers: { get: () => null }, + json: async () => ({ + results: [ + { + title: 'Exa title', + url: 'https://example.com/exa', + text: 'Exa snippet', + }, + ], + }), + }; +}; + `.trimStart(), + 'utf8' + ); + + try { + const result = spawnSync('node', ['-r', preloadPath, hookPath], { + encoding: 'utf8', + input: JSON.stringify({ + tool_name: 'WebSearch', + tool_input: { query: 'btc price' }, + }), + env: { + ...process.env, + CCS_HOME: ccsHome, + CCS_WEBSEARCH_TRACE: '1', + CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'transient-retry-test', + CCS_WEBSEARCH_TRACE_LAUNCHER: 'unit-test', + CCS_WEBSEARCH_ENABLED: '1', + CCS_WEBSEARCH_SKIP: '0', + CCS_WEBSEARCH_BRAVE: '0', + CCS_WEBSEARCH_DUCKDUCKGO: '0', + CCS_WEBSEARCH_EXA: '1', + CCS_WEBSEARCH_GEMINI: '0', + CCS_WEBSEARCH_GROK: '0', + CCS_WEBSEARCH_OPENCODE: '0', + CCS_WEBSEARCH_TAVILY: '0', + EXA_API_KEY: 'exa-test-key', + }, + }); + + expect(result.status).toBe(0); + const output = JSON.parse(result.stdout.trim()) as HookOutput; + expect(output.hookSpecificOutput.additionalContext).toContain('Provider: Exa'); + + const requests = JSON.parse(readFileSync(requestLogPath, 'utf8')) as string[]; + expect(requests.filter((url) => url.includes('api.exa.ai'))).toHaveLength(2); + + const traceEvents = readFileSync(tracePath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + expect( + traceEvents.some( + (event) => + event.event === 'websearch_provider_retry_scheduled' && + event.providerId === 'exa' && + event.reason === 'transient_failure' + ) + ).toBe(true); + expect( + traceEvents.some( + (event) => + event.event === 'websearch_provider_success' && event.providerId === 'exa' + ) + ).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index 51d6f1b0..27d5dbd0 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -150,7 +150,12 @@ describe('InstanceManager MCP sync', () => { JSON.stringify( { mcpServers: { - 'ccs-websearch': { command: 'node', args: ['/global/server.cjs'] }, + 'ccs-websearch': { + type: 'stdio', + command: 'node', + args: ['/global/server.cjs'], + env: {}, + }, shared: { command: 'global-shared' }, }, }, @@ -168,7 +173,12 @@ describe('InstanceManager MCP sync', () => { JSON.stringify( { mcpServers: { - 'ccs-websearch': { command: 'node', args: ['/old/server.cjs'] }, + 'ccs-websearch': { + type: 'stdio', + command: 'node', + args: ['/old/server.cjs'], + env: {}, + }, shared: { command: 'instance-shared' }, instanceOnly: { command: 'instance-only' }, }, @@ -185,7 +195,12 @@ describe('InstanceManager MCP sync', () => { mcpServers: Record; }; expect(instanceContent.mcpServers).toEqual({ - 'ccs-websearch': { command: 'node', args: ['/global/server.cjs'] }, + 'ccs-websearch': { + type: 'stdio', + command: 'node', + args: ['/global/server.cjs'], + env: {}, + }, shared: { command: 'instance-shared' }, instanceOnly: { command: 'instance-only' }, }); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 196a1f1c..acd83eb7 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -374,11 +374,13 @@ describe('CLAUDECODE environment stripping', () => { const claudeUserConfig = JSON.parse( fs.readFileSync(path.join(process.env.CCS_HOME as string, '.claude.json'), 'utf8') ) as { - mcpServers?: Record; + mcpServers?: Record; }; expect(claudeUserConfig.mcpServers?.['ccs-websearch']).toEqual({ + type: 'stdio', command: 'node', args: [path.join(ccsDir, 'mcp', 'ccs-websearch-server.cjs')], + env: {}, }); }); diff --git a/tests/unit/utils/websearch/mcp-installer.test.ts b/tests/unit/utils/websearch/mcp-installer.test.ts index 45aaf4df..5fb07d34 100644 --- a/tests/unit/utils/websearch/mcp-installer.test.ts +++ b/tests/unit/utils/websearch/mcp-installer.test.ts @@ -38,6 +38,15 @@ describe('ensureWebSearchMcp', () => { ); } + function getManagedConfig() { + return { + type: 'stdio', + command: 'node', + args: [getWebSearchMcpServerPath()], + env: {}, + }; + } + afterEach(() => { mock.restore(); @@ -79,14 +88,36 @@ describe('ensureWebSearchMcp', () => { expect(fs.existsSync(getWebSearchMcpServerPath())).toBe(true); const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as { - mcpServers: Record; + mcpServers: Record; }; expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] }); - expect(config.mcpServers[getWebSearchMcpServerName()]).toEqual({ - command: 'node', - args: [getWebSearchMcpServerPath()], + expect(config.mcpServers[getWebSearchMcpServerName()]).toEqual(getManagedConfig()); + }); + + it('preserves the existing ~/.claude.json permissions when provisioning WebSearch MCP', () => { + setupTempHome(); + writeEnabledConfig(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync(claudeUserConfigPath, JSON.stringify({ existing: true }, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o600, }); + fs.chmodSync(claudeUserConfigPath, 0o600); + + expect(ensureWebSearchMcp()).toBe(true); + expect(fs.statSync(claudeUserConfigPath).mode & 0o777).toBe(0o600); + }); + + it('writes new ~/.claude.json with 0600 permissions', () => { + setupTempHome(); + writeEnabledConfig(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + + expect(ensureWebSearchMcp()).toBe(true); + expect(fs.statSync(claudeUserConfigPath).mode & 0o777).toBe(0o600); }); it('returns false and preserves malformed ~/.claude.json', () => { diff --git a/tests/unit/utils/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts index 3bdd3c23..b696278d 100644 --- a/tests/unit/utils/websearch/status.test.ts +++ b/tests/unit/utils/websearch/status.test.ts @@ -1,5 +1,16 @@ -import { describe, expect, it } from 'bun:test'; -import { buildWebSearchReadiness } from '../../../../src/utils/websearch/status'; +import { describe, expect, it, spyOn } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as geminiCli from '../../../../src/utils/websearch/gemini-cli'; +import * as grokCli from '../../../../src/utils/websearch/grok-cli'; +import * as opencodeCli from '../../../../src/utils/websearch/opencode-cli'; +import * as providerSecrets from '../../../../src/utils/websearch/provider-secrets'; +import * as unifiedConfigLoader from '../../../../src/config/unified-config-loader'; +import { + buildWebSearchReadiness, + getWebSearchCliProviders, +} from '../../../../src/utils/websearch/status'; import type { WebSearchCliInfo } from '../../../../src/utils/websearch/types'; function provider(overrides: Partial & Pick): WebSearchCliInfo { @@ -81,4 +92,103 @@ describe('websearch readiness', () => { expect(readiness.readiness).toBe('ready'); expect(readiness.message).toContain('Exa'); }); + + it('treats cooled-down providers as temporarily unavailable in readiness status', () => { + const tempHome = mkdtempSync(join(tmpdir(), 'websearch-status-cooldown-')); + const statePath = join(tempHome, '.ccs', 'cache', 'websearch-provider-state.json'); + const originalCcsHome = process.env.CCS_HOME; + + mkdirSync(join(tempHome, '.ccs', 'cache'), { recursive: true }); + writeFileSync( + statePath, + JSON.stringify( + { + cooldowns: { + exa: { + until: Date.now() + 10 * 60 * 1000, + reason: 'quota_exhausted', + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + process.env.CCS_HOME = tempHome; + + const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({ + enabled: true, + providers: { + exa: { enabled: true, max_results: 5 }, + tavily: { enabled: false, max_results: 5 }, + duckduckgo: { enabled: false, max_results: 5 }, + brave: { enabled: false, max_results: 5 }, + gemini: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, + } as any); + const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({ + exa: { + envVar: 'EXA_API_KEY', + configured: true, + available: true, + source: 'process_env', + }, + tavily: { + envVar: 'TAVILY_API_KEY', + configured: false, + available: false, + source: 'none', + }, + brave: { + envVar: 'BRAVE_API_KEY', + configured: false, + available: false, + source: 'none', + }, + }); + const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false); + const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + + try { + const providers = getWebSearchCliProviders(); + const exa = providers.find((provider) => provider.id === 'exa'); + + expect(exa?.enabled).toBe(true); + expect(exa?.available).toBe(false); + expect(exa?.detail).toContain('Cooling down'); + expect(exa?.detail).toContain('quota exhaustion'); + + const readiness = buildWebSearchReadiness(true, providers); + expect(readiness.readiness).toBe('needs_setup'); + expect(readiness.message).toContain('Cooling down'); + } finally { + getConfigSpy.mockRestore(); + apiKeySpy.mockRestore(); + geminiStatusSpy.mockRestore(); + geminiAuthSpy.mockRestore(); + grokStatusSpy.mockRestore(); + opencodeStatusSpy.mockRestore(); + + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + rmSync(tempHome, { recursive: true, force: true }); + } + }); });