mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 20:15:40 +00:00
fix(cliproxy): preserve image-analysis runtime readiness
This commit is contained in:
1 parent
1a01c6fc68
commit
3b61673ad2
8 files changed
+374
-15
No files matched your search
@@ -20,11 +20,15 @@ import { CLIProxyProvider } from '../types';
|
|||||||
import { CompositeTierConfig } from '../../config/unified-config-types';
|
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||||
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
||||||
import { getImageAnalysisHookEnv } from '../../utils/hooks/get-image-analysis-hook-env';
|
import { getImageAnalysisHookEnv } 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';
|
||||||
|
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
|
||||||
import { stripClaudeCodeEnv } from '../../utils/shell-executor';
|
import { stripClaudeCodeEnv } from '../../utils/shell-executor';
|
||||||
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
|
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
|
||||||
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
|
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
|
||||||
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
||||||
import { MODEL_ENV_VAR_KEYS, normalizeModelIdForProvider } from '../model-id-normalizer';
|
import { MODEL_ENV_VAR_KEYS, normalizeModelIdForProvider } from '../model-id-normalizer';
|
||||||
|
import type { ProxyTarget } from '../proxy-target-resolver';
|
||||||
|
|
||||||
export interface RemoteProxyConfig {
|
export interface RemoteProxyConfig {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -61,8 +65,38 @@ export interface ProxyChainConfig {
|
|||||||
compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku';
|
compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku';
|
||||||
/** Optional inherited continuity directory from mapped account profile */
|
/** Optional inherited continuity directory from mapped account profile */
|
||||||
claudeConfigDir?: string;
|
claudeConfigDir?: string;
|
||||||
|
/** Execution-aware image analysis env prepared by the caller */
|
||||||
|
imageAnalysisEnv?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CliproxyImageAnalysisDeps {
|
||||||
|
getImageAnalysisHookEnv: typeof getImageAnalysisHookEnv;
|
||||||
|
hasImageAnalysisProfileHook: typeof hasImageAnalysisProfileHook;
|
||||||
|
hasImageAnalyzerHook: typeof hasImageAnalyzerHook;
|
||||||
|
resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolveCliproxyImageAnalysisEnvOptions {
|
||||||
|
profileName: string;
|
||||||
|
provider: CLIProxyProvider;
|
||||||
|
profileSettingsPath?: string;
|
||||||
|
isComposite?: boolean;
|
||||||
|
proxyTarget: ProxyTarget;
|
||||||
|
proxyReachable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliproxyImageAnalysisResolution {
|
||||||
|
env: Record<string, string>;
|
||||||
|
warning: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultCliproxyImageAnalysisDeps: CliproxyImageAnalysisDeps = {
|
||||||
|
getImageAnalysisHookEnv,
|
||||||
|
hasImageAnalysisProfileHook,
|
||||||
|
hasImageAnalyzerHook,
|
||||||
|
resolveImageAnalysisRuntimeStatus,
|
||||||
|
};
|
||||||
|
|
||||||
const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(xhigh|high|medium)$/i;
|
const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(xhigh|high|medium)$/i;
|
||||||
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
||||||
|
|
||||||
@@ -95,6 +129,49 @@ function normalizeCodexEnvForDirectUpstream(envVars: NodeJS.ProcessEnv): NodeJS.
|
|||||||
return nextEnv ?? envVars;
|
return nextEnv ?? envVars;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveCliproxyImageAnalysisEnv(
|
||||||
|
options: ResolveCliproxyImageAnalysisEnvOptions,
|
||||||
|
deps: Partial<CliproxyImageAnalysisDeps> = {}
|
||||||
|
): Promise<CliproxyImageAnalysisResolution> {
|
||||||
|
const resolvedDeps = { ...defaultCliproxyImageAnalysisDeps, ...deps };
|
||||||
|
const context = {
|
||||||
|
profileName: options.profileName,
|
||||||
|
profileType: 'cliproxy' as const,
|
||||||
|
cliproxyProvider: options.provider,
|
||||||
|
isComposite: options.isComposite,
|
||||||
|
settingsPath: options.profileSettingsPath,
|
||||||
|
hookInstalled: resolvedDeps.hasImageAnalysisProfileHook(
|
||||||
|
options.profileName,
|
||||||
|
options.profileSettingsPath
|
||||||
|
),
|
||||||
|
sharedHookInstalled: resolvedDeps.hasImageAnalyzerHook(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const env = resolvedDeps.getImageAnalysisHookEnv(context);
|
||||||
|
const currentProvider = env['CCS_CURRENT_PROVIDER'];
|
||||||
|
if (env['CCS_IMAGE_ANALYSIS_SKIP'] === '1' || !currentProvider) {
|
||||||
|
return { env, warning: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await resolvedDeps.resolveImageAnalysisRuntimeStatus(context, undefined, {
|
||||||
|
getProxyTarget: () => options.proxyTarget,
|
||||||
|
isCliproxyRunning: async () => options.proxyReachable,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status.effectiveRuntimeMode === 'native-read') {
|
||||||
|
return {
|
||||||
|
env: {
|
||||||
|
...env,
|
||||||
|
CCS_CURRENT_PROVIDER: '',
|
||||||
|
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||||
|
},
|
||||||
|
warning: `${status.effectiveRuntimeReason || `Image analysis via ${currentProvider} is unavailable.`} This session will use native Read.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { env, warning: null };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build final environment variables for Claude CLI execution
|
* Build final environment variables for Claude CLI execution
|
||||||
* Handles proxy chain ordering and integration with hooks
|
* Handles proxy chain ordering and integration with hooks
|
||||||
@@ -116,6 +193,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
|
|||||||
compositeTiers,
|
compositeTiers,
|
||||||
compositeDefaultTier,
|
compositeDefaultTier,
|
||||||
claudeConfigDir,
|
claudeConfigDir,
|
||||||
|
imageAnalysisEnv: resolvedImageAnalysisEnv,
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
// Build base env vars - check remote mode first
|
// Build base env vars - check remote mode first
|
||||||
@@ -253,7 +331,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
|
|||||||
|
|
||||||
// Add hook environment variables
|
// Add hook environment variables
|
||||||
const webSearchEnv = getWebSearchHookEnv();
|
const webSearchEnv = getWebSearchHookEnv();
|
||||||
const imageAnalysisEnv = getImageAnalysisHookEnv(provider);
|
const imageAnalysisEnv = resolvedImageAnalysisEnv ?? getImageAnalysisHookEnv(provider);
|
||||||
|
|
||||||
// Merge all environment variables (filter undefined values)
|
// Merge all environment variables (filter undefined values)
|
||||||
const baseEnv = Object.fromEntries(
|
const baseEnv = Object.fromEntries(
|
||||||
|
|||||||
@@ -66,7 +66,11 @@ import { resolveProfileContinuityInheritance } from '../../auth/profile-continui
|
|||||||
|
|
||||||
// Import modular components
|
// Import modular components
|
||||||
import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager';
|
import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager';
|
||||||
import { buildClaudeEnvironment, logEnvironment } from './env-resolver';
|
import {
|
||||||
|
buildClaudeEnvironment,
|
||||||
|
logEnvironment,
|
||||||
|
resolveCliproxyImageAnalysisEnv,
|
||||||
|
} from './env-resolver';
|
||||||
import {
|
import {
|
||||||
isNetworkError,
|
isNetworkError,
|
||||||
handleNetworkError,
|
handleNetworkError,
|
||||||
@@ -172,6 +176,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
port: cliproxyServerConfig.remote.port,
|
port: cliproxyServerConfig.remote.port,
|
||||||
protocol: cliproxyServerConfig.remote.protocol,
|
protocol: cliproxyServerConfig.remote.protocol,
|
||||||
auth_token: cliproxyServerConfig.remote.auth_token,
|
auth_token: cliproxyServerConfig.remote.auth_token,
|
||||||
|
management_key: cliproxyServerConfig.remote.management_key,
|
||||||
timeout: cliproxyServerConfig.remote.timeout,
|
timeout: cliproxyServerConfig.remote.timeout,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -819,6 +824,33 @@ export async function execClaudeWithCLIProxy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imageAnalysisProxyTarget =
|
||||||
|
useRemoteProxy && proxyConfig.host
|
||||||
|
? {
|
||||||
|
host: proxyConfig.host,
|
||||||
|
port: proxyConfig.port,
|
||||||
|
protocol: proxyConfig.protocol,
|
||||||
|
authToken: proxyConfig.authToken,
|
||||||
|
managementKey: proxyConfig.managementKey,
|
||||||
|
allowSelfSigned: proxyConfig.allowSelfSigned,
|
||||||
|
isRemote: true as const,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: cfg.port,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
// 9. Setup tool sanitization proxy
|
// 9. Setup tool sanitization proxy
|
||||||
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
|
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
|
||||||
let toolSanitizationPort: number | null = null;
|
let toolSanitizationPort: number | null = null;
|
||||||
@@ -861,6 +893,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
compositeTiers: cfg.compositeTiers,
|
compositeTiers: cfg.compositeTiers,
|
||||||
compositeDefaultTier: cfg.compositeDefaultTier,
|
compositeDefaultTier: cfg.compositeDefaultTier,
|
||||||
claudeConfigDir: inheritedClaudeConfigDir,
|
claudeConfigDir: inheritedClaudeConfigDir,
|
||||||
|
imageAnalysisEnv,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (initialEnvVars.ANTHROPIC_BASE_URL) {
|
if (initialEnvVars.ANTHROPIC_BASE_URL) {
|
||||||
@@ -957,6 +990,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
compositeTiers: cfg.compositeTiers,
|
compositeTiers: cfg.compositeTiers,
|
||||||
compositeDefaultTier: cfg.compositeDefaultTier,
|
compositeDefaultTier: cfg.compositeDefaultTier,
|
||||||
claudeConfigDir: inheritedClaudeConfigDir,
|
claudeConfigDir: inheritedClaudeConfigDir,
|
||||||
|
imageAnalysisEnv,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (cfg.isComposite && cfg.compositeTiers && cfg.compositeDefaultTier) {
|
if (cfg.isComposite && cfg.compositeTiers && cfg.compositeDefaultTier) {
|
||||||
@@ -973,6 +1007,9 @@ export async function execClaudeWithCLIProxy(
|
|||||||
|
|
||||||
const webSearchEnv = getWebSearchHookEnv();
|
const webSearchEnv = getWebSearchHookEnv();
|
||||||
logEnvironment(env, webSearchEnv, verbose);
|
logEnvironment(env, webSearchEnv, verbose);
|
||||||
|
if (imageAnalysisWarning) {
|
||||||
|
console.error(info(imageAnalysisWarning));
|
||||||
|
}
|
||||||
|
|
||||||
// 11b. Print thinking status feedback (TTY only, non-piped sessions)
|
// 11b. Print thinking status feedback (TTY only, non-piped sessions)
|
||||||
if (process.stderr.isTTY) {
|
if (process.stderr.isTTY) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* Supports both local (spawn CLIProxyAPI) and remote (connect to external) modes.
|
* Supports both local (spawn CLIProxyAPI) and remote (connect to external) modes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ResolvedProxyConfig } from './types';
|
import type { ResolvedProxyConfig } from './types';
|
||||||
import { CLIPROXY_DEFAULT_PORT, validatePort } from './config-generator';
|
import { CLIPROXY_DEFAULT_PORT, validatePort } from './config-generator';
|
||||||
|
|
||||||
/** CLI flags for proxy configuration */
|
/** CLI flags for proxy configuration */
|
||||||
@@ -233,6 +233,7 @@ export function resolveProxyConfig(
|
|||||||
port?: number;
|
port?: number;
|
||||||
protocol?: 'http' | 'https';
|
protocol?: 'http' | 'https';
|
||||||
auth_token?: string;
|
auth_token?: string;
|
||||||
|
management_key?: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
fallback_enabled?: boolean;
|
fallback_enabled?: boolean;
|
||||||
};
|
};
|
||||||
@@ -290,6 +291,7 @@ export function resolveProxyConfig(
|
|||||||
|
|
||||||
// Merge auth token: CLI > ENV > config.yaml
|
// Merge auth token: CLI > ENV > config.yaml
|
||||||
resolved.authToken = cliFlags.authToken ?? envConfig.authToken ?? yamlConfig.remote?.auth_token;
|
resolved.authToken = cliFlags.authToken ?? envConfig.authToken ?? yamlConfig.remote?.auth_token;
|
||||||
|
resolved.managementKey = yamlConfig.remote?.management_key;
|
||||||
|
|
||||||
// Merge timeout: CLI > ENV > config.yaml > default (2000ms in executor)
|
// Merge timeout: CLI > ENV > config.yaml > default (2000ms in executor)
|
||||||
resolved.timeout = cliFlags.timeout ?? envConfig.timeout ?? yamlConfig.remote?.timeout;
|
resolved.timeout = cliFlags.timeout ?? envConfig.timeout ?? yamlConfig.remote?.timeout;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
normalizeProtocol,
|
normalizeProtocol,
|
||||||
validateRemotePort,
|
validateRemotePort,
|
||||||
} from './config-generator';
|
} from './config-generator';
|
||||||
|
import { getProxyEnvVars } from './proxy-config-resolver';
|
||||||
import { getEffectiveManagementSecret } from './auth-token-manager';
|
import { getEffectiveManagementSecret } from './auth-token-manager';
|
||||||
|
|
||||||
/** Resolved proxy target for making requests */
|
/** Resolved proxy target for making requests */
|
||||||
@@ -27,6 +28,8 @@ export interface ProxyTarget {
|
|||||||
authToken?: string;
|
authToken?: string;
|
||||||
/** Optional management key for management API endpoints (/v0/management/*) */
|
/** Optional management key for management API endpoints (/v0/management/*) */
|
||||||
managementKey?: string;
|
managementKey?: string;
|
||||||
|
/** Whether HTTPS requests should allow self-signed certificates */
|
||||||
|
allowSelfSigned?: boolean;
|
||||||
/** True if targeting remote server, false if local */
|
/** True if targeting remote server, false if local */
|
||||||
isRemote: boolean;
|
isRemote: boolean;
|
||||||
}
|
}
|
||||||
@@ -46,6 +49,7 @@ function loadCliproxyServerConfig(): CliproxyServerConfig | undefined {
|
|||||||
*/
|
*/
|
||||||
export function getProxyTarget(): ProxyTarget {
|
export function getProxyTarget(): ProxyTarget {
|
||||||
const config = loadCliproxyServerConfig();
|
const config = loadCliproxyServerConfig();
|
||||||
|
const envConfig = getProxyEnvVars();
|
||||||
|
|
||||||
if (config?.remote?.enabled && config.remote?.host) {
|
if (config?.remote?.enabled && config.remote?.host) {
|
||||||
// Normalize protocol (handles case sensitivity and invalid values)
|
// Normalize protocol (handles case sensitivity and invalid values)
|
||||||
@@ -60,6 +64,7 @@ export function getProxyTarget(): ProxyTarget {
|
|||||||
protocol,
|
protocol,
|
||||||
authToken: config.remote.auth_token || undefined, // Empty string -> undefined
|
authToken: config.remote.auth_token || undefined, // Empty string -> undefined
|
||||||
managementKey: config.remote.management_key || undefined, // Empty string -> undefined
|
managementKey: config.remote.management_key || undefined, // Empty string -> undefined
|
||||||
|
allowSelfSigned: envConfig.allowSelfSigned,
|
||||||
isRemote: true,
|
isRemote: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Fetches and transforms auth data from remote CLIProxyAPI.
|
* Fetches and transforms auth data from remote CLIProxyAPI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as https from 'https';
|
||||||
import {
|
import {
|
||||||
getProxyTarget,
|
getProxyTarget,
|
||||||
buildProxyUrl,
|
buildProxyUrl,
|
||||||
@@ -15,6 +16,87 @@ import type { CLIProxyProvider } from './types';
|
|||||||
/** Timeout for remote fetch requests (ms) */
|
/** Timeout for remote fetch requests (ms) */
|
||||||
const REMOTE_FETCH_TIMEOUT_MS = 5000;
|
const REMOTE_FETCH_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
async function fetchRemoteAuthResponse(
|
||||||
|
url: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
target: ProxyTarget
|
||||||
|
): Promise<Response> {
|
||||||
|
if (target.protocol !== 'https' || !target.allowSelfSigned) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), REMOTE_FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<Response>((resolve, reject) => {
|
||||||
|
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const settle = (callback: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
const timeoutError = new Error('Request timeout');
|
||||||
|
req.destroy(timeoutError);
|
||||||
|
settle(() => reject(timeoutError));
|
||||||
|
}, REMOTE_FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const req = https.request(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
agent,
|
||||||
|
timeout: REMOTE_FETCH_TIMEOUT_MS,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let body = '';
|
||||||
|
res.setEncoding('utf8');
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
settle(() =>
|
||||||
|
resolve(
|
||||||
|
new Response(body, {
|
||||||
|
status: res.statusCode || 500,
|
||||||
|
statusText: res.statusMessage ?? '',
|
||||||
|
headers:
|
||||||
|
typeof res.headers['content-type'] === 'string'
|
||||||
|
? { 'Content-Type': res.headers['content-type'] }
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
settle(() => reject(error));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
const timeoutError = new Error('Request timeout');
|
||||||
|
req.destroy(timeoutError);
|
||||||
|
settle(() => reject(timeoutError));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Remote auth file from CLIProxyAPI /v0/management/auth-files */
|
/** Remote auth file from CLIProxyAPI /v0/management/auth-files */
|
||||||
interface RemoteAuthFile {
|
interface RemoteAuthFile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -60,16 +142,8 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise<Remot
|
|||||||
const url = buildProxyUrl(proxyTarget, '/v0/management/auth-files');
|
const url = buildProxyUrl(proxyTarget, '/v0/management/auth-files');
|
||||||
const headers = buildManagementHeaders(proxyTarget);
|
const headers = buildManagementHeaders(proxyTarget);
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), REMOTE_FETCH_TIMEOUT_MS);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetchRemoteAuthResponse(url, headers, proxyTarget);
|
||||||
signal: controller.signal,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
@@ -87,11 +161,12 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise<Remot
|
|||||||
|
|
||||||
return transformRemoteAuthFiles(data.files as RemoteAuthFile[]);
|
return transformRemoteAuthFiles(data.files as RemoteAuthFile[]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
throw new Error('Remote proxy connection timed out');
|
throw new Error('Remote proxy connection timed out');
|
||||||
}
|
}
|
||||||
|
if (error instanceof Error && error.message === 'Request timeout') {
|
||||||
|
throw new Error('Remote proxy connection timed out');
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,6 +268,8 @@ export interface ResolvedProxyConfig {
|
|||||||
protocol: 'http' | 'https';
|
protocol: 'http' | 'https';
|
||||||
/** Auth token for remote proxy authentication */
|
/** Auth token for remote proxy authentication */
|
||||||
authToken?: string;
|
authToken?: string;
|
||||||
|
/** Management key for remote management endpoints */
|
||||||
|
managementKey?: string;
|
||||||
/** Enable fallback to local when remote unreachable (default: true) */
|
/** Enable fallback to local when remote unreachable (default: true) */
|
||||||
fallbackEnabled: boolean;
|
fallbackEnabled: boolean;
|
||||||
/** Auto-start local proxy if not running (default: true) */
|
/** Auto-start local proxy if not running (default: true) */
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ 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 { afterEach, describe, expect, it } from 'bun:test';
|
import { afterEach, describe, expect, it } from 'bun:test';
|
||||||
import { buildClaudeEnvironment } from '../../../src/cliproxy/executor/env-resolver';
|
import {
|
||||||
|
buildClaudeEnvironment,
|
||||||
|
resolveCliproxyImageAnalysisEnv,
|
||||||
|
} from '../../../src/cliproxy/executor/env-resolver';
|
||||||
|
import type { ImageAnalysisStatus } from '../../../src/utils/hooks';
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
@@ -37,6 +41,37 @@ function createCodexSettingsFile(models: {
|
|||||||
return settingsPath;
|
return settingsPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createImageAnalysisStatus(
|
||||||
|
overrides: Partial<ImageAnalysisStatus> = {}
|
||||||
|
): ImageAnalysisStatus {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
supported: true,
|
||||||
|
status: 'active',
|
||||||
|
backendId: 'agy',
|
||||||
|
backendDisplayName: 'Antigravity',
|
||||||
|
model: 'gemini-2.5-pro',
|
||||||
|
resolutionSource: 'cliproxy-provider',
|
||||||
|
reason: null,
|
||||||
|
shouldPersistHook: true,
|
||||||
|
persistencePath: '/tmp/orq.settings.json',
|
||||||
|
runtimePath: '/api/provider/agy',
|
||||||
|
usesCurrentTarget: true,
|
||||||
|
usesCurrentAuthToken: true,
|
||||||
|
hookInstalled: true,
|
||||||
|
sharedHookInstalled: true,
|
||||||
|
authReadiness: 'ready',
|
||||||
|
authProvider: 'agy',
|
||||||
|
authDisplayName: 'Antigravity',
|
||||||
|
authReason: null,
|
||||||
|
proxyReadiness: 'ready',
|
||||||
|
proxyReason: 'Local CLIProxy service is reachable.',
|
||||||
|
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
||||||
|
effectiveRuntimeReason: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('buildClaudeEnvironment codex fallback normalization', () => {
|
describe('buildClaudeEnvironment codex fallback normalization', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
while (tempDirs.length > 0) {
|
while (tempDirs.length > 0) {
|
||||||
@@ -112,4 +147,117 @@ describe('buildClaudeEnvironment codex fallback normalization', () => {
|
|||||||
|
|
||||||
expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/.ccs/instances/pro');
|
expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/.ccs/instances/pro');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses an execution-aware image analysis env override when provided', () => {
|
||||||
|
const env = buildClaudeEnvironment({
|
||||||
|
provider: 'agy',
|
||||||
|
useRemoteProxy: false,
|
||||||
|
localPort: 8317,
|
||||||
|
verbose: false,
|
||||||
|
imageAnalysisEnv: {
|
||||||
|
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||||
|
CCS_IMAGE_ANALYSIS_TIMEOUT: '60',
|
||||||
|
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro',
|
||||||
|
CCS_CURRENT_PROVIDER: '',
|
||||||
|
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(env.CCS_CURRENT_PROVIDER).toBe('');
|
||||||
|
expect(env.CCS_IMAGE_ANALYSIS_SKIP).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveCliproxyImageAnalysisEnv', () => {
|
||||||
|
it('falls back to native read when runtime status is not launchable', async () => {
|
||||||
|
const result = await resolveCliproxyImageAnalysisEnv(
|
||||||
|
{
|
||||||
|
profileName: 'orq',
|
||||||
|
provider: 'agy',
|
||||||
|
profileSettingsPath: '/tmp/orq.settings.json',
|
||||||
|
proxyTarget: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 8317,
|
||||||
|
protocol: 'http',
|
||||||
|
isRemote: false,
|
||||||
|
},
|
||||||
|
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 (context, _config, deps) => {
|
||||||
|
expect(context.profileName).toBe('orq');
|
||||||
|
expect(context.cliproxyProvider).toBe('agy');
|
||||||
|
expect(context.hookInstalled).toBe(true);
|
||||||
|
expect(context.sharedHookInstalled).toBe(true);
|
||||||
|
expect(deps?.getProxyTarget?.().isRemote).toBe(false);
|
||||||
|
return createImageAnalysisStatus({
|
||||||
|
authReadiness: 'missing',
|
||||||
|
authReason: 'Antigravity auth is missing.',
|
||||||
|
effectiveRuntimeMode: 'native-read',
|
||||||
|
effectiveRuntimeReason: 'Antigravity auth is missing.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.env.CCS_CURRENT_PROVIDER).toBe('');
|
||||||
|
expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('1');
|
||||||
|
expect(result.warning).toContain('Antigravity auth is missing.');
|
||||||
|
expect(result.warning).toContain('native Read');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps cliproxy image analysis active when the execution target is reachable', async () => {
|
||||||
|
const result = await resolveCliproxyImageAnalysisEnv(
|
||||||
|
{
|
||||||
|
profileName: 'orq',
|
||||||
|
provider: 'agy',
|
||||||
|
isComposite: true,
|
||||||
|
proxyTarget: {
|
||||||
|
host: 'remote.example.com',
|
||||||
|
port: 9443,
|
||||||
|
protocol: 'https',
|
||||||
|
authToken: 'remote-token',
|
||||||
|
managementKey: 'remote-management-key',
|
||||||
|
allowSelfSigned: true,
|
||||||
|
isRemote: true,
|
||||||
|
},
|
||||||
|
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 (context, _config, deps) => {
|
||||||
|
expect(context.isComposite).toBe(true);
|
||||||
|
expect(deps?.getProxyTarget?.().host).toBe('remote.example.com');
|
||||||
|
expect(deps?.getProxyTarget?.().managementKey).toBe('remote-management-key');
|
||||||
|
expect(deps?.getProxyTarget?.().allowSelfSigned).toBe(true);
|
||||||
|
return createImageAnalysisStatus({
|
||||||
|
resolutionSource: 'cliproxy-composite',
|
||||||
|
proxyReadiness: 'remote',
|
||||||
|
proxyReason: 'Remote CLIProxy target remote.example.com:9443 is reachable.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.env.CCS_CURRENT_PROVIDER).toBe('agy');
|
||||||
|
expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0');
|
||||||
|
expect(result.warning).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -282,6 +282,18 @@ describe('proxy-config-resolver', () => {
|
|||||||
expect(config.host).toBe('yaml-host.example.com');
|
expect(config.host).toBe('yaml-host.example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should preserve YAML management key for remote mode', () => {
|
||||||
|
const { config } = resolveProxyConfig([], {
|
||||||
|
remote: {
|
||||||
|
host: 'yaml-host.example.com',
|
||||||
|
auth_token: 'remote-auth-token',
|
||||||
|
management_key: 'remote-management-key',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(config.mode).toBe('remote');
|
||||||
|
expect(config.managementKey).toBe('remote-management-key');
|
||||||
|
});
|
||||||
|
|
||||||
it('should allow CLI --proxy-host to override YAML enabled:false', () => {
|
it('should allow CLI --proxy-host to override YAML enabled:false', () => {
|
||||||
const { config } = resolveProxyConfig(['--proxy-host', 'cli-host'], {
|
const { config } = resolveProxyConfig(['--proxy-host', 'cli-host'], {
|
||||||
remote: { enabled: false, host: 'yaml-host' },
|
remote: { enabled: false, host: 'yaml-host' },
|
||||||
|
|||||||
Reference in new issue
Block a user