From 3b61673ad2753e67928e000daa6c396dde72fe80 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 1 Apr 2026 01:59:00 -0400 Subject: [PATCH] fix(cliproxy): preserve image-analysis runtime readiness --- src/cliproxy/executor/env-resolver.ts | 80 +++++++++- src/cliproxy/executor/index.ts | 39 ++++- src/cliproxy/proxy-config-resolver.ts | 4 +- src/cliproxy/proxy-target-resolver.ts | 5 + src/cliproxy/remote-auth-fetcher.ts | 97 +++++++++-- src/cliproxy/types.ts | 2 + .../env-resolver-codex-fallback.test.ts | 150 +++++++++++++++++- .../cliproxy/proxy-config-resolver.test.js | 12 ++ 8 files changed, 374 insertions(+), 15 deletions(-) diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index 043ed4c3..b16e0a96 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -20,11 +20,15 @@ import { CLIProxyProvider } from '../types'; import { CompositeTierConfig } from '../../config/unified-config-types'; import { getWebSearchHookEnv } from '../../utils/websearch-manager'; 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 { CodexReasoningProxy } from '../codex-reasoning-proxy'; import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; import { MODEL_ENV_VAR_KEYS, normalizeModelIdForProvider } from '../model-id-normalizer'; +import type { ProxyTarget } from '../proxy-target-resolver'; export interface RemoteProxyConfig { host: string; @@ -61,8 +65,38 @@ export interface ProxyChainConfig { compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku'; /** Optional inherited continuity directory from mapped account profile */ claudeConfigDir?: string; + /** Execution-aware image analysis env prepared by the caller */ + imageAnalysisEnv?: Record; } +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; + warning: string | null; +} + +const defaultCliproxyImageAnalysisDeps: CliproxyImageAnalysisDeps = { + getImageAnalysisHookEnv, + hasImageAnalysisProfileHook, + hasImageAnalyzerHook, + resolveImageAnalysisRuntimeStatus, +}; + const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(xhigh|high|medium)$/i; const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; @@ -95,6 +129,49 @@ function normalizeCodexEnvForDirectUpstream(envVars: NodeJS.ProcessEnv): NodeJS. return nextEnv ?? envVars; } +export async function resolveCliproxyImageAnalysisEnv( + options: ResolveCliproxyImageAnalysisEnvOptions, + deps: Partial = {} +): Promise { + 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 * Handles proxy chain ordering and integration with hooks @@ -116,6 +193,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record ENV > config.yaml 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) resolved.timeout = cliFlags.timeout ?? envConfig.timeout ?? yamlConfig.remote?.timeout; diff --git a/src/cliproxy/proxy-target-resolver.ts b/src/cliproxy/proxy-target-resolver.ts index a9bde5d2..c6cfa966 100644 --- a/src/cliproxy/proxy-target-resolver.ts +++ b/src/cliproxy/proxy-target-resolver.ts @@ -13,6 +13,7 @@ import { normalizeProtocol, validateRemotePort, } from './config-generator'; +import { getProxyEnvVars } from './proxy-config-resolver'; import { getEffectiveManagementSecret } from './auth-token-manager'; /** Resolved proxy target for making requests */ @@ -27,6 +28,8 @@ export interface ProxyTarget { authToken?: string; /** Optional management key for management API endpoints (/v0/management/*) */ managementKey?: string; + /** Whether HTTPS requests should allow self-signed certificates */ + allowSelfSigned?: boolean; /** True if targeting remote server, false if local */ isRemote: boolean; } @@ -46,6 +49,7 @@ function loadCliproxyServerConfig(): CliproxyServerConfig | undefined { */ export function getProxyTarget(): ProxyTarget { const config = loadCliproxyServerConfig(); + const envConfig = getProxyEnvVars(); if (config?.remote?.enabled && config.remote?.host) { // Normalize protocol (handles case sensitivity and invalid values) @@ -60,6 +64,7 @@ export function getProxyTarget(): ProxyTarget { protocol, authToken: config.remote.auth_token || undefined, // Empty string -> undefined managementKey: config.remote.management_key || undefined, // Empty string -> undefined + allowSelfSigned: envConfig.allowSelfSigned, isRemote: true, }; } diff --git a/src/cliproxy/remote-auth-fetcher.ts b/src/cliproxy/remote-auth-fetcher.ts index 8259582f..b1e81e55 100644 --- a/src/cliproxy/remote-auth-fetcher.ts +++ b/src/cliproxy/remote-auth-fetcher.ts @@ -3,6 +3,7 @@ * Fetches and transforms auth data from remote CLIProxyAPI. */ +import * as https from 'https'; import { getProxyTarget, buildProxyUrl, @@ -15,6 +16,87 @@ import type { CLIProxyProvider } from './types'; /** Timeout for remote fetch requests (ms) */ const REMOTE_FETCH_TIMEOUT_MS = 5000; +async function fetchRemoteAuthResponse( + url: string, + headers: Record, + target: ProxyTarget +): Promise { + 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((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 */ interface RemoteAuthFile { id: string; @@ -60,16 +142,8 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise controller.abort(), REMOTE_FETCH_TIMEOUT_MS); - try { - const response = await fetch(url, { - signal: controller.signal, - headers, - }); - - clearTimeout(timeoutId); + const response = await fetchRemoteAuthResponse(url, headers, proxyTarget); if (!response.ok) { if (response.status === 401 || response.status === 403) { @@ -87,11 +161,12 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise = {} +): 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', () => { afterEach(() => { while (tempDirs.length > 0) { @@ -112,4 +147,117 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { 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(); + }); }); diff --git a/tests/unit/cliproxy/proxy-config-resolver.test.js b/tests/unit/cliproxy/proxy-config-resolver.test.js index ce905846..0ebc8ebc 100644 --- a/tests/unit/cliproxy/proxy-config-resolver.test.js +++ b/tests/unit/cliproxy/proxy-config-resolver.test.js @@ -282,6 +282,18 @@ describe('proxy-config-resolver', () => { 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', () => { const { config } = resolveProxyConfig(['--proxy-host', 'cli-host'], { remote: { enabled: false, host: 'yaml-host' },