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:
Kai (Tam Nhu) Tran
2026-05-03 14:34:14 -04:00
committed by GitHub
22 changed files with 267 additions and 79 deletions
+4 -2
View File
@@ -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()}`);
+2 -2
View File
@@ -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}`;
+23
View File
@@ -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;
}
}
+2 -1
View File
@@ -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;
}
+15 -8
View File
@@ -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);
}
+2 -3
View File
@@ -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,
+2 -2
View File
@@ -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) {
+19 -5
View File
@@ -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: {
+2 -2
View File
@@ -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(
+2 -2
View File
@@ -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) {
+2 -13
View File
@@ -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)) {
+11 -9
View File
@@ -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 });
+31
View File
@@ -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',