fix(image-analysis): harden runtime and provisioning

This commit is contained in:
Tam Nhu Tran committed 2026-04-02 18:36:19 -04:00
1 parent 6d1c895721
commit 77b488f8b1
21 files changed
+811 -169

No files matched your search

@@ -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;
}
@@ -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<RemoteProxyStatus>;
fetchRemoteAuthStatus: (target: ProxyTarget) => Promise<RemoteAuthStatus[]>;
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
+3
View File
@@ -14,7 +14,10 @@ import {
export {
getImageAnalysisHookEnv,
applyImageAnalysisRuntimeOverrides,
resolveImageAnalysisRuntimeConnection,
type ImageAnalysisRuntimeOverrides,
type ImageAnalysisRuntimeConnection,
type ResolveImageAnalysisRuntimeConnectionOptions,
} from './get-image-analysis-hook-env';
export {
canonicalizeImageAnalysisConfig,
+3
View File
@@ -9,6 +9,9 @@ export {
getImageAnalysisMcpServerName,
getImageAnalysisMcpServerPath,
getImageAnalysisMcpRuntimePath,
hasImageAnalysisMcpServerInstalled,
hasImageAnalysisMcpConfig,
hasImageAnalysisMcpReady,
installImageAnalysisMcpServer,
ensureImageAnalysisMcpConfig,
ensureImageAnalysisMcp,
+142 -73
View File
@@ -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<T>(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<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;
}
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<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 {
@@ -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<string, unknown>)
: {};
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<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) {
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;
}