diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 74c24b71..f778ff8c 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -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...')); } diff --git a/src/cliproxy/binary/__tests__/binary-manager-install.test.ts b/src/cliproxy/binary/__tests__/binary-manager-install.test.ts index f19b20ec..ec80035b 100644 --- a/src/cliproxy/binary/__tests__/binary-manager-install.test.ts +++ b/src/cliproxy/binary/__tests__/binary-manager-install.test.ts @@ -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()}`); diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index 737354e0..474a3857 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -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}`; diff --git a/src/cliproxy/config/port-manager.ts b/src/cliproxy/config/port-manager.ts index 3f2b05d2..c924831d 100644 --- a/src/cliproxy/config/port-manager.ts +++ b/src/cliproxy/config/port-manager.ts @@ -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, + loadConfig: () => Pick = 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; + } +} diff --git a/src/cliproxy/proxy/proxy-target-resolver.ts b/src/cliproxy/proxy/proxy-target-resolver.ts index 1f7875d8..6ca16ebe 100644 --- a/src/cliproxy/proxy/proxy-target-resolver.ts +++ b/src/cliproxy/proxy/proxy-target-resolver.ts @@ -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', diff --git a/src/cliproxy/services/proxy-lifecycle-service.ts b/src/cliproxy/services/proxy-lifecycle-service.ts index dc4e818b..1deac483 100644 --- a/src/cliproxy/services/proxy-lifecycle-service.ts +++ b/src/cliproxy/services/proxy-lifecycle-service.ts @@ -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 { +export async function stopProxy(port: number = resolveLifecyclePort()): Promise { return stopProxySession(port); } @@ -56,7 +57,7 @@ export async function stopProxy(port?: number): Promise { * Start CLIProxy service (or reuse existing running instance) */ export async function startProxy( - port: number = CLIPROXY_DEFAULT_PORT, + port: number = resolveLifecyclePort(), verbose: boolean = false ): Promise { 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; } diff --git a/src/cliproxy/session-tracker.ts b/src/cliproxy/session-tracker.ts index b2e93276..ccc59e4c 100644 --- a/src/cliproxy/session-tracker.ts +++ b/src/cliproxy/session-tracker.ts @@ -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}` }; } } diff --git a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts index 747cadb0..001e995f 100644 --- a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts +++ b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts @@ -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 { await initUI(); diff --git a/src/commands/cliproxy/resolve-lifecycle-port.ts b/src/commands/cliproxy/resolve-lifecycle-port.ts deleted file mode 100644 index 04294739..00000000 --- a/src/commands/cliproxy/resolve-lifecycle-port.ts +++ /dev/null @@ -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; - -/** - * 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); -} diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 12360b4a..1e156462 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -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, diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index ea36d9b0..3da1ecf9 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -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) { diff --git a/src/cursor/cursor-profile-executor.ts b/src/cursor/cursor-profile-executor.ts index 293bce24..e2b9b531 100644 --- a/src/cursor/cursor-profile-executor.ts +++ b/src/cursor/cursor-profile-executor.ts @@ -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 { - 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: { diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index cb97bb32..2ceeaa62 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -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( diff --git a/src/dispatcher/flows/cliproxy-flow.ts b/src/dispatcher/flows/cliproxy-flow.ts index 577fd020..ff883345 100644 --- a/src/dispatcher/flows/cliproxy-flow.ts +++ b/src/dispatcher/flows/cliproxy-flow.ts @@ -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 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)) { diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index f9430e8c..64d3d3e1 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -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 => { 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 => { */ router.get('/proxy-status', async (_req: Request, res: Response): Promise => { 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 // 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 */ router.post('/proxy-start', async (_req: Request, res: Response): Promise => { 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 */ router.post('/proxy-stop', async (_req: Request, res: Response): Promise => { 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 => { */ router.post('/restart', async (_req: Request, res: Response): Promise => { 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 => { } // 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 }); diff --git a/src/web-server/routes/proxy-routes.ts b/src/web-server/routes/proxy-routes.ts index 9b13b1a2..a98867ed 100644 --- a/src/web-server/routes/proxy-routes.ts +++ b/src/web-server/routes/proxy-routes.ts @@ -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; + 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) => { diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts index 725c6389..029bd472 100644 --- a/src/web-server/services/cliproxy-dashboard-install-service.ts +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -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 { diff --git a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts index f0607add..e5bcd021 100644 --- a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts +++ b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts @@ -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); + }); }); diff --git a/tests/unit/cursor/cursor-profile-executor.test.ts b/tests/unit/cursor/cursor-profile-executor.test.ts index c0c2a1fc..4d7d6260 100644 --- a/tests/unit/cursor/cursor-profile-executor.test.ts +++ b/tests/unit/cursor/cursor-profile-executor.test.ts @@ -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); diff --git a/tests/unit/web-server/api-routes-remote-write-guard.test.ts b/tests/unit/web-server/api-routes-remote-write-guard.test.ts index 85d4d6a6..730c8a62 100644 --- a/tests/unit/web-server/api-routes-remote-write-guard.test.ts +++ b/tests/unit/web-server/api-routes-remote-write-guard.test.ts @@ -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',