From 509bd5dbef008e1e0b5cf129744887cee99b9726 Mon Sep 17 00:00:00 2001 From: Molko Date: Sun, 3 May 2026 07:40:48 -0400 Subject: [PATCH 1/2] fix(cliproxy): respect configured local port instead of hardcoding 8317 All call sites that spawn or probe CLIProxyApiPlus now read cliproxy_server.local.port from config via resolveLifecyclePort() instead of using the hardcoded CLIPROXY_DEFAULT_PORT constant. - Move resolveLifecyclePort helper to src/cliproxy/config/port-manager.ts - Fix 7 call sites: ccs.ts, config-command.ts, copilot-executor.ts, lifecycle.ts, binary-manager.ts, cliproxy-stats-routes.ts, cliproxy-local-proxy.ts - Remove duplicate resolveLocalCliproxyPort helper - Cache port resolution in /proxy-status handler to avoid repeated I/O Co-Authored-By: Claude Opus 4.7 --- src/ccs.ts | 6 +++--- src/cliproxy/binary-manager.ts | 5 +++-- src/cliproxy/binary/lifecycle.ts | 4 ++-- src/cliproxy/config/port-manager.ts | 13 +++++++++++++ .../cliproxy/proxy-lifecycle-subcommand.ts | 2 +- src/commands/cliproxy/resolve-lifecycle-port.ts | 15 --------------- src/commands/config-command.ts | 4 ++-- src/copilot/copilot-executor.ts | 4 ++-- src/web-server/routes/cliproxy-local-proxy.ts | 14 ++------------ src/web-server/routes/cliproxy-stats-routes.ts | 9 +++++---- .../commands/proxy-lifecycle-subcommand.test.ts | 3 +-- 11 files changed, 34 insertions(+), 45 deletions(-) delete mode 100644 src/commands/cliproxy/resolve-lifecycle-port.ts diff --git a/src/ccs.ts b/src/ccs.ts index 0fde15ea..19b797d4 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -22,7 +22,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, displayWebSearchStatus, @@ -929,7 +929,7 @@ async function main(): Promise { } 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; @@ -1385,7 +1385,7 @@ async function main(): Promise { }; } else if (imageAnalysisStatus.proxyReadiness === 'stopped') { const ensureServiceResult = await ensureCliproxyService( - CLIPROXY_DEFAULT_PORT, + resolveLifecyclePort(), verboseProxyLaunch ); if (!ensureServiceResult.started) { diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index baa11726..17cf408d 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,7 +341,7 @@ export async function installCliproxyVersion( const result = await stopProxyFn(); if (result.stopped) { // Wait for port to be fully released - const portFree = await waitForPortFreeFn(CLIPROXY_DEFAULT_PORT, 5000); + const portFree = await waitForPortFreeFn(resolveLifecyclePort(), 5000); if (!portFree && verbose) { console.log(formatWarn('Port did not free up in time, proceeding anyway...')); } 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..44e9a057 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,13 @@ 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. + */ +export function resolveLifecyclePort( + config: Pick = loadOrCreateUnifiedConfig() +): number { + return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); +} 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 e250d5e6..00000000 --- a/src/commands/cliproxy/resolve-lifecycle-port.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager'; -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; -import type { UnifiedConfig } from '../../config/unified-config-types'; - -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 bf47b324..907cbfe5 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -11,7 +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 { getDashboardAuthConfig } from '../config/unified-config-loader'; import { initUI, header, ok, info, warn, fail } from '../utils/ui'; import { resolveNamedCommand, type NamedCommandRoute } from './named-command-router'; @@ -137,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 b7d96962..c27c6000 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 { getGlobalEnvConfig } from '../config/unified-config-loader'; 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'; @@ -142,7 +142,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/web-server/routes/cliproxy-local-proxy.ts b/src/web-server/routes/cliproxy-local-proxy.ts index dc55e0fb..9c512e96 100644 --- a/src/web-server/routes/cliproxy-local-proxy.ts +++ b/src/web-server/routes/cliproxy-local-proxy.ts @@ -9,8 +9,7 @@ import http from 'http'; import { Request, Response, Router } from 'express'; -import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager'; -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { resolveLifecyclePort } from '../../cliproxy/config/port-manager'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; export interface CliproxyLocalProxyDeps { @@ -22,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); @@ -82,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..105c2e62 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}`); @@ -345,15 +345,16 @@ router.get('/proxy-status', async (_req: Request, res: Response): Promise return; } + const port = resolveLifecyclePort(); // 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 }); diff --git a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts index f0607add..32260077 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', () => { From 3862411bb7ac61fb52a13e53db95ae3b99d49c4b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 3 May 2026 14:07:31 -0400 Subject: [PATCH 2/2] fix(cliproxy): harden custom local port handling --- src/cliproxy/proxy/proxy-target-resolver.ts | 3 +- src/cursor/cursor-profile-executor.ts | 24 +++++++-- src/delegation/headless-executor.ts | 4 +- src/web-server/routes/proxy-routes.ts | 31 +++++++++++ .../cursor/cursor-profile-executor.test.ts | 51 +++++++++++++++++++ .../api-routes-remote-write-guard.test.ts | 50 ++++++++++++++++++ 6 files changed, 155 insertions(+), 8 deletions(-) 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/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/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/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',