From 64ecd8b3dcf49d7aff1533721d0cc35dd81ed44d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 8 Apr 2026 13:36:05 -0400 Subject: [PATCH] feat(image-analysis): make MCP-first provisioning self-healing --- lib/hooks/image-analyzer-transformer.cjs | 5 + src/ccs.ts | 98 +++++++------ src/cliproxy/executor/index.ts | 13 +- src/cliproxy/services/variant-settings.ts | 77 +++++----- src/copilot/copilot-executor.ts | 13 +- src/delegation/headless-executor.ts | 40 +++--- src/management/checks/image-analysis-check.ts | 27 +++- src/utils/hooks/image-analyzer-hook-utils.ts | 87 +++++++++++ .../image-analyzer-profile-hook-injector.ts | 54 +++++-- src/utils/hooks/index.ts | 1 + src/utils/image-analysis/index.ts | 7 + src/utils/image-analysis/repair.ts | 96 +++++++++++++ .../routes/image-analysis-routes.ts | 15 +- src/web-server/routes/settings-routes.ts | 2 + .../checks/image-analysis-check.test.ts | 126 ++++++++++++++++ ...ings-profile-image-analysis-launch.test.ts | 70 +++++++++ .../utils/claudecode-env-stripping.test.ts | 15 +- .../hooks/image-analyzer-hook-utils.test.ts | 135 ++++++++++++++++++ .../web-server/image-analysis-routes.test.ts | 73 ++++++++++ 19 files changed, 818 insertions(+), 136 deletions(-) create mode 100644 src/utils/hooks/image-analyzer-hook-utils.ts create mode 100644 src/utils/image-analysis/repair.ts create mode 100644 tests/unit/management/checks/image-analysis-check.test.ts create mode 100644 tests/unit/utils/hooks/image-analyzer-hook-utils.test.ts diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index 77f1ee11..6449915e 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -380,6 +380,11 @@ function outputSuccess(filePath, description, model, fileSize) { * Determine if hook should skip, with debug logging */ function shouldSkipHook() { + if (process.env.CCS_IMAGE_ANALYSIS_SKIP_HOOK === '1') { + debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP_HOOK=1'); + return true; + } + // Explicit skip signal if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') { debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP=1'); diff --git a/src/ccs.ts b/src/ccs.ts index f2ff905a..2d4caba9 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -37,7 +37,10 @@ import { appendThirdPartyImageAnalysisToolArgs, } from './utils/image-analysis'; import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader'; -import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; +import { + ensureProfileHooks as ensureImageAnalyzerHooks, + removeImageAnalysisProfileHook, +} from './utils/hooks/image-analyzer-profile-hook-injector'; import { applyImageAnalysisRuntimeOverrides, getImageAnalysisHookEnv, @@ -711,22 +714,30 @@ async function main(): Promise { if (profileInfo.type === 'cliproxy') { // CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants + const imageAnalysisMcpReady = + resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; if (resolvedTarget === 'claude') { ensureWebSearchMcpOrThrow(); - ensureImageAnalysisMcpOrThrow(); } - const imageAnalysisFallbackHookReady = - resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false; const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); - // Inject Image Analyzer hook into profile settings before launch - ensureImageAnalyzerHooks({ - profileName: profileInfo.name, - profileType: profileInfo.type, - cliproxyProvider: provider, - isComposite: profileInfo.isComposite, - settingsPath: profileInfo.settingsPath ? expandPath(profileInfo.settingsPath) : undefined, - sharedHookInstalled: imageAnalysisFallbackHookReady, - }); + const expandedCliproxySettingsPath = profileInfo.settingsPath + ? expandPath(profileInfo.settingsPath) + : undefined; + if (resolvedTarget === 'claude') { + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(profileInfo.name, expandedCliproxySettingsPath); + } else { + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + cliproxyProvider: provider, + isComposite: profileInfo.isComposite, + settingsPath: expandedCliproxySettingsPath, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } + } const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles const variantPort = profileInfo.port; // variant-specific port for isolation const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT; @@ -883,15 +894,19 @@ async function main(): Promise { } else if (profileInfo.type === 'copilot') { // COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy ensureWebSearchMcpOrThrow(); - ensureImageAnalysisMcpOrThrow(); - const imageAnalysisFallbackHookReady = - resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false; - // Inject Image Analyzer hook into profile settings before launch - ensureImageAnalyzerHooks({ - profileName: profileInfo.name, - profileType: profileInfo.type, - sharedHookInstalled: imageAnalysisFallbackHookReady, - }); + const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); + if (resolvedTarget === 'claude') { + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(profileInfo.name); + } else { + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } + } const { executeCopilotProfile } = await import('./copilot'); const copilotConfig = profileInfo.copilotConfig; @@ -981,8 +996,6 @@ async function main(): Promise { const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); - const imageAnalysisFallbackHookReady = - resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false; const expandedSettingsPath = resolvedSettingsPath ?? (profileInfo.settingsPath @@ -990,14 +1003,22 @@ async function main(): Promise { : getSettingsPath(profileInfo.name)); const settings = resolvedSettings ?? loadSettings(expandedSettingsPath); const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings); - ensureImageAnalyzerHooks({ - profileName: profileInfo.name, - profileType: profileInfo.type, - settingsPath: expandedSettingsPath, - settings, - cliproxyBridge, - sharedHookInstalled: imageAnalysisFallbackHookReady, - }); + let imageAnalysisFallbackHookReady: boolean | undefined; + if (resolvedTarget === 'claude') { + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(profileInfo.name, expandedSettingsPath); + } else { + imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + settingsPath: expandedSettingsPath, + settings, + cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } + } if (resolvedTarget !== 'claude') { const compatibility = evaluateTargetRuntimeCompatibility({ target: resolvedTarget, @@ -1116,14 +1137,11 @@ async function main(): Promise { apiKey: runtimeConnection.apiKey, allowSelfSigned: runtimeConnection.allowSelfSigned, }); - - if (!imageAnalysisMcpReady) { - imageAnalysisEnv = { - ...imageAnalysisEnv, - CCS_CURRENT_PROVIDER: '', - CCS_IMAGE_ANALYSIS_SKIP: '1', - }; - } + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_IMAGE_ANALYSIS_SKIP_HOOK: + resolvedTarget === 'claude' && imageAnalysisMcpReady ? '1' : '0', + }; const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; if ( diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 625216ec..5057e0e1 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -978,15 +978,12 @@ export async function execClaudeWithCLIProxy( }); const imageAnalysisProvisioningFailed = !imageAnalysisMcpReady && imageAnalysisResolution.env.CCS_IMAGE_ANALYSIS_ENABLED === '1'; - const imageAnalysisEnv = imageAnalysisProvisioningFailed - ? { - ...imageAnalysisResolution.env, - CCS_CURRENT_PROVIDER: '', - CCS_IMAGE_ANALYSIS_SKIP: '1', - } - : imageAnalysisResolution.env; + const imageAnalysisEnv = { + ...imageAnalysisResolution.env, + CCS_IMAGE_ANALYSIS_SKIP_HOOK: imageAnalysisMcpReady ? '1' : '0', + }; const imageAnalysisWarning = imageAnalysisProvisioningFailed - ? 'ImageAnalysis MCP provisioning failed. This session will use native Read.' + ? 'ImageAnalysis MCP provisioning failed. This session will use compatibility fallback when available.' : imageAnalysisResolution.warning; // 9. Setup tool sanitization proxy diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 2e3b89ec..f0aa20eb 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -16,7 +16,10 @@ import { CLIProxyProvider } from '../types'; import { CompositeTierConfig } from '../../config/unified-config-types'; import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; -import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { + ensureProfileHooks as ensureImageAnalyzerHooks, + removeImageAnalysisProfileHook, +} from '../../utils/hooks/image-analyzer-profile-hook-injector'; import { prepareImageAnalysisFallbackHook } from '../../utils/hooks'; import { getEffectiveApiKey } from '../auth-token-manager'; import { warn } from '../../utils/ui'; @@ -157,21 +160,23 @@ export function createSettingsFile( try { ensureWebSearchMcpOrThrow(); - ensureImageAnalysisMcpOrThrow(); + const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(`${provider}-${name}`, settingsPath); + } else { + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: `${provider}-${name}`, + profileType: 'cliproxy', + cliproxyProvider: provider, + settingsPath, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; } - const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); - - // Inject Image Analyzer hooks into variant settings - ensureImageAnalyzerHooks({ - profileName: `${provider}-${name}`, - profileType: 'cliproxy', - cliproxyProvider: provider, - settingsPath, - sharedHookInstalled: imageAnalysisFallbackHookReady, - }); return settingsPath; } @@ -199,21 +204,23 @@ export function createSettingsFileUnified( try { ensureWebSearchMcpOrThrow(); - ensureImageAnalysisMcpOrThrow(); + const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(`${provider}-${name}`, settingsPath); + } else { + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: `${provider}-${name}`, + profileType: 'cliproxy', + cliproxyProvider: provider, + settingsPath, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; } - const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); - - // Inject Image Analyzer hooks into variant settings - ensureImageAnalyzerHooks({ - profileName: `${provider}-${name}`, - profileType: 'cliproxy', - cliproxyProvider: provider, - settingsPath, - sharedHookInstalled: imageAnalysisFallbackHookReady, - }); return settingsPath; } @@ -305,20 +312,24 @@ export function createCompositeSettingsFile( if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) { try { ensureWebSearchMcpOrThrow(); - ensureImageAnalysisMcpOrThrow(); + const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(`composite-${name}`, settingsPath); + } else { + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: `composite-${name}`, + profileType: 'cliproxy', + cliproxyProvider: tiers[defaultTier].provider, + isComposite: true, + settingsPath, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; } - const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); - ensureImageAnalyzerHooks({ - profileName: `composite-${name}`, - profileType: 'cliproxy', - cliproxyProvider: tiers[defaultTier].provider, - isComposite: true, - settingsPath, - sharedHookInstalled: imageAnalysisFallbackHookReady, - }); } return settingsPath; diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 18b84154..f8e8a1e0 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -267,15 +267,12 @@ export async function executeCopilotProfile( const imageAnalysisProvisioningFailed = !imageAnalysisMcpReady && imageAnalysisResolution.env.CCS_IMAGE_ANALYSIS_ENABLED === '1'; const imageAnalysisWarning = imageAnalysisProvisioningFailed - ? 'ImageAnalysis MCP provisioning failed. This session will use native Read.' + ? 'ImageAnalysis MCP provisioning failed. This session will use compatibility fallback when available.' : imageAnalysisResolution.warning; - const imageAnalysisEnv = imageAnalysisProvisioningFailed - ? { - ...imageAnalysisResolution.env, - CCS_CURRENT_PROVIDER: '', - CCS_IMAGE_ANALYSIS_SKIP: '1', - } - : imageAnalysisResolution.env; + const imageAnalysisEnv = { + ...imageAnalysisResolution.env, + CCS_IMAGE_ANALYSIS_SKIP_HOOK: imageAnalysisMcpReady ? '1' : '0', + }; const env = stripClaudeCodeEnv({ ...process.env, ...globalEnv, diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index a9d05247..569302f2 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -31,7 +31,10 @@ import { resolveImageAnalysisRuntimeConnection, resolveImageAnalysisRuntimeStatus, } from '../utils/hooks'; -import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../utils/hooks/image-analyzer-profile-hook-injector'; +import { + ensureProfileHooks as ensureImageAnalyzerHooks, + removeImageAnalysisProfileHook, +} from '../utils/hooks/image-analyzer-profile-hook-injector'; import { resolveCliproxyBridgeMetadata } from '../api/services'; import { ensureCliproxyService } from '../cliproxy'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; @@ -127,15 +130,20 @@ export class HeadlessExecutor { const settings = loadSettings(settingsPath); const cliproxyBridge = resolveCliproxyBridgeMetadata(settings); - const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); - ensureImageAnalyzerHooks({ - profileName: profile, - profileType: 'settings', - settingsPath, - settings, - cliproxyBridge, - sharedHookInstalled: imageAnalysisFallbackHookReady, - }); + let imageAnalysisFallbackHookReady: boolean | undefined; + if (imageAnalysisMcpReady) { + removeImageAnalysisProfileHook(profile, settingsPath); + } else { + imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: profile, + profileType: 'settings', + settingsPath, + settings, + cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + } const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({ profileName: profile, profileType: 'settings', @@ -158,14 +166,10 @@ export class HeadlessExecutor { apiKey: runtimeConnection.apiKey, allowSelfSigned: runtimeConnection.allowSelfSigned, }); - - if (!imageAnalysisMcpReady) { - imageAnalysisEnv = { - ...imageAnalysisEnv, - CCS_CURRENT_PROVIDER: '', - CCS_IMAGE_ANALYSIS_SKIP: '1', - }; - } + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_IMAGE_ANALYSIS_SKIP_HOOK: imageAnalysisMcpReady ? '1' : '0', + }; const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; if ( diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts index 7ef0645c..52f5233c 100644 --- a/src/management/checks/image-analysis-check.ts +++ b/src/management/checks/image-analysis-check.ts @@ -7,10 +7,16 @@ import { getImageAnalysisConfig } from '../../config/unified-config-loader'; import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../../config/unified-config-types'; +import { + countManagedImageAnalysisHookFiles, + hasImageAnalysisMcpReady, + repairImageAnalysisRuntimeState, +} from '../../utils/image-analysis'; import { ok, warn, dim } from '../../utils/ui'; import { isCliproxyRunning } from '../../cliproxy/stats-fetcher'; import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config-generator'; import type { HealthCheck } from './types'; +import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; /** * Run image analysis configuration check @@ -65,6 +71,16 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise } console.log(` ${ok('Timeout:')} ${config.timeout}s`); + const staleHookCount = countManagedImageAnalysisHookFiles(); + if (staleHookCount > 0) { + results.warnings.push({ + name: 'Image Analysis', + message: `${staleHookCount} stale CCS-managed image hook setting file(s) were detected`, + fix: 'Run: ccs doctor --fix', + }); + console.log(` ${warn('Hooks:')} ${staleHookCount} stale setting file(s) can be repaired`); + } + // Check 4: CLIProxy availability (only if enabled) const cliproxyAvailable = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT); if (!cliproxyAvailable) { @@ -102,6 +118,8 @@ export async function fixImageAnalysisConfig(): Promise { const config = loadOrCreateUnifiedConfig(); let fixed = false; + const hadManagedToolReady = hasImageAnalysisMcpReady(); + const hadSharedHookReady = hasImageAnalyzerHook(); // Fix missing provider_models if ( @@ -130,5 +148,12 @@ export async function fixImageAnalysisConfig(): Promise { updateUnifiedConfig({ image_analysis: config.image_analysis }); } - return fixed; + const repairStats = repairImageAnalysisRuntimeState(); + return ( + fixed || + repairStats.cleanedSettingsFiles > 0 || + repairStats.syncedInstances > 0 || + (!hadManagedToolReady && repairStats.managedToolReady) || + (!hadSharedHookReady && repairStats.sharedHookReady) + ); } diff --git a/src/utils/hooks/image-analyzer-hook-utils.ts b/src/utils/hooks/image-analyzer-hook-utils.ts new file mode 100644 index 00000000..2a779d78 --- /dev/null +++ b/src/utils/hooks/image-analyzer-hook-utils.ts @@ -0,0 +1,87 @@ +/** + * Image Analyzer Hook Utilities + * + * Shared helper functions for CCS-managed image hook detection and cleanup. + * + * @module utils/hooks/image-analyzer-hook-utils + */ + +function normalizeCommand(command: string): string { + return command.replace(/\\/g, '/').replace(/\/+/g, '/'); +} + +function extractManagedHookPath(command: string): string | null { + const normalizedCommand = normalizeCommand(command); + const exactPathMatch = normalizedCommand.match( + /(?:^|["'\s])([^"'\s]*\/\.ccs\/hooks\/image-analyzer-transformer\.cjs)(?:["'\s]|$)/ + ); + return exactPathMatch?.[1] ?? null; +} + +/** + * Check if a hook entry is a CCS-managed image analyzer hook. + * Matches current and legacy path variants by suffix rather than full path. + */ +export function isCcsImageAnalyzerHook(hook: Record): boolean { + if (hook.matcher !== 'Read') return false; + + const hookArray = hook.hooks as Array> | undefined; + if (!hookArray?.[0]?.command) return false; + + const command = hookArray[0].command; + if (typeof command !== 'string') return false; + + return extractManagedHookPath(command) !== null; +} + +/** + * Remove duplicate CCS-managed image hooks from settings, keeping only the first one. + */ +export function deduplicateCcsImageAnalyzerHooks(settings: Record): boolean { + const hooks = settings.hooks as Record | undefined; + if (!hooks?.PreToolUse) return false; + + let foundFirst = false; + const originalLength = hooks.PreToolUse.length; + + hooks.PreToolUse = hooks.PreToolUse.filter((entry: unknown) => { + const hook = entry as Record; + if (!isCcsImageAnalyzerHook(hook)) return true; + + if (!foundFirst) { + foundFirst = true; + return true; + } + + return false; + }); + + return hooks.PreToolUse.length < originalLength; +} + +/** + * Remove all CCS-managed image hooks from settings while preserving unrelated hooks. + */ +export function removeCcsImageAnalyzerHooks(settings: Record): boolean { + const hooks = settings.hooks as Record | undefined; + if (!hooks?.PreToolUse) return false; + + const originalLength = hooks.PreToolUse.length; + hooks.PreToolUse = hooks.PreToolUse.filter((entry: unknown) => { + const hook = entry as Record; + return !isCcsImageAnalyzerHook(hook); + }); + + if (hooks.PreToolUse.length === originalLength) { + return false; + } + + if (hooks.PreToolUse.length === 0) { + delete hooks.PreToolUse; + } + if (Object.keys(hooks).length === 0) { + delete settings.hooks; + } + + return true; +} diff --git a/src/utils/hooks/image-analyzer-profile-hook-injector.ts b/src/utils/hooks/image-analyzer-profile-hook-injector.ts index 7517dcf9..73a0517e 100644 --- a/src/utils/hooks/image-analyzer-profile-hook-injector.ts +++ b/src/utils/hooks/image-analyzer-profile-hook-injector.ts @@ -16,6 +16,11 @@ import { getImageAnalyzerHookConfig, getImageAnalyzerHookPath, } from './image-analyzer-hook-configuration'; +import { + deduplicateCcsImageAnalyzerHooks, + isCcsImageAnalyzerHook, + removeCcsImageAnalyzerHooks, +} from './image-analyzer-hook-utils'; import { getImageAnalysisConfig } from '../../config/unified-config-loader'; import { getCcsDir } from '../config-manager'; import { @@ -41,17 +46,7 @@ function hasCcsHook(settings: Record): boolean { if (!hooks?.PreToolUse) return false; return hooks.PreToolUse.some((h: unknown) => { - const hook = h as Record; - if (hook.matcher !== 'Read') return false; - - const hookArray = hook.hooks as Array> | undefined; - const command = hookArray?.[0]?.command; - if (typeof command !== 'string') return false; - - const normalized = command - .replace(/\\/g, '/') // Windows backslashes - .replace(/\/+/g, '/'); // Collapse multiple slashes - return normalized.includes('.ccs/hooks/image-analyzer-transformer'); + return isCcsImageAnalyzerHook(h as Record); }); } @@ -88,6 +83,39 @@ export function hasImageAnalysisProfileHook( } } +export function removeImageAnalysisProfileHook( + profileName: string, + settingsPath?: string | null +): boolean { + if (!VALID_PROFILE_NAME.test(profileName)) { + return false; + } + + const resolvedSettingsPath = getImageAnalysisProfileSettingsPath(profileName, settingsPath); + if (!fs.existsSync(resolvedSettingsPath)) { + return false; + } + + try { + const content = fs.readFileSync(resolvedSettingsPath, 'utf8'); + const settings = JSON.parse(content) as Record; + const removed = removeCcsImageAnalyzerHooks(settings); + if (!removed) { + return false; + } + + fs.writeFileSync(resolvedSettingsPath, JSON.stringify(settings, null, 2), 'utf8'); + if (process.env.CCS_DEBUG) { + console.error( + info(`Removed image analyzer hook from ${path.basename(resolvedSettingsPath)}`) + ); + } + return true; + } catch { + return false; + } +} + /** * One-time migration marker management */ @@ -174,6 +202,10 @@ export function ensureProfileHooks(input: string | ImageAnalysisResolutionContex // Check if CCS hook already present if (hasCcsHook(settings)) { + const hadDuplicates = deduplicateCcsImageAnalyzerHooks(settings); + if (hadDuplicates) { + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8'); + } // Update timeout if needed return updateHookTimeoutIfNeeded(settings, settingsPath); } diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 6f61058e..6cbe1e7e 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -38,6 +38,7 @@ export { uninstallImageAnalyzerHook, } from './image-analyzer-hook-installer'; export { ensureProfileHooks as ensureImageAnalyzerProfileHooks } from './image-analyzer-profile-hook-injector'; +export { removeImageAnalysisProfileHook } from './image-analyzer-profile-hook-injector'; export function prepareImageAnalysisFallbackHook(): boolean { return hasInstalledImageAnalyzerHook() || installSharedImageAnalyzerHook(); diff --git a/src/utils/image-analysis/index.ts b/src/utils/image-analysis/index.ts index 852a612a..1efa150e 100644 --- a/src/utils/image-analysis/index.ts +++ b/src/utils/image-analysis/index.ts @@ -25,6 +25,13 @@ export { appendThirdPartyImageAnalysisToolArgs, getImageAnalysisSteeringPrompt, } from './claude-tool-args'; +export { + cleanupManagedImageAnalysisHooks, + countManagedImageAnalysisHookFiles, + repairImageAnalysisRuntimeState, + syncManagedImageAnalysisInstances, + type ImageAnalysisRepairStats, +} from './repair'; export const IMAGE_ANALYSIS_PROMPT_TEMPLATES = ['default', 'screenshot', 'document'] as const; export type ImageAnalysisPromptTemplate = (typeof IMAGE_ANALYSIS_PROMPT_TEMPLATES)[number]; diff --git a/src/utils/image-analysis/repair.ts b/src/utils/image-analysis/repair.ts new file mode 100644 index 00000000..59cadc87 --- /dev/null +++ b/src/utils/image-analysis/repair.ts @@ -0,0 +1,96 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import InstanceManager from '../../management/instance-manager'; +import { getCcsDir } from '../config-manager'; +import { prepareImageAnalysisFallbackHook } from '../hooks'; +import { removeCcsImageAnalyzerHooks } from '../hooks/image-analyzer-hook-utils'; +import { ensureImageAnalysisMcpOrThrow } from './mcp-installer'; + +export interface ImageAnalysisRepairStats { + cleanedSettingsFiles: number; + syncedInstances: number; + managedToolReady: boolean; + sharedHookReady: boolean; +} + +function visitManagedImageAnalysisSettings( + callback: (settings: Record, settingsPath: string) => void, + baseDir = getCcsDir() +): void { + if (!fs.existsSync(baseDir)) { + return; + } + + for (const entry of fs.readdirSync(baseDir)) { + if (!entry.endsWith('.settings.json')) { + continue; + } + + const settingsPath = path.join(baseDir, entry); + try { + const stat = fs.statSync(settingsPath); + if (!stat.isFile()) { + continue; + } + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as Record; + callback(settings, settingsPath); + } catch { + // Best-effort cleanup; preserve malformed files for manual recovery. + } + } +} + +export function countManagedImageAnalysisHookFiles(baseDir = getCcsDir()): number { + let count = 0; + visitManagedImageAnalysisSettings((settings) => { + if ( + removeCcsImageAnalyzerHooks(JSON.parse(JSON.stringify(settings)) as Record) + ) { + count += 1; + } + }, baseDir); + + return count; +} + +export function cleanupManagedImageAnalysisHooks(baseDir = getCcsDir()): number { + let cleaned = 0; + visitManagedImageAnalysisSettings((settings, settingsPath) => { + if (!removeCcsImageAnalyzerHooks(settings)) { + return; + } + + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); + cleaned += 1; + }, baseDir); + + return cleaned; +} + +export function syncManagedImageAnalysisInstances( + instanceManager: InstanceManager = new InstanceManager() +): number { + let synced = 0; + for (const instanceName of instanceManager.listInstances()) { + const instancePath = instanceManager.getInstancePath(instanceName); + if (instanceManager.syncMcpServers(instancePath)) { + synced += 1; + } + } + return synced; +} + +export function repairImageAnalysisRuntimeState(): ImageAnalysisRepairStats { + const managedToolReady = ensureImageAnalysisMcpOrThrow(); + const sharedHookReady = prepareImageAnalysisFallbackHook(); + const cleanedSettingsFiles = cleanupManagedImageAnalysisHooks(); + const syncedInstances = managedToolReady ? syncManagedImageAnalysisInstances() : 0; + + return { + cleanedSettingsFiles, + syncedInstances, + managedToolReady, + sharedHookReady, + }; +} diff --git a/src/web-server/routes/image-analysis-routes.ts b/src/web-server/routes/image-analysis-routes.ts index 970d0e02..70a76480 100644 --- a/src/web-server/routes/image-analysis-routes.ts +++ b/src/web-server/routes/image-analysis-routes.ts @@ -16,14 +16,12 @@ import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer' import { normalizeImageAnalysisBackendId, resolveImageAnalysisRuntimeStatus, - prepareImageAnalysisFallbackHook, } from '../../utils/hooks'; import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector'; -import { InstanceManager } from '../../management/instance-manager'; import { - ensureImageAnalysisMcpOrThrow, hasImageAnalysisMcpReady, + repairImageAnalysisRuntimeState, } from '../../utils/image-analysis'; const router = Router(); @@ -96,13 +94,6 @@ function resolveCurrentTargetMode( return 'active'; } -function syncManagedImageAnalysisToInstances(): void { - const instanceManager = new InstanceManager(); - for (const instanceName of instanceManager.listInstances()) { - instanceManager.syncMcpServers(instanceManager.getInstancePath(instanceName)); - } -} - function resolveBackendState( status: Awaited> ): BackendState { @@ -469,9 +460,7 @@ router.put('/', async (req: Request, res: Response): Promise => { const nextEnabled = body.enabled ?? currentConfig.enabled; if (nextEnabled) { - ensureImageAnalysisMcpOrThrow(); - prepareImageAnalysisFallbackHook(); - syncManagedImageAnalysisToInstances(); + repairImageAnalysisRuntimeState(); } res.json(await buildDashboardPayload()); diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 531b5cd9..28df3ec2 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -19,6 +19,7 @@ import { } from '../../cliproxy'; import { regenerateConfig } from '../../cliproxy/config-generator'; import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; +import { removeCcsImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-hook-utils'; import { resolveCliproxyBridgeMetadata } from '../../api/services'; import { getImageAnalysisConfig, @@ -508,6 +509,7 @@ router.put('/:profile', (req: Request, res: Response): void => { // Deduplicate CCS hooks to prevent accumulation (fixes #450) // This handles cases where duplicate hooks were added by previous versions deduplicateCcsHooks(normalizedSettings as Record); + removeCcsImageAnalyzerHooks(normalizedSettings as Record); const ccsDir = getCcsDir(); diff --git a/tests/unit/management/checks/image-analysis-check.test.ts b/tests/unit/management/checks/image-analysis-check.test.ts new file mode 100644 index 00000000..7bea255e --- /dev/null +++ b/tests/unit/management/checks/image-analysis-check.test.ts @@ -0,0 +1,126 @@ +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 { getImageAnalysisConfig } from '../../../../src/config/unified-config-loader'; +import { fixImageAnalysisConfig } from '../../../../src/management/checks/image-analysis-check'; + +describe('image-analysis-check', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analysis-check-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(path.join(ccsDir, 'instances', 'demo'), { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'image_analysis:', + ' enabled: true', + ' timeout: 5', + ' provider_models: {}', + '', + ].join('\n'), + 'utf8' + ); + fs.writeFileSync( + path.join(ccsDir, 'glm.settings.json'), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/gemini', + ANTHROPIC_AUTH_TOKEN: 'glm-token', + }, + hooks: { + PreToolUse: [ + { + matcher: 'Read', + hooks: [ + { + type: 'command', + command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"', + timeout: 65000, + }, + ], + }, + ], + }, + }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'instances', 'demo', '.claude.json'), + JSON.stringify( + { + mcpServers: { + custom: { + type: 'stdio', + command: 'node', + args: ['custom-server.cjs'], + env: {}, + }, + }, + }, + null, + 2 + ) + '\n' + ); + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('repairs invalid config, removes stale hooks, and syncs managed MCP entries into instances', async () => { + const fixed = await fixImageAnalysisConfig(); + const ccsDir = path.join(tempHome, '.ccs'); + + expect(fixed).toBe(true); + + const config = getImageAnalysisConfig(); + expect(config.timeout).toBe(60); + expect(Object.keys(config.provider_models).length).toBeGreaterThan(0); + + const repairedSettings = JSON.parse( + fs.readFileSync(path.join(ccsDir, 'glm.settings.json'), 'utf8') + ) as { + hooks?: { PreToolUse?: Array<{ matcher?: string }> }; + }; + expect(repairedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false).toBe( + false + ); + + const globalClaudeConfig = JSON.parse( + fs.readFileSync(path.join(tempHome, '.claude.json'), 'utf8') + ) as { + mcpServers?: Record; + }; + expect(globalClaudeConfig.mcpServers?.['ccs-image-analysis']).toBeDefined(); + + const instanceClaudeConfig = JSON.parse( + fs.readFileSync(path.join(ccsDir, 'instances', 'demo', '.claude.json'), 'utf8') + ) as { + mcpServers?: Record; + }; + expect(instanceClaudeConfig.mcpServers?.custom).toEqual({ + type: 'stdio', + command: 'node', + args: ['custom-server.cjs'], + env: {}, + }); + expect(instanceClaudeConfig.mcpServers?.['ccs-image-analysis']).toBeDefined(); + }); +}); diff --git a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts index 5154512e..1430fd72 100644 --- a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts +++ b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts @@ -92,6 +92,9 @@ describe('settings profile ImageAnalysis launch', () => { `#!/bin/sh printf "%s\n" "$@" > "${claudeArgsLogPath}" { + printf "currentProvider=%s\n" "$CCS_CURRENT_PROVIDER" + printf "skip=%s\n" "$CCS_IMAGE_ANALYSIS_SKIP" + printf "skipHook=%s\n" "$CCS_IMAGE_ANALYSIS_SKIP_HOOK" printf "runtimeApiKey=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY" printf "runtimeBaseUrl=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL" printf "runtimePath=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_PATH" @@ -167,6 +170,73 @@ exit 0 expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET); }); + it('suppresses stale CCS image hooks during a healthy MCP-first launch', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'stale-token', + ANTHROPIC_MODEL: 'glm-5', + }, + hooks: { + PreToolUse: [ + { + matcher: 'Read', + hooks: [ + { + type: 'command', + command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"', + timeout: 65000, + }, + ], + }, + ], + }, + }, + null, + 2 + ) + '\n' + ); + + const result = runCcs(['glm', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + const persistedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + hooks?: { PreToolUse?: Array<{ matcher?: string }> }; + }; + + expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET); + expect(launchedEnv).toContain('skipHook=1'); + expect( + persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false + ).toBe(false); + }); + + it('keeps the legacy hook available when MCP provisioning fails', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync(path.join(tmpHome, '.claude.json'), '{not-json', 'utf8'); + + const result = runCcs(['glm', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + const persistedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + hooks?: { PreToolUse?: Array<{ matcher?: string }> }; + }; + + expect(launchedEnv).not.toContain('skipHook=1'); + expect(persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read')).toBe( + true + ); + }); + it('pins bridge-backed image analysis to the current CLIProxy auth token', () => { if (process.platform === 'win32') return; diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index aa13eefd..b00908fe 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -384,7 +384,7 @@ describe('CLAUDECODE environment stripping', () => { }); }); - it('headless executor prepares image-analysis MCP and compatibility hook fallback', async () => { + it('headless executor prepares image-analysis MCP and suppresses the legacy hook on healthy launches', async () => { writeConfigWithAutoUpdatePreference(false); const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs'); const settingsPath = path.join(ccsDir, 'glm.settings.json'); @@ -398,11 +398,16 @@ describe('CLAUDECODE environment stripping', () => { expect(result.success).toBe(true); expect(spawnCalls.length).toBeGreaterThan(0); + const launch = spawnCalls[0]; + const env = launch.options?.env as NodeJS.ProcessEnv; const persistedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { hooks?: { PreToolUse?: Array<{ matcher?: string }> }; }; - expect(persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read')).toBe(true); + expect( + persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false + ).toBe(false); + expect(env.CCS_IMAGE_ANALYSIS_SKIP_HOOK).toBe('1'); const claudeUserConfig = JSON.parse( fs.readFileSync(path.join(process.env.CCS_HOME as string, '.claude.json'), 'utf8') @@ -415,8 +420,10 @@ describe('CLAUDECODE environment stripping', () => { args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')], env: {}, }); - expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(true); - expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(true); + expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe( + false + ); + expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(false); }); it('headless executor propagates a WebSearch trace launch id when tracing is enabled', async () => { diff --git a/tests/unit/utils/hooks/image-analyzer-hook-utils.test.ts b/tests/unit/utils/hooks/image-analyzer-hook-utils.test.ts new file mode 100644 index 00000000..428903fb --- /dev/null +++ b/tests/unit/utils/hooks/image-analyzer-hook-utils.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'bun:test'; +import { + deduplicateCcsImageAnalyzerHooks, + isCcsImageAnalyzerHook, + removeCcsImageAnalyzerHooks, +} from '../../../../src/utils/hooks/image-analyzer-hook-utils'; + +describe('image-analyzer-hook-utils', () => { + it('detects CCS-managed image hooks across current and legacy path variants', () => { + expect( + isCcsImageAnalyzerHook({ + matcher: 'Read', + hooks: [ + { + type: 'command', + command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer.cjs"', + }, + ], + }) + ).toBe(true); + + expect( + isCcsImageAnalyzerHook({ + matcher: 'Read', + hooks: [ + { + type: 'command', + command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"', + }, + ], + }) + ).toBe(true); + + expect( + isCcsImageAnalyzerHook({ + matcher: 'Read', + hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }], + }) + ).toBe(false); + + expect( + isCcsImageAnalyzerHook({ + matcher: 'Read', + hooks: [ + { + type: 'command', + command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer-custom.cjs"', + }, + ], + }) + ).toBe(false); + }); + + it('deduplicates only CCS-managed image hooks', () => { + const settings = { + hooks: { + PreToolUse: [ + { + matcher: 'Read', + hooks: [ + { + type: 'command', + command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer.cjs"', + }, + ], + }, + { + matcher: 'Read', + hooks: [ + { + type: 'command', + command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"', + }, + ], + }, + { + matcher: 'Read', + hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }], + }, + ], + }, + } satisfies Record; + + expect(deduplicateCcsImageAnalyzerHooks(settings)).toBe(true); + expect((settings.hooks.PreToolUse as unknown[])).toHaveLength(2); + }); + + it('removes only CCS-managed image hooks and preserves unrelated hooks', () => { + const settings = { + hooks: { + PreToolUse: [ + { + matcher: 'Read', + hooks: [ + { + type: 'command', + command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer.cjs"', + }, + ], + }, + { + matcher: 'Read', + hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }], + }, + { + matcher: 'WebSearch', + hooks: [ + { + type: 'command', + command: 'node "/Users/kaitran/.ccs/hooks/websearch-transformer.cjs"', + }, + ], + }, + ], + }, + } satisfies Record; + + expect(removeCcsImageAnalyzerHooks(settings)).toBe(true); + expect(settings.hooks.PreToolUse).toEqual([ + { + matcher: 'Read', + hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }], + }, + { + matcher: 'WebSearch', + hooks: [ + { + type: 'command', + command: 'node "/Users/kaitran/.ccs/hooks/websearch-transformer.cjs"', + }, + ], + }, + ]); + }); +}); diff --git a/tests/unit/web-server/image-analysis-routes.test.ts b/tests/unit/web-server/image-analysis-routes.test.ts index 2b329b15..27a6ef6f 100644 --- a/tests/unit/web-server/image-analysis-routes.test.ts +++ b/tests/unit/web-server/image-analysis-routes.test.ts @@ -192,6 +192,54 @@ describe('image-analysis routes', () => { }); it('updates the saved config through the dashboard route and provisions the local runtime', async () => { + const staleSettingsPath = path.join(tempHome, '.ccs', 'legacy.settings.json'); + fs.writeFileSync( + staleSettingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/gemini', + ANTHROPIC_AUTH_TOKEN: 'legacy-token', + }, + hooks: { + PreToolUse: [ + { + matcher: 'Read', + hooks: [ + { + type: 'command', + command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"', + timeout: 65000, + }, + ], + }, + ], + }, + }, + null, + 2 + ) + ); + const instanceDir = path.join(tempHome, '.ccs', 'instances', 'demo'); + fs.mkdirSync(instanceDir, { recursive: true }); + fs.writeFileSync( + path.join(instanceDir, '.claude.json'), + JSON.stringify( + { + mcpServers: { + custom: { + type: 'stdio', + command: 'node', + args: ['custom-server.cjs'], + env: {}, + }, + }, + }, + null, + 2 + ) + ); + const response = await fetch(`${baseUrl}/api/image-analysis`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -245,6 +293,31 @@ describe('image-analysis routes', () => { args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')], env: {}, }); + + const repairedSettings = JSON.parse(fs.readFileSync(staleSettingsPath, 'utf8')) as { + hooks?: { PreToolUse?: Array<{ matcher?: string }> }; + }; + expect(repairedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false).toBe( + false + ); + + const instanceConfig = JSON.parse( + fs.readFileSync(path.join(instanceDir, '.claude.json'), 'utf8') + ) as { + mcpServers?: Record; + }; + expect(instanceConfig.mcpServers?.custom).toEqual({ + type: 'stdio', + command: 'node', + args: ['custom-server.cjs'], + env: {}, + }); + expect(instanceConfig.mcpServers?.['ccs-image-analysis']).toEqual({ + type: 'stdio', + command: 'node', + args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')], + env: {}, + }); }); it('rejects profile mappings that point to a missing backend with a client error', async () => {