mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 22:15:33 +00:00
fix(image-analysis): harden runtime and provisioning
This commit is contained in:
1 parent
6d1c895721
commit
77b488f8b1
21 files changed
+811
-169
No files matched your search
+20
-12
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageAnalysisArgs = imageAnalysisMcpReady
|
||||
? appendThirdPartyImageAnalysisToolArgs(remainingArgs)
|
||||
: remainingArgs;
|
||||
const launchArgs = [
|
||||
'--settings',
|
||||
expandedSettingsPath,
|
||||
...appendThirdPartyWebSearchToolArgs(appendThirdPartyImageAnalysisToolArgs(remainingArgs)),
|
||||
...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs),
|
||||
];
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
launcher: 'ccs.settings-profile',
|
||||
|
||||
@@ -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<CliproxyImageAnalysisDeps> = {}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -75,7 +75,7 @@ export async function handleUninstallCommand(): Promise<void> {
|
||||
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'));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
export {
|
||||
getImageAnalysisHookEnv,
|
||||
applyImageAnalysisRuntimeOverrides,
|
||||
resolveImageAnalysisRuntimeConnection,
|
||||
type ImageAnalysisRuntimeOverrides,
|
||||
type ImageAnalysisRuntimeConnection,
|
||||
type ResolveImageAnalysisRuntimeConnectionOptions,
|
||||
} from './get-image-analysis-hook-env';
|
||||
export {
|
||||
canonicalizeImageAnalysisConfig,
|
||||
|
||||
@@ -9,6 +9,9 @@ export {
|
||||
getImageAnalysisMcpServerName,
|
||||
getImageAnalysisMcpServerPath,
|
||||
getImageAnalysisMcpRuntimePath,
|
||||
hasImageAnalysisMcpServerInstalled,
|
||||
hasImageAnalysisMcpConfig,
|
||||
hasImageAnalysisMcpReady,
|
||||
installImageAnalysisMcpServer,
|
||||
ensureImageAnalysisMcpConfig,
|
||||
ensureImageAnalysisMcp,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>
|
||||
status: Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>,
|
||||
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<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>
|
||||
): 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<void> => {
|
||||
};
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
Reference in new issue
Block a user