From 77b488f8b185e2427a3777f12bc3ed2d8f8cc522 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 2 Apr 2026 18:36:19 -0400 Subject: [PATCH] fix(image-analysis): harden runtime and provisioning --- lib/hooks/image-analysis-runtime.cjs | 21 +- lib/mcp/ccs-image-analysis-server.cjs | 139 +++++++++-- src/ccs.ts | 32 ++- src/cliproxy/executor/env-resolver.ts | 20 +- src/cliproxy/executor/index.ts | 37 ++- src/commands/install-command.ts | 2 +- src/copilot/copilot-executor.ts | 38 +++- src/delegation/headless-executor.ts | 30 ++- .../hooks/get-image-analysis-hook-env.ts | 57 +++++ .../hooks/image-analysis-runtime-status.ts | 24 +- src/utils/hooks/index.ts | 3 + src/utils/image-analysis/index.ts | 3 + src/utils/image-analysis/mcp-installer.ts | 215 ++++++++++++------ .../routes/image-analysis-routes.ts | 46 +++- .../env-resolver-codex-fallback.test.ts | 40 ++++ .../ccs-image-analysis-mcp-server.test.ts | 109 ++++++++- ...ings-profile-image-analysis-launch.test.ts | 16 +- .../hooks/get-image-analysis-hook-env.test.ts | 61 +++++ .../image-analysis-runtime-status.test.ts | 42 +++- .../image-analysis/mcp-installer.test.ts | 15 +- .../web-server/image-analysis-routes.test.ts | 30 ++- 21 files changed, 811 insertions(+), 169 deletions(-) create mode 100644 tests/unit/utils/hooks/get-image-analysis-hook-env.test.ts diff --git a/lib/hooks/image-analysis-runtime.cjs b/lib/hooks/image-analysis-runtime.cjs index 91188ce5..13f1e66b 100644 --- a/lib/hooks/image-analysis-runtime.cjs +++ b/lib/hooks/image-analysis-runtime.cjs @@ -9,6 +9,7 @@ const DEFAULT_MODEL = 'gemini-2.5-flash'; const DEFAULT_TIMEOUT_SEC = 60; const MAX_FILE_SIZE_MB = 10; const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; +const MAX_PROMPT_TEMPLATE_BYTES = 32 * 1024; const SCREENSHOT_NAME_REGEX = /(screen[-_ ]?shot|screen[-_ ]?capture|screencap|snapshot|snip|clip|capture)/i; const TEMPLATE_FILE_NAMES = { @@ -103,6 +104,10 @@ function selectPromptTemplate(filePath, requestedTemplate) { function readPromptFile(filePath) { try { + const stats = fs.statSync(filePath); + if (stats.size > MAX_PROMPT_TEMPLATE_BYTES) { + return null; + } const content = fs.readFileSync(filePath, 'utf8').trim(); return content.length > 0 ? content : null; } catch { @@ -189,10 +194,6 @@ function getRuntimeBaseUrl() { return normalizedBaseUrl; } - if (normalizedPath.length > 0 && normalizedPath !== '/') { - return normalizedBaseUrl; - } - parsed.pathname = runtimePath; return parsed.toString().replace(/\/+$/, ''); } catch { @@ -217,6 +218,11 @@ function getApiKey() { return process.env.CCS_CLIPROXY_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || 'ccs-internal-managed'; } +function shouldAllowSelfSigned() { + const value = `${process.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED || ''}`.trim().toLowerCase(); + return value === '1' || value === 'true' || value === 'yes'; +} + function getTimeoutMs(timeoutMs) { if (typeof timeoutMs === 'number' && timeoutMs > 0) { return timeoutMs; @@ -301,6 +307,7 @@ function analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs) { return new Promise((resolve, reject) => { const endpoint = new URL(getRuntimeEndpoint()); const transport = endpoint.protocol === 'https:' ? https : http; + const apiKey = getApiKey(); const requestBody = JSON.stringify({ model, max_tokens: 4096, @@ -325,9 +332,13 @@ function analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs) { headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(requestBody), - 'x-api-key': getApiKey(), + 'x-api-key': apiKey, + Authorization: `Bearer ${apiKey}`, }, timeout: timeoutMs, + ...(endpoint.protocol === 'https:' && shouldAllowSelfSigned() + ? { rejectUnauthorized: false } + : {}), }, (res) => { let data = ''; diff --git a/lib/mcp/ccs-image-analysis-server.cjs b/lib/mcp/ccs-image-analysis-server.cjs index 3616d540..a5d2a057 100644 --- a/lib/mcp/ccs-image-analysis-server.cjs +++ b/lib/mcp/ccs-image-analysis-server.cjs @@ -55,11 +55,11 @@ function getTools() { inputSchema: { type: 'object', properties: { - filePath: { - type: 'string', - description: - 'Absolute or workspace-relative path to a local image or PDF file to analyze.', - }, + filePath: { + type: 'string', + description: + 'Workspace-relative path, or an absolute path inside the current workspace, to a local image or PDF file to analyze.', + }, focus: { type: 'string', description: @@ -128,6 +128,20 @@ function normalizeTemplate(value) { return TEMPLATE_NAMES.includes(normalized) ? normalized : undefined; } +function normalizePathForComparison(value) { + return process.platform === 'win32' ? value.toLowerCase() : value; +} + +function isPathWithinWorkspace(workspaceRoot, candidatePath) { + const relativePath = path.relative(workspaceRoot, candidatePath); + return ( + relativePath === '' || + (!relativePath.startsWith('..') && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath)) + ); +} + function resolveFilePath(toolArgs) { if (!toolArgs || typeof toolArgs !== 'object') { return ''; @@ -143,6 +157,60 @@ function resolveFilePath(toolArgs) { return ''; } +function resolveWorkspaceFilePath(toolArgs) { + const requestedPath = resolveFilePath(toolArgs); + if (!requestedPath) { + return { filePath: '', error: null }; + } + + const workspaceRoot = (() => { + try { + return fs.realpathSync(process.cwd()); + } catch { + return path.resolve(process.cwd()); + } + })(); + const absolutePath = path.resolve(process.cwd(), requestedPath); + const comparisonPath = (() => { + if (fs.existsSync(absolutePath)) { + return fs.realpathSync(absolutePath); + } + + const suffixSegments = []; + let currentPath = absolutePath; + while (!fs.existsSync(currentPath)) { + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + break; + } + suffixSegments.unshift(path.basename(currentPath)); + currentPath = parentPath; + } + + const resolvedExistingPath = fs.existsSync(currentPath) + ? fs.realpathSync(currentPath) + : path.resolve(currentPath); + return path.join(resolvedExistingPath, ...suffixSegments); + })(); + + if ( + !isPathWithinWorkspace( + normalizePathForComparison(workspaceRoot), + normalizePathForComparison(comparisonPath) + ) + ) { + return { + filePath: '', + error: 'ImageAnalysis only allows files inside the current workspace.', + }; + } + + return { + filePath: absolutePath, + error: null, + }; +} + function resolveFocus(toolArgs) { return typeof toolArgs.focus === 'string' && toolArgs.focus.trim().length > 0 ? toolArgs.focus.trim() @@ -213,9 +281,13 @@ async function handleToolCall(message) { return; } - const filePath = resolveFilePath(toolArgs); + const { filePath, error: filePathError } = resolveWorkspaceFilePath(toolArgs); if (!filePath) { - writeError(id, -32602, `Tool "${TOOL_NAME}" requires a non-empty filePath.`); + writeError( + id, + -32602, + filePathError || `Tool "${TOOL_NAME}" requires a non-empty filePath.` + ); return; } @@ -296,22 +368,53 @@ let inputBuffer = Buffer.alloc(0); function processIncomingBuffer() { while (true) { - const newlineIndex = inputBuffer.indexOf(0x0a); - if (newlineIndex === -1) { - return; - } + let body; + const startsWithLegacyHeaders = inputBuffer + .slice(0, Math.min(inputBuffer.length, 32)) + .toString('utf8') + .toLowerCase() + .startsWith('content-length:'); - const lineBuffer = inputBuffer.subarray(0, newlineIndex); - inputBuffer = inputBuffer.subarray(newlineIndex + 1); - const line = lineBuffer.toString('utf8').replace(/\r$/, '').trim(); - if (!line) { - continue; + if (startsWithLegacyHeaders) { + const headerEnd = inputBuffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + 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 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; + } } try { - const message = JSON.parse(line); + const message = JSON.parse(body); Promise.resolve(handleMessage(message)).catch((error) => { - if (process.env.CCS_DEBUG) { + if (message && message.id !== undefined) { + writeError(message.id, -32603, (error && error.message) || 'Internal error'); + } else if (process.env.CCS_DEBUG) { console.error(`[ccs-image-analysis] ${error instanceof Error ? error.stack : error}`); } }); diff --git a/src/ccs.ts b/src/ccs.ts index acc261b5..f45172c3 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -42,6 +42,7 @@ import { applyImageAnalysisRuntimeOverrides, getImageAnalysisHookEnv, prepareImageAnalysisFallbackHook, + resolveImageAnalysisRuntimeConnection, resolveImageAnalysisRuntimeStatus, } from './utils/hooks'; import { fail, info, warn } from './utils/ui'; @@ -80,11 +81,7 @@ import { resolveDroidReasoningRuntime, } from './targets/droid-reasoning-runtime'; import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router'; -import { - resolveCliproxyBridgeMetadata, - resolveCliproxyBridgeProfile, -} from './api/services/cliproxy-profile-bridge'; -import { getEffectiveApiKey } from './cliproxy/auth-token-manager'; +import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge'; // Version and Update check utilities import { getVersion } from './utils/version'; @@ -899,9 +896,10 @@ async function main(): Promise { process.exit(exitCode); } else if (profileInfo.type === 'settings') { // Settings-based profiles (glm, glmt) are third-party providers + const imageAnalysisMcpReady = + resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; if (resolvedTarget === 'claude') { ensureWebSearchMcpOrThrow(); - ensureImageAnalysisMcpOrThrow(); } // Display WebSearch status (single line, equilibrium UX) @@ -1045,9 +1043,7 @@ async function main(): Promise { cliproxyBridge, sharedHookInstalled: imageAnalysisFallbackHookReady, }); - const currentBridgeAuthToken = cliproxyBridge - ? resolveCliproxyBridgeProfile(cliproxyBridge.provider).apiKey - : getEffectiveApiKey(); + const runtimeConnection = resolveImageAnalysisRuntimeConnection(); let imageAnalysisEnv = getImageAnalysisHookEnv({ profileName: profileInfo.name, profileType: profileInfo.type, @@ -1058,10 +1054,19 @@ async function main(): Promise { backendId: imageAnalysisStatus.backendId, model: imageAnalysisStatus.model, runtimePath: imageAnalysisStatus.runtimePath, - baseUrl: cliproxyBridge?.currentBaseUrl || '', - apiKey: currentBridgeAuthToken, + baseUrl: runtimeConnection.baseUrl, + apiKey: runtimeConnection.apiKey, + allowSelfSigned: runtimeConnection.allowSelfSigned, }); + if (!imageAnalysisMcpReady) { + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } + const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; if ( resolvedTarget === 'claude' && @@ -1159,10 +1164,13 @@ async function main(): Promise { return; } + const imageAnalysisArgs = imageAnalysisMcpReady + ? appendThirdPartyImageAnalysisToolArgs(remainingArgs) + : remainingArgs; const launchArgs = [ '--settings', expandedSettingsPath, - ...appendThirdPartyWebSearchToolArgs(appendThirdPartyImageAnalysisToolArgs(remainingArgs)), + ...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs), ]; const traceEnv = createWebSearchTraceContext({ launcher: 'ccs.settings-profile', diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index 688bfd05..a6233879 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -23,6 +23,7 @@ import { getWebSearchHookEnv } from '../../utils/websearch-manager'; import { applyImageAnalysisRuntimeOverrides, getImageAnalysisHookEnv, + resolveImageAnalysisRuntimeConnection, } from '../../utils/hooks/get-image-analysis-hook-env'; import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks/image-analysis-runtime-status'; import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector'; @@ -89,6 +90,7 @@ interface ResolveCliproxyImageAnalysisEnvOptions { profileSettingsPath?: string; isComposite?: boolean; proxyTarget: ProxyTarget; + tunnelPort?: number | null; proxyReachable: boolean; } @@ -154,14 +156,6 @@ function loadImageAnalysisSettings(settingsPath?: string): Settings | undefined } } -function buildDirectProxyBaseUrl(target: ProxyTarget): string { - const isDefaultPort = - (target.protocol === 'https' && target.port === 443) || - (target.protocol === 'http' && target.port === 80); - const portSuffix = isDefaultPort ? '' : `:${target.port}`; - return `${target.protocol}://${target.host}${portSuffix}`; -} - export async function resolveCliproxyImageAnalysisEnv( options: ResolveCliproxyImageAnalysisEnvOptions, deps: Partial = {} @@ -204,15 +198,21 @@ export async function resolveCliproxyImageAnalysisEnv( }; } + const runtimeConnection = resolveImageAnalysisRuntimeConnection({ + proxyTarget: options.proxyTarget, + tunnelPort: options.tunnelPort, + }); + return { env: applyImageAnalysisRuntimeOverrides(env, { backendId: status.backendId, model: status.model, runtimePath: status.runtimePath, - baseUrl: buildDirectProxyBaseUrl(options.proxyTarget), + baseUrl: runtimeConnection.baseUrl, apiKey: options.proxyTarget.isRemote - ? options.proxyTarget.authToken || '' + ? runtimeConnection.apiKey : resolvedDeps.getLocalRuntimeApiKey(), + allowSelfSigned: runtimeConnection.allowSelfSigned, }), warning: null, }; diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 16ee372b..b7a38327 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -209,7 +209,7 @@ export async function execClaudeWithCLIProxy( // Setup first-class CCS WebSearch runtime ensureWebSearchMcpOrThrow(); - ensureImageAnalysisMcpOrThrow(); + const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); displayWebSearchStatus(); const providerConfig = getProviderConfig(provider); @@ -843,15 +843,27 @@ export async function execClaudeWithCLIProxy( protocol: 'http' as const, isRemote: false as const, }; - const { env: imageAnalysisEnv, warning: imageAnalysisWarning } = - await resolveCliproxyImageAnalysisEnv({ - profileName: cfg.profileName || provider, - provider, - profileSettingsPath: cfg.customSettingsPath, - isComposite: cfg.isComposite, - proxyTarget: imageAnalysisProxyTarget, - proxyReachable: true, - }); + const imageAnalysisResolution = await resolveCliproxyImageAnalysisEnv({ + profileName: cfg.profileName || provider, + provider, + profileSettingsPath: cfg.customSettingsPath, + isComposite: cfg.isComposite, + proxyTarget: imageAnalysisProxyTarget, + tunnelPort, + proxyReachable: true, + }); + 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 imageAnalysisWarning = imageAnalysisProvisioningFailed + ? 'ImageAnalysis MCP provisioning failed. This session will use native Read.' + : imageAnalysisResolution.warning; // 9. Setup tool sanitization proxy let toolSanitizationProxy: ToolSanitizationProxy | null = null; @@ -1076,10 +1088,13 @@ export async function execClaudeWithCLIProxy( : getProviderSettingsPath(provider); let claude: ChildProcess; + const imageAnalysisArgs = imageAnalysisMcpReady + ? appendThirdPartyImageAnalysisToolArgs(claudeArgs) + : claudeArgs; const launchArgs = [ '--settings', settingsPath, - ...appendThirdPartyWebSearchToolArgs(appendThirdPartyImageAnalysisToolArgs(claudeArgs)), + ...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs), ]; const traceEnv = createWebSearchTraceContext({ launcher: 'cliproxy.executor', diff --git a/src/commands/install-command.ts b/src/commands/install-command.ts index a829f00a..4353c670 100644 --- a/src/commands/install-command.ts +++ b/src/commands/install-command.ts @@ -75,7 +75,7 @@ export async function handleUninstallCommand(): Promise { console.log(ok('Uninstall complete!')); console.log(''); console.log(info('~/.ccs/ directory preserved')); - console.log(info('To reinstall: ccs --install')); + console.log(info('To reinstall: npm install -g @kaitranntt/ccs --force')); } else { console.log(info('Nothing to uninstall')); } diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index c78763d6..18b84154 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -31,6 +31,7 @@ import { import { applyImageAnalysisRuntimeOverrides, getImageAnalysisHookEnv, + resolveImageAnalysisRuntimeConnection, resolveImageAnalysisRuntimeStatus, } from '../utils/hooks'; import { stripClaudeCodeEnv } from '../utils/shell-executor'; @@ -153,13 +154,17 @@ export async function resolveCopilotImageAnalysisEnv( } } + const runtimeConnection = resolveImageAnalysisRuntimeConnection(); return { env: applyImageAnalysisRuntimeOverrides(env, { backendId: status.backendId, model: status.model, runtimePath: status.runtimePath, - baseUrl: `http://127.0.0.1:${CLIPROXY_DEFAULT_PORT}`, - apiKey: resolvedDeps.getLocalRuntimeApiKey(), + baseUrl: runtimeConnection.baseUrl, + apiKey: runtimeConnection.proxyTarget.isRemote + ? runtimeConnection.apiKey + : resolvedDeps.getLocalRuntimeApiKey(), + allowSelfSigned: runtimeConnection.allowSelfSigned, }), warning: null, }; @@ -252,11 +257,25 @@ export async function executeCopilotProfile( // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles const globalEnvConfig = getGlobalEnvConfig(); const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; + const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); + syncWebSearchMcpToConfigDir(claudeConfigDir); + syncImageAnalysisMcpToConfigDir(claudeConfigDir); // Merge with current environment (global env first, copilot overrides, then hook env vars) const webSearchEnv = getWebSearchHookEnv(); - const { env: imageAnalysisEnv, warning: imageAnalysisWarning } = - await resolveCopilotImageAnalysisEnv(); + const imageAnalysisResolution = await resolveCopilotImageAnalysisEnv(); + const imageAnalysisProvisioningFailed = + !imageAnalysisMcpReady && imageAnalysisResolution.env.CCS_IMAGE_ANALYSIS_ENABLED === '1'; + const imageAnalysisWarning = imageAnalysisProvisioningFailed + ? 'ImageAnalysis MCP provisioning failed. This session will use native Read.' + : imageAnalysisResolution.warning; + const imageAnalysisEnv = imageAnalysisProvisioningFailed + ? { + ...imageAnalysisResolution.env, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + } + : imageAnalysisResolution.env; const env = stripClaudeCodeEnv({ ...process.env, ...globalEnv, @@ -272,15 +291,12 @@ export async function executeCopilotProfile( } console.log(''); - ensureImageAnalysisMcpOrThrow(); - syncWebSearchMcpToConfigDir(claudeConfigDir); - syncImageAnalysisMcpToConfigDir(claudeConfigDir); - // Spawn Claude CLI return new Promise((resolve) => { - const launchArgs = appendThirdPartyWebSearchToolArgs( - appendThirdPartyImageAnalysisToolArgs(claudeArgs) - ); + const imageAnalysisArgs = imageAnalysisMcpReady + ? appendThirdPartyImageAnalysisToolArgs(claudeArgs) + : claudeArgs; + const launchArgs = appendThirdPartyWebSearchToolArgs(imageAnalysisArgs); const traceEnv = createWebSearchTraceContext({ launcher: 'copilot.executor', args: launchArgs, diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 903d5a6e..a9d05247 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -28,12 +28,12 @@ import { applyImageAnalysisRuntimeOverrides, getImageAnalysisHookEnv, prepareImageAnalysisFallbackHook, + resolveImageAnalysisRuntimeConnection, resolveImageAnalysisRuntimeStatus, } from '../utils/hooks'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../utils/hooks/image-analyzer-profile-hook-injector'; -import { resolveCliproxyBridgeMetadata, resolveCliproxyBridgeProfile } from '../api/services'; +import { resolveCliproxyBridgeMetadata } from '../api/services'; import { ensureCliproxyService } from '../cliproxy'; -import { getEffectiveApiKey } from '../cliproxy/auth-token-manager'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { appendThirdPartyWebSearchToolArgs, @@ -121,7 +121,7 @@ export class HeadlessExecutor { } ensureWebSearchMcpOrThrow(); - ensureImageAnalysisMcpOrThrow(); + const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); @@ -143,9 +143,7 @@ export class HeadlessExecutor { cliproxyBridge, sharedHookInstalled: imageAnalysisFallbackHookReady, }); - const currentBridgeAuthToken = cliproxyBridge - ? resolveCliproxyBridgeProfile(cliproxyBridge.provider).apiKey - : getEffectiveApiKey(); + const runtimeConnection = resolveImageAnalysisRuntimeConnection(); let imageAnalysisEnv = getImageAnalysisHookEnv({ profileName: profile, profileType: 'settings', @@ -156,10 +154,19 @@ export class HeadlessExecutor { backendId: imageAnalysisStatus.backendId, model: imageAnalysisStatus.model, runtimePath: imageAnalysisStatus.runtimePath, - baseUrl: cliproxyBridge?.currentBaseUrl || '', - apiKey: currentBridgeAuthToken, + baseUrl: runtimeConnection.baseUrl, + apiKey: runtimeConnection.apiKey, + allowSelfSigned: runtimeConnection.allowSelfSigned, }); + if (!imageAnalysisMcpReady) { + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } + const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; if ( imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' && @@ -279,9 +286,10 @@ export class HeadlessExecutor { } } - const launchArgs = appendThirdPartyWebSearchToolArgs( - appendThirdPartyImageAnalysisToolArgs(args) - ); + const imageAnalysisArgs = imageAnalysisMcpReady + ? appendThirdPartyImageAnalysisToolArgs(args) + : args; + const launchArgs = appendThirdPartyWebSearchToolArgs(imageAnalysisArgs); const traceEnv = createWebSearchTraceContext({ launcher: 'delegation.headless-executor', args: launchArgs, diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts index b68546eb..1bae3cf8 100644 --- a/src/utils/hooks/get-image-analysis-hook-env.ts +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -9,7 +9,13 @@ import { getImageAnalysisConfig } from '../../config/unified-config-loader'; import { resolveCliproxyBridgeProfile } from '../../api/services/cliproxy-profile-bridge'; +import { getEffectiveApiKey } from '../../cliproxy/auth-token-manager'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import { + buildProxyUrl, + getProxyTarget, + type ProxyTarget, +} from '../../cliproxy/proxy-target-resolver'; import { getPromptsDir } from '../image-analysis/hook-installer'; import { resolveImageAnalysisStatus, @@ -31,6 +37,53 @@ export interface ImageAnalysisRuntimeOverrides { runtimePath?: string | null; baseUrl?: string | null; apiKey?: string | null; + allowSelfSigned?: boolean | null; +} + +export interface ImageAnalysisRuntimeConnection { + baseUrl: string; + apiKey: string; + allowSelfSigned: boolean; + proxyTarget: ProxyTarget; +} + +export interface ResolveImageAnalysisRuntimeConnectionOptions { + proxyTarget?: ProxyTarget; + tunnelPort?: number | null; +} + +function stripTrailingSlash(value: string): string { + return value.replace(/\/+$/, ''); +} + +export function resolveImageAnalysisRuntimeConnection( + options: ResolveImageAnalysisRuntimeConnectionOptions = {} +): ImageAnalysisRuntimeConnection { + const proxyTarget = options.proxyTarget ?? getProxyTarget(); + const apiKey = proxyTarget.authToken?.trim() || getEffectiveApiKey(); + + if (proxyTarget.isRemote && options.tunnelPort && options.tunnelPort > 0) { + return { + baseUrl: `http://127.0.0.1:${options.tunnelPort}`, + apiKey, + allowSelfSigned: false, + proxyTarget: { + host: '127.0.0.1', + port: options.tunnelPort, + protocol: 'http', + isRemote: false, + }, + }; + } + + return { + baseUrl: stripTrailingSlash(buildProxyUrl(proxyTarget, '')), + apiKey, + allowSelfSigned: Boolean( + proxyTarget.isRemote && proxyTarget.protocol === 'https' && proxyTarget.allowSelfSigned + ), + proxyTarget, + }; } /** @@ -116,5 +169,9 @@ export function applyImageAnalysisRuntimeOverrides( } } + if (overrides.allowSelfSigned !== undefined) { + nextEnv.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED = overrides.allowSelfSigned ? '1' : '0'; + } + return nextEnv; } diff --git a/src/utils/hooks/image-analysis-runtime-status.ts b/src/utils/hooks/image-analysis-runtime-status.ts index 7897b8c5..9ec8a388 100644 --- a/src/utils/hooks/image-analysis-runtime-status.ts +++ b/src/utils/hooks/image-analysis-runtime-status.ts @@ -1,4 +1,9 @@ import { getAuthStatus, initializeAccounts, type AuthStatus } from '../../cliproxy/auth-handler'; +import { + checkRemoteProxy, + type RemoteProxyClientConfig, + type RemoteProxyStatus, +} from '../../cliproxy/remote-proxy-client'; import { fetchRemoteAuthStatus, type RemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; import { getProxyTarget, type ProxyTarget } from '../../cliproxy/proxy-target-resolver'; import { getProviderDisplayName, isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; @@ -15,6 +20,7 @@ import { } from './image-analysis-backend-resolver'; interface ImageAnalysisRuntimeStatusDeps { + checkRemoteProxy: (config: RemoteProxyClientConfig) => Promise; fetchRemoteAuthStatus: (target: ProxyTarget) => Promise; getAuthStatus: (provider: CLIProxyProvider) => AuthStatus; getProxyTarget: () => ProxyTarget; @@ -23,6 +29,7 @@ interface ImageAnalysisRuntimeStatusDeps { } const defaultDeps: ImageAnalysisRuntimeStatusDeps = { + checkRemoteProxy, fetchRemoteAuthStatus, getAuthStatus, getProxyTarget, @@ -91,16 +98,25 @@ async function resolveProxyReadiness( } const target = deps.getProxyTarget(); - const reachable = await deps.isCliproxyRunning(); if (target.isRemote) { + const remoteStatus = await deps.checkRemoteProxy({ + host: target.host, + port: target.port, + protocol: target.protocol, + authToken: target.authToken, + allowSelfSigned: target.allowSelfSigned, + }); + return { - proxyReadiness: reachable ? 'remote' : 'unavailable', - proxyReason: reachable + proxyReadiness: remoteStatus.reachable ? 'remote' : 'unavailable', + proxyReason: remoteStatus.reachable ? `Remote CLIProxy target ${target.host}:${target.port} is reachable.` - : `Remote CLIProxy target ${target.host}:${target.port} is unreachable.`, + : remoteStatus.error || + `Remote CLIProxy target ${target.host}:${target.port} is unreachable.`, }; } + const reachable = await deps.isCliproxyRunning(); return { proxyReadiness: reachable ? 'ready' : 'stopped', proxyReason: reachable diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 913ff285..6f61058e 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -14,7 +14,10 @@ import { export { getImageAnalysisHookEnv, applyImageAnalysisRuntimeOverrides, + resolveImageAnalysisRuntimeConnection, type ImageAnalysisRuntimeOverrides, + type ImageAnalysisRuntimeConnection, + type ResolveImageAnalysisRuntimeConnectionOptions, } from './get-image-analysis-hook-env'; export { canonicalizeImageAnalysisConfig, diff --git a/src/utils/image-analysis/index.ts b/src/utils/image-analysis/index.ts index 89d5968d..852a612a 100644 --- a/src/utils/image-analysis/index.ts +++ b/src/utils/image-analysis/index.ts @@ -9,6 +9,9 @@ export { getImageAnalysisMcpServerName, getImageAnalysisMcpServerPath, getImageAnalysisMcpRuntimePath, + hasImageAnalysisMcpServerInstalled, + hasImageAnalysisMcpConfig, + hasImageAnalysisMcpReady, installImageAnalysisMcpServer, ensureImageAnalysisMcpConfig, ensureImageAnalysisMcp, diff --git a/src/utils/image-analysis/mcp-installer.ts b/src/utils/image-analysis/mcp-installer.ts index 11d53607..c8b3e571 100644 --- a/src/utils/image-analysis/mcp-installer.ts +++ b/src/utils/image-analysis/mcp-installer.ts @@ -4,6 +4,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import * as lockfile from 'proper-lockfile'; import { getImageAnalysisConfig } from '../../config/unified-config-loader'; import { getCcsDir } from '../config-manager'; import { getClaudeUserConfigPath } from '../claude-config-path'; @@ -119,53 +120,115 @@ function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): bo } } -function removeManagedServerConfig(configPath: string): boolean { - if (!fs.existsSync(configPath)) { - return false; +function withClaudeUserConfigLock(configPath: string, callback: () => T): T { + const lockTarget = fs.existsSync(configPath) ? configPath : path.dirname(configPath); + let release: (() => void) | undefined; + + if (!fs.existsSync(path.dirname(configPath))) { + fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 }); } + try { + release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void; + return callback(); + } finally { + if (release) { + try { + release(); + } catch { + // Best-effort release. + } + } + } +} + +export function hasImageAnalysisMcpServerInstalled(): boolean { + return ( + fs.existsSync(getImageAnalysisMcpServerPath()) && + fs.existsSync(getImageAnalysisMcpRuntimePath()) + ); +} + +export function hasImageAnalysisMcpConfig(configPath = getClaudeUserConfigPath()): boolean { const config = readClaudeUserConfig(configPath); if (config === null) { - if (process.env.CCS_DEBUG) { - console.error(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`)); - } return false; } const existingServers = config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) - ? { ...(config.mcpServers as Record) } + ? (config.mcpServers as Record) : {}; + const currentConfig = existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME]; - if (!(IMAGE_ANALYSIS_MCP_SERVER_NAME in existingServers)) { + return ( + typeof currentConfig === 'object' && + currentConfig !== null && + JSON.stringify(currentConfig) === + JSON.stringify({ + type: 'stdio', + command: 'node', + args: [getImageAnalysisMcpServerPath()], + env: {}, + }) + ); +} + +export function hasImageAnalysisMcpReady(configPath = getClaudeUserConfigPath()): boolean { + return hasImageAnalysisMcpServerInstalled() && hasImageAnalysisMcpConfig(configPath); +} + +function removeManagedServerConfig(configPath: string): boolean { + if (!fs.existsSync(configPath)) { return false; } - delete existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME]; - - const nextConfig: ClaudeUserConfig = { ...config }; - if (Object.keys(existingServers).length === 0) { - delete nextConfig.mcpServers; - } else { - nextConfig.mcpServers = existingServers; - } - - try { - writeClaudeUserConfig(configPath, nextConfig); - if (process.env.CCS_DEBUG) { - console.error(info(`Removed Image Analysis MCP config from ${configPath}`)); + return withClaudeUserConfigLock(configPath, () => { + const config = readClaudeUserConfig(configPath); + if (config === null) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`)); + } + return false; } - return true; - } catch (error) { - if (process.env.CCS_DEBUG) { - console.error( - warn( - `Failed to remove Image Analysis MCP config from ${configPath}: ${(error as Error).message}` - ) - ); + + const existingServers = + config.mcpServers && + typeof config.mcpServers === 'object' && + !Array.isArray(config.mcpServers) + ? { ...(config.mcpServers as Record) } + : {}; + + if (!(IMAGE_ANALYSIS_MCP_SERVER_NAME in existingServers)) { + return false; } - return false; - } + + delete existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME]; + + const nextConfig: ClaudeUserConfig = { ...config }; + if (Object.keys(existingServers).length === 0) { + delete nextConfig.mcpServers; + } else { + nextConfig.mcpServers = existingServers; + } + + try { + writeClaudeUserConfig(configPath, nextConfig); + if (process.env.CCS_DEBUG) { + console.error(info(`Removed Image Analysis MCP config from ${configPath}`)); + } + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error( + warn( + `Failed to remove Image Analysis MCP config from ${configPath}: ${(error as Error).message}` + ) + ); + } + return false; + } + }); } export function installImageAnalysisMcpServer(): boolean { @@ -258,23 +321,9 @@ export function ensureImageAnalysisMcpConfig(): boolean { const claudeUserConfigPath = getClaudeUserConfigPath(); const claudeUserConfigDir = path.dirname(claudeUserConfigPath); - const config = readClaudeUserConfig(claudeUserConfigPath); - - if (config === null) { - if (process.env.CCS_DEBUG) { - console.error(warn('Malformed ~/.claude.json prevents Image Analysis MCP provisioning')); - } - return false; - } - if (!fs.existsSync(claudeUserConfigDir)) { fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 }); } - - const existingServers = - config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) - ? (config.mcpServers as Record) - : {}; const desiredServerConfig: ManagedImageAnalysisMcpConfig = { type: 'stdio', command: 'node', @@ -282,35 +331,52 @@ export function ensureImageAnalysisMcpConfig(): boolean { env: {}, }; - const currentConfig = existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME]; - if ( - typeof currentConfig === 'object' && - currentConfig !== null && - JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig) - ) { - return true; - } + return withClaudeUserConfigLock(claudeUserConfigPath, () => { + const config = readClaudeUserConfig(claudeUserConfigPath); - const nextConfig: ClaudeUserConfig = { - ...config, - mcpServers: { - ...existingServers, - [IMAGE_ANALYSIS_MCP_SERVER_NAME]: desiredServerConfig, - }, - }; + if (config === null) { + if (process.env.CCS_DEBUG) { + console.error(warn('Malformed ~/.claude.json prevents Image Analysis MCP provisioning')); + } + return false; + } - try { - writeClaudeUserConfig(claudeUserConfigPath, nextConfig); - if (process.env.CCS_DEBUG) { - console.error(info(`Ensured Image Analysis MCP config in ${claudeUserConfigPath}`)); + const existingServers = + config.mcpServers && + typeof config.mcpServers === 'object' && + !Array.isArray(config.mcpServers) + ? (config.mcpServers as Record) + : {}; + const currentConfig = existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME]; + if ( + typeof currentConfig === 'object' && + currentConfig !== null && + JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig) + ) { + return true; } - return true; - } catch (error) { - if (process.env.CCS_DEBUG) { - console.error(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`)); + + const nextConfig: ClaudeUserConfig = { + ...config, + mcpServers: { + ...existingServers, + [IMAGE_ANALYSIS_MCP_SERVER_NAME]: desiredServerConfig, + }, + }; + + try { + writeClaudeUserConfig(claudeUserConfigPath, nextConfig); + if (process.env.CCS_DEBUG) { + console.error(info(`Ensured Image Analysis MCP config in ${claudeUserConfigPath}`)); + } + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`)); + } + return false; } - return false; - } + }); } export function ensureImageAnalysisMcp(): boolean { @@ -377,17 +443,20 @@ export function uninstallImageAnalysisMcp(): boolean { return removedConfig || removedServer; } -export function ensureImageAnalysisMcpOrThrow(): void { +export function ensureImageAnalysisMcpOrThrow(): boolean { const imageConfig = getImageAnalysisConfig(); if (!imageConfig.enabled) { - return; + return false; } - if (!ensureImageAnalysisMcp()) { + const ready = ensureImageAnalysisMcp(); + if (!ready) { console.error( warn( 'Image Analysis is enabled, but CCS could not prepare the local ImageAnalysis tool. This session will fall back to native Read.' ) ); } + + return ready; } diff --git a/src/web-server/routes/image-analysis-routes.ts b/src/web-server/routes/image-analysis-routes.ts index 06148c82..970d0e02 100644 --- a/src/web-server/routes/image-analysis-routes.ts +++ b/src/web-server/routes/image-analysis-routes.ts @@ -16,9 +16,15 @@ 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, +} from '../../utils/image-analysis'; const router = Router(); const IMAGE_ANALYSIS_LOCAL_ACCESS_ERROR = @@ -78,16 +84,25 @@ function resolveTarget(target: unknown): DashboardTarget { function resolveCurrentTargetMode( target: DashboardTarget, - status: Awaited> + status: Awaited>, + managedToolReady: boolean ): CurrentTargetMode { if (!status.enabled) return 'disabled'; if (target !== 'claude') return 'bypassed'; if (status.nativeReadPreference) return 'native'; + if (!managedToolReady) return 'setup'; if (!status.backendId) return 'unresolved'; if (status.effectiveRuntimeMode === 'native-read') return 'fallback'; 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 { @@ -109,6 +124,7 @@ async function buildDashboardPayload() { const config = getImageAnalysisConfig(); const { profiles, variants } = listApiProfiles(); const sharedHookInstalled = hasImageAnalyzerHook(); + const managedToolReady = hasImageAnalysisMcpReady(); const profileRows = await Promise.all( profiles.map(async (profile) => { @@ -146,7 +162,11 @@ async function buildDashboardPayload() { status: status.status, effectiveRuntimeMode: status.effectiveRuntimeMode, effectiveRuntimeReason: status.effectiveRuntimeReason, - currentTargetMode: resolveCurrentTargetMode(resolveTarget(profile.target), status), + currentTargetMode: resolveCurrentTargetMode( + resolveTarget(profile.target), + status, + managedToolReady + ), profileModel: status.profileModel, nativeReadPreference: status.nativeReadPreference, nativeImageCapable: status.nativeImageCapable, @@ -190,7 +210,11 @@ async function buildDashboardPayload() { status: status.status, effectiveRuntimeMode: status.effectiveRuntimeMode, effectiveRuntimeReason: status.effectiveRuntimeReason, - currentTargetMode: resolveCurrentTargetMode(resolveTarget(variant.target), status), + currentTargetMode: resolveCurrentTargetMode( + resolveTarget(variant.target), + status, + managedToolReady + ), profileModel: status.profileModel, nativeReadPreference: status.nativeReadPreference, nativeImageCapable: status.nativeImageCapable, @@ -260,6 +284,11 @@ async function buildDashboardPayload() { summaryState = 'disabled'; title = 'Disabled'; detail = 'Image is turned off globally. Images and PDFs fall back to native file access.'; + } else if (!managedToolReady) { + summaryState = 'needs_setup'; + title = 'Needs local runtime'; + detail = + 'CCS could not provision the local ImageAnalysis MCP runtime yet. Profiles will fall back to native Read until provisioning succeeds.'; } else if (backendRows.length === 0) { summaryState = 'needs_setup'; title = 'Needs provider models'; @@ -288,6 +317,10 @@ async function buildDashboardPayload() { bypassedProfileCount, nativeProfileCount, }, + runtime: { + managedToolReady, + sharedHookInstalled, + }, backends: backendRows, profiles: allProfileRows, catalog: { @@ -434,6 +467,13 @@ router.put('/', async (req: Request, res: Response): Promise => { }; }); + const nextEnabled = body.enabled ?? currentConfig.enabled; + if (nextEnabled) { + ensureImageAnalysisMcpOrThrow(); + prepareImageAnalysisFallbackHook(); + syncManagedImageAnalysisToInstances(); + } + res.json(await buildDashboardPayload()); } catch (error) { res.status(500).json({ error: (error as Error).message }); diff --git a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts index 24cb3d18..36036cfd 100644 --- a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts +++ b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts @@ -261,6 +261,7 @@ describe('resolveCliproxyImageAnalysisEnv', () => { expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('https://remote.example.com:9443'); expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/agy'); expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('remote-token'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED).toBe('1'); expect(result.warning).toBeNull(); }); @@ -296,6 +297,45 @@ describe('resolveCliproxyImageAnalysisEnv', () => { expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('http://127.0.0.1:8317'); expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/agy'); expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('local-runtime-token'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED).toBe('0'); + expect(result.warning).toBeNull(); + }); + + it('routes remote HTTPS image analysis through the local tunnel when available', async () => { + const result = await resolveCliproxyImageAnalysisEnv( + { + profileName: 'orq', + provider: 'agy', + profileSettingsPath: '/tmp/orq.settings.json', + proxyTarget: { + host: 'remote.example.com', + port: 9443, + protocol: 'https', + authToken: 'remote-token', + managementKey: 'remote-management-key', + allowSelfSigned: true, + isRemote: true, + }, + tunnelPort: 9911, + proxyReachable: true, + }, + { + getImageAnalysisHookEnv: () => ({ + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_IMAGE_ANALYSIS_TIMEOUT: '60', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro', + CCS_CURRENT_PROVIDER: 'agy', + CCS_IMAGE_ANALYSIS_SKIP: '0', + }), + hasImageAnalysisProfileHook: () => true, + hasImageAnalyzerHook: () => true, + resolveImageAnalysisRuntimeStatus: async () => createImageAnalysisStatus(), + } + ); + + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('http://127.0.0.1:9911'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('remote-token'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED).toBe('0'); expect(result.warning).toBeNull(); }); }); diff --git a/tests/unit/hooks/ccs-image-analysis-mcp-server.test.ts b/tests/unit/hooks/ccs-image-analysis-mcp-server.test.ts index 6e77eb3b..b4919ede 100644 --- a/tests/unit/hooks/ccs-image-analysis-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-image-analysis-mcp-server.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'bun:test'; import { spawn } from 'child_process'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import * as http from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -11,6 +11,11 @@ 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}`; +} + function collectResponses( child: ReturnType, expectedCount: number @@ -133,6 +138,7 @@ describe('ccs-image-analysis MCP server', () => { }); const child = spawn('node', [serverPath], { + cwd: tempDir, env: { ...process.env, CCS_IMAGE_ANALYSIS_ENABLED: '1', @@ -189,7 +195,7 @@ describe('ccs-image-analysis MCP server', () => { filePath: { type: 'string', description: - 'Absolute or workspace-relative path to a local image or PDF file to analyze.', + 'Workspace-relative path, or an absolute path inside the current workspace, to a local image or PDF file to analyze.', }, focus: { type: 'string', @@ -228,7 +234,10 @@ describe('ccs-image-analysis MCP server', () => { }); it('returns a structured tool error when the file does not exist', async () => { + tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-missing-')); + const missingPath = join(tempDir, 'missing-image.png'); const child = spawn('node', [serverPath], { + cwd: tempDir, env: { ...process.env, CCS_IMAGE_ANALYSIS_ENABLED: '1', @@ -260,7 +269,7 @@ describe('ccs-image-analysis MCP server', () => { method: 'tools/call', params: { name: 'ImageAnalysis', - arguments: { filePath: join(tmpdir(), 'missing-image.png') }, + arguments: { filePath: missingPath }, }, }) ); @@ -272,7 +281,7 @@ describe('ccs-image-analysis MCP server', () => { content: [ { type: 'text', - text: `ImageAnalysis could not find file: ${join(tmpdir(), 'missing-image.png')}`, + text: `ImageAnalysis could not find file: ${missingPath}`, }, ], isError: true, @@ -282,6 +291,62 @@ describe('ccs-image-analysis MCP server', () => { } }); + it('rejects paths outside the current workspace', async () => { + tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-scope-')); + const workspaceDir = join(tempDir, 'workspace'); + mkdirSync(workspaceDir, { recursive: true }); + const outsidePath = join(tempDir, 'outside.png'); + createTestPng(outsidePath); + + const child = spawn('node', [serverPath], { + cwd: workspaceDir, + env: { + ...process.env, + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_IMAGE_ANALYSIS_SKIP: '0', + CCS_CURRENT_PROVIDER: 'agy', + CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + try { + const responsesPromise = collectResponses(child, 2); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bun-test', version: '1.0.0' }, + }, + }) + ); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'ImageAnalysis', + arguments: { filePath: '../outside.png' }, + }, + }) + ); + + const responses = await responsesPromise; + const toolCall = responses.find((message) => message.id === 2); + expect(toolCall?.error).toEqual({ + code: -32602, + message: 'ImageAnalysis only allows files inside the current workspace.', + }); + } finally { + child.kill(); + } + }); + it('sends PDF files as document blocks to the provider-scoped route', async () => { tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-pdf-')); const pdfPath = join(tempDir, 'manual.pdf'); @@ -322,6 +387,7 @@ describe('ccs-image-analysis MCP server', () => { }); const child = spawn('node', [serverPath], { + cwd: tempDir, env: { ...process.env, CCS_IMAGE_ANALYSIS_ENABLED: '1', @@ -377,4 +443,39 @@ describe('ccs-image-analysis MCP server', () => { child.kill(); } }); + + it('accepts legacy Content-Length framed requests for compatibility', async () => { + const child = spawn('node', [serverPath], { + env: { + ...process.env, + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_IMAGE_ANALYSIS_SKIP: '1', + CCS_CURRENT_PROVIDER: 'agy', + }, + 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/targets/settings-profile-image-analysis-launch.test.ts b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts index 5baed5f7..5154512e 100644 --- a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts +++ b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts @@ -151,8 +151,20 @@ exit 0 expect(result.stderr).not.toContain('could not prepare the local ImageAnalysis tool'); expect(fs.existsSync(claudeArgsLogPath)).toBe(true); const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); - expect(launchedArgs).toContain('--append-system-prompt'); - expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET); + expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET); + }); + + it('falls back to native Read when the ImageAnalysis MCP runtime cannot be provisioned', () => { + 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); + expect(result.stderr).toContain('could not prepare the local ImageAnalysis tool'); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET); }); it('pins bridge-backed image analysis to the current CLIProxy auth token', () => { diff --git a/tests/unit/utils/hooks/get-image-analysis-hook-env.test.ts b/tests/unit/utils/hooks/get-image-analysis-hook-env.test.ts new file mode 100644 index 00000000..4dbf93bb --- /dev/null +++ b/tests/unit/utils/hooks/get-image-analysis-hook-env.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test'; +import { resolveImageAnalysisRuntimeConnection } from '../../../../src/utils/hooks'; + +describe('resolveImageAnalysisRuntimeConnection', () => { + it('returns a direct local runtime connection for local CLIProxy targets', () => { + const connection = resolveImageAnalysisRuntimeConnection({ + proxyTarget: { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, + }, + }); + + expect(connection.baseUrl).toBe('http://127.0.0.1:8317'); + expect(connection.allowSelfSigned).toBe(false); + expect(connection.proxyTarget.isRemote).toBe(false); + }); + + it('returns the remote runtime base URL and TLS flag for self-signed targets', () => { + const connection = resolveImageAnalysisRuntimeConnection({ + proxyTarget: { + host: 'remote.example.com', + port: 9443, + protocol: 'https', + authToken: 'remote-token', + allowSelfSigned: true, + isRemote: true, + }, + }); + + expect(connection.baseUrl).toBe('https://remote.example.com:9443'); + expect(connection.apiKey).toBe('remote-token'); + expect(connection.allowSelfSigned).toBe(true); + expect(connection.proxyTarget.isRemote).toBe(true); + }); + + it('prefers the local tunnel when one is active for a remote target', () => { + const connection = resolveImageAnalysisRuntimeConnection({ + proxyTarget: { + host: 'remote.example.com', + port: 9443, + protocol: 'https', + authToken: 'remote-token', + allowSelfSigned: true, + isRemote: true, + }, + tunnelPort: 9911, + }); + + expect(connection.baseUrl).toBe('http://127.0.0.1:9911'); + expect(connection.apiKey).toBe('remote-token'); + expect(connection.allowSelfSigned).toBe(false); + expect(connection.proxyTarget).toMatchObject({ + host: '127.0.0.1', + port: 9911, + protocol: 'http', + isRemote: false, + }); + }); +}); diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts index 40ae163d..ccb02d26 100644 --- a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts +++ b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts @@ -38,6 +38,7 @@ function createStatus(overrides: Partial = {}): ImageAnalys describe('image-analysis-runtime-status', () => { it('falls back to native read when provider auth is missing', async () => { const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { + checkRemoteProxy: async () => ({ reachable: false, error: 'not-used' }), getProxyTarget: () => ({ host: '127.0.0.1', port: 8317, @@ -63,6 +64,7 @@ describe('image-analysis-runtime-status', () => { it('marks an idle local proxy as launchable when auth is ready', async () => { const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { + checkRemoteProxy: async () => ({ reachable: false, error: 'not-used' }), getProxyTarget: () => ({ host: '127.0.0.1', port: 8317, @@ -107,7 +109,10 @@ describe('image-analysis-runtime-status', () => { source: 'remote', }, ], - isCliproxyRunning: async () => false, + checkRemoteProxy: async () => ({ + reachable: false, + error: 'Remote CLIProxy target remote.example:443 is unreachable.', + }), }); expect(status.authReadiness).toBe('ready'); @@ -123,6 +128,7 @@ describe('image-analysis-runtime-status', () => { reason: 'Profile hook is missing from the persisted settings file.', }), { + checkRemoteProxy: async () => ({ reachable: false, error: 'not-used' }), getProxyTarget: () => ({ host: '127.0.0.1', port: 8317, @@ -147,4 +153,38 @@ describe('image-analysis-runtime-status', () => { expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); expect(status.effectiveRuntimeReason).toContain('Profile hook is missing'); }); + + it('marks a reachable remote proxy as remote instead of inspecting local 8317', async () => { + const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { + getProxyTarget: () => ({ + host: 'remote.example', + port: 443, + protocol: 'https', + authToken: 'token', + managementKey: 'secret', + allowSelfSigned: true, + isRemote: true, + }), + fetchRemoteAuthStatus: async () => [ + { + provider: 'ghcp', + displayName: 'GitHub Copilot (OAuth)', + authenticated: true, + tokenFiles: 1, + accounts: [], + defaultAccount: null, + source: 'remote', + }, + ], + checkRemoteProxy: async () => ({ + reachable: true, + latencyMs: 42, + }), + isCliproxyRunning: async () => false, + }); + + expect(status.proxyReadiness).toBe('remote'); + expect(status.proxyReason).toContain('remote.example:443'); + expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); + }); }); diff --git a/tests/unit/utils/image-analysis/mcp-installer.test.ts b/tests/unit/utils/image-analysis/mcp-installer.test.ts index b958bb5a..cae4076a 100644 --- a/tests/unit/utils/image-analysis/mcp-installer.test.ts +++ b/tests/unit/utils/image-analysis/mcp-installer.test.ts @@ -1,12 +1,14 @@ -import { afterEach, describe, expect, it, mock } from 'bun:test'; +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import * as lockfile from 'proper-lockfile'; import { ensureImageAnalysisMcp, getImageAnalysisMcpRuntimePath, getImageAnalysisMcpServerName, getImageAnalysisMcpServerPath, + hasImageAnalysisMcpReady, uninstallImageAnalysisMcp, } from '../../../../src/utils/image-analysis'; @@ -102,6 +104,7 @@ describe('ensureImageAnalysisMcp', () => { expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] }); expect(config.mcpServers[getImageAnalysisMcpServerName()]).toEqual(getManagedConfig()); + expect(hasImageAnalysisMcpReady(claudeUserConfigPath)).toBe(true); }); it('removes the managed MCP runtime while preserving unrelated server entries', () => { @@ -177,4 +180,14 @@ describe('ensureImageAnalysisMcp', () => { expect(fs.existsSync(getImageAnalysisMcpServerPath())).toBe(true); expect(fs.existsSync(getImageAnalysisMcpRuntimePath())).toBe(true); }); + + it('serializes ~/.claude.json updates with a file lock', () => { + setupTempHome(); + writeEnabledConfig(); + + const lockSpy = spyOn(lockfile, 'lockSync'); + + expect(ensureImageAnalysisMcp()).toBe(true); + expect(lockSpy).toHaveBeenCalled(); + }); }); diff --git a/tests/unit/web-server/image-analysis-routes.test.ts b/tests/unit/web-server/image-analysis-routes.test.ts index fb34f566..2b329b15 100644 --- a/tests/unit/web-server/image-analysis-routes.test.ts +++ b/tests/unit/web-server/image-analysis-routes.test.ts @@ -171,7 +171,7 @@ describe('image-analysis routes', () => { expect.objectContaining({ name: 'glm', target: 'claude', - currentTargetMode: 'fallback', + currentTargetMode: 'setup', }), expect.objectContaining({ name: 'codexProfile', @@ -185,9 +185,13 @@ describe('image-analysis routes', () => { backendCount: 2, bypassedProfileCount: 1, }); + expect(payload.runtime).toMatchObject({ + managedToolReady: false, + sharedHookInstalled: false, + }); }); - it('updates the saved config through the dashboard route', async () => { + it('updates the saved config through the dashboard route and provisions the local runtime', async () => { const response = await fetch(`${baseUrl}/api/image-analysis`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -219,6 +223,28 @@ describe('image-analysis routes', () => { expect(payload.config.providerModels).toMatchObject({ gemini: 'gemini-2.5-pro', }); + expect(payload.runtime).toMatchObject({ + managedToolReady: true, + sharedHookInstalled: true, + }); + + const ccsDir = path.join(tempHome, '.ccs'); + expect(fs.existsSync(path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs'))).toBe(true); + expect(fs.existsSync(path.join(ccsDir, 'mcp', 'image-analysis-runtime.cjs'))).toBe(true); + 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); + + const claudeConfig = JSON.parse( + fs.readFileSync(path.join(tempHome, '.claude.json'), 'utf8') + ) as { + mcpServers?: Record; + }; + expect(claudeConfig.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 () => {