mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 14:19:56 +00:00
fix(image-analysis): harden runtime and provisioning
This commit is contained in:
@@ -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 = '';
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export {
|
|||||||
getImageAnalysisMcpServerName,
|
getImageAnalysisMcpServerName,
|
||||||
getImageAnalysisMcpServerPath,
|
getImageAnalysisMcpServerPath,
|
||||||
getImageAnalysisMcpRuntimePath,
|
getImageAnalysisMcpRuntimePath,
|
||||||
|
hasImageAnalysisMcpServerInstalled,
|
||||||
|
hasImageAnalysisMcpConfig,
|
||||||
|
hasImageAnalysisMcpReady,
|
||||||
installImageAnalysisMcpServer,
|
installImageAnalysisMcpServer,
|
||||||
ensureImageAnalysisMcpConfig,
|
ensureImageAnalysisMcpConfig,
|
||||||
ensureImageAnalysisMcp,
|
ensureImageAnalysisMcp,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user