Merge pull request #167 from kaitranntt/kai/fix/cliproxy-server-debug

feat(cliproxy): add remote CLIProxy routing with dev merge
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-21 19:39:25 -05:00
committed by GitHub
13 changed files with 788 additions and 102 deletions
+42 -8
View File
@@ -51,6 +51,7 @@ import {
import { registerSession, unregisterSession, cleanupOrphanedSessions } from './session-tracker';
import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector';
import { withStartupLock } from './startup-lock';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
/** Default executor configuration */
const DEFAULT_CONFIG: ExecutorConfig = {
@@ -126,7 +127,25 @@ export async function execClaudeWithCLIProxy(
// 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults)
// This filters proxy flags from args and returns resolved config
const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args);
const unifiedConfig = loadOrCreateUnifiedConfig();
const cliproxyServerConfig = unifiedConfig.cliproxy_server;
const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, {
remote: cliproxyServerConfig?.remote
? {
enabled: cliproxyServerConfig.remote.enabled,
host: cliproxyServerConfig.remote.host,
port: cliproxyServerConfig.remote.port,
protocol: cliproxyServerConfig.remote.protocol,
auth_token: cliproxyServerConfig.remote.auth_token,
}
: undefined,
local: cliproxyServerConfig?.local
? {
port: cliproxyServerConfig.local.port,
auto_start: cliproxyServerConfig.local.auto_start,
}
: undefined,
});
// Use resolved port from proxy config (overrides ExecutorConfig)
if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) {
@@ -516,14 +535,29 @@ export async function execClaudeWithCLIProxy(
// 7. Execute Claude CLI with proxied environment
// Use remote or local env vars based on mode
// When remote is configured (even if using local), pass config for URL rewriting
const remoteRewriteConfig =
proxyConfig.mode === 'remote' && proxyConfig.host
? {
host: proxyConfig.host,
port: proxyConfig.port,
protocol: proxyConfig.protocol,
authToken: proxyConfig.authToken,
}
: undefined;
const envVars = useRemoteProxy
? getRemoteEnvVars(provider, {
host: proxyConfig.host ?? 'localhost',
port: proxyConfig.port,
protocol: proxyConfig.protocol,
authToken: proxyConfig.authToken,
})
: getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath);
? getRemoteEnvVars(
provider,
{
host: proxyConfig.host ?? 'localhost',
port: proxyConfig.port,
protocol: proxyConfig.protocol,
authToken: proxyConfig.authToken,
},
cfg.customSettingsPath
)
: getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath, remoteRewriteConfig);
const webSearchEnv = getWebSearchHookEnv();
const env = {
...process.env,
+120 -22
View File
@@ -392,6 +392,48 @@ function getGlobalEnvVars(): Record<string, string> {
return globalEnvConfig.env;
}
/** Remote proxy configuration for URL rewriting */
interface RemoteProxyRewriteConfig {
host: string;
port?: number;
protocol: 'http' | 'https';
authToken?: string;
}
/**
* Rewrite localhost URLs to remote server URLs.
* Handles various localhost patterns: 127.0.0.1, localhost, 0.0.0.0
*/
function rewriteLocalhostUrls(
envVars: NodeJS.ProcessEnv,
provider: CLIProxyProvider,
remoteConfig: RemoteProxyRewriteConfig
): NodeJS.ProcessEnv {
const result = { ...envVars };
const baseUrl = result.ANTHROPIC_BASE_URL;
if (!baseUrl) return result;
// Check if URL points to localhost (127.0.0.1, localhost, 0.0.0.0)
const localhostPattern = /^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0)(:\d+)?/i;
if (!localhostPattern.test(baseUrl)) return result;
// Build remote URL with smart port handling
const defaultPort = remoteConfig.protocol === 'https' ? 443 : 80;
const effectivePort = remoteConfig.port ?? defaultPort;
const portSuffix = effectivePort === defaultPort ? '' : `:${effectivePort}`;
const remoteBaseUrl = `${remoteConfig.protocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`;
result.ANTHROPIC_BASE_URL = remoteBaseUrl;
// Update auth token if provided
if (remoteConfig.authToken) {
result.ANTHROPIC_AUTH_TOKEN = remoteConfig.authToken;
}
return result;
}
/**
* Get effective environment variables for provider
*
@@ -402,15 +444,20 @@ function getGlobalEnvVars(): Record<string, string> {
*
* All results are merged with global_env vars (telemetry/reporting disables).
* User takes full responsibility for custom settings.
*
* If remoteRewriteConfig is provided, localhost URLs are rewritten to remote server.
*/
export function getEffectiveEnvVars(
provider: CLIProxyProvider,
port: number = CLIPROXY_DEFAULT_PORT,
customSettingsPath?: string
customSettingsPath?: string,
remoteRewriteConfig?: RemoteProxyRewriteConfig
): NodeJS.ProcessEnv {
// Get global env vars (DISABLE_TELEMETRY, etc.)
const globalEnv = getGlobalEnvVars();
let envVars: NodeJS.ProcessEnv;
// Priority 1: Custom settings path (for user-defined variants)
if (customSettingsPath) {
const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir());
@@ -421,7 +468,12 @@ export function getEffectiveEnvVars(
if (settings.env && typeof settings.env === 'object') {
// Custom variant settings found - merge with global env
return { ...globalEnv, ...settings.env };
envVars = { ...globalEnv, ...settings.env };
// Apply remote rewrite if configured
if (remoteRewriteConfig) {
envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig);
}
return envVars;
}
} catch {
// Invalid JSON - fall through to provider defaults
@@ -443,7 +495,12 @@ export function getEffectiveEnvVars(
if (settings.env && typeof settings.env === 'object') {
// User override found - merge with global env
return { ...globalEnv, ...settings.env };
envVars = { ...globalEnv, ...settings.env };
// Apply remote rewrite if configured
if (remoteRewriteConfig) {
envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig);
}
return envVars;
}
} catch {
// Invalid JSON or structure - fall through to defaults
@@ -483,48 +540,89 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
/**
* Get environment variables for remote proxy mode.
* Uses the remote proxy's provider endpoint as the base URL.
* Respects user model settings from custom settings path or provider settings file.
*
* @param provider CLIProxy provider (gemini, codex, agy, qwen, iflow)
* @param remoteConfig Remote proxy connection details
* @param customSettingsPath Optional path to user's custom settings file
* @returns Environment variables for Claude CLI
*/
export function getRemoteEnvVars(
provider: CLIProxyProvider,
remoteConfig: { host: string; port?: number; protocol: 'http' | 'https'; authToken?: string }
remoteConfig: { host: string; port?: number; protocol: 'http' | 'https'; authToken?: string },
customSettingsPath?: string
): Record<string, string> {
// Build URL with smart port handling - omit if using protocol default
const defaultPort = remoteConfig.protocol === 'https' ? 443 : 80;
const effectivePort = remoteConfig.port ?? defaultPort;
const portSuffix = effectivePort === defaultPort ? '' : `:${effectivePort}`;
const baseUrl = `${remoteConfig.protocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`;
const models = getModelMapping(provider);
// Get global env vars (DISABLE_TELEMETRY, etc.)
const globalEnv = getGlobalEnvVars();
// Get additional env vars from base config (ANTHROPIC_MAX_TOKENS, etc.)
const baseEnvVars = getEnvVarsFromConfig(provider);
// Load user settings with priority: custom path > user settings file > base config
let userEnvVars: Record<string, string> = {};
// Filter out core env vars from base config to avoid conflicts
const {
ANTHROPIC_BASE_URL: _baseUrl,
ANTHROPIC_AUTH_TOKEN: _authToken,
ANTHROPIC_MODEL: _model,
ANTHROPIC_DEFAULT_OPUS_MODEL: _opusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnetModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: _haikuModel,
...additionalEnvVars
} = baseEnvVars;
// Priority 1: Custom settings path (for user-defined variants)
if (customSettingsPath) {
const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir());
if (fs.existsSync(expandedPath)) {
try {
const content = fs.readFileSync(expandedPath, 'utf-8');
const settings: ProviderSettings = JSON.parse(content);
if (settings.env && typeof settings.env === 'object') {
userEnvVars = settings.env as Record<string, string>;
}
} catch {
// Invalid JSON - fall through to provider defaults
console.warn(warn(`Invalid settings file: ${customSettingsPath}`));
}
}
}
// Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json)
if (Object.keys(userEnvVars).length === 0) {
const settingsPath = getProviderSettingsPath(provider);
if (fs.existsSync(settingsPath)) {
try {
const content = fs.readFileSync(settingsPath, 'utf-8');
const settings: ProviderSettings = JSON.parse(content);
if (settings.env && typeof settings.env === 'object') {
userEnvVars = settings.env as Record<string, string>;
}
} catch {
// Invalid JSON - fall through to base config
}
}
}
// Priority 3: Base config defaults
if (Object.keys(userEnvVars).length === 0) {
const models = getModelMapping(provider);
const baseEnvVars = getEnvVarsFromConfig(provider);
// Filter out URL/auth from base config (we'll set those from remote config)
const {
ANTHROPIC_BASE_URL: _baseUrl,
ANTHROPIC_AUTH_TOKEN: _authToken,
...additionalEnvVars
} = baseEnvVars;
userEnvVars = {
...additionalEnvVars,
ANTHROPIC_MODEL: models.claudeModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
};
}
// Build final env: global + user settings + remote URL/auth override
const env: Record<string, string> = {
...globalEnv,
...additionalEnvVars,
...userEnvVars,
// Always override URL and auth token with remote config
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || CCS_INTERNAL_API_KEY,
ANTHROPIC_MODEL: models.claudeModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
};
return env;
+100
View File
@@ -0,0 +1,100 @@
/**
* Proxy Target Resolver
*
* Determines whether CLIProxyAPI requests should go to local or remote
* based on unified config. Used by stats-fetcher, auth-routes, and UI.
*/
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
import type { CliproxyServerConfig } from '../config/unified-config-types';
/** Default CLIProxyAPI port */
const DEFAULT_CLIPROXY_PORT = 8317;
/** Resolved proxy target for making requests */
export interface ProxyTarget {
/** Target hostname or IP */
host: string;
/** Target port */
port: number;
/** Protocol (http/https) */
protocol: 'http' | 'https';
/** Optional auth token - only send header if defined and non-empty */
authToken?: string;
/** True if targeting remote server, false if local */
isRemote: boolean;
}
/**
* Load cliproxy_server configuration from unified config.
* Returns undefined if not configured.
*/
function loadCliproxyServerConfig(): CliproxyServerConfig | undefined {
const config = loadOrCreateUnifiedConfig();
return config.cliproxy_server;
}
/**
* Get the current CLIProxyAPI target based on unified config.
* Returns remote server config if enabled, otherwise localhost.
*/
export function getProxyTarget(): ProxyTarget {
const config = loadCliproxyServerConfig();
if (config?.remote?.enabled && config.remote?.host) {
const protocol = config.remote.protocol ?? 'http';
// Default port based on protocol if not specified
const defaultPort = protocol === 'https' ? 443 : 80;
const port = config.remote.port ?? defaultPort;
return {
host: config.remote.host,
port,
protocol,
authToken: config.remote.auth_token || undefined, // Empty string -> undefined
isRemote: true,
};
}
return {
host: '127.0.0.1',
port: config?.local?.port ?? DEFAULT_CLIPROXY_PORT,
protocol: 'http',
isRemote: false,
};
}
/**
* Build URL for proxy endpoint
* @param target Resolved proxy target
* @param path Endpoint path (e.g., '/v0/management/usage')
*/
export function buildProxyUrl(target: ProxyTarget, path: string): string {
// Normalize path to ensure leading slash
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${target.protocol}://${target.host}:${target.port}${normalizedPath}`;
}
/**
* Build request headers for proxy requests
* Handles optional auth token - only adds Authorization header if token is set.
*
* @param target Resolved proxy target
* @param additionalHeaders Extra headers to merge
*/
export function buildProxyHeaders(
target: ProxyTarget,
additionalHeaders: Record<string, string> = {}
): Record<string, string> {
const headers: Record<string, string> = {
Accept: 'application/json',
...additionalHeaders,
};
// Only add auth header if token is configured
if (target.authToken) {
headers['Authorization'] = `Bearer ${target.authToken}`;
}
return headers;
}
+159
View File
@@ -0,0 +1,159 @@
/**
* Remote Auth Fetcher
* Fetches and transforms auth data from remote CLIProxyAPI.
*/
import {
getProxyTarget,
buildProxyUrl,
buildProxyHeaders,
ProxyTarget,
} from './proxy-target-resolver';
/** Timeout for remote fetch requests (ms) */
const REMOTE_FETCH_TIMEOUT_MS = 5000;
/** Remote auth file from CLIProxyAPI /v0/management/auth-files */
interface RemoteAuthFile {
id: string;
name: string;
type: string;
provider: string;
email?: string;
status: 'active' | 'disabled' | 'unavailable';
source: 'file' | 'memory';
}
/** Account info for UI display */
export interface RemoteAccountInfo {
id: string;
email: string;
isDefault: boolean;
status: 'active' | 'disabled' | 'unavailable';
}
/** Auth status for a provider (UI format) */
export interface RemoteAuthStatus {
provider: string;
displayName: string;
authenticated: boolean;
tokenFiles: number;
accounts: RemoteAccountInfo[];
defaultAccount: string | null;
source: 'remote';
}
/** Map CLIProxyAPI provider names to CCS internal names */
const PROVIDER_MAP: Record<string, string> = {
gemini: 'gemini',
'gemini-cli': 'gemini', // CLIProxyAPI uses 'gemini-cli' for Gemini CLI auth
antigravity: 'agy',
codex: 'codex',
qwen: 'qwen',
iflow: 'iflow',
};
/** Display names for providers */
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
gemini: 'Google Gemini',
agy: 'AntiGravity',
codex: 'Codex',
qwen: 'Qwen',
iflow: 'iFlow',
};
/**
* Fetch auth status from remote CLIProxyAPI
* @throws Error if remote is unreachable or returns error
*/
export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise<RemoteAuthStatus[]> {
const proxyTarget = target ?? getProxyTarget();
if (!proxyTarget.isRemote) {
throw new Error('fetchRemoteAuthStatus called but remote mode not enabled');
}
const url = buildProxyUrl(proxyTarget, '/v0/management/auth-files');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REMOTE_FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: buildProxyHeaders(proxyTarget),
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new Error('Authentication failed - check auth token in settings');
}
throw new Error(`Remote returned ${response.status}: ${response.statusText}`);
}
const data: unknown = await response.json();
// Validate response structure
if (!data || typeof data !== 'object' || !('files' in data) || !Array.isArray(data.files)) {
throw new Error('Invalid response format from remote auth endpoint');
}
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');
}
throw error;
}
}
/**
* Transform CLIProxyAPI auth files to CCS AuthStatus format
* @param files Array of auth files from remote API
*/
function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] {
const byProvider = new Map<string, RemoteAuthFile[]>();
for (const file of files) {
const provider = PROVIDER_MAP[file.provider.toLowerCase()];
if (!provider) {
// Unknown provider, skip (could add logging in debug mode)
continue;
}
const existing = byProvider.get(provider);
if (existing) {
existing.push(file);
} else {
byProvider.set(provider, [file]);
}
}
const result: RemoteAuthStatus[] = [];
for (const [provider, providerFiles] of byProvider) {
const activeFiles = providerFiles.filter((f) => f.status === 'active');
const accounts: RemoteAccountInfo[] = providerFiles.map((f, idx) => ({
id: f.id,
email: f.email || f.name || 'Unknown',
isDefault: idx === 0,
status: f.status,
}));
result.push({
provider,
displayName: PROVIDER_DISPLAY_NAMES[provider] || provider,
authenticated: activeFiles.length > 0,
tokenFiles: providerFiles.length,
accounts,
defaultAccount: accounts.find((a) => a.isDefault)?.id || null,
source: 'remote',
});
}
return result;
}
+81 -38
View File
@@ -5,7 +5,8 @@
* Requires usage-statistics-enabled: true in config.yaml.
*/
import { CCS_CONTROL_PANEL_SECRET, CLIPROXY_DEFAULT_PORT } from './config-generator';
import { CCS_CONTROL_PANEL_SECRET } from './config-generator';
import { getProxyTarget, buildProxyUrl, buildProxyHeaders } from './proxy-target-resolver';
/** Per-account usage statistics */
export interface AccountUsageStats {
@@ -95,19 +96,27 @@ interface UsageApiResponse {
* @param port CLIProxyAPI port (default: 8317)
* @returns Stats object or null if unavailable
*/
export async function fetchCliproxyStats(
port: number = CLIPROXY_DEFAULT_PORT
): Promise<CliproxyStats | null> {
export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout
const response = await fetch(`http://127.0.0.1:${port}/v0/management/usage`, {
// Dynamic target resolution
const target = getProxyTarget();
// Allow port override for local testing only
if (port !== undefined && !target.isRemote) {
target.port = port;
}
const url = buildProxyUrl(target, '/v0/management/usage');
// For management endpoints, use CCS control panel secret for local, remote auth for remote
const headers = target.isRemote
? buildProxyHeaders(target)
: { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` };
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
},
headers,
});
clearTimeout(timeoutId);
@@ -222,20 +231,27 @@ export interface CliproxyModelsResponse {
* @param port CLIProxyAPI port (default: 8317)
* @returns Categorized models or null if unavailable
*/
export async function fetchCliproxyModels(
port: number = CLIPROXY_DEFAULT_PORT
): Promise<CliproxyModelsResponse | null> {
export async function fetchCliproxyModels(port?: number): Promise<CliproxyModelsResponse | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`http://127.0.0.1:${port}/v1/models`, {
// Dynamic target resolution
const target = getProxyTarget();
// Allow port override for local testing only
if (port !== undefined && !target.isRemote) {
target.port = port;
}
const url = buildProxyUrl(target, '/v1/models');
// For /v1 endpoints: use remote auth token for remote, ccs-internal-managed for local
const headers = target.isRemote
? buildProxyHeaders(target)
: { Accept: 'application/json', Authorization: 'Bearer ccs-internal-managed' };
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: 'application/json',
// Use the internal API key for /v1 endpoints
Authorization: 'Bearer ccs-internal-managed',
},
headers,
});
clearTimeout(timeoutId);
@@ -293,19 +309,27 @@ interface ErrorLogsApiResponse {
* @param port CLIProxyAPI port (default: 8317)
* @returns Array of error log metadata or null if unavailable
*/
export async function fetchCliproxyErrorLogs(
port: number = CLIPROXY_DEFAULT_PORT
): Promise<CliproxyErrorLog[] | null> {
export async function fetchCliproxyErrorLogs(port?: number): Promise<CliproxyErrorLog[] | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`http://127.0.0.1:${port}/v0/management/request-error-logs`, {
// Dynamic target resolution
const target = getProxyTarget();
// Allow port override for local testing only
if (port !== undefined && !target.isRemote) {
target.port = port;
}
const url = buildProxyUrl(target, '/v0/management/request-error-logs');
// For management endpoints, use CCS control panel secret for local, remote auth for remote
const headers = target.isRemote
? buildProxyHeaders(target)
: { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` };
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
},
headers,
});
clearTimeout(timeoutId);
@@ -329,22 +353,33 @@ export async function fetchCliproxyErrorLogs(
*/
export async function fetchCliproxyErrorLogContent(
name: string,
port: number = CLIPROXY_DEFAULT_PORT
port?: number
): Promise<string | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(
`http://127.0.0.1:${port}/v0/management/request-error-logs/${encodeURIComponent(name)}`,
{
signal: controller.signal,
headers: {
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
},
}
// Dynamic target resolution
const target = getProxyTarget();
// Allow port override for local testing only
if (port !== undefined && !target.isRemote) {
target.port = port;
}
const url = buildProxyUrl(
target,
`/v0/management/request-error-logs/${encodeURIComponent(name)}`
);
// For management endpoints, use CCS control panel secret for local, remote auth for remote
const headers = target.isRemote
? buildProxyHeaders(target)
: { Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` };
const response = await fetch(url, {
signal: controller.signal,
headers,
});
clearTimeout(timeoutId);
if (!response.ok) {
@@ -362,13 +397,21 @@ export async function fetchCliproxyErrorLogContent(
* @param port CLIProxyAPI port (default: 8317)
* @returns true if proxy is running
*/
export async function isCliproxyRunning(port: number = CLIPROXY_DEFAULT_PORT): Promise<boolean> {
export async function isCliproxyRunning(port?: number): Promise<boolean> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout
// Use root endpoint - CLIProxyAPI returns server info at /
const response = await fetch(`http://127.0.0.1:${port}/`, {
// Dynamic target resolution
const target = getProxyTarget();
// Allow port override for local testing only
if (port !== undefined && !target.isRemote) {
target.port = port;
}
const url = buildProxyUrl(target, '/');
// Health check - no auth needed for root endpoint
const response = await fetch(url, {
signal: controller.signal,
});
+68 -6
View File
@@ -21,6 +21,8 @@ import {
removeAccount as removeAccountFn,
touchAccount,
} from '../../cliproxy/account-manager';
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
import type { CLIProxyProvider } from '../../cliproxy/types';
const router = Router();
@@ -34,7 +36,15 @@ const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'i
*/
router.get('/', async (_req: Request, res: Response): Promise<void> => {
try {
// Initialize accounts from existing tokens on first request
// Check if remote mode is enabled
const target = getProxyTarget();
if (target.isRemote) {
const authStatus = await fetchRemoteAuthStatus(target);
res.json({ authStatus, source: 'remote' });
return;
}
// Local mode: Initialize accounts from existing tokens on first request
initializeAccounts();
// Fetch CLIProxyAPI usage stats to determine active providers
@@ -85,7 +95,17 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
res.json({ authStatus });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
// Return appropriate error for remote vs local mode
const target = getProxyTarget();
if (target.isRemote) {
res.status(503).json({
error: (error as Error).message,
authStatus: [],
source: 'remote',
});
} else {
res.status(500).json({ error: (error as Error).message });
}
}
});
@@ -94,16 +114,40 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
/**
* GET /api/cliproxy/accounts - Get all accounts across all providers
*/
router.get('/accounts', (_req: Request, res: Response) => {
router.get('/accounts', async (_req: Request, res: Response): Promise<void> => {
try {
// Initialize accounts from existing tokens
// Check if remote mode is enabled
const target = getProxyTarget();
if (target.isRemote) {
const authStatus = await fetchRemoteAuthStatus(target);
// Transform RemoteAuthStatus[] to account summary format
const accounts = authStatus.flatMap((status) =>
status.accounts.map((acc) => ({
provider: status.provider,
...acc,
}))
);
res.json({ accounts, source: 'remote' });
return;
}
// Local mode: Initialize accounts from existing tokens
initializeAccounts();
const accounts = getAllAccountsSummary();
res.json({ accounts });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list accounts';
res.status(500).json({ error: message });
const target = getProxyTarget();
if (target.isRemote) {
res.status(503).json({
error: (error as Error).message,
accounts: [],
source: 'remote',
});
} else {
const message = error instanceof Error ? error.message : 'Failed to list accounts';
res.status(500).json({ error: message });
}
}
});
@@ -132,6 +176,15 @@ router.get('/accounts/:provider', (req: Request, res: Response): void => {
* POST /api/cliproxy/accounts/:provider/default - Set default account for provider
*/
router.post('/accounts/:provider/default', (req: Request, res: Response): void => {
// Check if remote mode is enabled - account management not available
const target = getProxyTarget();
if (target.isRemote) {
res.status(501).json({
error: 'Account management not available in remote mode',
});
return;
}
const { provider } = req.params;
const { accountId } = req.body;
@@ -166,6 +219,15 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void =
* DELETE /api/cliproxy/accounts/:provider/:accountId - Remove an account
*/
router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): void => {
// Check if remote mode is enabled - account management not available
const target = getProxyTarget();
if (target.isRemote) {
res.status(501).json({
error: 'Account management not available in remote mode',
});
return;
}
const { provider, accountId } = req.params;
// Validate provider
@@ -3,10 +3,14 @@
*
* Embeds the CLIProxy management.html with auto-authentication.
* Uses postMessage to inject credentials into the iframe.
* Supports both local and remote CLIProxy server connections.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { RefreshCw, AlertCircle, Key, X, Gauge } from 'lucide-react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { RefreshCw, AlertCircle, Key, X, Gauge, Globe } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { CliproxyServerConfig } from '@/lib/api-client';
/** CLIProxyAPI default port */
const CLIPROXY_DEFAULT_PORT = 8317;
@@ -25,30 +29,94 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
const [isConnected, setIsConnected] = useState(false);
const [showLoginHint, setShowLoginHint] = useState(true);
const managementUrl = `http://localhost:${port}/management.html`;
// Fetch cliproxy_server config for remote/local mode detection
const { data: cliproxyConfig, error: configError } = useQuery<CliproxyServerConfig>({
queryKey: ['cliproxy-server-config'],
queryFn: () => api.cliproxyServer.get(),
staleTime: 30000, // 30 seconds
});
// Log config fetch errors (fallback to local mode on error)
useEffect(() => {
if (configError) {
console.warn('[ControlPanelEmbed] Config fetch failed, using local mode:', configError);
}
}, [configError]);
// Calculate URLs and settings based on remote or local mode
const { managementUrl, checkUrl, authToken, isRemote, displayHost } = useMemo(() => {
const remote = cliproxyConfig?.remote;
if (remote?.enabled && remote?.host) {
const protocol = remote.protocol || 'http';
// Use port from config, or default based on protocol (443 for https, 80 for http)
const remotePort = remote.port || (protocol === 'https' ? 443 : 80);
// Only include port in URL if it's non-standard
const portSuffix =
(protocol === 'https' && remotePort === 443) || (protocol === 'http' && remotePort === 80)
? ''
: `:${remotePort}`;
const baseUrl = `${protocol}://${remote.host}${portSuffix}`;
return {
managementUrl: `${baseUrl}/management.html`,
checkUrl: `${baseUrl}/`,
authToken: remote.auth_token || undefined,
isRemote: true,
displayHost: `${remote.host}${portSuffix}`,
};
}
// Local mode
return {
managementUrl: `http://localhost:${port}/management.html`,
checkUrl: `http://localhost:${port}/`,
authToken: CCS_CONTROL_PANEL_SECRET,
isRemote: false,
displayHost: `localhost:${port}`,
};
}, [cliproxyConfig, port]);
// Check if CLIProxy is running
useEffect(() => {
const controller = new AbortController();
const checkConnection = async () => {
try {
const response = await fetch(`http://localhost:${port}/`, {
signal: AbortSignal.timeout(2000),
const response = await fetch(checkUrl, {
signal: controller.signal,
});
if (response.ok) {
setIsConnected(true);
setError(null);
} else {
setIsConnected(false);
setError('CLIProxy returned an error');
setError(
isRemote
? `Remote CLIProxy at ${displayHost} returned an error`
: 'CLIProxy returned an error'
);
}
} catch {
} catch (e) {
// Ignore abort errors (component unmounting)
if (e instanceof Error && e.name === 'AbortError') return;
setIsConnected(false);
setError('CLIProxy is not running');
setError(
isRemote
? `Remote CLIProxy at ${displayHost} is not reachable`
: 'CLIProxy is not running'
);
}
};
checkConnection();
}, [port]);
// Start connection check with timeout
const timeoutId = setTimeout(() => controller.abort(), 2000);
checkConnection().finally(() => clearTimeout(timeoutId));
// Cleanup: abort fetch on unmount
return () => controller.abort();
}, [checkUrl, isRemote, displayHost]);
// Handle iframe load - attempt to auto-login via postMessage
const handleIframeLoad = useCallback(() => {
@@ -57,26 +125,38 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
// Try to inject credentials via postMessage
// The management.html needs to listen for this message
// If it doesn't support it, user will see the login page
if (iframeRef.current?.contentWindow) {
if (iframeRef.current?.contentWindow && authToken) {
try {
// Derive apiBase from checkUrl (remove trailing slash)
const apiBase = checkUrl.replace(/\/$/, '');
// Security: Validate iframe src matches target origin before sending credentials
const iframeSrc = iframeRef.current.src;
if (!iframeSrc.startsWith(apiBase)) {
console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage');
return;
}
// Send credentials to iframe
iframeRef.current.contentWindow.postMessage(
{
type: 'ccs-auto-login',
apiBase: `http://localhost:${port}`,
managementKey: CCS_CONTROL_PANEL_SECRET,
apiBase,
managementKey: authToken,
},
`http://localhost:${port}`
apiBase
);
} catch {
} catch (e) {
// Cross-origin restriction - expected if not same origin
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin');
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e);
}
}
}, [port]);
}, [checkUrl, authToken]);
const handleRefresh = () => {
setIsLoading(true);
setError(null);
setIsConnected(false);
if (iframeRef.current) {
iframeRef.current.src = managementUrl;
}
@@ -119,15 +199,24 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
return (
<div className="flex-1 flex flex-col relative">
{/* Login hint banner */}
{/* Remote indicator and login hint banner */}
{showLoginHint && !isLoading && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
{isRemote && (
<>
<Globe className="h-3.5 w-3.5 text-green-600" />
<span className="text-green-600 font-medium">Remote</span>
<span className="text-blue-300 dark:text-blue-700">|</span>
</>
)}
<Key className="h-3.5 w-3.5 text-blue-600" />
<span>
Key:{' '}
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
ccs
{authToken && authToken.length > 4
? `***${authToken.slice(-4)}`
: authToken || 'ccs'}
</code>
</span>
<button
@@ -145,7 +234,11 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div className="text-center">
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground">Loading Control Panel...</p>
<p className="text-sm text-muted-foreground">
{isRemote
? `Loading Control Panel from ${displayHost}...`
: 'Loading Control Panel...'}
</p>
</div>
</div>
)}
@@ -31,6 +31,7 @@ export function ProviderEditor({
authStatus,
catalog,
logoProvider,
isRemoteMode,
onAddAccount,
onSetDefault,
onRemoveAccount,
@@ -124,6 +125,7 @@ export function ProviderEditor({
hasChanges={hasChanges}
isRawJsonValid={isRawJsonValid}
isSaving={saveMutation.isPending}
isRemoteMode={isRemoteMode}
onRefetch={refetch}
onSave={() => saveMutation.mutate()}
/>
@@ -5,7 +5,7 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Save, Loader2, RefreshCw } from 'lucide-react';
import { Save, Loader2, RefreshCw, Globe } from 'lucide-react';
import { ProviderLogo } from '../provider-logo';
import type { SettingsResponse } from './types';
@@ -18,6 +18,7 @@ interface ProviderEditorHeaderProps {
hasChanges: boolean;
isRawJsonValid: boolean;
isSaving: boolean;
isRemoteMode?: boolean;
onRefetch: () => void;
onSave: () => void;
}
@@ -31,6 +32,7 @@ export function ProviderEditorHeader({
hasChanges,
isRawJsonValid,
isSaving,
isRemoteMode,
onRefetch,
onSave,
}: ProviderEditorHeaderProps) {
@@ -41,16 +43,31 @@ export function ProviderEditorHeader({
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{displayName}</h2>
{data?.path && (
{isRemoteMode && (
<Badge
variant="secondary"
className="text-xs gap-1 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
<Globe className="w-3 h-3" />
Remote
</Badge>
)}
{!isRemoteMode && data?.path && (
<Badge variant="outline" className="text-xs">
{data.path.replace(/^.*\//, '')}
{data.path.replace(/^.*[\\/]/, '')}
</Badge>
)}
</div>
{data && (
<p className="text-xs text-muted-foreground mt-0.5">
Last modified: {new Date(data.mtime).toLocaleString()}
{isRemoteMode ? (
<p className="text-xs text-blue-600 dark:text-blue-400 mt-0.5">
Traffic auto-routed to remote server
</p>
) : (
data && (
<p className="text-xs text-muted-foreground mt-0.5">
Last modified: {new Date(data.mtime).toLocaleString()}
</p>
)
)}
</div>
</div>
@@ -21,6 +21,8 @@ export interface ProviderEditorProps {
catalog?: ProviderCatalog;
/** Provider type for logo display (defaults to provider) */
logoProvider?: string;
/** True if using remote CLIProxy mode (hides local paths) */
isRemoteMode?: boolean;
onAddAccount: () => void;
onSetDefault: (accountId: string) => void;
onRemoveAccount: (accountId: string) => void;
@@ -3,11 +3,24 @@
*
* Displays CLIProxy process status with start/stop/restart controls.
* Shows: running state, port, session count, uptime, update availability.
* In remote mode: shows remote server info instead of local controls.
*/
import { Activity, Power, RefreshCw, Clock, Users, Square, RotateCw, ArrowUp } from 'lucide-react';
import {
Activity,
Power,
RefreshCw,
Clock,
Users,
Square,
RotateCw,
ArrowUp,
Globe,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useQuery } from '@tanstack/react-query';
import { api, type CliproxyServerConfig } from '@/lib/api-client';
import {
useProxyStatus,
useStartProxy,
@@ -48,10 +61,32 @@ export function ProxyStatusWidget() {
const startProxy = useStartProxy();
const stopProxy = useStopProxy();
// Fetch cliproxy_server config for remote mode detection
const { data: cliproxyConfig } = useQuery<CliproxyServerConfig>({
queryKey: ['cliproxy-server-config'],
queryFn: () => api.cliproxyServer.get(),
staleTime: 30000, // 30 seconds
});
// Determine if remote mode is enabled
const remoteConfig = cliproxyConfig?.remote;
const isRemoteMode = remoteConfig?.enabled && remoteConfig?.host;
const isRunning = status?.running ?? false;
const isActioning = startProxy.isPending || stopProxy.isPending;
const hasUpdate = updateCheck?.hasUpdate ?? false;
// Build remote display info
const remoteDisplayHost = isRemoteMode
? (() => {
const protocol = remoteConfig.protocol || 'http';
const port = remoteConfig.port || (protocol === 'https' ? 443 : 80);
const isDefaultPort =
(protocol === 'https' && port === 443) || (protocol === 'http' && port === 80);
return isDefaultPort ? remoteConfig.host : `${remoteConfig.host}:${port}`;
})()
: null;
// Restart = stop then start
const handleRestart = async () => {
await stopProxy.mutateAsync();
@@ -60,6 +95,43 @@ export function ProxyStatusWidget() {
startProxy.mutate();
};
// Remote mode: show remote server info
if (isRemoteMode) {
return (
<div
className={cn(
'rounded-lg border p-3 transition-colors',
'border-blue-500/30 bg-blue-500/5'
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium">Remote Proxy</span>
<Badge
variant="secondary"
className="text-[10px] h-4 px-1.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
Active
</Badge>
</div>
<Activity className="w-3 h-3 text-blue-600" />
</div>
<div className="mt-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1 mb-1">
<span className="font-mono">{remoteDisplayHost}</span>
</div>
<p className="text-[10px] text-muted-foreground/70 leading-tight">
Traffic auto-routed to remote server
</p>
</div>
</div>
);
}
// Local mode: show original controls
return (
<div
className={cn(
+2 -1
View File
@@ -249,7 +249,8 @@ export const api = {
},
cliproxy: {
list: () => request<{ variants: Variant[] }>('/cliproxy'),
getAuthStatus: () => request<{ authStatus: AuthStatus[] }>('/cliproxy/auth'),
getAuthStatus: () =>
request<{ authStatus: AuthStatus[]; source?: 'remote' | 'local' }>('/cliproxy/auth'),
create: (data: CreateVariant) =>
request('/cliproxy', {
method: 'POST',
+3
View File
@@ -192,6 +192,7 @@ export function CliproxyPage() {
} | null>(null);
const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]);
const isRemoteMode = authData?.source === 'remote';
const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]);
// Auto-select first provider if nothing selected
@@ -338,6 +339,7 @@ export function CliproxyPage() {
authStatus={parentAuthForVariant}
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
logoProvider={selectedVariantData.provider}
isRemoteMode={isRemoteMode}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedVariantData.provider,
@@ -365,6 +367,7 @@ export function CliproxyPage() {
displayName={selectedStatus.displayName}
authStatus={selectedStatus}
catalog={MODEL_CATALOGS[selectedStatus.provider]}
isRemoteMode={isRemoteMode}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedStatus.provider,