fix(cliproxy): preserve image-analysis runtime readiness

This commit is contained in:
Tam Nhu Tran
2026-04-01 15:32:37 -04:00
parent 1a01c6fc68
commit 3b61673ad2
8 changed files with 374 additions and 15 deletions
+79 -1
View File
@@ -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<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 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<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
* Handles proxy chain ordering and integration with hooks
@@ -116,6 +193,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
compositeTiers,
compositeDefaultTier,
claudeConfigDir,
imageAnalysisEnv: resolvedImageAnalysisEnv,
} = config;
// Build base env vars - check remote mode first
@@ -253,7 +331,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
// Add hook environment variables
const webSearchEnv = getWebSearchHookEnv();
const imageAnalysisEnv = getImageAnalysisHookEnv(provider);
const imageAnalysisEnv = resolvedImageAnalysisEnv ?? getImageAnalysisHookEnv(provider);
// Merge all environment variables (filter undefined values)
const baseEnv = Object.fromEntries(
+38 -1
View File
@@ -66,7 +66,11 @@ import { resolveProfileContinuityInheritance } from '../../auth/profile-continui
// Import modular components
import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager';
import { buildClaudeEnvironment, logEnvironment } from './env-resolver';
import {
buildClaudeEnvironment,
logEnvironment,
resolveCliproxyImageAnalysisEnv,
} from './env-resolver';
import {
isNetworkError,
handleNetworkError,
@@ -172,6 +176,7 @@ export async function execClaudeWithCLIProxy(
port: cliproxyServerConfig.remote.port,
protocol: cliproxyServerConfig.remote.protocol,
auth_token: cliproxyServerConfig.remote.auth_token,
management_key: cliproxyServerConfig.remote.management_key,
timeout: cliproxyServerConfig.remote.timeout,
}
: 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
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
let toolSanitizationPort: number | null = null;
@@ -861,6 +893,7 @@ export async function execClaudeWithCLIProxy(
compositeTiers: cfg.compositeTiers,
compositeDefaultTier: cfg.compositeDefaultTier,
claudeConfigDir: inheritedClaudeConfigDir,
imageAnalysisEnv,
});
if (initialEnvVars.ANTHROPIC_BASE_URL) {
@@ -957,6 +990,7 @@ export async function execClaudeWithCLIProxy(
compositeTiers: cfg.compositeTiers,
compositeDefaultTier: cfg.compositeDefaultTier,
claudeConfigDir: inheritedClaudeConfigDir,
imageAnalysisEnv,
});
if (cfg.isComposite && cfg.compositeTiers && cfg.compositeDefaultTier) {
@@ -973,6 +1007,9 @@ export async function execClaudeWithCLIProxy(
const webSearchEnv = getWebSearchHookEnv();
logEnvironment(env, webSearchEnv, verbose);
if (imageAnalysisWarning) {
console.error(info(imageAnalysisWarning));
}
// 11b. Print thinking status feedback (TTY only, non-piped sessions)
if (process.stderr.isTTY) {
+3 -1
View File
@@ -7,7 +7,7 @@
* 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';
/** CLI flags for proxy configuration */
@@ -233,6 +233,7 @@ export function resolveProxyConfig(
port?: number;
protocol?: 'http' | 'https';
auth_token?: string;
management_key?: string;
timeout?: number;
fallback_enabled?: boolean;
};
@@ -290,6 +291,7 @@ export function resolveProxyConfig(
// Merge auth token: CLI > 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;
+5
View File
@@ -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,
};
}
+86 -11
View File
@@ -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<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 */
interface RemoteAuthFile {
id: string;
@@ -60,16 +142,8 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise<Remot
const url = buildProxyUrl(proxyTarget, '/v0/management/auth-files');
const headers = buildManagementHeaders(proxyTarget);
const controller = new AbortController();
const timeoutId = setTimeout(() => 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<Remot
return transformRemoteAuthFiles(data.files as RemoteAuthFile[]);
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
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;
}
}
+2
View File
@@ -268,6 +268,8 @@ export interface ResolvedProxyConfig {
protocol: 'http' | 'https';
/** Auth token for remote proxy authentication */
authToken?: string;
/** Management key for remote management endpoints */
managementKey?: string;
/** Enable fallback to local when remote unreachable (default: true) */
fallbackEnabled: boolean;
/** 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 path from 'path';
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[] = [];
@@ -37,6 +41,37 @@ function createCodexSettingsFile(models: {
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', () => {
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();
});
});
@@ -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' },