fix(image-analysis): harden runtime and provisioning

This commit is contained in:
Tam Nhu Tran
2026-04-02 18:36:19 -04:00
parent 6d1c895721
commit 77b488f8b1
21 changed files with 811 additions and 169 deletions
+16 -5
View File
@@ -9,6 +9,7 @@ const DEFAULT_MODEL = 'gemini-2.5-flash';
const DEFAULT_TIMEOUT_SEC = 60; const DEFAULT_TIMEOUT_SEC = 60;
const MAX_FILE_SIZE_MB = 10; const MAX_FILE_SIZE_MB = 10;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const MAX_PROMPT_TEMPLATE_BYTES = 32 * 1024;
const SCREENSHOT_NAME_REGEX = const SCREENSHOT_NAME_REGEX =
/(screen[-_ ]?shot|screen[-_ ]?capture|screencap|snapshot|snip|clip|capture)/i; /(screen[-_ ]?shot|screen[-_ ]?capture|screencap|snapshot|snip|clip|capture)/i;
const TEMPLATE_FILE_NAMES = { const TEMPLATE_FILE_NAMES = {
@@ -103,6 +104,10 @@ function selectPromptTemplate(filePath, requestedTemplate) {
function readPromptFile(filePath) { function readPromptFile(filePath) {
try { try {
const stats = fs.statSync(filePath);
if (stats.size > MAX_PROMPT_TEMPLATE_BYTES) {
return null;
}
const content = fs.readFileSync(filePath, 'utf8').trim(); const content = fs.readFileSync(filePath, 'utf8').trim();
return content.length > 0 ? content : null; return content.length > 0 ? content : null;
} catch { } catch {
@@ -189,10 +194,6 @@ function getRuntimeBaseUrl() {
return normalizedBaseUrl; return normalizedBaseUrl;
} }
if (normalizedPath.length > 0 && normalizedPath !== '/') {
return normalizedBaseUrl;
}
parsed.pathname = runtimePath; parsed.pathname = runtimePath;
return parsed.toString().replace(/\/+$/, ''); return parsed.toString().replace(/\/+$/, '');
} catch { } catch {
@@ -217,6 +218,11 @@ function getApiKey() {
return process.env.CCS_CLIPROXY_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || 'ccs-internal-managed'; 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) { function getTimeoutMs(timeoutMs) {
if (typeof timeoutMs === 'number' && timeoutMs > 0) { if (typeof timeoutMs === 'number' && timeoutMs > 0) {
return timeoutMs; return timeoutMs;
@@ -301,6 +307,7 @@ function analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const endpoint = new URL(getRuntimeEndpoint()); const endpoint = new URL(getRuntimeEndpoint());
const transport = endpoint.protocol === 'https:' ? https : http; const transport = endpoint.protocol === 'https:' ? https : http;
const apiKey = getApiKey();
const requestBody = JSON.stringify({ const requestBody = JSON.stringify({
model, model,
max_tokens: 4096, max_tokens: 4096,
@@ -325,9 +332,13 @@ function analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs) {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestBody), 'Content-Length': Buffer.byteLength(requestBody),
'x-api-key': getApiKey(), 'x-api-key': apiKey,
Authorization: `Bearer ${apiKey}`,
}, },
timeout: timeoutMs, timeout: timeoutMs,
...(endpoint.protocol === 'https:' && shouldAllowSelfSigned()
? { rejectUnauthorized: false }
: {}),
}, },
(res) => { (res) => {
let data = ''; let data = '';
+121 -18
View File
@@ -55,11 +55,11 @@ function getTools() {
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
filePath: { filePath: {
type: 'string', type: 'string',
description: 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: { focus: {
type: 'string', type: 'string',
description: description:
@@ -128,6 +128,20 @@ function normalizeTemplate(value) {
return TEMPLATE_NAMES.includes(normalized) ? normalized : undefined; 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) { function resolveFilePath(toolArgs) {
if (!toolArgs || typeof toolArgs !== 'object') { if (!toolArgs || typeof toolArgs !== 'object') {
return ''; return '';
@@ -143,6 +157,60 @@ function resolveFilePath(toolArgs) {
return ''; 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) { function resolveFocus(toolArgs) {
return typeof toolArgs.focus === 'string' && toolArgs.focus.trim().length > 0 return typeof toolArgs.focus === 'string' && toolArgs.focus.trim().length > 0
? toolArgs.focus.trim() ? toolArgs.focus.trim()
@@ -213,9 +281,13 @@ async function handleToolCall(message) {
return; return;
} }
const filePath = resolveFilePath(toolArgs); const { filePath, error: filePathError } = resolveWorkspaceFilePath(toolArgs);
if (!filePath) { 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; return;
} }
@@ -296,22 +368,53 @@ let inputBuffer = Buffer.alloc(0);
function processIncomingBuffer() { function processIncomingBuffer() {
while (true) { while (true) {
const newlineIndex = inputBuffer.indexOf(0x0a); let body;
if (newlineIndex === -1) { const startsWithLegacyHeaders = inputBuffer
return; .slice(0, Math.min(inputBuffer.length, 32))
} .toString('utf8')
.toLowerCase()
.startsWith('content-length:');
const lineBuffer = inputBuffer.subarray(0, newlineIndex); if (startsWithLegacyHeaders) {
inputBuffer = inputBuffer.subarray(newlineIndex + 1); const headerEnd = inputBuffer.indexOf('\r\n\r\n');
const line = lineBuffer.toString('utf8').replace(/\r$/, '').trim(); if (headerEnd === -1) {
if (!line) { return;
continue; }
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 { try {
const message = JSON.parse(line); const message = JSON.parse(body);
Promise.resolve(handleMessage(message)).catch((error) => { 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}`); console.error(`[ccs-image-analysis] ${error instanceof Error ? error.stack : error}`);
} }
}); });
+20 -12
View File
@@ -42,6 +42,7 @@ import {
applyImageAnalysisRuntimeOverrides, applyImageAnalysisRuntimeOverrides,
getImageAnalysisHookEnv, getImageAnalysisHookEnv,
prepareImageAnalysisFallbackHook, prepareImageAnalysisFallbackHook,
resolveImageAnalysisRuntimeConnection,
resolveImageAnalysisRuntimeStatus, resolveImageAnalysisRuntimeStatus,
} from './utils/hooks'; } from './utils/hooks';
import { fail, info, warn } from './utils/ui'; import { fail, info, warn } from './utils/ui';
@@ -80,11 +81,7 @@ import {
resolveDroidReasoningRuntime, resolveDroidReasoningRuntime,
} from './targets/droid-reasoning-runtime'; } from './targets/droid-reasoning-runtime';
import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router'; import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router';
import { import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge';
resolveCliproxyBridgeMetadata,
resolveCliproxyBridgeProfile,
} from './api/services/cliproxy-profile-bridge';
import { getEffectiveApiKey } from './cliproxy/auth-token-manager';
// Version and Update check utilities // Version and Update check utilities
import { getVersion } from './utils/version'; import { getVersion } from './utils/version';
@@ -899,9 +896,10 @@ async function main(): Promise<void> {
process.exit(exitCode); process.exit(exitCode);
} else if (profileInfo.type === 'settings') { } else if (profileInfo.type === 'settings') {
// Settings-based profiles (glm, glmt) are third-party providers // Settings-based profiles (glm, glmt) are third-party providers
const imageAnalysisMcpReady =
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
if (resolvedTarget === 'claude') { if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow(); ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
} }
// Display WebSearch status (single line, equilibrium UX) // Display WebSearch status (single line, equilibrium UX)
@@ -1045,9 +1043,7 @@ async function main(): Promise<void> {
cliproxyBridge, cliproxyBridge,
sharedHookInstalled: imageAnalysisFallbackHookReady, sharedHookInstalled: imageAnalysisFallbackHookReady,
}); });
const currentBridgeAuthToken = cliproxyBridge const runtimeConnection = resolveImageAnalysisRuntimeConnection();
? resolveCliproxyBridgeProfile(cliproxyBridge.provider).apiKey
: getEffectiveApiKey();
let imageAnalysisEnv = getImageAnalysisHookEnv({ let imageAnalysisEnv = getImageAnalysisHookEnv({
profileName: profileInfo.name, profileName: profileInfo.name,
profileType: profileInfo.type, profileType: profileInfo.type,
@@ -1058,10 +1054,19 @@ async function main(): Promise<void> {
backendId: imageAnalysisStatus.backendId, backendId: imageAnalysisStatus.backendId,
model: imageAnalysisStatus.model, model: imageAnalysisStatus.model,
runtimePath: imageAnalysisStatus.runtimePath, runtimePath: imageAnalysisStatus.runtimePath,
baseUrl: cliproxyBridge?.currentBaseUrl || '', baseUrl: runtimeConnection.baseUrl,
apiKey: currentBridgeAuthToken, apiKey: runtimeConnection.apiKey,
allowSelfSigned: runtimeConnection.allowSelfSigned,
}); });
if (!imageAnalysisMcpReady) {
imageAnalysisEnv = {
...imageAnalysisEnv,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
}
const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER'];
if ( if (
resolvedTarget === 'claude' && resolvedTarget === 'claude' &&
@@ -1159,10 +1164,13 @@ async function main(): Promise<void> {
return; return;
} }
const imageAnalysisArgs = imageAnalysisMcpReady
? appendThirdPartyImageAnalysisToolArgs(remainingArgs)
: remainingArgs;
const launchArgs = [ const launchArgs = [
'--settings', '--settings',
expandedSettingsPath, expandedSettingsPath,
...appendThirdPartyWebSearchToolArgs(appendThirdPartyImageAnalysisToolArgs(remainingArgs)), ...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs),
]; ];
const traceEnv = createWebSearchTraceContext({ const traceEnv = createWebSearchTraceContext({
launcher: 'ccs.settings-profile', launcher: 'ccs.settings-profile',
+10 -10
View File
@@ -23,6 +23,7 @@ import { getWebSearchHookEnv } from '../../utils/websearch-manager';
import { import {
applyImageAnalysisRuntimeOverrides, applyImageAnalysisRuntimeOverrides,
getImageAnalysisHookEnv, getImageAnalysisHookEnv,
resolveImageAnalysisRuntimeConnection,
} from '../../utils/hooks/get-image-analysis-hook-env'; } from '../../utils/hooks/get-image-analysis-hook-env';
import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks/image-analysis-runtime-status'; import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks/image-analysis-runtime-status';
import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector'; import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector';
@@ -89,6 +90,7 @@ interface ResolveCliproxyImageAnalysisEnvOptions {
profileSettingsPath?: string; profileSettingsPath?: string;
isComposite?: boolean; isComposite?: boolean;
proxyTarget: ProxyTarget; proxyTarget: ProxyTarget;
tunnelPort?: number | null;
proxyReachable: boolean; 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( export async function resolveCliproxyImageAnalysisEnv(
options: ResolveCliproxyImageAnalysisEnvOptions, options: ResolveCliproxyImageAnalysisEnvOptions,
deps: Partial<CliproxyImageAnalysisDeps> = {} deps: Partial<CliproxyImageAnalysisDeps> = {}
@@ -204,15 +198,21 @@ export async function resolveCliproxyImageAnalysisEnv(
}; };
} }
const runtimeConnection = resolveImageAnalysisRuntimeConnection({
proxyTarget: options.proxyTarget,
tunnelPort: options.tunnelPort,
});
return { return {
env: applyImageAnalysisRuntimeOverrides(env, { env: applyImageAnalysisRuntimeOverrides(env, {
backendId: status.backendId, backendId: status.backendId,
model: status.model, model: status.model,
runtimePath: status.runtimePath, runtimePath: status.runtimePath,
baseUrl: buildDirectProxyBaseUrl(options.proxyTarget), baseUrl: runtimeConnection.baseUrl,
apiKey: options.proxyTarget.isRemote apiKey: options.proxyTarget.isRemote
? options.proxyTarget.authToken || '' ? runtimeConnection.apiKey
: resolvedDeps.getLocalRuntimeApiKey(), : resolvedDeps.getLocalRuntimeApiKey(),
allowSelfSigned: runtimeConnection.allowSelfSigned,
}), }),
warning: null, warning: null,
}; };
+26 -11
View File
@@ -209,7 +209,7 @@ export async function execClaudeWithCLIProxy(
// Setup first-class CCS WebSearch runtime // Setup first-class CCS WebSearch runtime
ensureWebSearchMcpOrThrow(); ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow(); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
displayWebSearchStatus(); displayWebSearchStatus();
const providerConfig = getProviderConfig(provider); const providerConfig = getProviderConfig(provider);
@@ -843,15 +843,27 @@ export async function execClaudeWithCLIProxy(
protocol: 'http' as const, protocol: 'http' as const,
isRemote: false as const, isRemote: false as const,
}; };
const { env: imageAnalysisEnv, warning: imageAnalysisWarning } = const imageAnalysisResolution = await resolveCliproxyImageAnalysisEnv({
await resolveCliproxyImageAnalysisEnv({ profileName: cfg.profileName || provider,
profileName: cfg.profileName || provider, provider,
provider, profileSettingsPath: cfg.customSettingsPath,
profileSettingsPath: cfg.customSettingsPath, isComposite: cfg.isComposite,
isComposite: cfg.isComposite, proxyTarget: imageAnalysisProxyTarget,
proxyTarget: imageAnalysisProxyTarget, tunnelPort,
proxyReachable: true, 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 // 9. Setup tool sanitization proxy
let toolSanitizationProxy: ToolSanitizationProxy | null = null; let toolSanitizationProxy: ToolSanitizationProxy | null = null;
@@ -1076,10 +1088,13 @@ export async function execClaudeWithCLIProxy(
: getProviderSettingsPath(provider); : getProviderSettingsPath(provider);
let claude: ChildProcess; let claude: ChildProcess;
const imageAnalysisArgs = imageAnalysisMcpReady
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
: claudeArgs;
const launchArgs = [ const launchArgs = [
'--settings', '--settings',
settingsPath, settingsPath,
...appendThirdPartyWebSearchToolArgs(appendThirdPartyImageAnalysisToolArgs(claudeArgs)), ...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs),
]; ];
const traceEnv = createWebSearchTraceContext({ const traceEnv = createWebSearchTraceContext({
launcher: 'cliproxy.executor', launcher: 'cliproxy.executor',
+1 -1
View File
@@ -75,7 +75,7 @@ export async function handleUninstallCommand(): Promise<void> {
console.log(ok('Uninstall complete!')); console.log(ok('Uninstall complete!'));
console.log(''); console.log('');
console.log(info('~/.ccs/ directory preserved')); 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 { } else {
console.log(info('Nothing to uninstall')); console.log(info('Nothing to uninstall'));
} }
+27 -11
View File
@@ -31,6 +31,7 @@ import {
import { import {
applyImageAnalysisRuntimeOverrides, applyImageAnalysisRuntimeOverrides,
getImageAnalysisHookEnv, getImageAnalysisHookEnv,
resolveImageAnalysisRuntimeConnection,
resolveImageAnalysisRuntimeStatus, resolveImageAnalysisRuntimeStatus,
} from '../utils/hooks'; } from '../utils/hooks';
import { stripClaudeCodeEnv } from '../utils/shell-executor'; import { stripClaudeCodeEnv } from '../utils/shell-executor';
@@ -153,13 +154,17 @@ export async function resolveCopilotImageAnalysisEnv(
} }
} }
const runtimeConnection = resolveImageAnalysisRuntimeConnection();
return { return {
env: applyImageAnalysisRuntimeOverrides(env, { env: applyImageAnalysisRuntimeOverrides(env, {
backendId: status.backendId, backendId: status.backendId,
model: status.model, model: status.model,
runtimePath: status.runtimePath, runtimePath: status.runtimePath,
baseUrl: `http://127.0.0.1:${CLIPROXY_DEFAULT_PORT}`, baseUrl: runtimeConnection.baseUrl,
apiKey: resolvedDeps.getLocalRuntimeApiKey(), apiKey: runtimeConnection.proxyTarget.isRemote
? runtimeConnection.apiKey
: resolvedDeps.getLocalRuntimeApiKey(),
allowSelfSigned: runtimeConnection.allowSelfSigned,
}), }),
warning: null, warning: null,
}; };
@@ -252,11 +257,25 @@ export async function executeCopilotProfile(
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
const globalEnvConfig = getGlobalEnvConfig(); const globalEnvConfig = getGlobalEnvConfig();
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; 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) // Merge with current environment (global env first, copilot overrides, then hook env vars)
const webSearchEnv = getWebSearchHookEnv(); const webSearchEnv = getWebSearchHookEnv();
const { env: imageAnalysisEnv, warning: imageAnalysisWarning } = const imageAnalysisResolution = await resolveCopilotImageAnalysisEnv();
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({ const env = stripClaudeCodeEnv({
...process.env, ...process.env,
...globalEnv, ...globalEnv,
@@ -272,15 +291,12 @@ export async function executeCopilotProfile(
} }
console.log(''); console.log('');
ensureImageAnalysisMcpOrThrow();
syncWebSearchMcpToConfigDir(claudeConfigDir);
syncImageAnalysisMcpToConfigDir(claudeConfigDir);
// Spawn Claude CLI // Spawn Claude CLI
return new Promise((resolve) => { return new Promise((resolve) => {
const launchArgs = appendThirdPartyWebSearchToolArgs( const imageAnalysisArgs = imageAnalysisMcpReady
appendThirdPartyImageAnalysisToolArgs(claudeArgs) ? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
); : claudeArgs;
const launchArgs = appendThirdPartyWebSearchToolArgs(imageAnalysisArgs);
const traceEnv = createWebSearchTraceContext({ const traceEnv = createWebSearchTraceContext({
launcher: 'copilot.executor', launcher: 'copilot.executor',
args: launchArgs, args: launchArgs,
+19 -11
View File
@@ -28,12 +28,12 @@ import {
applyImageAnalysisRuntimeOverrides, applyImageAnalysisRuntimeOverrides,
getImageAnalysisHookEnv, getImageAnalysisHookEnv,
prepareImageAnalysisFallbackHook, prepareImageAnalysisFallbackHook,
resolveImageAnalysisRuntimeConnection,
resolveImageAnalysisRuntimeStatus, resolveImageAnalysisRuntimeStatus,
} from '../utils/hooks'; } from '../utils/hooks';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../utils/hooks/image-analyzer-profile-hook-injector'; 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 { ensureCliproxyService } from '../cliproxy';
import { getEffectiveApiKey } from '../cliproxy/auth-token-manager';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { import {
appendThirdPartyWebSearchToolArgs, appendThirdPartyWebSearchToolArgs,
@@ -121,7 +121,7 @@ export class HeadlessExecutor {
} }
ensureWebSearchMcpOrThrow(); ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow(); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
@@ -143,9 +143,7 @@ export class HeadlessExecutor {
cliproxyBridge, cliproxyBridge,
sharedHookInstalled: imageAnalysisFallbackHookReady, sharedHookInstalled: imageAnalysisFallbackHookReady,
}); });
const currentBridgeAuthToken = cliproxyBridge const runtimeConnection = resolveImageAnalysisRuntimeConnection();
? resolveCliproxyBridgeProfile(cliproxyBridge.provider).apiKey
: getEffectiveApiKey();
let imageAnalysisEnv = getImageAnalysisHookEnv({ let imageAnalysisEnv = getImageAnalysisHookEnv({
profileName: profile, profileName: profile,
profileType: 'settings', profileType: 'settings',
@@ -156,10 +154,19 @@ export class HeadlessExecutor {
backendId: imageAnalysisStatus.backendId, backendId: imageAnalysisStatus.backendId,
model: imageAnalysisStatus.model, model: imageAnalysisStatus.model,
runtimePath: imageAnalysisStatus.runtimePath, runtimePath: imageAnalysisStatus.runtimePath,
baseUrl: cliproxyBridge?.currentBaseUrl || '', baseUrl: runtimeConnection.baseUrl,
apiKey: currentBridgeAuthToken, apiKey: runtimeConnection.apiKey,
allowSelfSigned: runtimeConnection.allowSelfSigned,
}); });
if (!imageAnalysisMcpReady) {
imageAnalysisEnv = {
...imageAnalysisEnv,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
}
const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER'];
if ( if (
imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' && imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' &&
@@ -279,9 +286,10 @@ export class HeadlessExecutor {
} }
} }
const launchArgs = appendThirdPartyWebSearchToolArgs( const imageAnalysisArgs = imageAnalysisMcpReady
appendThirdPartyImageAnalysisToolArgs(args) ? appendThirdPartyImageAnalysisToolArgs(args)
); : args;
const launchArgs = appendThirdPartyWebSearchToolArgs(imageAnalysisArgs);
const traceEnv = createWebSearchTraceContext({ const traceEnv = createWebSearchTraceContext({
launcher: 'delegation.headless-executor', launcher: 'delegation.headless-executor',
args: launchArgs, args: launchArgs,
@@ -9,7 +9,13 @@
import { getImageAnalysisConfig } from '../../config/unified-config-loader'; import { getImageAnalysisConfig } from '../../config/unified-config-loader';
import { resolveCliproxyBridgeProfile } from '../../api/services/cliproxy-profile-bridge'; import { resolveCliproxyBridgeProfile } from '../../api/services/cliproxy-profile-bridge';
import { getEffectiveApiKey } from '../../cliproxy/auth-token-manager';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import {
buildProxyUrl,
getProxyTarget,
type ProxyTarget,
} from '../../cliproxy/proxy-target-resolver';
import { getPromptsDir } from '../image-analysis/hook-installer'; import { getPromptsDir } from '../image-analysis/hook-installer';
import { import {
resolveImageAnalysisStatus, resolveImageAnalysisStatus,
@@ -31,6 +37,53 @@ export interface ImageAnalysisRuntimeOverrides {
runtimePath?: string | null; runtimePath?: string | null;
baseUrl?: string | null; baseUrl?: string | null;
apiKey?: 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; return nextEnv;
} }
@@ -1,4 +1,9 @@
import { getAuthStatus, initializeAccounts, type AuthStatus } from '../../cliproxy/auth-handler'; 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 { fetchRemoteAuthStatus, type RemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
import { getProxyTarget, type ProxyTarget } from '../../cliproxy/proxy-target-resolver'; import { getProxyTarget, type ProxyTarget } from '../../cliproxy/proxy-target-resolver';
import { getProviderDisplayName, isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; import { getProviderDisplayName, isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
@@ -15,6 +20,7 @@ import {
} from './image-analysis-backend-resolver'; } from './image-analysis-backend-resolver';
interface ImageAnalysisRuntimeStatusDeps { interface ImageAnalysisRuntimeStatusDeps {
checkRemoteProxy: (config: RemoteProxyClientConfig) => Promise<RemoteProxyStatus>;
fetchRemoteAuthStatus: (target: ProxyTarget) => Promise<RemoteAuthStatus[]>; fetchRemoteAuthStatus: (target: ProxyTarget) => Promise<RemoteAuthStatus[]>;
getAuthStatus: (provider: CLIProxyProvider) => AuthStatus; getAuthStatus: (provider: CLIProxyProvider) => AuthStatus;
getProxyTarget: () => ProxyTarget; getProxyTarget: () => ProxyTarget;
@@ -23,6 +29,7 @@ interface ImageAnalysisRuntimeStatusDeps {
} }
const defaultDeps: ImageAnalysisRuntimeStatusDeps = { const defaultDeps: ImageAnalysisRuntimeStatusDeps = {
checkRemoteProxy,
fetchRemoteAuthStatus, fetchRemoteAuthStatus,
getAuthStatus, getAuthStatus,
getProxyTarget, getProxyTarget,
@@ -91,16 +98,25 @@ async function resolveProxyReadiness(
} }
const target = deps.getProxyTarget(); const target = deps.getProxyTarget();
const reachable = await deps.isCliproxyRunning();
if (target.isRemote) { if (target.isRemote) {
const remoteStatus = await deps.checkRemoteProxy({
host: target.host,
port: target.port,
protocol: target.protocol,
authToken: target.authToken,
allowSelfSigned: target.allowSelfSigned,
});
return { return {
proxyReadiness: reachable ? 'remote' : 'unavailable', proxyReadiness: remoteStatus.reachable ? 'remote' : 'unavailable',
proxyReason: reachable proxyReason: remoteStatus.reachable
? `Remote CLIProxy target ${target.host}:${target.port} is 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 { return {
proxyReadiness: reachable ? 'ready' : 'stopped', proxyReadiness: reachable ? 'ready' : 'stopped',
proxyReason: reachable proxyReason: reachable
+3
View File
@@ -14,7 +14,10 @@ import {
export { export {
getImageAnalysisHookEnv, getImageAnalysisHookEnv,
applyImageAnalysisRuntimeOverrides, applyImageAnalysisRuntimeOverrides,
resolveImageAnalysisRuntimeConnection,
type ImageAnalysisRuntimeOverrides, type ImageAnalysisRuntimeOverrides,
type ImageAnalysisRuntimeConnection,
type ResolveImageAnalysisRuntimeConnectionOptions,
} from './get-image-analysis-hook-env'; } from './get-image-analysis-hook-env';
export { export {
canonicalizeImageAnalysisConfig, canonicalizeImageAnalysisConfig,
+3
View File
@@ -9,6 +9,9 @@ export {
getImageAnalysisMcpServerName, getImageAnalysisMcpServerName,
getImageAnalysisMcpServerPath, getImageAnalysisMcpServerPath,
getImageAnalysisMcpRuntimePath, getImageAnalysisMcpRuntimePath,
hasImageAnalysisMcpServerInstalled,
hasImageAnalysisMcpConfig,
hasImageAnalysisMcpReady,
installImageAnalysisMcpServer, installImageAnalysisMcpServer,
ensureImageAnalysisMcpConfig, ensureImageAnalysisMcpConfig,
ensureImageAnalysisMcp, ensureImageAnalysisMcp,
+142 -73
View File
@@ -4,6 +4,7 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as lockfile from 'proper-lockfile';
import { getImageAnalysisConfig } from '../../config/unified-config-loader'; import { getImageAnalysisConfig } from '../../config/unified-config-loader';
import { getCcsDir } from '../config-manager'; import { getCcsDir } from '../config-manager';
import { getClaudeUserConfigPath } from '../claude-config-path'; import { getClaudeUserConfigPath } from '../claude-config-path';
@@ -119,53 +120,115 @@ function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): bo
} }
} }
function removeManagedServerConfig(configPath: string): boolean { function withClaudeUserConfigLock<T>(configPath: string, callback: () => T): T {
if (!fs.existsSync(configPath)) { const lockTarget = fs.existsSync(configPath) ? configPath : path.dirname(configPath);
return false; 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); const config = readClaudeUserConfig(configPath);
if (config === null) { if (config === null) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`));
}
return false; return false;
} }
const existingServers = const existingServers =
config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
? { ...(config.mcpServers as Record<string, unknown>) } ? (config.mcpServers as Record<string, unknown>)
: {}; : {};
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; return false;
} }
delete existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME]; return withClaudeUserConfigLock(configPath, () => {
const config = readClaudeUserConfig(configPath);
const nextConfig: ClaudeUserConfig = { ...config }; if (config === null) {
if (Object.keys(existingServers).length === 0) { if (process.env.CCS_DEBUG) {
delete nextConfig.mcpServers; console.error(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`));
} else { }
nextConfig.mcpServers = existingServers; return false;
}
try {
writeClaudeUserConfig(configPath, nextConfig);
if (process.env.CCS_DEBUG) {
console.error(info(`Removed Image Analysis MCP config from ${configPath}`));
} }
return true;
} catch (error) { const existingServers =
if (process.env.CCS_DEBUG) { config.mcpServers &&
console.error( typeof config.mcpServers === 'object' &&
warn( !Array.isArray(config.mcpServers)
`Failed to remove Image Analysis MCP config from ${configPath}: ${(error as Error).message}` ? { ...(config.mcpServers as Record<string, unknown>) }
) : {};
);
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 { export function installImageAnalysisMcpServer(): boolean {
@@ -258,23 +321,9 @@ export function ensureImageAnalysisMcpConfig(): boolean {
const claudeUserConfigPath = getClaudeUserConfigPath(); const claudeUserConfigPath = getClaudeUserConfigPath();
const claudeUserConfigDir = path.dirname(claudeUserConfigPath); 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)) { if (!fs.existsSync(claudeUserConfigDir)) {
fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 }); fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 });
} }
const existingServers =
config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
? (config.mcpServers as Record<string, unknown>)
: {};
const desiredServerConfig: ManagedImageAnalysisMcpConfig = { const desiredServerConfig: ManagedImageAnalysisMcpConfig = {
type: 'stdio', type: 'stdio',
command: 'node', command: 'node',
@@ -282,35 +331,52 @@ export function ensureImageAnalysisMcpConfig(): boolean {
env: {}, env: {},
}; };
const currentConfig = existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME]; return withClaudeUserConfigLock(claudeUserConfigPath, () => {
if ( const config = readClaudeUserConfig(claudeUserConfigPath);
typeof currentConfig === 'object' &&
currentConfig !== null &&
JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig)
) {
return true;
}
const nextConfig: ClaudeUserConfig = { if (config === null) {
...config, if (process.env.CCS_DEBUG) {
mcpServers: { console.error(warn('Malformed ~/.claude.json prevents Image Analysis MCP provisioning'));
...existingServers, }
[IMAGE_ANALYSIS_MCP_SERVER_NAME]: desiredServerConfig, return false;
}, }
};
try { const existingServers =
writeClaudeUserConfig(claudeUserConfigPath, nextConfig); config.mcpServers &&
if (process.env.CCS_DEBUG) { typeof config.mcpServers === 'object' &&
console.error(info(`Ensured Image Analysis MCP config in ${claudeUserConfigPath}`)); !Array.isArray(config.mcpServers)
? (config.mcpServers as Record<string, unknown>)
: {};
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) { const nextConfig: ClaudeUserConfig = {
if (process.env.CCS_DEBUG) { ...config,
console.error(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`)); 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 { export function ensureImageAnalysisMcp(): boolean {
@@ -377,17 +443,20 @@ export function uninstallImageAnalysisMcp(): boolean {
return removedConfig || removedServer; return removedConfig || removedServer;
} }
export function ensureImageAnalysisMcpOrThrow(): void { export function ensureImageAnalysisMcpOrThrow(): boolean {
const imageConfig = getImageAnalysisConfig(); const imageConfig = getImageAnalysisConfig();
if (!imageConfig.enabled) { if (!imageConfig.enabled) {
return; return false;
} }
if (!ensureImageAnalysisMcp()) { const ready = ensureImageAnalysisMcp();
if (!ready) {
console.error( console.error(
warn( warn(
'Image Analysis is enabled, but CCS could not prepare the local ImageAnalysis tool. This session will fall back to native Read.' 'Image Analysis is enabled, but CCS could not prepare the local ImageAnalysis tool. This session will fall back to native Read.'
) )
); );
} }
return ready;
} }
+43 -3
View File
@@ -16,9 +16,15 @@ import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer'
import { import {
normalizeImageAnalysisBackendId, normalizeImageAnalysisBackendId,
resolveImageAnalysisRuntimeStatus, resolveImageAnalysisRuntimeStatus,
prepareImageAnalysisFallbackHook,
} from '../../utils/hooks'; } from '../../utils/hooks';
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector'; 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 router = Router();
const IMAGE_ANALYSIS_LOCAL_ACCESS_ERROR = const IMAGE_ANALYSIS_LOCAL_ACCESS_ERROR =
@@ -78,16 +84,25 @@ function resolveTarget(target: unknown): DashboardTarget {
function resolveCurrentTargetMode( function resolveCurrentTargetMode(
target: DashboardTarget, target: DashboardTarget,
status: Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>> status: Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>,
managedToolReady: boolean
): CurrentTargetMode { ): CurrentTargetMode {
if (!status.enabled) return 'disabled'; if (!status.enabled) return 'disabled';
if (target !== 'claude') return 'bypassed'; if (target !== 'claude') return 'bypassed';
if (status.nativeReadPreference) return 'native'; if (status.nativeReadPreference) return 'native';
if (!managedToolReady) return 'setup';
if (!status.backendId) return 'unresolved'; if (!status.backendId) return 'unresolved';
if (status.effectiveRuntimeMode === 'native-read') return 'fallback'; if (status.effectiveRuntimeMode === 'native-read') return 'fallback';
return 'active'; return 'active';
} }
function syncManagedImageAnalysisToInstances(): void {
const instanceManager = new InstanceManager();
for (const instanceName of instanceManager.listInstances()) {
instanceManager.syncMcpServers(instanceManager.getInstancePath(instanceName));
}
}
function resolveBackendState( function resolveBackendState(
status: Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>> status: Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>
): BackendState { ): BackendState {
@@ -109,6 +124,7 @@ async function buildDashboardPayload() {
const config = getImageAnalysisConfig(); const config = getImageAnalysisConfig();
const { profiles, variants } = listApiProfiles(); const { profiles, variants } = listApiProfiles();
const sharedHookInstalled = hasImageAnalyzerHook(); const sharedHookInstalled = hasImageAnalyzerHook();
const managedToolReady = hasImageAnalysisMcpReady();
const profileRows = await Promise.all( const profileRows = await Promise.all(
profiles.map(async (profile) => { profiles.map(async (profile) => {
@@ -146,7 +162,11 @@ async function buildDashboardPayload() {
status: status.status, status: status.status,
effectiveRuntimeMode: status.effectiveRuntimeMode, effectiveRuntimeMode: status.effectiveRuntimeMode,
effectiveRuntimeReason: status.effectiveRuntimeReason, effectiveRuntimeReason: status.effectiveRuntimeReason,
currentTargetMode: resolveCurrentTargetMode(resolveTarget(profile.target), status), currentTargetMode: resolveCurrentTargetMode(
resolveTarget(profile.target),
status,
managedToolReady
),
profileModel: status.profileModel, profileModel: status.profileModel,
nativeReadPreference: status.nativeReadPreference, nativeReadPreference: status.nativeReadPreference,
nativeImageCapable: status.nativeImageCapable, nativeImageCapable: status.nativeImageCapable,
@@ -190,7 +210,11 @@ async function buildDashboardPayload() {
status: status.status, status: status.status,
effectiveRuntimeMode: status.effectiveRuntimeMode, effectiveRuntimeMode: status.effectiveRuntimeMode,
effectiveRuntimeReason: status.effectiveRuntimeReason, effectiveRuntimeReason: status.effectiveRuntimeReason,
currentTargetMode: resolveCurrentTargetMode(resolveTarget(variant.target), status), currentTargetMode: resolveCurrentTargetMode(
resolveTarget(variant.target),
status,
managedToolReady
),
profileModel: status.profileModel, profileModel: status.profileModel,
nativeReadPreference: status.nativeReadPreference, nativeReadPreference: status.nativeReadPreference,
nativeImageCapable: status.nativeImageCapable, nativeImageCapable: status.nativeImageCapable,
@@ -260,6 +284,11 @@ async function buildDashboardPayload() {
summaryState = 'disabled'; summaryState = 'disabled';
title = 'Disabled'; title = 'Disabled';
detail = 'Image is turned off globally. Images and PDFs fall back to native file access.'; 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) { } else if (backendRows.length === 0) {
summaryState = 'needs_setup'; summaryState = 'needs_setup';
title = 'Needs provider models'; title = 'Needs provider models';
@@ -288,6 +317,10 @@ async function buildDashboardPayload() {
bypassedProfileCount, bypassedProfileCount,
nativeProfileCount, nativeProfileCount,
}, },
runtime: {
managedToolReady,
sharedHookInstalled,
},
backends: backendRows, backends: backendRows,
profiles: allProfileRows, profiles: allProfileRows,
catalog: { catalog: {
@@ -434,6 +467,13 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
}; };
}); });
const nextEnabled = body.enabled ?? currentConfig.enabled;
if (nextEnabled) {
ensureImageAnalysisMcpOrThrow();
prepareImageAnalysisFallbackHook();
syncManagedImageAnalysisToInstances();
}
res.json(await buildDashboardPayload()); res.json(await buildDashboardPayload());
} catch (error) { } catch (error) {
res.status(500).json({ error: (error as Error).message }); res.status(500).json({ error: (error as Error).message });
@@ -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_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_PATH).toBe('/api/provider/agy');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('remote-token'); 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(); 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_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_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_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(); expect(result.warning).toBeNull();
}); });
}); });
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from 'bun:test'; import { afterEach, describe, expect, it } from 'bun:test';
import { spawn } from 'child_process'; 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 * as http from 'node:http';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -11,6 +11,11 @@ function encodeMessage(message: unknown): string {
return `${JSON.stringify(message)}\n`; 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( function collectResponses(
child: ReturnType<typeof spawn>, child: ReturnType<typeof spawn>,
expectedCount: number expectedCount: number
@@ -133,6 +138,7 @@ describe('ccs-image-analysis MCP server', () => {
}); });
const child = spawn('node', [serverPath], { const child = spawn('node', [serverPath], {
cwd: tempDir,
env: { env: {
...process.env, ...process.env,
CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_IMAGE_ANALYSIS_ENABLED: '1',
@@ -189,7 +195,7 @@ describe('ccs-image-analysis MCP server', () => {
filePath: { filePath: {
type: 'string', type: 'string',
description: 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: { focus: {
type: 'string', 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 () => { 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], { const child = spawn('node', [serverPath], {
cwd: tempDir,
env: { env: {
...process.env, ...process.env,
CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_IMAGE_ANALYSIS_ENABLED: '1',
@@ -260,7 +269,7 @@ describe('ccs-image-analysis MCP server', () => {
method: 'tools/call', method: 'tools/call',
params: { params: {
name: 'ImageAnalysis', name: 'ImageAnalysis',
arguments: { filePath: join(tmpdir(), 'missing-image.png') }, arguments: { filePath: missingPath },
}, },
}) })
); );
@@ -272,7 +281,7 @@ describe('ccs-image-analysis MCP server', () => {
content: [ content: [
{ {
type: 'text', type: 'text',
text: `ImageAnalysis could not find file: ${join(tmpdir(), 'missing-image.png')}`, text: `ImageAnalysis could not find file: ${missingPath}`,
}, },
], ],
isError: true, 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 () => { it('sends PDF files as document blocks to the provider-scoped route', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-pdf-')); tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-pdf-'));
const pdfPath = join(tempDir, 'manual.pdf'); const pdfPath = join(tempDir, 'manual.pdf');
@@ -322,6 +387,7 @@ describe('ccs-image-analysis MCP server', () => {
}); });
const child = spawn('node', [serverPath], { const child = spawn('node', [serverPath], {
cwd: tempDir,
env: { env: {
...process.env, ...process.env,
CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_IMAGE_ANALYSIS_ENABLED: '1',
@@ -377,4 +443,39 @@ describe('ccs-image-analysis MCP server', () => {
child.kill(); 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();
}
});
}); });
@@ -151,8 +151,20 @@ exit 0
expect(result.stderr).not.toContain('could not prepare the local ImageAnalysis tool'); expect(result.stderr).not.toContain('could not prepare the local ImageAnalysis tool');
expect(fs.existsSync(claudeArgsLogPath)).toBe(true); expect(fs.existsSync(claudeArgsLogPath)).toBe(true);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).toContain('--append-system-prompt'); expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET);
expect(launchedArgs).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', () => { it('pins bridge-backed image analysis to the current CLIProxy auth token', () => {
@@ -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,
});
});
});
@@ -38,6 +38,7 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
describe('image-analysis-runtime-status', () => { describe('image-analysis-runtime-status', () => {
it('falls back to native read when provider auth is missing', async () => { it('falls back to native read when provider auth is missing', async () => {
const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), {
checkRemoteProxy: async () => ({ reachable: false, error: 'not-used' }),
getProxyTarget: () => ({ getProxyTarget: () => ({
host: '127.0.0.1', host: '127.0.0.1',
port: 8317, port: 8317,
@@ -63,6 +64,7 @@ describe('image-analysis-runtime-status', () => {
it('marks an idle local proxy as launchable when auth is ready', async () => { it('marks an idle local proxy as launchable when auth is ready', async () => {
const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), {
checkRemoteProxy: async () => ({ reachable: false, error: 'not-used' }),
getProxyTarget: () => ({ getProxyTarget: () => ({
host: '127.0.0.1', host: '127.0.0.1',
port: 8317, port: 8317,
@@ -107,7 +109,10 @@ describe('image-analysis-runtime-status', () => {
source: 'remote', source: 'remote',
}, },
], ],
isCliproxyRunning: async () => false, checkRemoteProxy: async () => ({
reachable: false,
error: 'Remote CLIProxy target remote.example:443 is unreachable.',
}),
}); });
expect(status.authReadiness).toBe('ready'); expect(status.authReadiness).toBe('ready');
@@ -123,6 +128,7 @@ describe('image-analysis-runtime-status', () => {
reason: 'Profile hook is missing from the persisted settings file.', reason: 'Profile hook is missing from the persisted settings file.',
}), }),
{ {
checkRemoteProxy: async () => ({ reachable: false, error: 'not-used' }),
getProxyTarget: () => ({ getProxyTarget: () => ({
host: '127.0.0.1', host: '127.0.0.1',
port: 8317, port: 8317,
@@ -147,4 +153,38 @@ describe('image-analysis-runtime-status', () => {
expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis');
expect(status.effectiveRuntimeReason).toContain('Profile hook is missing'); 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');
});
}); });
@@ -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 fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import * as lockfile from 'proper-lockfile';
import { import {
ensureImageAnalysisMcp, ensureImageAnalysisMcp,
getImageAnalysisMcpRuntimePath, getImageAnalysisMcpRuntimePath,
getImageAnalysisMcpServerName, getImageAnalysisMcpServerName,
getImageAnalysisMcpServerPath, getImageAnalysisMcpServerPath,
hasImageAnalysisMcpReady,
uninstallImageAnalysisMcp, uninstallImageAnalysisMcp,
} from '../../../../src/utils/image-analysis'; } from '../../../../src/utils/image-analysis';
@@ -102,6 +104,7 @@ describe('ensureImageAnalysisMcp', () => {
expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] }); expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] });
expect(config.mcpServers[getImageAnalysisMcpServerName()]).toEqual(getManagedConfig()); expect(config.mcpServers[getImageAnalysisMcpServerName()]).toEqual(getManagedConfig());
expect(hasImageAnalysisMcpReady(claudeUserConfigPath)).toBe(true);
}); });
it('removes the managed MCP runtime while preserving unrelated server entries', () => { 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(getImageAnalysisMcpServerPath())).toBe(true);
expect(fs.existsSync(getImageAnalysisMcpRuntimePath())).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();
});
}); });
@@ -171,7 +171,7 @@ describe('image-analysis routes', () => {
expect.objectContaining({ expect.objectContaining({
name: 'glm', name: 'glm',
target: 'claude', target: 'claude',
currentTargetMode: 'fallback', currentTargetMode: 'setup',
}), }),
expect.objectContaining({ expect.objectContaining({
name: 'codexProfile', name: 'codexProfile',
@@ -185,9 +185,13 @@ describe('image-analysis routes', () => {
backendCount: 2, backendCount: 2,
bypassedProfileCount: 1, 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`, { const response = await fetch(`${baseUrl}/api/image-analysis`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -219,6 +223,28 @@ describe('image-analysis routes', () => {
expect(payload.config.providerModels).toMatchObject({ expect(payload.config.providerModels).toMatchObject({
gemini: 'gemini-2.5-pro', 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<string, unknown>;
};
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 () => { it('rejects profile mappings that point to a missing backend with a client error', async () => {