mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 08:19:59 +00:00
Merge pull request #1170 from roeseth/fix/cliproxy-local-port-config
fix(cliproxy): respect configured local port instead of hardcoding 8317
This commit is contained in:
@@ -8,7 +8,8 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { info, warn } from '../utils/ui';
|
||||
import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config/config-generator';
|
||||
import { getBinDir } from './config/config-generator';
|
||||
import { resolveLifecyclePort } from './config/port-manager';
|
||||
import { BinaryInfo, BinaryManagerConfig } from './types';
|
||||
import {
|
||||
BACKEND_CONFIG,
|
||||
@@ -340,8 +341,9 @@ export async function installCliproxyVersion(
|
||||
if (verbose) console.log(formatInfo('Stopping running CLIProxy before update...'));
|
||||
const result = await stopProxyFn();
|
||||
if (result.stopped) {
|
||||
const stoppedPort = result.port ?? resolveLifecyclePort();
|
||||
// Wait for port to be fully released
|
||||
const portFree = await waitForPortFreeFn(CLIPROXY_DEFAULT_PORT, 5000);
|
||||
const portFree = await waitForPortFreeFn(stoppedPort, 5000);
|
||||
if (!portFree && verbose) {
|
||||
console.log(formatWarn('Port did not free up in time, proceeding anyway...'));
|
||||
}
|
||||
|
||||
@@ -176,6 +176,32 @@ describe('installCliproxyVersion', () => {
|
||||
expect(calls.ensureBinary).toBe(1);
|
||||
});
|
||||
|
||||
it('waits for the port that was actually stopped before continuing install', async () => {
|
||||
let waitedPort: number | undefined;
|
||||
|
||||
const binaryManager = await import(
|
||||
`../../binary-manager?binary-manager-stopped-port=${Date.now()}`
|
||||
);
|
||||
|
||||
await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', {
|
||||
createManager: () => ({
|
||||
isBinaryInstalled: () => false,
|
||||
deleteBinary: () => undefined,
|
||||
ensureBinary: async () => '/tmp/ccs-bin/plus/cliproxy',
|
||||
}),
|
||||
stopProxyFn: async () => ({ stopped: true, port: 8317 }),
|
||||
waitForPortFreeFn: async (port: number) => {
|
||||
waitedPort = port;
|
||||
return true;
|
||||
},
|
||||
formatInfo: (message: string) => message,
|
||||
formatWarn: (message: string) => message,
|
||||
getInstalledVersion: () => '6.6.80',
|
||||
});
|
||||
|
||||
expect(waitedPort).toBe(8317);
|
||||
});
|
||||
|
||||
it('fails fast when runtime startup forbids installing a missing binary', async () => {
|
||||
const binaryManager = await import(`../../binary-manager?binary-manager-runtime=${Date.now()}`);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { downloadAndInstall, deleteBinary, getBinaryPath } from './installer';
|
||||
import { info, warn } from '../../utils/ui';
|
||||
import { isCliproxyRunning } from '../services/stats-fetcher';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../config/config-generator';
|
||||
import { resolveLifecyclePort } from '../config/port-manager';
|
||||
import {
|
||||
CLIPROXY_MAX_STABLE_VERSION,
|
||||
CLIPROXY_FAULTY_RANGE,
|
||||
@@ -82,7 +82,7 @@ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean):
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT);
|
||||
const proxyRunning = await isCliproxyRunning(resolveLifecyclePort());
|
||||
const latestNote = isAboveMaxStable(latestVersion) ? ` (latest v${latestVersion} unstable)` : '';
|
||||
const updateMsg = `${backendLabel} update: v${currentVersion} -> v${targetVersion}${latestNote}`;
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
* Handles port number validation and default port resolution
|
||||
*/
|
||||
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import type { UnifiedConfig } from '../../config/unified-config-types';
|
||||
|
||||
/** Default CLIProxy port */
|
||||
export const CLIPROXY_DEFAULT_PORT = 8317;
|
||||
|
||||
@@ -57,3 +60,23 @@ export function normalizeProtocol(protocol: string | undefined): 'http' | 'https
|
||||
// Invalid protocol (e.g., 'ftp') - default to http
|
||||
return 'http';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the local CLIProxy lifecycle port from unified config.
|
||||
* Falls back to default port when unset, invalid, or unreadable.
|
||||
*/
|
||||
export function resolveLifecyclePort(
|
||||
config?: Pick<UnifiedConfig, 'cliproxy_server'>,
|
||||
loadConfig: () => Pick<UnifiedConfig, 'cliproxy_server'> = loadOrCreateUnifiedConfig
|
||||
): number {
|
||||
if (config) {
|
||||
return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
|
||||
}
|
||||
|
||||
try {
|
||||
const loadedConfig = loadConfig();
|
||||
return validatePort(loadedConfig.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
|
||||
} catch {
|
||||
return CLIPROXY_DEFAULT_PORT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
getRemoteDefaultPort,
|
||||
normalizeProtocol,
|
||||
validatePort,
|
||||
validateRemotePort,
|
||||
} from '../config/port-manager';
|
||||
import { getProxyEnvVars } from './proxy-config-resolver';
|
||||
@@ -69,7 +70,7 @@ export function getProxyTarget(): ProxyTarget {
|
||||
};
|
||||
}
|
||||
|
||||
const localPort = config?.local?.port ?? CLIPROXY_DEFAULT_PORT;
|
||||
const localPort = validatePort(config?.local?.port ?? CLIPROXY_DEFAULT_PORT);
|
||||
|
||||
return {
|
||||
host: '127.0.0.1',
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getProxyStatus as getProxyStatusSession,
|
||||
} from '../session-tracker';
|
||||
import { ensureCliproxyService } from '../service-manager';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../config/config-generator';
|
||||
import { resolveLifecyclePort } from '../config/port-manager';
|
||||
|
||||
/** Proxy status result */
|
||||
export interface ProxyStatusResult {
|
||||
@@ -24,6 +24,7 @@ export interface ProxyStatusResult {
|
||||
/** Stop proxy result */
|
||||
export interface StopProxyResult {
|
||||
stopped: boolean;
|
||||
port?: number;
|
||||
pid?: number;
|
||||
sessionCount?: number;
|
||||
error?: string;
|
||||
@@ -41,14 +42,14 @@ export interface StartProxyResult {
|
||||
/**
|
||||
* Get current proxy status
|
||||
*/
|
||||
export function getProxyStatus(port?: number): ProxyStatusResult {
|
||||
export function getProxyStatus(port: number = resolveLifecyclePort()): ProxyStatusResult {
|
||||
return getProxyStatusSession(port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the running CLIProxy instance
|
||||
*/
|
||||
export async function stopProxy(port?: number): Promise<StopProxyResult> {
|
||||
export async function stopProxy(port: number = resolveLifecyclePort()): Promise<StopProxyResult> {
|
||||
return stopProxySession(port);
|
||||
}
|
||||
|
||||
@@ -56,7 +57,7 @@ export async function stopProxy(port?: number): Promise<StopProxyResult> {
|
||||
* Start CLIProxy service (or reuse existing running instance)
|
||||
*/
|
||||
export async function startProxy(
|
||||
port: number = CLIPROXY_DEFAULT_PORT,
|
||||
port: number = resolveLifecyclePort(),
|
||||
verbose: boolean = false
|
||||
): Promise<StartProxyResult> {
|
||||
return ensureCliproxyService(port, verbose);
|
||||
@@ -66,7 +67,7 @@ export async function startProxy(
|
||||
* Check if proxy is currently running
|
||||
*/
|
||||
export function isProxyRunning(): boolean {
|
||||
const status = getProxyStatusSession();
|
||||
const status = getProxyStatusSession(resolveLifecyclePort());
|
||||
return status.running;
|
||||
}
|
||||
|
||||
@@ -74,6 +75,6 @@ export function isProxyRunning(): boolean {
|
||||
* Get active session count
|
||||
*/
|
||||
export function getActiveSessionCount(): number {
|
||||
const status = getProxyStatusSession();
|
||||
const status = getProxyStatusSession(resolveLifecyclePort());
|
||||
return status.sessionCount ?? 0;
|
||||
}
|
||||
|
||||
@@ -395,6 +395,7 @@ export function cleanupOrphanedSessions(port: number): void {
|
||||
*/
|
||||
export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
|
||||
stopped: boolean;
|
||||
port?: number;
|
||||
pid?: number;
|
||||
sessionCount?: number;
|
||||
error?: string;
|
||||
@@ -406,12 +407,13 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
|
||||
const portProcess = await getPortProcess(port);
|
||||
|
||||
if (!portProcess) {
|
||||
return { stopped: false, error: 'No active CLIProxy session found' };
|
||||
return { stopped: false, port, error: 'No active CLIProxy session found' };
|
||||
}
|
||||
|
||||
if (!isCLIProxyProcess(portProcess)) {
|
||||
return {
|
||||
stopped: false,
|
||||
port,
|
||||
error: `Port ${port} is in use by ${portProcess.processName}, not CLIProxy`,
|
||||
};
|
||||
}
|
||||
@@ -432,20 +434,25 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
return { stopped: true, pid: portProcess.pid, sessionCount: 0 };
|
||||
return { stopped: true, port, pid: portProcess.pid, sessionCount: 0 };
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code === 'ESRCH') {
|
||||
return { stopped: false, error: 'CLIProxy process already terminated' };
|
||||
return { stopped: false, port, error: 'CLIProxy process already terminated' };
|
||||
}
|
||||
return { stopped: false, pid: portProcess.pid, error: `Failed to stop: ${error.message}` };
|
||||
return {
|
||||
stopped: false,
|
||||
port,
|
||||
pid: portProcess.pid,
|
||||
error: `Failed to stop: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if proxy is running
|
||||
if (!isProcessRunning(lock.pid)) {
|
||||
deleteSessionLockForPort(port);
|
||||
return { stopped: false, error: 'CLIProxy was not running (cleaned up stale lock)' };
|
||||
return { stopped: false, port, error: 'CLIProxy was not running (cleaned up stale lock)' };
|
||||
}
|
||||
|
||||
const sessionCount = lock.sessions.length;
|
||||
@@ -470,15 +477,15 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
|
||||
// Clean up session lock
|
||||
deleteSessionLockForPort(port);
|
||||
|
||||
return { stopped: true, pid, sessionCount };
|
||||
return { stopped: true, port, pid, sessionCount };
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code === 'ESRCH') {
|
||||
// Process already gone
|
||||
deleteSessionLockForPort(port);
|
||||
return { stopped: false, error: 'CLIProxy process already terminated' };
|
||||
return { stopped: false, port, error: 'CLIProxy process already terminated' };
|
||||
}
|
||||
return { stopped: false, pid, error: `Failed to stop: ${error.message}` };
|
||||
return { stopped: false, port, pid, error: `Failed to stop: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { initUI, header, color, dim, ok, warn, info } from '../../utils/ui';
|
||||
import { getProxyStatus, startProxy, stopProxy } from '../../cliproxy/services';
|
||||
import { detectRunningProxy } from '../../cliproxy/proxy/proxy-detector';
|
||||
import { resolveLifecyclePort } from './resolve-lifecycle-port';
|
||||
import { resolveLifecyclePort } from '../../cliproxy/config/port-manager';
|
||||
|
||||
export async function handleStart(verbose = false): Promise<void> {
|
||||
await initUI();
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager';
|
||||
|
||||
import type { UnifiedConfig } from '../../config/unified-config-types';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
|
||||
|
||||
type LifecyclePortConfig = Pick<UnifiedConfig, 'cliproxy_server'>;
|
||||
|
||||
/**
|
||||
* Resolve the local CLIProxy lifecycle port from unified config.
|
||||
* Falls back to default port when unset/invalid.
|
||||
*/
|
||||
export function resolveLifecyclePort(
|
||||
config: LifecyclePortConfig = loadOrCreateUnifiedConfig()
|
||||
): number {
|
||||
return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
|
||||
}
|
||||
@@ -11,8 +11,7 @@ import open from 'open';
|
||||
import { startServer } from '../web-server';
|
||||
import { setupGracefulShutdown } from '../web-server/shutdown';
|
||||
import { ensureCliproxyService } from '../cliproxy/service-manager';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/config-generator';
|
||||
|
||||
import { resolveLifecyclePort } from '../cliproxy/config/port-manager';
|
||||
import { initUI, header, ok, info, warn, fail } from '../utils/ui';
|
||||
import { resolveNamedCommand, type NamedCommandRoute } from './named-command-router';
|
||||
import {
|
||||
@@ -138,7 +137,7 @@ export async function handleConfigCommand(
|
||||
|
||||
// Ensure CLIProxy service is running for dashboard features
|
||||
console.log(deps.info('Starting CLIProxy service...'));
|
||||
const cliproxyResult = await deps.ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose);
|
||||
const cliproxyResult = await deps.ensureCliproxyService(resolveLifecyclePort(), verbose);
|
||||
logger.info('cliproxy.ensure_result', 'Config command checked CLIProxy availability', {
|
||||
started: cliproxyResult.started,
|
||||
alreadyRunning: cliproxyResult.alreadyRunning,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { CopilotConfig } from '../config/unified-config-types';
|
||||
|
||||
import { ensureCliproxyService } from '../cliproxy';
|
||||
import { getEffectiveApiKey } from '../cliproxy/auth/auth-token-manager';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
||||
import { resolveLifecyclePort } from '../cliproxy/config/port-manager';
|
||||
import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth';
|
||||
import { isDaemonRunning, startDaemon } from './copilot-daemon';
|
||||
import { ensureCopilotApi } from './copilot-package-manager';
|
||||
@@ -143,7 +143,7 @@ export async function resolveCopilotImageAnalysisEnv(
|
||||
|
||||
if (status.proxyReadiness === 'stopped') {
|
||||
const ensureServiceResult = await resolvedDeps.ensureCliproxyService(
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
resolveLifecyclePort(),
|
||||
verbose
|
||||
);
|
||||
if (!ensureServiceResult.started) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { spawn } from 'child_process';
|
||||
import type { CursorConfig } from '../config/unified-config-types';
|
||||
|
||||
import { ensureCliproxyService } from '../cliproxy';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
||||
import { resolveLifecyclePort } from '../cliproxy/config/port-manager';
|
||||
import { fail, info, ok } from '../utils/ui';
|
||||
import {
|
||||
appendThirdPartyWebSearchToolArgs,
|
||||
@@ -22,6 +22,13 @@ interface CursorImageAnalysisResolution {
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
interface CursorImageAnalysisDeps {
|
||||
getImageAnalysisHookEnv?: typeof getImageAnalysisHookEnv;
|
||||
resolveImageAnalysisRuntimeStatus?: typeof resolveImageAnalysisRuntimeStatus;
|
||||
ensureCliproxyService?: typeof ensureCliproxyService;
|
||||
resolveLifecyclePort?: typeof resolveLifecyclePort;
|
||||
}
|
||||
|
||||
export function generateCursorEnv(
|
||||
config: CursorConfig,
|
||||
claudeConfigDir?: string
|
||||
@@ -45,9 +52,16 @@ export function generateCursorEnv(
|
||||
}
|
||||
|
||||
export async function resolveCursorImageAnalysisEnv(
|
||||
verbose = false
|
||||
verbose = false,
|
||||
deps: CursorImageAnalysisDeps = {}
|
||||
): Promise<CursorImageAnalysisResolution> {
|
||||
const env = getImageAnalysisHookEnv({
|
||||
const getImageAnalysisHookEnvFn = deps.getImageAnalysisHookEnv ?? getImageAnalysisHookEnv;
|
||||
const resolveImageAnalysisRuntimeStatusFn =
|
||||
deps.resolveImageAnalysisRuntimeStatus ?? resolveImageAnalysisRuntimeStatus;
|
||||
const ensureCliproxyServiceFn = deps.ensureCliproxyService ?? ensureCliproxyService;
|
||||
const resolveLifecyclePortFn = deps.resolveLifecyclePort ?? resolveLifecyclePort;
|
||||
|
||||
const env = getImageAnalysisHookEnvFn({
|
||||
profileName: 'cursor',
|
||||
profileType: 'cursor',
|
||||
});
|
||||
@@ -56,7 +70,7 @@ export async function resolveCursorImageAnalysisEnv(
|
||||
return { env, warning: null };
|
||||
}
|
||||
|
||||
const status = await resolveImageAnalysisRuntimeStatus({
|
||||
const status = await resolveImageAnalysisRuntimeStatusFn({
|
||||
profileName: 'cursor',
|
||||
profileType: 'cursor',
|
||||
});
|
||||
@@ -73,7 +87,7 @@ export async function resolveCursorImageAnalysisEnv(
|
||||
}
|
||||
|
||||
if (status.proxyReadiness === 'stopped') {
|
||||
const ensureServiceResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose);
|
||||
const ensureServiceResult = await ensureCliproxyServiceFn(resolveLifecyclePortFn(), verbose);
|
||||
if (!ensureServiceResult.started) {
|
||||
return {
|
||||
env: {
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
} from '../utils/hooks/image-analyzer-profile-hook-injector';
|
||||
import { resolveCliproxyBridgeMetadata } from '../api/services';
|
||||
import { ensureCliproxyService } from '../cliproxy';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
||||
import { resolveLifecyclePort } from '../cliproxy/config/port-manager';
|
||||
import {
|
||||
buildOpenAICompatProxyEnv,
|
||||
resolveOpenAICompatProfileConfig,
|
||||
@@ -212,7 +212,7 @@ export class HeadlessExecutor {
|
||||
imageAnalysisProvider &&
|
||||
imageAnalysisStatus.proxyReadiness === 'stopped'
|
||||
) {
|
||||
const ensureServiceResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, false);
|
||||
const ensureServiceResult = await ensureCliproxyService(resolveLifecyclePort(), false);
|
||||
if (!ensureServiceResult.started) {
|
||||
console.error(
|
||||
warn(
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
isAuthenticated,
|
||||
} from '../../cliproxy';
|
||||
import { getEffectiveEnvVars, getCompositeEnvVars } from '../../cliproxy/config/env-builder';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
|
||||
import { resolveLifecyclePort } from '../../cliproxy/config/port-manager';
|
||||
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
|
||||
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
|
||||
import {
|
||||
@@ -62,7 +62,7 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
}
|
||||
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
|
||||
const variantPort = profileInfo.port; // variant-specific port for isolation
|
||||
const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT;
|
||||
const cliproxyPort = variantPort || resolveLifecyclePort();
|
||||
|
||||
if (resolvedTarget !== 'claude') {
|
||||
const adapter = targetAdapter;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
resolveImageAnalysisRuntimeStatus,
|
||||
} from '../../utils/hooks';
|
||||
import { ensureCliproxyService } from '../../cliproxy';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
|
||||
import { resolveLifecyclePort } from '../../cliproxy/config/port-manager';
|
||||
import type { ProfileDetectionResult } from '../../auth/profile-detector';
|
||||
import type { loadSettings } from '../../config/config-loader-facade';
|
||||
import type { resolveCliproxyBridgeMetadata } from '../../api/services/cliproxy-profile-bridge';
|
||||
@@ -104,7 +104,7 @@ export async function resolveSettingsImageAnalysisEnv(
|
||||
};
|
||||
} else if (imageAnalysisStatus.proxyReadiness === 'stopped') {
|
||||
const ensureServiceResult = await ensureCliproxyService(
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
resolveLifecyclePort(),
|
||||
verboseProxyLaunch
|
||||
);
|
||||
if (!ensureServiceResult.started) {
|
||||
|
||||
@@ -9,10 +9,8 @@
|
||||
|
||||
import http from 'http';
|
||||
import { Request, Response, Router } from 'express';
|
||||
import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager';
|
||||
|
||||
import { resolveLifecyclePort } from '../../cliproxy/config/port-manager';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
|
||||
|
||||
export interface CliproxyLocalProxyDeps {
|
||||
enforceAccess?: (req: Request, res: Response) => boolean;
|
||||
@@ -23,15 +21,6 @@ export interface CliproxyLocalProxyDeps {
|
||||
/** Proxy request timeout in milliseconds (30 seconds) */
|
||||
const PROXY_TIMEOUT_MS = 30_000;
|
||||
|
||||
function resolveLocalCliproxyPort(): number {
|
||||
try {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
|
||||
} catch {
|
||||
return CLIPROXY_DEFAULT_PORT;
|
||||
}
|
||||
}
|
||||
|
||||
function isJsonContentType(contentType: string | string[] | undefined): boolean {
|
||||
const values = Array.isArray(contentType) ? contentType : [contentType];
|
||||
return values.some((value) => value?.toLowerCase().includes('application/json') === true);
|
||||
@@ -83,7 +72,7 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {}
|
||||
'CLIProxy local proxy requires localhost access when dashboard auth is disabled.'
|
||||
));
|
||||
const createRequest = deps.request ?? http.request;
|
||||
const resolveTargetPort = deps.resolveTargetPort ?? resolveLocalCliproxyPort;
|
||||
const resolveTargetPort = deps.resolveTargetPort ?? resolveLifecyclePort;
|
||||
|
||||
router.use((req: Request, res: Response, next) => {
|
||||
if (enforceAccess(req, res)) {
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
CLIPROXY_MAX_STABLE_VERSION,
|
||||
CLIPROXY_FAULTY_RANGE,
|
||||
} from '../../cliproxy/binary/platform-detector';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
|
||||
import { resolveLifecyclePort } from '../../cliproxy/config/port-manager';
|
||||
import {
|
||||
MODEL_ENV_VAR_KEYS,
|
||||
canonicalizeModelIdForProvider,
|
||||
@@ -321,7 +321,7 @@ router.get('/usage', handleStatsRequest);
|
||||
*/
|
||||
router.get('/status', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const running = await isCliproxyRunning();
|
||||
const running = await isCliproxyRunning(resolveLifecyclePort());
|
||||
res.json({ running });
|
||||
} catch (error) {
|
||||
console.error(`[cliproxy-stats] ${(error as Error).message}`);
|
||||
@@ -336,8 +336,9 @@ router.get('/status', async (_req: Request, res: Response): Promise<void> => {
|
||||
*/
|
||||
router.get('/proxy-status', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const port = resolveLifecyclePort();
|
||||
// First check session tracker for detailed info
|
||||
const sessionStatus = getProxyProcessStatus();
|
||||
const sessionStatus = getProxyProcessStatus(port);
|
||||
|
||||
// If session tracker says running, trust it
|
||||
if (sessionStatus.running) {
|
||||
@@ -347,13 +348,13 @@ router.get('/proxy-status', async (_req: Request, res: Response): Promise<void>
|
||||
|
||||
// Session tracker says not running, but proxy might be running without session tracking
|
||||
// (e.g., started before session persistence was implemented)
|
||||
const actuallyRunning = await isCliproxyRunning();
|
||||
const actuallyRunning = await isCliproxyRunning(port);
|
||||
|
||||
if (actuallyRunning) {
|
||||
// Proxy running but no session lock - legacy/untracked instance
|
||||
res.json({
|
||||
running: true,
|
||||
port: CLIPROXY_DEFAULT_PORT,
|
||||
port,
|
||||
sessionCount: 0, // Unknown sessions
|
||||
// No pid/startedAt since we don't have session lock
|
||||
});
|
||||
@@ -373,7 +374,7 @@ router.get('/proxy-status', async (_req: Request, res: Response): Promise<void>
|
||||
*/
|
||||
router.post('/proxy-start', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const result = await ensureCliproxyService();
|
||||
const result = await ensureCliproxyService(resolveLifecyclePort());
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error(`[cliproxy-stats] ${(error as Error).message}`);
|
||||
@@ -387,7 +388,7 @@ router.post('/proxy-start', async (_req: Request, res: Response): Promise<void>
|
||||
*/
|
||||
router.post('/proxy-stop', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const result = await stopProxy();
|
||||
const result = await stopProxy(resolveLifecyclePort());
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error(`[cliproxy-stats] ${(error as Error).message}`);
|
||||
@@ -1086,6 +1087,7 @@ router.post('/install', async (req: Request, res: Response): Promise<void> => {
|
||||
*/
|
||||
router.post('/restart', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const port = resolveLifecyclePort();
|
||||
if (isRunningUnderSupervisord()) {
|
||||
// Docker mode: delegate to supervisord which owns the process lifecycle
|
||||
const result = restartCliproxyViaSupervisord();
|
||||
@@ -1094,13 +1096,13 @@ router.post('/restart', async (_req: Request, res: Response): Promise<void> => {
|
||||
}
|
||||
|
||||
// Local mode: direct process management
|
||||
await stopProxy();
|
||||
await stopProxy(port);
|
||||
|
||||
// Small delay to ensure port is released
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
// Start proxy
|
||||
const startResult = await ensureCliproxyService();
|
||||
const startResult = await ensureCliproxyService(port);
|
||||
|
||||
if (startResult.started || startResult.alreadyRunning) {
|
||||
res.json({ success: true, port: startResult.port });
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Router, Request, Response } from 'express';
|
||||
import { testConnection } from '../../cliproxy/services/remote-proxy-client';
|
||||
import { isProxyRunning } from '../../cliproxy/services/proxy-lifecycle-service';
|
||||
import { DEFAULT_BACKEND } from '../../cliproxy/binary/platform-detector';
|
||||
import { validatePort } from '../../cliproxy/config/port-manager';
|
||||
import {
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG,
|
||||
CliproxyServerConfig,
|
||||
@@ -58,6 +59,36 @@ router.get('/', async (_req: Request, res: Response) => {
|
||||
router.put('/', (req: Request, res: Response) => {
|
||||
try {
|
||||
const updates = req.body as Partial<CliproxyServerConfig>;
|
||||
const currentConfig = loadOrCreateUnifiedConfig();
|
||||
const currentLocalPort = validatePort(
|
||||
currentConfig.cliproxy_server?.local?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.local.port
|
||||
);
|
||||
const requestedLocalPort = updates.local?.port;
|
||||
|
||||
if (
|
||||
requestedLocalPort !== undefined &&
|
||||
(!Number.isInteger(requestedLocalPort) ||
|
||||
requestedLocalPort < 1 ||
|
||||
requestedLocalPort > 65535)
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid local port. Must be an integer between 1 and 65535.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLocalPort =
|
||||
requestedLocalPort === undefined ? currentLocalPort : validatePort(requestedLocalPort);
|
||||
|
||||
if (nextLocalPort !== currentLocalPort && isProxyRunning()) {
|
||||
res.status(409).json({
|
||||
error:
|
||||
'Proxy is running on the current local port. Stop CLIProxy before changing local.port.',
|
||||
proxyRunning: true,
|
||||
currentLocalPort,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Atomic read-modify-write — avoids race between load and save
|
||||
const updated = mutateConfig((config) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { installCliproxyVersion, resolveLocalBackend } from '../../cliproxy/binary-manager';
|
||||
import { resolveLifecyclePort } from '../../cliproxy/config/port-manager';
|
||||
import { ensureCliproxyService, type ServiceStartResult } from '../../cliproxy/service-manager';
|
||||
import { getProxyStatus as getProxyProcessStatus } from '../../cliproxy/session-tracker';
|
||||
import { isCliproxyRunning } from '../../cliproxy/services/stats-fetcher';
|
||||
@@ -24,10 +25,10 @@ interface InstallDashboardCliproxyVersionDeps {
|
||||
}
|
||||
|
||||
const defaultDeps: InstallDashboardCliproxyVersionDeps = {
|
||||
getProxyStatus: getProxyProcessStatus,
|
||||
isCliproxyRunning,
|
||||
getProxyStatus: () => getProxyProcessStatus(resolveLifecyclePort()),
|
||||
isCliproxyRunning: () => isCliproxyRunning(resolveLifecyclePort()),
|
||||
installCliproxyVersion,
|
||||
ensureCliproxyService: () => ensureCliproxyService(),
|
||||
ensureCliproxyService: () => ensureCliproxyService(resolveLifecyclePort()),
|
||||
};
|
||||
|
||||
export interface DashboardCliproxyInstallResult {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager';
|
||||
import { resolveLifecyclePort } from '../../../src/commands/cliproxy/resolve-lifecycle-port';
|
||||
import { CLIPROXY_DEFAULT_PORT, resolveLifecyclePort } from '../../../src/cliproxy/config/port-manager';
|
||||
|
||||
describe('resolveLifecyclePort', () => {
|
||||
it('uses configured cliproxy_server.local.port', () => {
|
||||
@@ -30,4 +29,12 @@ describe('resolveLifecyclePort', () => {
|
||||
it('falls back to default port when config file is missing', () => {
|
||||
expect(resolveLifecyclePort({})).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('falls back to default port when loading unified config throws', () => {
|
||||
expect(
|
||||
resolveLifecyclePort(undefined, () => {
|
||||
throw new Error('malformed config');
|
||||
})
|
||||
).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,57 @@ describe('cursor-profile-executor', () => {
|
||||
expect(warning).toBeNull();
|
||||
});
|
||||
|
||||
it('starts local CLIProxy on the configured lifecycle port for cursor image analysis', async () => {
|
||||
let ensuredPort: number | undefined;
|
||||
|
||||
const { env, warning } = await resolveCursorImageAnalysisEnv(false, {
|
||||
getImageAnalysisHookEnv: () => ({
|
||||
CCS_CURRENT_PROVIDER: 'ghcp',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '0',
|
||||
}),
|
||||
resolveImageAnalysisRuntimeStatus: async () => ({
|
||||
enabled: true,
|
||||
supported: true,
|
||||
status: 'active',
|
||||
backendId: 'ghcp',
|
||||
backendDisplayName: 'GitHub Copilot (OAuth)',
|
||||
model: 'claude-haiku-4.5',
|
||||
resolutionSource: 'cursor-alias',
|
||||
reason: null,
|
||||
shouldPersistHook: true,
|
||||
persistencePath: 'cursor.settings.json',
|
||||
runtimePath: '/api/provider/ghcp',
|
||||
usesCurrentTarget: true,
|
||||
usesCurrentAuthToken: true,
|
||||
hookInstalled: true,
|
||||
sharedHookInstalled: true,
|
||||
authReadiness: 'ready',
|
||||
authProvider: 'ghcp',
|
||||
authDisplayName: 'GitHub Copilot (OAuth)',
|
||||
authReason: null,
|
||||
proxyReadiness: 'stopped',
|
||||
proxyReason:
|
||||
'Local CLIProxy service is idle. CCS will start it automatically when image analysis is needed.',
|
||||
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
||||
effectiveRuntimeReason: null,
|
||||
}),
|
||||
ensureCliproxyService: async (port: number) => {
|
||||
ensuredPort = port;
|
||||
return {
|
||||
started: true,
|
||||
alreadyRunning: false,
|
||||
port,
|
||||
};
|
||||
},
|
||||
resolveLifecyclePort: () => 9321,
|
||||
});
|
||||
|
||||
expect(ensuredPort).toBe(9321);
|
||||
expect(env.CCS_CURRENT_PROVIDER).toBe('ghcp');
|
||||
expect(env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0');
|
||||
expect(warning).toBeNull();
|
||||
});
|
||||
|
||||
it('fails fast when Cursor integration is disabled', async () => {
|
||||
const exitCode = await executeCursorProfile({ ...BASE_CONFIG, enabled: false }, []);
|
||||
expect(exitCode).toBe(1);
|
||||
|
||||
@@ -6,6 +6,8 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { apiRoutes } from '../../../src/web-server/routes';
|
||||
import { mutateConfig, loadOrCreateUnifiedConfig } from '../../../src/config/config-loader-facade';
|
||||
import { registerSession, deleteSessionLockForPort } from '../../../src/cliproxy/session-tracker';
|
||||
import {
|
||||
authMiddleware,
|
||||
createSessionMiddleware,
|
||||
@@ -125,6 +127,54 @@ describe('api-routes remote write guard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid local ports at the cliproxy-server API boundary', async () => {
|
||||
forcedRemoteAddress = '127.0.0.1';
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy-server`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
local: { port: 70000 },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Invalid local port. Must be an integer between 1 and 65535.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects local port changes while the current local proxy session is still running', async () => {
|
||||
forcedRemoteAddress = '127.0.0.1';
|
||||
mutateConfig((config) => {
|
||||
if (!config.cliproxy_server) {
|
||||
throw new Error('cliproxy_server defaults were not initialized');
|
||||
}
|
||||
config.cliproxy_server.local.port = 8317;
|
||||
});
|
||||
registerSession(8317, process.pid);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy-server`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
local: { port: 9000 },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Proxy is running on the current local port. Stop CLIProxy before changing local.port.',
|
||||
proxyRunning: true,
|
||||
currentLocalPort: 8317,
|
||||
});
|
||||
expect(loadOrCreateUnifiedConfig().cliproxy_server?.local?.port).toBe(8317);
|
||||
} finally {
|
||||
deleteSessionLockForPort(8317);
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks remote PATCH requests when dashboard auth is disabled', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/codex/config/patch`, {
|
||||
method: 'PATCH',
|
||||
|
||||
Reference in New Issue
Block a user